Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 43 additions & 20 deletions app/api/Requirements/EvidencePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EvidenceChecks> 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<string> Missing(IReadOnlyCollection<EvidenceFacts> 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<List<EvidenceCheck>>(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<string>();
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<List<EvidenceCheck>>(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<Guid, bool> ReadContains(string? stored) =>
stored is null ? [] : JsonSerializer.Deserialize<Dictionary<Guid, bool>>(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);
31 changes: 20 additions & 11 deletions app/api/Requirements/RequirementEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,21 @@ private static async Task<Results<Ok<List<RequirementSummary>>, 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);
}));
}

Expand Down Expand Up @@ -102,7 +103,7 @@ private static async Task<Results<Created<RequirementResponse>, 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<Results<Ok<RequirementResponse>, NotFound>> Get(
Expand All @@ -116,7 +117,7 @@ private static async Task<Results<Ok<RequirementResponse>, 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.
Expand Down Expand Up @@ -174,9 +175,10 @@ private static async Task<Results<Ok<RequirementResponse>, 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<Results<Ok<RequirementResponse>, NotFound, ProblemHttpResult>> Archive(
Expand Down Expand Up @@ -214,7 +216,7 @@ private static async Task<Results<Ok<RequirementResponse>, 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
Expand Down Expand Up @@ -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<AcceptanceCriterion> criteria, List<LinkedDocumentResponse> documents, int? criteriaVersion) => new(
Requirement r, IEnumerable<AcceptanceCriterion> criteria, List<LinkedDocumentResponse> documents, int? criteriaVersion,
List<string> 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<List<string>> 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)
{
Expand All @@ -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<string> 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<CriterionResponse> AcceptanceCriteria,
List<LinkedDocumentResponse> Documents, int? CriteriaVersion, string? Milestone);
List<LinkedDocumentResponse> Documents, int? CriteriaVersion, string? Milestone, bool EvidenceComplete, List<string> EvidenceMissing);

internal sealed record CriterionResponse(Guid Id, string Text, int Position);

Expand Down
12 changes: 12 additions & 0 deletions app/web/src/app/app-shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,7 +26,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {

/**
* 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) {
Expand Down Expand Up @@ -114,6 +116,7 @@ export default async function Page({ params }: Props) {
</section>
<section className="requirement-section" aria-labelledby="evidence-heading">
<h2 id="evidence-heading">Evidence</h2>
<EvidenceStatus complete={requirement.evidenceComplete} missing={requirement.evidenceMissing} />
{repository || evidence.length > 0 ? (
<EvidencePanel projectId={project.id} requirementId={requirement.id} evidence={evidence} history={evidenceHistory}
suggestions={suggestions} moveTargets={moveTargets}
Expand Down
32 changes: 32 additions & 0 deletions app/web/src/components/evidence-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Check, X } from "lucide-react";
import { EVIDENCE_PART_TEXT, EVIDENCE_PARTS, type EvidencePart } from "@/lib/projects";

/**
* Shows whether a requirement's evidence meets the team's Evidence complete rule and which of its
* parts are met or missing (ADR-057). It says nothing about acceptance, which is a person's
* decision in the Review section. Nothing renders when the API sent no status (ADR-044).
*/
export function EvidenceStatus({ complete, missing }: { complete: boolean; missing: EvidencePart[] }) {
if (!complete && missing.length === 0) return null;
return (
<div className="evidence-status">
<p className="evidence-status-heading">
{complete
? <span className="badge evidence-complete">Evidence complete</span>
: <span className="badge">Evidence incomplete</span>}
</p>
<ul className="evidence-parts" aria-label="Evidence complete rule">
{EVIDENCE_PARTS.map(part => {
const met = !missing.includes(part);
return (
<li key={part} className={met ? "is-met" : "is-missing"}>
{met ? <Check size={14} aria-hidden="true" /> : <X size={14} aria-hidden="true" />}
<span>{EVIDENCE_PART_TEXT[part]}</span>
<span className="evidence-part-state">{met ? "Met" : "Missing"}</span>
</li>
);
})}
</ul>
</div>
);
}
10 changes: 8 additions & 2 deletions app/web/src/components/requirement-rows.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -21,6 +22,11 @@ export function RequirementRows({ requirements, label }: { requirements: Require
{requirement.milestone && <span className="milestone-label">{requirement.milestone}</span>}
<span className="row-meta row-count">{evidenceCountText(requirement.evidenceCount)}</span>
{requirement.evidenceComplete && <span className="badge evidence-complete">Evidence complete</span>}
{requirement.evidenceMissing.length > 0 && (
<span className="row-meta evidence-missing">
Missing {requirement.evidenceMissing.map(part => EVIDENCE_PART_SHORT[part]).join(", ")}
</span>
)}
<span className={`badge review-badge${status.tone ? ` is-${status.tone}` : ""}${status.outdated ? " is-outdated" : ""}`}>
{status.tone && <span aria-hidden="true" />}{status.text}
</span>
Expand Down
Loading
Loading