From 330b3d41192644dff219d5a0c7f947ac2827eafc Mon Sep 17 00:00:00 2001 From: TahubCS Date: Wed, 4 Nov 2026 14:58:53 +0000 Subject: [PATCH 1/4] feat: require an issue, a merged pull request with passing checks, and a release for Evidence complete Replace the Evidence complete rule with the team's rule from US-14 (ADR-056). A requirement is Evidence complete only when an issue is linked, a merged pull request is linked, the checks on the final commit of every merged pull request passed, and every merged pull request is included in a linked release. The API returns the missing parts, and the requirement list and page show which parts are met or missing, separately from Accepted. --- app/api/Requirements/EvidencePolicy.cs | 63 +++++++++---- app/api/Requirements/RequirementEndpoints.cs | 31 ++++--- app/web/src/app/app-shell.css | 12 +++ .../requirements/[requirementId]/page.tsx | 5 +- app/web/src/components/evidence-status.tsx | 32 +++++++ app/web/src/components/requirement-rows.tsx | 10 +- app/web/src/lib/projects.ts | 55 +++++++++-- app/web/src/lib/requirements.ts | 16 ++-- docs/API.md | 6 +- docs/ARCHITECTURE.md | 2 +- docs/DECISIONS.md | 40 +++++++- docs/HANDOFF.md | 43 ++++++++- docs/PROJECT.md | 2 +- docs/TESTING.md | 13 ++- tests/api/project-data.spec.ts | 24 +++-- tests/e2e/milestones.spec.ts | 40 +++++++- tests/schema/milestones.spec.ts | 91 +++++++++++++------ 17 files changed, 389 insertions(+), 96 deletions(-) create mode 100644 app/web/src/components/evidence-status.tsx diff --git a/app/api/Requirements/EvidencePolicy.cs b/app/api/Requirements/EvidencePolicy.cs index f91e4e3..cc598e4 100644 --- a/app/api/Requirements/EvidencePolicy.cs +++ b/app/api/Requirements/EvidencePolicy.cs @@ -3,31 +3,54 @@ namespace SpecThread.Api.Requirements; -// The fixed rule for "Evidence complete" until a configurable evidence policy exists (ADR-053). -// It describes the recorded evidence only; it is not acceptance, which a person decides (ADR-004). +// The team's fixed rule for "Evidence complete" until a configurable evidence policy exists +// (ADR-057, replacing ADR-053's rule). It describes the recorded evidence only; it is not +// acceptance, which a person decides (ADR-004). internal static class EvidencePolicy { - private static readonly JsonSerializerOptions ChecksJson = new(JsonSerializerDefaults.Web); - private static readonly string[] Code = ["pull_request", "commit"]; + public const string Issue = "issue"; + public const string MergedPullRequest = "merged_pull_request"; + public const string PassingChecks = "passing_checks"; + public const string Release = "release"; + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static readonly string[] Acceptable = ["passed", "skipped", "neutral"]; - // Complete when at least one pull request or commit is linked, the checks of every linked one - // could be read, none of them failed, is still running, or was cancelled, and at least one passed. - public static bool IsComplete(IEnumerable evidence) + // The parts of the rule the evidence does not meet, in this order: + // `issue`: no issue is linked. + // `merged_pull_request`: no merged pull request is linked. + // `passing_checks`: a merged pull request's final commit has unreadable checks, no passed + // check, or a check that failed, is still running, or was cancelled; also when none is merged. + // `release`: a merged pull request is not in the history of any linked release; also when + // none is merged. + // Linked commits and unmerged pull requests neither count toward nor block it. + public static List Missing(IReadOnlyCollection evidence) { - var code = evidence.Where(e => Code.Contains(e.Kind)).ToList(); - if (code.Count == 0) return false; - var passed = false; - foreach (var item in code) - { - if (item.Checks is null) return false; - var checks = JsonSerializer.Deserialize>(item.Checks, ChecksJson) ?? []; - if (checks.Any(c => !Acceptable.Contains(c.Result))) return false; - passed |= checks.Any(c => c.Result == "passed"); - } - return passed; + var missing = new List(); + if (!evidence.Any(e => e.Kind == "issue")) missing.Add(Issue); + + var merged = evidence.Where(e => e.Kind == "pull_request" && e.State == "merged").ToList(); + if (merged.Count == 0) return [.. missing, MergedPullRequest, PassingChecks, Release]; + + if (!merged.TrueForAll(pr => ChecksPassed(pr.Checks))) missing.Add(PassingChecks); + var releases = evidence.Where(e => e.Kind == "release").Select(e => ReadContains(e.Contains)).ToList(); + if (!merged.TrueForAll(pr => releases.Exists(contains => contains.GetValueOrDefault(pr.Id)))) missing.Add(Release); + return missing; } + + // Checks pass when they could be read, at least one passed, and the rest passed, were skipped, or were neutral. + private static bool ChecksPassed(string? stored) + { + if (stored is null) return false; + var checks = JsonSerializer.Deserialize>(stored, Json) ?? []; + return checks.Exists(c => c.Result == "passed") && checks.TrueForAll(c => Acceptable.Contains(c.Result)); + } + + // Which linked changes a release's history includes, by evidence ID; empty when not yet read (ADR-037). + private static Dictionary ReadContains(string? stored) => + stored is null ? [] : JsonSerializer.Deserialize>(stored, Json) ?? []; } -// The kind of an evidence link and its stored check results (JSON, or null when unreadable). -internal sealed record EvidenceChecks(string Kind, string? Checks); +// What the rule reads from one evidence link: its kind, GitHub state, stored check results (JSON, +// or null when unreadable), and for a release which linked changes it contains (JSON, or null). +internal sealed record EvidenceFacts(Guid Id, string Kind, string? State, string? Checks, string? Contains); diff --git a/app/api/Requirements/RequirementEndpoints.cs b/app/api/Requirements/RequirementEndpoints.cs index f6a0f6b..4a4655d 100644 --- a/app/api/Requirements/RequirementEndpoints.cs +++ b/app/api/Requirements/RequirementEndpoints.cs @@ -47,20 +47,21 @@ private static async Task>, NotFound>> List( .GroupBy(v => v.RequirementId) .ToDictionary(group => group.Key, group => group.OrderByDescending(v => v.DecidedAt).ThenByDescending(v => v.Id).First()); var evidence = (await db.RequirementEvidence.AsNoTracking().Where(e => ids.Contains(e.RequirementId)) - .Select(e => new { e.RequirementId, e.Id, e.Kind, e.Checks }).ToListAsync(cancel)) + .Select(e => new { e.RequirementId, Facts = new EvidenceFacts(e.Id, e.Kind, e.State, e.Checks, e.Contains) }).ToListAsync(cancel)) .ToLookup(e => e.RequirementId); var criteriaVersions = await CriteriaVersionEndpoints.LatestNumbersAsync(db, ids, cancel); return TypedResults.Ok(requirements.ConvertAll(r => { - var links = evidence[r.Id].ToList(); + var links = evidence[r.Id].Select(e => e.Facts).ToList(); + var missing = EvidencePolicy.Missing(links); int? criteriaVersion = criteriaVersions.TryGetValue(r.Id, out var number) ? number : null; return new RequirementSummary( r.Id, r.ProjectId, r.Title, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, links.Count, reviews.TryGetValue(r.Id, out var review) ? ReviewEndpoints.Summarize(review, r.Version, links.Select(e => e.Id), criteriaVersion) : null, - r.Milestone, criteriaVersion, EvidencePolicy.IsComplete(links.Select(e => new EvidenceChecks(e.Kind, e.Checks)))); + r.Milestone, criteriaVersion, missing.Count == 0, missing); })); } @@ -102,7 +103,7 @@ private static async Task, ValidationProble var documents = await LoadDocuments(db, requirement.Id, cancel); await transaction.CommitAsync(cancel); - return TypedResults.Created($"/requirements/{requirement.Id}", ToResponse(requirement, criteria, documents, 1)); + return TypedResults.Created($"/requirements/{requirement.Id}", ToResponse(requirement, criteria, documents, 1, EvidencePolicy.Missing([]))); } private static async Task, NotFound>> Get( @@ -116,7 +117,7 @@ private static async Task, NotFound>> Get( .OrderBy(c => c.Position) .ToListAsync(cancel); return TypedResults.Ok(ToResponse(requirement.Requirement, criteria, await LoadDocuments(db, requirementId, cancel), - await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel))); + await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel), await MissingEvidenceAsync(db, requirementId, cancel))); } // Replaces the whole criteria and document lists; a stale version means someone else saved first. @@ -174,9 +175,10 @@ private static async Task, ValidationProblem, No return Stale(); // Disposing the transaction rolls back the criteria deletion. } var documents = await LoadDocuments(db, requirementId, cancel); + var missing = await MissingEvidenceAsync(db, requirementId, cancel); await transaction.CommitAsync(cancel); - return TypedResults.Ok(ToResponse(requirement, criteria, documents, criteriaVersion)); + return TypedResults.Ok(ToResponse(requirement, criteria, documents, criteriaVersion, missing)); } private static Task, NotFound, ProblemHttpResult>> Archive( @@ -214,7 +216,7 @@ private static async Task, NotFound, ProblemHttp .OrderBy(c => c.Position) .ToListAsync(cancel); return TypedResults.Ok(ToResponse(found.Requirement, criteria, await LoadDocuments(db, requirementId, cancel), - await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel))); + await CriteriaVersionEndpoints.LatestNumberAsync(db, requirementId, cancel), await MissingEvidenceAsync(db, requirementId, cancel))); } // Like FindForMember, but locks the project's team first and then reads the requirement, so @@ -313,9 +315,16 @@ private static ProblemHttpResult AcceptedCriteriaChange(int criteriaVersion) => // `criteriaVersion` is null only for a requirement the previous API created during a deploy (ADR-044). private static RequirementResponse ToResponse( - Requirement r, IEnumerable criteria, List documents, int? criteriaVersion) => new( + Requirement r, IEnumerable criteria, List documents, int? criteriaVersion, + List evidenceMissing) => new( r.Id, r.ProjectId, r.Title, r.Description, r.CreatedBy, r.UpdatedBy ?? r.CreatedBy, r.Version, r.CreatedAt, r.UpdatedAt, r.ArchivedAt, - criteria.Select(c => new CriterionResponse(c.Id, c.Text, c.Position)).ToList(), documents, criteriaVersion, r.Milestone); + criteria.Select(c => new CriterionResponse(c.Id, c.Text, c.Position)).ToList(), documents, criteriaVersion, r.Milestone, + evidenceMissing.Count == 0, evidenceMissing); + + // The parts of the Evidence complete rule one requirement's links do not meet (ADR-057). + private static async Task> MissingEvidenceAsync(SpecThreadDbContext db, Guid requirementId, CancellationToken cancel) => + EvidencePolicy.Missing(await db.RequirementEvidence.AsNoTracking().Where(e => e.RequirementId == requirementId) + .Select(e => new EvidenceFacts(e.Id, e.Kind, e.State, e.Checks, e.Contains)).ToListAsync(cancel)); internal sealed record MemberRequirement(Requirement Requirement, bool ProjectArchived) { @@ -334,12 +343,12 @@ internal sealed record UpdateRequirementRequest( internal sealed record RequirementSummary( Guid Id, Guid ProjectId, string Title, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, int EvidenceCount, ReviewSummary? Review, - string? Milestone, int? CriteriaVersion, bool EvidenceComplete); + string? Milestone, int? CriteriaVersion, bool EvidenceComplete, List EvidenceMissing); internal sealed record RequirementResponse( Guid Id, Guid ProjectId, string Title, string Description, string CreatedBy, string UpdatedBy, int Version, DateTime CreatedAt, DateTime UpdatedAt, DateTime? ArchivedAt, List AcceptanceCriteria, - List Documents, int? CriteriaVersion, string? Milestone); + List Documents, int? CriteriaVersion, string? Milestone, bool EvidenceComplete, List EvidenceMissing); internal sealed record CriterionResponse(Guid Id, string Text, int Position); diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index 3579857..094d4d1 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -426,6 +426,7 @@ a.row:focus-visible { outline-offset: -2px; } .row { flex-wrap: wrap; } .row-title { flex: 1 1 60%; } .row-detail { order: 5; flex-basis: 100%; } + .row .evidence-missing { order: 6; flex-basis: 100%; margin-left: 0; white-space: normal; } .scaffold-page { padding: 28px 18px 48px; } .scaffold-status { padding: 17px; } .scaffold-navigation { margin-top: 34px; } @@ -461,6 +462,17 @@ a.row:focus-visible { outline-offset: -2px; } .row .row-count + .evidence-complete { margin-left: 0; } .row .evidence-complete + .badge { margin-left: 0; } .app-main .badge.evidence-complete { border-color: #24533c; color: var(--app-ok); } +.row .evidence-missing { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.row .row-count + .evidence-missing { margin-left: 0; } +.row .evidence-missing + .badge { margin-left: 0; } +.evidence-status { margin-bottom: 14px; } +.evidence-status-heading { margin: 0 0 8px; } +.evidence-parts { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; font-size: 13px; } +.evidence-parts li { display: flex; align-items: center; gap: 8px; color: var(--app-text-2); } +.evidence-parts li > svg { flex: none; } +.evidence-parts .is-met > svg { color: var(--app-ok); } +.evidence-parts .is-missing > svg { color: var(--app-warn); } +.evidence-part-state { margin-left: auto; color: var(--app-text-3); } .requirement-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 13px; } .requirement-filter select { min-width: 0; max-width: 100%; height: 36px; padding: 0 12px; border: 1px solid var(--app-line-strong); border-radius: 8px; background: var(--app-inset); color: var(--app-text); font: inherit; } .requirement-filter label { color: var(--app-text); font-weight: 500; } diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx index 10edffd..ab742ff 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowLeft, FileText, Pencil } from "lucide-react"; import { EvidenceHistory, EvidencePanel } from "@/components/evidence-panel"; +import { EvidenceStatus } from "@/components/evidence-status"; import { RequirementArchive, RequirementRestore } from "@/components/requirement-archive"; import { RequirementFollowUps } from "@/components/requirement-follow-ups"; import { RequirementMilestone } from "@/components/requirement-milestone"; @@ -25,7 +26,8 @@ export async function generateMetadata({ params }: Props): Promise { /** * Shows who created a requirement and when, its description, ordered acceptance criteria and their - * version, milestone, linked documents, follow-up links, evidence, and review decisions, with + * version, milestone, linked documents, follow-up links, evidence and its Evidence complete status, + * and review decisions, with * edit and archive while it can change. */ export default async function Page({ params }: Props) { @@ -114,6 +116,7 @@ export default async function Page({ params }: Props) {

Evidence

+ {repository || evidence.length > 0 ? ( +

+ {complete + ? Evidence complete + : Evidence incomplete} +

+
    + {EVIDENCE_PARTS.map(part => { + const met = !missing.includes(part); + return ( +
  • + {met ?
  • + ); + })} +
+ + ); +} diff --git a/app/web/src/components/requirement-rows.tsx b/app/web/src/components/requirement-rows.tsx index b7060a2..43c850c 100644 --- a/app/web/src/components/requirement-rows.tsx +++ b/app/web/src/components/requirement-rows.tsx @@ -1,12 +1,13 @@ import Link from "next/link"; import { FileText } from "lucide-react"; import { evidenceCountText } from "@/lib/evidence"; -import { formatDate, type RequirementSummary } from "@/lib/projects"; +import { EVIDENCE_PART_SHORT, formatDate, type RequirementSummary } from "@/lib/projects"; import { reviewStatus } from "@/lib/reviews"; /** * Renders requirements as linked rows with each one's milestone, evidence count, whether its - * evidence is complete, its review status, and the date it was last changed or archived. + * evidence is complete or which parts it is missing (ADR-057), its review status, and the date it + * was last changed or archived. */ export function RequirementRows({ requirements, label }: { requirements: RequirementSummary[]; label: string }) { return ( @@ -21,6 +22,11 @@ export function RequirementRows({ requirements, label }: { requirements: Require {requirement.milestone && {requirement.milestone}} {evidenceCountText(requirement.evidenceCount)} {requirement.evidenceComplete && Evidence complete} + {requirement.evidenceMissing.length > 0 && ( + + Missing {requirement.evidenceMissing.map(part => EVIDENCE_PART_SHORT[part]).join(", ")} + + )} {status.tone && diff --git a/app/web/src/lib/projects.ts b/app/web/src/lib/projects.ts index c1bf22a..52cd83e 100644 --- a/app/web/src/lib/projects.ts +++ b/app/web/src/lib/projects.ts @@ -55,15 +55,56 @@ export function fieldError(problem: unknown, field: string): string | null { /** * A requirement as listed for a project (docs/API.md). `evidenceCount` is how many evidence links * it has; `review` is its latest decision, or null. `milestone` is its milestone or sprint label - * (ADR-053), `criteriaVersion` its current acceptance-criteria version (ADR-051), and - * `evidenceComplete` whether its evidence meets the built-in evidence rule (ADR-053). + * (ADR-053), and `criteriaVersion` its current acceptance-criteria version (ADR-051). + * `evidenceComplete` is whether its evidence meets the team's Evidence complete rule, and + * `evidenceMissing` lists the parts of that rule it does not meet (ADR-057). */ export type RequirementSummary = { id: string; projectId: string; title: string; version: number; createdAt: string; updatedAt: string; archivedAt: string | null; evidenceCount: number; review: ReviewSummary | null; - milestone: string | null; criteriaVersion: number | null; evidenceComplete: boolean; + milestone: string | null; criteriaVersion: number | null; evidenceComplete: boolean; evidenceMissing: EvidencePart[]; }; +/** + * The parts of the Evidence complete rule, in the order the API lists missing ones (ADR-057): a + * linked issue; a merged pull request; passing checks on each merged pull request's final commit; + * and a linked release that includes each merged pull request. + */ +export const EVIDENCE_PARTS = ["issue", "merged_pull_request", "passing_checks", "release"] as const; +export type EvidencePart = typeof EVIDENCE_PARTS[number]; + +/** What each part of the Evidence complete rule asks for, as shown on a requirement page. */ +export const EVIDENCE_PART_TEXT: Record = { + issue: "A linked issue", + merged_pull_request: "A merged pull request", + passing_checks: "Checks passed on the final commit of each merged pull request", + release: "A linked release that includes each merged pull request", +}; + +/** Short names for missing parts in a requirement row. */ +export const EVIDENCE_PART_SHORT: Record = { + issue: "issue", merged_pull_request: "merged pull request", passing_checks: "passing checks", release: "release", +}; + +/** + * Reads `evidenceComplete` and `evidenceMissing` from an API response, or undefined when either + * has the wrong shape. The previous API (ADR-044) may send neither, or no `evidenceMissing`: that + * reads as not complete and nothing listed, so no part is claimed missing under the old rule. + */ +export function parseEvidenceStatus(item: Record | null): { evidenceComplete: boolean; evidenceMissing: EvidencePart[] } | undefined { + if (item?.evidenceMissing === undefined) { + return item?.evidenceComplete === undefined || typeof item.evidenceComplete === "boolean" + ? { evidenceComplete: false, evidenceMissing: [] } : undefined; + } + const missing = item.evidenceMissing; + if (typeof item.evidenceComplete !== "boolean" || !Array.isArray(missing) || + !missing.every(part => (EVIDENCE_PARTS as readonly unknown[]).includes(part)) || new Set(missing).size !== missing.length || + item.evidenceComplete !== (missing.length === 0)) { + return undefined; + } + return { evidenceComplete: item.evidenceComplete, evidenceMissing: EVIDENCE_PARTS.filter(part => missing.includes(part)) }; +} + /** A project member with the name and email other members may see (docs/API.md). */ export type ProjectMember = { userId: string; name: string; email: string; joinedAt: string; isOwner: boolean }; @@ -87,7 +128,7 @@ export const isLabel = (value: unknown): value is string => typeof value === "st /** * Validates a requirement list from an API response. Throws when the shape is not the documented one. - * The previous API (ADR-044) sends no `milestone`, `criteriaVersion`, or `evidenceComplete`. + * The previous API (ADR-044) may send no `milestone`, `criteriaVersion`, `evidenceComplete`, or `evidenceMissing`. */ export function parseRequirementSummaries(value: unknown): RequirementSummary[] { if (!Array.isArray(value)) throw new Error("The API returned an unexpected requirement list."); @@ -95,18 +136,18 @@ export function parseRequirementSummaries(value: unknown): RequirementSummary[] const item = entry as Record | null; const milestone = optionalField(item?.milestone, isLabel); const criteriaVersion = optionalField(item?.criteriaVersion, isVersion); - const evidenceComplete = item?.evidenceComplete === undefined ? false : item.evidenceComplete; + const evidence = parseEvidenceStatus(item); if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.projectId !== "string" || typeof item.title !== "string" || typeof item.version !== "number" || !isDate(item.createdAt) || !isDate(item.updatedAt) || (item.archivedAt !== null && typeof item.archivedAt !== "string") || typeof item.evidenceCount !== "number" || !Number.isInteger(item.evidenceCount) || item.evidenceCount < 0 || - milestone === undefined || criteriaVersion === undefined || typeof evidenceComplete !== "boolean") { + milestone === undefined || criteriaVersion === undefined || evidence === undefined) { throw new Error("The API returned an unexpected requirement."); } return { id: item.id, projectId: item.projectId, title: item.title, version: item.version, createdAt: item.createdAt, updatedAt: item.updatedAt, archivedAt: item.archivedAt, evidenceCount: item.evidenceCount, - review: parseReviewSummary(item.review), milestone, criteriaVersion, evidenceComplete, + review: parseReviewSummary(item.review), milestone, criteriaVersion, ...evidence, }; }); } diff --git a/app/web/src/lib/requirements.ts b/app/web/src/lib/requirements.ts index 4a44e00..4563373 100644 --- a/app/web/src/lib/requirements.ts +++ b/app/web/src/lib/requirements.ts @@ -1,5 +1,5 @@ import type { ProjectDocument } from "./documents"; -import { isLabel, optionalField, type RequirementSummary } from "./projects"; +import { isLabel, optionalField, parseEvidenceStatus, type RequirementSummary } from "./projects"; import { isVersion } from "./reviews"; /** A project document a requirement links to. It stays linked after it is archived (ADR-049). */ @@ -7,8 +7,8 @@ export type LinkedDocument = { id: string; title: string; fileName: string; contentType: string; archivedAt: string | null; linkedBy: string; linkedAt: string; }; -/** A requirement with its description, ordered acceptance criteria, and linked documents (docs/API.md). */ -export type Requirement = Omit & { +/** A requirement with its description, ordered acceptance criteria, linked documents, and evidence status (docs/API.md). */ +export type Requirement = Omit & { description: string; createdBy: string; /** Who saved the current version (the creator until someone edits it). */ @@ -68,8 +68,9 @@ export type RequirementErrors = { /** * Validates one requirement from an API response. Throws when the shape is not the documented one. * Vercel deploys the web before CI deploys the API (ADR-044), so a response from the previous - * API may lack `updatedBy`, `documents`, `criteriaVersion`, and `milestone`: they read as the - * creator, no documents, no recorded criteria version, and no milestone. + * API may lack `updatedBy`, `documents`, `criteriaVersion`, `milestone`, `evidenceComplete`, and + * `evidenceMissing`: they read as the creator, no documents, no recorded criteria version, no + * milestone, and no evidence status. */ export function parseRequirement(value: unknown): Requirement { const item = value as Record | null; @@ -79,16 +80,17 @@ export function parseRequirement(value: unknown): Requirement { const updatedBy = item?.updatedBy === undefined ? item?.createdBy : item.updatedBy; const criteriaVersion = optionalField(item?.criteriaVersion, isVersion); const milestone = optionalField(item?.milestone, isLabel); + const evidence = parseEvidenceStatus(item); if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.projectId !== "string" || typeof item.title !== "string" || typeof item.description !== "string" || typeof item.createdBy !== "string" || typeof updatedBy !== "string" || typeof item.version !== "number" || !isDate(item.createdAt) || !isDate(item.updatedAt) || (item.archivedAt !== null && typeof item.archivedAt !== "string") || !Array.isArray(criteria) || !Array.isArray(documents) || - criteriaVersion === undefined || milestone === undefined) { + criteriaVersion === undefined || milestone === undefined || evidence === undefined) { throw new Error("The API returned an unexpected requirement."); } return { id: item.id, projectId: item.projectId, title: item.title, description: item.description, createdBy: item.createdBy, - updatedBy, criteriaVersion, milestone, + updatedBy, criteriaVersion, milestone, ...evidence, version: item.version, createdAt: item.createdAt as string, updatedAt: item.updatedAt as string, archivedAt: item.archivedAt, acceptanceCriteria: criteria.map((entry: unknown) => { const criterion = entry as Record | null; diff --git a/docs/API.md b/docs/API.md index 4c128ec..7720326 100644 --- a/docs/API.md +++ b/docs/API.md @@ -203,8 +203,10 @@ type RequirementSummary = { criteriaChanged: boolean; criteriaVersion: number | null } | null; milestone: string | null; // ADR-053 criteriaVersion: number | null; // the current criteria version; null only for one created during a deploy - // Whether the linked code meets the built-in evidence rule (ADR-053). Only on the list. + // Whether the evidence meets the team's Evidence complete rule, and the parts it does not + // meet, in this order (ADR-057). evidenceComplete is true exactly when evidenceMissing is empty. evidenceComplete: boolean; + evidenceMissing: ("issue" | "merged_pull_request" | "passing_checks" | "release")[]; }; type CriteriaVersion = { number: number; criteria: string[]; createdBy: string; createdAt: string }; type FollowUpLink = { requirementId: string; title: string; archivedAt: string | null; linkedBy: string; linkedAt: string }; @@ -224,7 +226,7 @@ type Review = { evidence: { id: string; kind: string; label: string; title: string; state: string | null }[]; decidedBy: string; decidedAt: string; }; -type Requirement = Omit & { +type Requirement = Omit & { description: string; createdBy: string; // createdAt and updatedAt come from RequirementSummary updatedBy: string; // who saved the current version; the creator until someone edits it acceptanceCriteria: { id: string; text: string; position: number }[]; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5a34aa8..1b02f58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -167,7 +167,7 @@ Better Auth runs in Next.js with authentication-only database access. Users sign A deny-by-default Next.js proxy validates the Better Auth session for every non-public page and redirects signed-out visitors to login with the requested page in `next` (ADR-023). Login and signup redirect an existing session to that validated page, defaulting to the dashboard. The route scaffold links placeholder pages with example IDs until product data and API membership authorization are implemented. -The API exposes anonymous GET /health returning { "status": "ok" } and authenticated GET /me returning { "userId": "" }. Projects, requirements, and acceptance criteria are created, read, edited, archived, and restored through /projects and /requirements with inherited team-membership authorization (ADR-040, docs/API.md). Project membership writes are retired; joining a team requires invitation acceptance. Members upload, list, download, and archive project documents of up to 4 MiB through /projects/{id}/documents and /documents (ADR-047), and a requirement links up to 20 of them, saved with the requirement (ADR-049). Each saved change to a requirement's acceptance criteria is kept as a version, and changing accepted criteria needs confirmation (ADR-051); requirements can be marked as follow-ups of others with a recorded history (ADR-052) and carry one milestone label, with a fixed Evidence complete rule on the list (ADR-053). Evidence links are found by number, address, or title, and every link added or removed is recorded (ADR-054). Pull requests that close a linked issue are suggested for a person to confirm or reject, and a link can move between requirements (ADR-056). Next.js server code calls them through `apiFetch` with the user's JWT. The invitation token preview is also anonymous; all mutations and other product endpoints require a valid Better Auth JWT by default (ADR-015). /health is a liveness check, not database readiness. Development exposes /openapi/v1.json; production does not. EF Core maps six auth tables (user, session, account, verification, jwks, rateLimit) and nineteen product tables in public, and owns every migration in app/api/Migrations (see DATABASE.md) and future migrations. Startup does not create, migrate, or query the database. Database use fails explicitly if ConnectionStrings:Database is missing. RLS and revoked browser-role grants deny Data API access by default; this does not replace server-side project authorization. Supabase-managed schemas must not be modified. See DATABASE.md. +The API exposes anonymous GET /health returning { "status": "ok" } and authenticated GET /me returning { "userId": "" }. Projects, requirements, and acceptance criteria are created, read, edited, archived, and restored through /projects and /requirements with inherited team-membership authorization (ADR-040, docs/API.md). Project membership writes are retired; joining a team requires invitation acceptance. Members upload, list, download, and archive project documents of up to 4 MiB through /projects/{id}/documents and /documents (ADR-047), and a requirement links up to 20 of them, saved with the requirement (ADR-049). Each saved change to a requirement's acceptance criteria is kept as a version, and changing accepted criteria needs confirmation (ADR-051); requirements can be marked as follow-ups of others with a recorded history (ADR-052) and carry one milestone label (ADR-053), and each shows whether it is Evidence complete under the team's fixed rule and which parts are missing (ADR-057). Evidence links are found by number, address, or title, and every link added or removed is recorded (ADR-054). Pull requests that close a linked issue are suggested for a person to confirm or reject, and a link can move between requirements (ADR-057). Next.js server code calls them through `apiFetch` with the user's JWT. The invitation token preview is also anonymous; all mutations and other product endpoints require a valid Better Auth JWT by default (ADR-015). /health is a liveness check, not database readiness. Development exposes /openapi/v1.json; production does not. EF Core maps six auth tables (user, session, account, verification, jwks, rateLimit) and nineteen product tables in public, and owns every migration in app/api/Migrations (see DATABASE.md) and future migrations. Startup does not create, migrate, or query the database. Database use fails explicitly if ConnectionStrings:Database is missing. RLS and revoked browser-role grants deny Data API access by default; this does not replace server-side project authorization. Supabase-managed schemas must not be modified. See DATABASE.md. Teams (ADR-039/040) adds member-only reads, atomic creation, a real Members table, and personal favorite/expansion preferences. Your teams has expandable groups with diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index affcdcf..ef6558c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -818,7 +818,7 @@ looks only at who created the requirement, not at who edited it last. A decision can be made with no evidence linked. Nothing is locked after acceptance: an edit or a changed link marks the decision outdated and waits for a new one. There is no assignment of reviewers, no notification, and no evidence policy; "My reviews" and -Inbox stay previews (ADR-053 later adds a fixed Evidence complete rule). The snapshot does not record check results or release +Inbox stay previews (ADR-057 later sets a fixed Evidence complete rule). The snapshot does not record check results or release contents, and a refresh that changes a link's state does not mark the decision outdated. ADR-051 later records the criteria version each decision was made against and asks for confirmation before accepted criteria change. The list endpoint reads every decision of the listed requirements to find @@ -1349,6 +1349,8 @@ shows counts above the rows ("Sprint 1 · 2 requirements · 1 Evidence complete Accepted"); Accepted counts a latest decision of accepted that is neither outdated nor on earlier criteria. +ADR-057 replaces the Evidence complete rule above; it was not a team decision. + Consequences: Labels match exactly, so "Sprint 1" and "sprint 1" are two milestones; the suggestions reduce that. There is no milestone record, date, or rename; the Milestones page stays a placeholder. The rule is a fixed stand-in until a configurable @@ -1499,6 +1501,42 @@ widens the event actions, and adds `other_requirement_id`. Its rollback drops th keeps a move as a removal and an addition and a confirmation as an addition, and deletes rejected events, which have no earlier form. +ADR-057: Evidence complete needs an issue, a merged pull request with passing checks, and a release + +Status: Accepted + +Context: ADR-053's rule for "Evidence complete" (linked code with passing checks; issues +and releases neither count nor block) was written during development without a team +decision. The team's rule is the one in Jira story US-14 (SPEC-22): the default policy +requires a linked issue, a merged pull request, passing checks on its final commit, and a +recorded release; a requirement missing any part shows which part; and Evidence complete +is shown separately from Accepted. This decision replaces ADR-053's rule; the rest of +ADR-053 (milestone labels) stands. + +Decision: `EvidencePolicy` in the API lists the parts a requirement's evidence does not +meet, in this order: `issue` (no issue is linked, in any state); `merged_pull_request` (no +linked pull request is merged); `passing_checks` (a merged pull request's stored checks, +which are those of its final commit (ADR-035), could not be read, include none that passed, +or include one that failed, is still running, or was cancelled; passed, skipped, and neutral +are fine); and `release` (a merged pull request is not in the history of any linked release, +as compared through GitHub (ADR-037)). Every merged pull request must meet the last two. +Without a merged pull request, `passing_checks` and `release` are missing too. A requirement +is Evidence complete when nothing is missing. Linked commits and unmerged pull requests +neither count toward nor block it. The requirement list and a single requirement both carry +`evidenceComplete` and `evidenceMissing`. The list row shows "Evidence complete" or the +missing parts in short; the requirement page lists each part of the rule as Met or Missing +in its Evidence section, apart from the Review section, where acceptance stays a person's +decision (ADR-004, ADR-038). + +Consequences: Requirements that were Evidence complete under ADR-053 with only commits, or +without an issue or a release, no longer are, and counts on the list fall until those links +are added. A release counts only once GitHub has confirmed it contains the merged pull +request; a pull request merged after it was linked counts once the evidence is refreshed, +as its state and merge commit are a snapshot (ADR-033, ADR-037). Check results are as fresh as the last refresh. During a deploy the web may +read the previous API, whose list sends `evidenceComplete` under ADR-053's rule and no +`evidenceMissing`: the web then shows no evidence status rather than the old claim. The rule +is still fixed; a configurable policy per project would be a later decision. No migration. + ADR-NNN: Title Status: Proposed, Accepted, Superseded, or Rejected diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 42b224a..9d7434a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,47 @@ # Shared handoff -## Current task: Link suggestions from closing references, and moving a link (2026-11-05) +## Current task: US-14, the team's Evidence complete rule (2026-11-04) + +- Branch: `feature/us-14-evidence-policy`, from `main` at `031dd88` (PR #25 merged). +- Scope: Jira SPEC-22 (US-14). The team's rule replaces ADR-053's, which was not a team + decision (ADR-057). Acceptance criteria: the default policy requires a linked issue, a + merged pull request, passing checks on its final commit, and a recorded release; a + requirement missing any part shows which part; Evidence complete is shown separately + from Accepted. +- Completed: + - API: `EvidencePolicy.Missing` returns the unmet parts in order (`issue`, + `merged_pull_request`, `passing_checks`, `release`). The list and every single + requirement response (get, create, update, archive, restore) carry `evidenceComplete` + and `evidenceMissing`. + - Web: parsers read both fields (the previous API's list reads as no status, not the old + claim); a row shows "Evidence complete" or "Missing issue, ..." (wrapping at phone + width); the requirement page's Evidence section lists each part as Met or Missing under + an "Evidence complete" or "Evidence incomplete" badge, apart from the Review section. +- Decisions and assumptions: an issue counts in any state. "A recorded release" means a + linked release that GitHub confirmed contains the merged pull request (ADR-037), so an + unrelated release does not satisfy it; with several merged pull requests, each needs + passing checks and a release that contains it. Linked commits and unmerged pull requests + neither count nor block. Without a merged pull request, checks and release are listed as + missing too. +- Changed files: API `Requirements/EvidencePolicy.cs`, `Requirements/RequirementEndpoints.cs`; + web `lib/projects.ts`, `lib/requirements.ts`, `components/requirement-rows.tsx`, new + `components/evidence-status.tsx`, the requirement page, `app-shell.css`; tests + `tests/schema/milestones.spec.ts` (the rule test rewritten: 22 cases, list and single + requirement), `tests/e2e/milestones.spec.ts`, `tests/api/project-data.spec.ts`; docs + DECISIONS (ADR-057, notes on ADR-038 and ADR-053), PROJECT, API, ARCHITECTURE, TESTING. + No migration and no new environment variable. +- Checks: see the PR description for the full `npm test` result. `npm run lint`, + `npm run typecheck`, `dotnet format app/api --verify-no-changes --no-restore`, + `npm run build:api` (0 warnings), and `git diff --check` passed. Run locally with .NET SDK + 10.0.114 (the only one installable in that environment; `global.json` pins 10.0.401 and + was pointed at 10.0.100 only while checking, not committed). +- Known issues and risks: requirements that were Evidence complete under the old rule + (commits only, or no issue or release) stop being so after deploy, and list counts fall. + A pull request merged after it was linked counts only once the evidence is refreshed. Not + exercised: the real GitHub. +- Next step: review and open the PR to `main`; then move SPEC-22 to Done. + +## Previous task: Link suggestions from closing references, and moving a link (2026-11-05) - Branch: `feature/evidence-suggestions-and-moves`, from `main` at `031dd88` (PR #25 merged). - Scope: two stories for Danielle (Tech Lead), ADR-056: suggest links from explicit diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 04ffd61..847e83f 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -174,7 +174,7 @@ Use precise product language: “Check passed” means a recorded automated check reported success. -“Evidence complete” means the configured evidence policy is satisfied. Until a configurable policy exists, a fixed rule applies (ADR-053): at least one pull request or commit is linked; the checks of every linked pull request and commit could be read; none of those checks failed, is still running, or was cancelled (passed, skipped, and neutral are acceptable); and at least one check passed. Linked issues and releases neither count toward nor block it. +“Evidence complete” means the configured evidence policy is satisfied. Until a configurable policy exists, the team's fixed rule applies (ADR-057, story US-14): an issue is linked; a merged pull request is linked; the checks on the final commit of every merged pull request could be read, at least one passed, and none failed, is still running, or was cancelled (passed, skipped, and neutral are acceptable); and every merged pull request is included in a linked release. A requirement that is missing any part shows which part. Linked commits and unmerged pull requests neither count toward nor block it. Evidence complete is shown separately from Accepted. “Accepted” means an authorized human approved the requirement. diff --git a/docs/TESTING.md b/docs/TESTING.md index 82cbd36..44268a8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -319,15 +319,18 @@ currently require network access during the web build. lock); 404 for outsiders, 401 anonymous; table constraints, RLS, and rollback. tests/e2e/follow-ups.spec.ts: adding, following the link both ways, removing, the change list on the page and the history page, and a failed and a refused add. -- Milestones and Evidence complete (ADR-053). tests/schema/milestones.spec.ts (API on +- Milestones (ADR-053) and Evidence complete (ADR-057). tests/schema/milestones.spec.ts (API on 5123): a label set on create, set and cleared later without a version change, the length limit, archived and outsider refusals; the exact-label filter alone and with - `archived=true`, the labels endpoint; every case of the Evidence complete rule with - evidence rows and checks inserted in the database; the column's check and rollback. + `archived=true`, the labels endpoint; every case of the Evidence complete rule and its + missing parts, on the list and on the requirement, with issues, pull requests, commits, + checks, and releases inserted in the database; the column's check and rollback. tests/e2e/milestones.spec.ts: a label on create and on the page, filtering, the counts - with seeded evidence and an acceptance, the empty state, 390px without horizontal + with a seeded issue, merged pull request, release, and acceptance, the missing parts in + the row and as Met/Missing on the requirement page, the empty state, 390px without horizontal scrolling, and a failed save keeping the label. Not exercised: 20 follow-ups through - the browser, and an Evidence complete requirement fed by the fake GitHub. + the browser, and an Evidence complete requirement fed by the fake GitHub. Parsing of + `evidenceMissing`, including the previous API's response, is in tests/api/project-data.spec.ts. - Telemetry (ADR-055). tests/api/telemetry.spec.ts (API on 5127): spans named by route template with the caller's `user.id`; an invitation token in the path and query, a token in an unmatched path, and the bearer token never sent; the error log linked by trace ID; diff --git a/tests/api/project-data.spec.ts b/tests/api/project-data.spec.ts index f4a86bf..0231dde 100644 --- a/tests/api/project-data.spec.ts +++ b/tests/api/project-data.spec.ts @@ -59,13 +59,21 @@ test("only UUID-shaped project IDs are sent to the API", () => { test("requirement and member lists are accepted only in the documented shape", () => { const requirement = { id: "r1", projectId: "p1", title: "Guest checkout", version: 2, createdAt: "2026-10-01T10:00:00Z", updatedAt: "2026-10-02T10:00:00Z", archivedAt: null, evidenceCount: 3, review: null, - milestone: "Sprint 3", criteriaVersion: 2, evidenceComplete: true }; + milestone: "Sprint 3", criteriaVersion: 2, evidenceComplete: true, evidenceMissing: [] }; expect(parseRequirementSummaries([{ ...requirement, description: "ignored" }])).toEqual([requirement]); // The previous API (ADR-044) sends none of the milestone, criteria version, or evidence rule fields. const previous: Record = { ...requirement }; - for (const field of ["milestone", "criteriaVersion", "evidenceComplete"]) delete previous[field]; - expect(parseRequirementSummaries([previous])).toEqual([{ ...previous, milestone: null, criteriaVersion: null, evidenceComplete: false }]); - for (const value of [{ milestone: "" }, { milestone: 3 }, { criteriaVersion: 0 }, { criteriaVersion: "2" }, { evidenceComplete: "yes" }, { evidenceComplete: null }]) { + for (const field of ["milestone", "criteriaVersion", "evidenceComplete", "evidenceMissing"]) delete previous[field]; + expect(parseRequirementSummaries([previous])).toEqual([{ ...previous, milestone: null, criteriaVersion: null, evidenceComplete: false, evidenceMissing: [] }]); + // The API before ADR-057 sends `evidenceComplete` under the old rule; it is not shown as complete. + expect(parseRequirementSummaries([{ ...previous, evidenceComplete: true }])[0]).toMatchObject({ evidenceComplete: false, evidenceMissing: [] }); + // Missing parts are read in the rule's order. + expect(parseRequirementSummaries([{ ...requirement, evidenceComplete: false, evidenceMissing: ["release", "issue"] }])[0]) + .toMatchObject({ evidenceComplete: false, evidenceMissing: ["issue", "release"] }); + for (const value of [{ milestone: "" }, { milestone: 3 }, { criteriaVersion: 0 }, { criteriaVersion: "2" }, { evidenceComplete: "yes" }, { evidenceComplete: null }, + { evidenceMissing: null }, { evidenceMissing: "issue" }, { evidenceComplete: false, evidenceMissing: ["commit"] }, + { evidenceComplete: false, evidenceMissing: ["issue", "issue"] }, { evidenceComplete: true, evidenceMissing: ["issue"] }, + { evidenceComplete: false, evidenceMissing: [] }, { evidenceComplete: undefined, evidenceMissing: [] }]) { expect(() => parseRequirementSummaries([{ ...requirement, ...value }]), JSON.stringify(value)).toThrow("unexpected requirement"); } expect(parseRequirementSummaries([{ ...requirement, evidenceCount: 0 }])[0].evidenceCount).toBe(0); @@ -112,9 +120,13 @@ test("a requirement response is accepted only in the documented shape, with crit acceptanceCriteria: [{ id: "c2", text: "Second", position: 1 }, { id: "c1", text: "First", position: 0 }], documents: [{ ...document, extra: 1 }] }; const parsed = parseRequirement(requirement); - expect(parsed).toMatchObject({ criteriaVersion: null, milestone: null }); + expect(parsed).toMatchObject({ criteriaVersion: null, milestone: null, evidenceComplete: false, evidenceMissing: [] }); expect(parseRequirement({ ...requirement, criteriaVersion: 4, milestone: "Sprint 3" })).toMatchObject({ criteriaVersion: 4, milestone: "Sprint 3" }); - for (const value of [{ criteriaVersion: 0 }, { criteriaVersion: "4" }, { milestone: "" }, { milestone: 5 }]) { + expect(parseRequirement({ ...requirement, evidenceComplete: false, evidenceMissing: ["passing_checks", "merged_pull_request"] })) + .toMatchObject({ evidenceComplete: false, evidenceMissing: ["merged_pull_request", "passing_checks"] }); + expect(parseRequirement({ ...requirement, evidenceComplete: true, evidenceMissing: [] })).toMatchObject({ evidenceComplete: true, evidenceMissing: [] }); + for (const value of [{ criteriaVersion: 0 }, { criteriaVersion: "4" }, { milestone: "" }, { milestone: 5 }, + { evidenceComplete: true, evidenceMissing: ["release"] }, { evidenceComplete: "no", evidenceMissing: [] }, { evidenceComplete: false, evidenceMissing: ["x"] }]) { expect(() => parseRequirement({ ...requirement, ...value }), JSON.stringify(value)).toThrow("unexpected requirement"); } expect(parsed.acceptanceCriteria.map(criterion => criterion.text)).toEqual(["First", "Second"]); diff --git a/tests/e2e/milestones.spec.ts b/tests/e2e/milestones.spec.ts index c4bf201..661526d 100644 --- a/tests/e2e/milestones.spec.ts +++ b/tests/e2e/milestones.spec.ts @@ -43,18 +43,33 @@ test("requirements are grouped by milestone, filtered one set at a time, and cou await expect(page.getByRole("status").filter({ hasText: "Milestone saved." })).toBeVisible(); await expect(page.getByText(/^Version 1 ·/)).toBeVisible(); - // "Box opens" has a merged pull request whose checks passed and an acceptance of what is there now. + // "Box opens" has an issue, a merged pull request whose checks passed, a release that includes + // it (ADR-057), and an acceptance of what is there now. const reviewer = await createTestSession("Robert Client"); await runTestSql(` - WITH link AS ( + WITH issue AS ( + INSERT INTO public.requirement_evidence (requirement_id,kind,repository_id,repository_owner,repository_name,number,state,title,url, + github_created_at,github_updated_at,linked_by) + VALUES ('${opens}','issue',1,'acme','box',2,'closed','Box must open','https://github.com/acme/box/issues/2',now(),now(),'${owner.userId}') + RETURNING id), + pull AS ( INSERT INTO public.requirement_evidence (requirement_id,kind,repository_id,repository_owner,repository_name,number,state,title,url, github_created_at,github_updated_at,linked_by,checks,check_count) VALUES ('${opens}','pull_request',1,'acme','box',1,'merged','Open the box','https://github.com/acme/box/pull/1',now(),now(),'${owner.userId}', '[{"name":"build","result":"passed","url":null,"completedAt":null,"kind":"check"}]',1) + RETURNING id), + release AS ( + INSERT INTO public.requirement_evidence (requirement_id,kind,repository_id,repository_owner,repository_name,tag,title,url, + github_created_at,github_updated_at,linked_by,contains) + SELECT '${opens}','release',1,'acme','box','v1.0.0','v1.0.0','https://github.com/acme/box/releases/tag/v1.0.0',now(),now(),'${owner.userId}', + jsonb_build_object(pull.id::text, true) FROM pull RETURNING id) INSERT INTO public.requirement_reviews (requirement_id,decision,note,requirement_version,criteria_version,evidence,decided_by) - SELECT '${opens}','accepted','',1,1,jsonb_build_array(jsonb_build_object('id',id,'kind','pull_request','label','#1','title','Open the box','state','merged')), - '${reviewer.userId}' FROM link;`); + SELECT '${opens}','accepted','',1,1,jsonb_build_array( + jsonb_build_object('id',issue.id,'kind','issue','label','#2','title','Box must open','state','closed'), + jsonb_build_object('id',pull.id,'kind','pull_request','label','#1','title','Open the box','state','merged'), + jsonb_build_object('id',release.id,'kind','release','label','v1.0.0','title','v1.0.0','state',null)), + '${reviewer.userId}' FROM issue, pull, release;`); await page.goto(`/projects/${projectId}/requirements`); await expect(page.getByLabel("Requirement counts")).toHaveText("3 requirements · 1 Evidence complete · 1 Accepted"); @@ -65,9 +80,26 @@ test("requirements are grouped by milestone, filtered one set at a time, and cou await expect(rows).toHaveCount(2); await expect(page.getByLabel("Requirement counts")).toHaveText("Sprint 1 · 2 requirements · 1 Evidence complete · 1 Accepted"); await expect(rows.filter({ hasText: "Box opens" })).toContainText("Evidence complete"); + await expect(rows.filter({ hasText: "Box opens" })).not.toContainText("Missing"); await expect(rows.filter({ hasText: "Box closes" })).not.toContainText("Evidence complete"); + await expect(rows.filter({ hasText: "Box closes" })).toContainText("Missing issue, merged pull request, passing checks, release"); await page.screenshot({ path: testInfo.outputPath("milestone-filter.png"), fullPage: true }); + // The requirement page lists every part of the rule, separately from the review. + await rows.filter({ hasText: "Box opens" }).click(); + const parts = page.getByRole("list", { name: "Evidence complete rule" }).getByRole("listitem"); + await expect(parts).toHaveText([ + "A linked issueMet", "A merged pull requestMet", "Checks passed on the final commit of each merged pull requestMet", + "A linked release that includes each merged pull requestMet", + ]); + await expect(page.getByRole("region", { name: "Evidence" }).getByText("Evidence complete", { exact: true })).toBeVisible(); + await page.goto(`/projects/${projectId}/requirements`); + await rows.filter({ hasText: "Box closes" }).click(); + await expect(page.getByRole("region", { name: "Evidence" }).getByText("Evidence incomplete", { exact: true })).toBeVisible(); + await expect(parts.filter({ hasText: "Missing" })).toHaveCount(4); + await page.screenshot({ path: testInfo.outputPath("evidence-status.png"), fullPage: true }); + await page.goto(`/projects/${projectId}/requirements?milestone=Sprint%201`); + await page.getByLabel("Milestone or sprint").selectOption("Sprint 2"); await page.getByRole("button", { name: "Filter" }).click(); await expect(rows).toHaveCount(1); diff --git a/tests/schema/milestones.spec.ts b/tests/schema/milestones.spec.ts index ce35124..97d912a 100644 --- a/tests/schema/milestones.spec.ts +++ b/tests/schema/milestones.spec.ts @@ -3,7 +3,7 @@ import { expect, test, type APIRequestContext } from "@playwright/test"; import { startTestDatabase } from "../../scripts/test-database.mjs"; import { startApi } from "../support/api-process"; -// Milestone labels, the milestone filter, and the built-in Evidence complete rule (ADR-053), +// Milestone labels, the milestone filter (ADR-053), and the team's Evidence complete rule (ADR-057), // against a disposable PostgreSQL database. Evidence links are inserted as rows, so GitHub is not involved. const issuer = "http://127.0.0.1:5101"; let database: Awaited>; @@ -116,39 +116,76 @@ test("the list filters by an exact label, and the labels in use are listed in or expect((await request.get(`${api.baseURL}/projects/${projectId}/milestones`)).status()).toBe(401); }); -test("a requirement is Evidence complete only when its linked code has readable checks that all passed or were skipped", async ({ request }) => { +test("a requirement is Evidence complete only with an issue, a merged pull request whose checks passed, and a release that includes it", async ({ request }) => { const projectId = await createProject(request); const check = (result: string) => ({ name: `ci-${result}`, result, url: null, completedAt: null, kind: "check" }); - const link = async (requirementId: string, kind: "issue" | "pull_request" | "commit" | "release", checks: unknown) => { - const number = nextNumber++; - const identity = kind === "commit" ? { sha: number.toString(16).padStart(40, "0") } - : kind === "release" ? { tag: `v${number}` } : { number, state: "open" }; - const row = { requirement_id: requirementId, kind, repository_id: 1, repository_owner: "acme", repository_name: "box", title: "t", - url: "https://github.com/acme/box", github_created_at: new Date(), github_updated_at: new Date(), linked_by: "owner", - checks: checks === null ? null : JSON.stringify(checks), ...identity }; - const names = Object.keys(row); - await database.pool.query(`INSERT INTO public.requirement_evidence (${names.join(",")}) VALUES (${names.map((_, i) => `$${i + 1}`).join(",")})`, Object.values(row)); + type Link = { kind: "issue" | "pull_request" | "commit" | "release"; state?: string; checks?: unknown; contains?: number[] }; + // Inserts the links in order; a release's `contains` names the earlier links it includes by position. + const link = async (requirementId: string, links: Link[]) => { + const ids: string[] = []; + for (const { kind, state, checks = null, contains } of links) { + const number = nextNumber++; + const identity = kind === "commit" ? { sha: number.toString(16).padStart(40, "0") } + : kind === "release" ? { tag: `v${number}` } : { number, state: state ?? "open" }; + const row = { requirement_id: requirementId, kind, repository_id: 1, repository_owner: "acme", repository_name: "box", title: "t", + url: "https://github.com/acme/box", github_created_at: new Date(), github_updated_at: new Date(), linked_by: "owner", + checks: checks === null ? null : JSON.stringify(checks), + contains: contains === undefined ? null : JSON.stringify(Object.fromEntries(ids.map((id, index) => [id, contains.includes(index)]))), + ...identity }; + const names = Object.keys(row); + const { rows: [inserted] } = await database.pool.query( + `INSERT INTO public.requirement_evidence (${names.join(",")}) VALUES (${names.map((_, i) => `$${i + 1}`).join(",")}) RETURNING id`, + Object.values(row)); + ids.push(inserted.id); + } }; - const cases: [string, [Parameters[1], unknown][], boolean][] = [ - ["no evidence", [], false], - ["only an issue", [["issue", null]], false], - ["unreadable checks", [["pull_request", null]], false], - ["no checks", [["pull_request", []]], false], - ["only skipped", [["pull_request", [check("skipped")]]], false], - ["passed", [["pull_request", [check("passed")]]], true], - ["passed, skipped, and neutral, with a release and an issue", [["pull_request", [check("passed"), check("skipped")]], ["commit", [check("neutral")]], ["release", null], ["issue", null]], true], - ["one failed", [["pull_request", [check("passed")]], ["commit", [check("failed")]]], false], - ["one running", [["pull_request", [check("passed"), check("running")]]], false], - ["one cancelled", [["commit", [check("passed"), check("cancelled")]]], false], - ["one unreadable", [["pull_request", [check("passed")]], ["commit", null]], false], + const passed = [check("passed")]; + const issue: Link = { kind: "issue" }; + const merged = (checks: unknown = passed): Link => ({ kind: "pull_request", state: "merged", checks }); + const released = (...positions: number[]): Link => ({ kind: "release", contains: positions }); + const cases: [string, Link[], string[]][] = [ + ["no evidence", [], ["issue", "merged_pull_request", "passing_checks", "release"]], + ["only an issue", [issue], ["merged_pull_request", "passing_checks", "release"]], + ["complete", [issue, merged(), released(1)], []], + ["a closed issue counts", [{ kind: "issue", state: "closed" }, merged(), released(1)], []], + ["no issue", [merged(), released(0)], ["issue"]], + ["an open pull request does not count", [issue, { kind: "pull_request", checks: passed }, released(1)], + ["merged_pull_request", "passing_checks", "release"]], + ["a commit does not count", [issue, { kind: "commit", checks: passed }, released(1)], + ["merged_pull_request", "passing_checks", "release"]], + ["unreadable checks", [issue, merged(null), released(1)], ["passing_checks"]], + ["no checks", [issue, merged([]), released(1)], ["passing_checks"]], + ["only skipped", [issue, merged([check("skipped")]), released(1)], ["passing_checks"]], + ["passed, skipped, and neutral", [issue, merged([check("passed"), check("skipped"), check("neutral")]), released(1)], []], + ["one failed", [issue, merged([check("passed"), check("failed")]), released(1)], ["passing_checks"]], + ["one running", [issue, merged([check("passed"), check("running")]), released(1)], ["passing_checks"]], + ["one cancelled", [issue, merged([check("passed"), check("cancelled")]), released(1)], ["passing_checks"]], + ["no release", [issue, merged()], ["release"]], + ["a release without the pull request", [issue, merged(), released()], ["release"]], + ["a release not yet compared", [issue, merged(), { kind: "release" }], ["release"]], + ["checks failed and no release", [issue, merged([check("failed")])], ["passing_checks", "release"]], + ["two merged, one failed", [issue, merged(), merged([check("failed")]), released(1, 2)], ["passing_checks"]], + ["two merged, one released", [issue, merged(), merged(), released(1)], ["release"]], + ["two merged, each in its own release", [issue, merged(), merged(), released(1), released(2)], []], + ["failing commits and open pull requests do not block", + [issue, merged(), { kind: "commit", checks: [check("failed")] }, { kind: "pull_request", checks: null }, released(1)], []], ]; + const ids: Record = {}; for (const [title, links] of cases) { const requirement = await createRequirement(request, projectId, title, "Sprint 1"); - for (const [kind, checks] of links) await link(requirement.id, kind, checks); + ids[title] = requirement.id; + await link(requirement.id, links); + } + const status = (missing: string[]) => ({ evidenceComplete: missing.length === 0, evidenceMissing: missing }); + const member = await as(request, "member"); + const listed = await (await member.get(`/projects/${projectId}/requirements?milestone=Sprint%201`)).json(); + expect(Object.fromEntries(listed.map((r: { title: string; evidenceComplete: boolean; evidenceMissing: string[] }) => + [r.title, { evidenceComplete: r.evidenceComplete, evidenceMissing: r.evidenceMissing }]))) + .toEqual(Object.fromEntries(cases.map(([title, , missing]) => [title, status(missing)]))); + // The requirement itself carries the same status. + for (const [title, , missing] of cases) { + expect(await (await member.get(`/requirements/${ids[title]}`)).json(), title).toMatchObject(status(missing)); } - const listed = await (await (await as(request, "member")).get(`/projects/${projectId}/requirements?milestone=Sprint%201`)).json(); - expect(Object.fromEntries(listed.map((r: { title: string; evidenceComplete: boolean }) => [r.title, r.evidenceComplete]))) - .toEqual(Object.fromEntries(cases.map(([title, , complete]) => [title, complete]))); }); test("the migration constrains labels, and its rollback removes only the column", async () => { From 06c87298d0786ad45111080d52e494674956de95 Mon Sep 17 00:00:00 2001 From: TahubCS Date: Mon, 9 Nov 2026 14:31:10 +0000 Subject: [PATCH 2/4] test: check the evidence status on every requirement response, the older API, and phone width --- tests/e2e/milestones.spec.ts | 17 ++++++++++++++++- tests/schema/milestones.spec.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/e2e/milestones.spec.ts b/tests/e2e/milestones.spec.ts index 661526d..707ff57 100644 --- a/tests/e2e/milestones.spec.ts +++ b/tests/e2e/milestones.spec.ts @@ -33,7 +33,7 @@ async function createRequirement(page: Page, projectId: string, title: string, m test("requirements are grouped by milestone, filtered one set at a time, and counted", async ({ page }, testInfo) => { const projectId = await createProject(page); const opens = await createRequirement(page, projectId, "Box opens", "Sprint 1"); - await createRequirement(page, projectId, "Box closes", "Sprint 1"); + const closes = await createRequirement(page, projectId, "Box closes", "Sprint 1"); await createRequirement(page, projectId, "Box ships"); // A label set later on the requirement page, suggested from the labels in use. @@ -111,6 +111,21 @@ test("requirements are grouped by milestone, filtered one set at a time, and cou await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/projects/${projectId}/requirements?milestone=Sprint%201`); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + // The rule's parts fit on the requirement page too. + await page.goto(`/projects/${projectId}/requirements/${closes}`); + await expect(page.getByRole("list", { name: "Evidence complete rule" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + // During a deploy the previous API (ADR-044) sends no evidence status, and the page then claims none. + const now = new Date().toISOString(); + await failApi(owner.userId, { method: "GET", path: `^/requirements/${closes}$`, status: 200, body: { + id: closes, projectId, title: "Box closes", description: "", createdBy: owner.userId, updatedBy: owner.userId, version: 1, + createdAt: now, updatedAt: now, archivedAt: null, acceptanceCriteria: [{ id: "c1", text: "Done", position: 0 }], documents: [], + criteriaVersion: 1, milestone: "Sprint 1" } }); + await page.reload(); + await expect(page.getByRole("heading", { name: "Evidence" })).toBeVisible(); + await expect(page.getByRole("list", { name: "Evidence complete rule" })).toHaveCount(0); + await expect(page.getByText("Evidence incomplete")).toHaveCount(0); }); test("a milestone that cannot be saved is explained and the requirement keeps its label", async ({ page }) => { diff --git a/tests/schema/milestones.spec.ts b/tests/schema/milestones.spec.ts index 97d912a..293ea13 100644 --- a/tests/schema/milestones.spec.ts +++ b/tests/schema/milestones.spec.ts @@ -186,6 +186,22 @@ test("a requirement is Evidence complete only with an issue, a merged pull reque for (const [title, , missing] of cases) { expect(await (await member.get(`/requirements/${ids[title]}`)).json(), title).toMatchObject(status(missing)); } + + // So does every other response that returns one requirement: create, edit, archive, and restore. + const created = await member.post(`/projects/${projectId}/requirements`, { title: "New", acceptanceCriteria: ["Done"] }); + expect(created.status()).toBe(201); + expect(await created.json()).toMatchObject(status(["issue", "merged_pull_request", "passing_checks", "release"])); + const complete = await (await member.get(`/requirements/${ids.complete}`)).json(); + const edited = await member.put(`/requirements/${ids.complete}`, { title: "complete", description: "Edited", + acceptanceCriteria: complete.acceptanceCriteria.map((c: { text: string }) => c.text), documentIds: [], version: complete.version }); + expect(edited.status()).toBe(200); + expect(await edited.json()).toMatchObject({ description: "Edited", ...status([]) }); + const archived = await member.post(`/requirements/${ids["no issue"]}/archive`); + expect(archived.status()).toBe(200); + expect(await archived.json()).toMatchObject({ archivedAt: expect.any(String), ...status(["issue"]) }); + const restored = await member.post(`/requirements/${ids["no issue"]}/restore`); + expect(restored.status()).toBe(200); + expect(await restored.json()).toMatchObject({ archivedAt: null, ...status(["issue"]) }); }); test("the migration constrains labels, and its rollback removes only the column", async () => { From cd87768afb04c0a2f14d6e86fb47c6cdca2bd45a Mon Sep 17 00:00:00 2001 From: TahubCS Date: Mon, 9 Nov 2026 14:40:11 +0000 Subject: [PATCH 3/4] test: catch an edit response, an older API's bad value, and cut-off missing parts on phones --- tests/api/project-data.spec.ts | 4 ++++ tests/e2e/milestones.spec.ts | 4 ++++ tests/schema/milestones.spec.ts | 9 +++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/api/project-data.spec.ts b/tests/api/project-data.spec.ts index 0231dde..c19e415 100644 --- a/tests/api/project-data.spec.ts +++ b/tests/api/project-data.spec.ts @@ -67,6 +67,10 @@ test("requirement and member lists are accepted only in the documented shape", ( expect(parseRequirementSummaries([previous])).toEqual([{ ...previous, milestone: null, criteriaVersion: null, evidenceComplete: false, evidenceMissing: [] }]); // The API before ADR-057 sends `evidenceComplete` under the old rule; it is not shown as complete. expect(parseRequirementSummaries([{ ...previous, evidenceComplete: true }])[0]).toMatchObject({ evidenceComplete: false, evidenceMissing: [] }); + // Even then, a value of the wrong shape is refused. + for (const evidenceComplete of ["yes", null, 1]) { + expect(() => parseRequirementSummaries([{ ...previous, evidenceComplete }]), String(evidenceComplete)).toThrow("unexpected requirement"); + } // Missing parts are read in the rule's order. expect(parseRequirementSummaries([{ ...requirement, evidenceComplete: false, evidenceMissing: ["release", "issue"] }])[0]) .toMatchObject({ evidenceComplete: false, evidenceMissing: ["issue", "release"] }); diff --git a/tests/e2e/milestones.spec.ts b/tests/e2e/milestones.spec.ts index 707ff57..bc9b57d 100644 --- a/tests/e2e/milestones.spec.ts +++ b/tests/e2e/milestones.spec.ts @@ -111,6 +111,10 @@ test("requirements are grouped by milestone, filtered one set at a time, and cou await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`/projects/${projectId}/requirements?milestone=Sprint%201`); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + // The missing parts wrap onto their own line rather than being cut off. + const missing = page.getByText("Missing issue, merged pull request, passing checks, release"); + await expect(missing).toBeVisible(); + expect(await missing.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); // The rule's parts fit on the requirement page too. await page.goto(`/projects/${projectId}/requirements/${closes}`); await expect(page.getByRole("list", { name: "Evidence complete rule" })).toBeVisible(); diff --git a/tests/schema/milestones.spec.ts b/tests/schema/milestones.spec.ts index 293ea13..17e4276 100644 --- a/tests/schema/milestones.spec.ts +++ b/tests/schema/milestones.spec.ts @@ -191,11 +191,12 @@ test("a requirement is Evidence complete only with an issue, a merged pull reque const created = await member.post(`/projects/${projectId}/requirements`, { title: "New", acceptanceCriteria: ["Done"] }); expect(created.status()).toBe(201); expect(await created.json()).toMatchObject(status(["issue", "merged_pull_request", "passing_checks", "release"])); - const complete = await (await member.get(`/requirements/${ids.complete}`)).json(); - const edited = await member.put(`/requirements/${ids.complete}`, { title: "complete", description: "Edited", - acceptanceCriteria: complete.acceptanceCriteria.map((c: { text: string }) => c.text), documentIds: [], version: complete.version }); + // Edited with a part still missing, so a response that claims nothing missing is caught. + const before = await (await member.get(`/requirements/${ids["no issue"]}`)).json(); + const edited = await member.put(`/requirements/${ids["no issue"]}`, { title: "no issue", description: "Edited", + acceptanceCriteria: before.acceptanceCriteria.map((c: { text: string }) => c.text), documentIds: [], version: before.version }); expect(edited.status()).toBe(200); - expect(await edited.json()).toMatchObject({ description: "Edited", ...status([]) }); + expect(await edited.json()).toMatchObject({ description: "Edited", ...status(["issue"]) }); const archived = await member.post(`/requirements/${ids["no issue"]}/archive`); expect(archived.status()).toBe(200); expect(await archived.json()).toMatchObject({ archivedAt: expect.any(String), ...status(["issue"]) }); From 9efc0b7f1e5bc7c80fc28a143d9b3fb9e49f633e Mon Sep 17 00:00:00 2001 From: TahubCS Date: Mon, 9 Nov 2026 14:47:54 +0000 Subject: [PATCH 4/4] docs: record the checks and the pull request in the handoff --- docs/HANDOFF.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3c35103..bd1fb3e 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -2,7 +2,8 @@ ## Current task: US-14, the team's Evidence complete rule (2026-11-04) -- Branch: `feature/us-14-evidence-policy`, from `main` at `031dd88` (PR #25 merged). +- Branch: `feature/us-14-evidence-policy`, from `main` at `031dd88`, with `main` merged in at + `fd89908` (PR #26 and #27). PR #28. - Scope: Jira SPEC-22 (US-14). The team's rule replaces ADR-053's, which was not a team decision (ADR-057). Acceptance criteria: the default policy requires a linked issue, a merged pull request, passing checks on its final commit, and a recorded release; a @@ -27,19 +28,24 @@ web `lib/projects.ts`, `lib/requirements.ts`, `components/requirement-rows.tsx`, new `components/evidence-status.tsx`, the requirement page, `app-shell.css`; tests `tests/schema/milestones.spec.ts` (the rule test rewritten: 22 cases, list and single - requirement), `tests/e2e/milestones.spec.ts`, `tests/api/project-data.spec.ts`; docs + requirement, and the create, edit, archive, and restore responses), + `tests/e2e/milestones.spec.ts` (row text, Met/Missing, the previous API, 390px), + `tests/api/project-data.spec.ts`; docs DECISIONS (ADR-057, notes on ADR-038 and ADR-053), PROJECT, API, ARCHITECTURE, TESTING. No migration and no new environment variable. -- Checks: see the PR description for the full `npm test` result. `npm run lint`, - `npm run typecheck`, `dotnet format app/api --verify-no-changes --no-restore`, - `npm run build:api` (0 warnings), and `git diff --check` passed. Run locally with .NET SDK +- Checks (on the PR's contents, after merging `main` at `fd89908`): `npm test` 409 passed; + `npm run lint`, `npm run typecheck`, `dotnet format app/api --verify-no-changes --no-restore`, + `npm run build:api` (0 warnings), and `git diff --check` passed. Mutation check: each of 32 + changed lines (17 API, 8 web parsing, 7 UI and layout) was broken one at a time and its + tests run; three went uncaught at first (the edit response, an older API's malformed + value, cut-off text on phones), tests were added, and all 32 now fail a test. Run locally with .NET SDK 10.0.114 (the only one installable in that environment; `global.json` pins 10.0.401 and was pointed at 10.0.100 only while checking, not committed). - Known issues and risks: requirements that were Evidence complete under the old rule (commits only, or no issue or release) stop being so after deploy, and list counts fall. A pull request merged after it was linked counts only once the evidence is refreshed. Not exercised: the real GitHub. -- Next step: review and open the PR to `main`; then move SPEC-22 to Done. +- Next step: review and merge PR #28; then move SPEC-22 to Done. ## Previous task: CI pipeline feedback and deployment availability (2026-10-30)