From 4a61d88ea5dafc105008b7d1317bc076ae1c23d9 Mon Sep 17 00:00:00 2001 From: sormond319 Date: Thu, 5 Nov 2026 20:28:09 -0500 Subject: [PATCH 1/2] feat: suggest links from closing references and move links between requirements - Pull requests whose description closes a linked issue are suggested, from the issue's GitHub timeline, on Find suggestions and on Refresh. They live in requirement_evidence_suggestions and are not evidence until a person confirms them. - Confirming or rejecting is recorded with who and when; a decided pull request is not suggested again. - POST /requirements/{id}/evidence/{evidenceId}/move moves any link to another active requirement in one transaction. Both link histories show it, and release comparisons follow the link. - Migration RequirementEvidenceSuggestions; ADR-056; API, DATABASE, TESTING, PROJECT, ARCHITECTURE, and HANDOFF updated. Co-Authored-By: Claude Opus 5.5 --- app/api/Data/ProductModels.cs | 31 +- app/api/Data/SpecThreadDbContext.cs | 35 +- app/api/GitHub/EvidenceChangeEndpoints.cs | 368 ++++ app/api/GitHub/EvidenceEndpoints.cs | 76 +- app/api/GitHub/GitHubClient.cs | 55 + ...RequirementEvidenceSuggestions.Designer.cs | 1644 +++++++++++++++++ ...06010723_RequirementEvidenceSuggestions.cs | 169 ++ .../SpecThreadDbContextModelSnapshot.cs | 113 +- app/web/src/app/app-shell.css | 7 + .../requirements/[requirementId]/page.tsx | 7 +- app/web/src/app/projects/evidence-actions.ts | 75 +- app/web/src/components/evidence-panel.tsx | 109 +- app/web/src/lib/evidence.ts | 68 +- app/web/src/lib/project-data.ts | 13 +- docs/API.md | 33 +- docs/ARCHITECTURE.md | 2 +- docs/DATABASE.md | 35 +- docs/DECISIONS.md | 55 +- docs/HANDOFF.md | 50 +- docs/PROJECT.md | 22 + docs/TESTING.md | 16 +- ...uirement-evidence-suggestions-rollback.sql | 24 + .../requirement-evidence-suggestions.sql | 57 + ...erate-requirement-evidence-suggestions.mjs | 51 + scripts/test-database.mjs | 1 + scripts/test-github.mjs | 20 + tests/api/project-data.spec.ts | 28 +- tests/e2e/evidence.spec.ts | 119 ++ tests/schema/evidence-suggestions.spec.ts | 365 ++++ tests/schema/evidence.spec.ts | 8 +- tests/schema/github-repository.spec.ts | 2 +- tests/schema/reviews.spec.ts | 2 +- tests/support/github.ts | 2 + 33 files changed, 3596 insertions(+), 66 deletions(-) create mode 100644 app/api/GitHub/EvidenceChangeEndpoints.cs create mode 100644 app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.Designer.cs create mode 100644 app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.cs create mode 100644 docs/schema/requirement-evidence-suggestions-rollback.sql create mode 100644 docs/schema/requirement-evidence-suggestions.sql create mode 100644 scripts/generate-requirement-evidence-suggestions.mjs create mode 100644 tests/schema/evidence-suggestions.spec.ts diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index f2f6bb8..384505b 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -137,9 +137,11 @@ public sealed class RequirementFollowUpEvent public DateTime OccurredAt { get; set; } } -// An evidence link added to or removed from a requirement, with who did it and when (ADR-054). -// Kind, Label, and Title are copied from the link, which a removal deletes. EvidenceId is the -// link's ID, kept without a foreign key for the same reason. Rows are only ever added. +// A change to a requirement's evidence, with who made it and when (ADR-054, ADR-056). Action is +// "added", "removed", "confirmed" or "rejected" (a suggestion), or "moved_in" or "moved_out", +// where OtherRequirementId is the requirement on the other side of the move. Kind, Label, and +// Title are copied from the link, which a removal deletes. EvidenceId is the link's ID, or a +// rejected suggestion's, kept without a foreign key for the same reason. Rows are only ever added. public sealed class RequirementEvidenceEvent { public Guid Id { get; set; } @@ -151,6 +153,29 @@ public sealed class RequirementEvidenceEvent public required string Title { get; set; } public required string Actor { get; set; } public DateTime OccurredAt { get; set; } + public Guid? OtherRequirementId { get; set; } +} + +// A pull request whose text closes an issue linked to the requirement (ADR-056). It is not +// evidence: nothing reads this table when counting, reviewing, or judging evidence, and it enters +// requirement_evidence only when a person confirms it (ADR-036). Status is "pending", +// "confirmed", or "rejected"; a decided row stays, so the same pull request is not suggested +// again. Reference is the keyword and issue found, such as "Closes #184", never the text around it. +public sealed class RequirementEvidenceSuggestion +{ + public Guid Id { get; set; } + public Guid RequirementId { get; set; } + public long RepositoryId { get; set; } + public int Number { get; set; } + public required string Title { get; set; } + public required string State { get; set; } + public string? Author { get; set; } + public required string Url { get; set; } + public required string Reference { get; set; } + public string Status { get; set; } = "pending"; + public DateTime FoundAt { get; set; } + public string? DecidedBy { get; set; } + public DateTime? DecidedAt { get; set; } } // A person's decision on a requirement (ADR-038). Evidence is a JSON array of the evidence links diff --git a/app/api/Data/SpecThreadDbContext.cs b/app/api/Data/SpecThreadDbContext.cs index 41de101..c31add1 100644 --- a/app/api/Data/SpecThreadDbContext.cs +++ b/app/api/Data/SpecThreadDbContext.cs @@ -14,6 +14,7 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet RequirementEvidence => Set(); public DbSet RequirementReviews => Set(); public DbSet RequirementEvidenceEvents => Set(); + public DbSet RequirementEvidenceSuggestions => Set(); public DbSet Teams => Set(); public DbSet TeamMembers => Set(); public DbSet UserOnboardings => Set(); @@ -266,7 +267,10 @@ protected override void OnModelCreating(ModelBuilder model) var evidenceEvent = model.Entity(); evidenceEvent.ToTable("requirement_evidence_events", table => { - table.HasCheckConstraint("ck_requirement_evidence_events_action", "action IN ('added', 'removed')"); + table.HasCheckConstraint("ck_requirement_evidence_events_action", + "action IN ('added', 'removed', 'confirmed', 'rejected', 'moved_in', 'moved_out')"); + table.HasCheckConstraint("ck_requirement_evidence_events_move", + "(action IN ('moved_in', 'moved_out')) = (other_requirement_id IS NOT NULL)"); table.HasCheckConstraint("ck_requirement_evidence_events_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); table.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); }); @@ -280,10 +284,39 @@ protected override void OnModelCreating(ModelBuilder model) evidenceEvent.Property(x => x.Title).HasColumnName("title"); evidenceEvent.Property(x => x.Actor).HasColumnName("actor"); evidenceEvent.Property(x => x.OccurredAt).HasColumnName("occurred_at").HasDefaultValueSql("now()"); + evidenceEvent.Property(x => x.OtherRequirementId).HasColumnName("other_requirement_id"); evidenceEvent.HasIndex(x => new { x.RequirementId, x.OccurredAt }); evidenceEvent.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + evidenceEvent.HasOne().WithMany().HasForeignKey(x => x.OtherRequirementId).OnDelete(DeleteBehavior.Restrict); evidenceEvent.HasOne().WithMany().HasForeignKey(x => x.Actor).OnDelete(DeleteBehavior.Restrict); + var suggestion = model.Entity(); + suggestion.ToTable("requirement_evidence_suggestions", table => + { + table.HasCheckConstraint("ck_requirement_evidence_suggestions_status", "status IN ('pending', 'confirmed', 'rejected')"); + table.HasCheckConstraint("ck_requirement_evidence_suggestions_state", "state IN ('open', 'closed', 'merged')"); + table.HasCheckConstraint("ck_requirement_evidence_suggestions_number", "number > 0 AND repository_id > 0"); + table.HasCheckConstraint("ck_requirement_evidence_suggestions_decision", + "(status = 'pending') = (decided_by IS NULL) AND (decided_by IS NULL) = (decided_at IS NULL)"); + }); + suggestion.HasKey(x => x.Id); + suggestion.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + suggestion.Property(x => x.RequirementId).HasColumnName("requirement_id"); + suggestion.Property(x => x.RepositoryId).HasColumnName("repository_id"); + suggestion.Property(x => x.Number).HasColumnName("number"); + suggestion.Property(x => x.Title).HasColumnName("title"); + suggestion.Property(x => x.State).HasColumnName("state"); + suggestion.Property(x => x.Author).HasColumnName("author"); + suggestion.Property(x => x.Url).HasColumnName("url"); + suggestion.Property(x => x.Reference).HasColumnName("reference"); + suggestion.Property(x => x.Status).HasColumnName("status").HasDefaultValue("pending"); + suggestion.Property(x => x.FoundAt).HasColumnName("found_at").HasDefaultValueSql("now()"); + suggestion.Property(x => x.DecidedBy).HasColumnName("decided_by"); + suggestion.Property(x => x.DecidedAt).HasColumnName("decided_at"); + suggestion.HasIndex(x => new { x.RequirementId, x.RepositoryId, x.Number }).IsUnique(); + suggestion.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + suggestion.HasOne().WithMany().HasForeignKey(x => x.DecidedBy).OnDelete(DeleteBehavior.Restrict); + var review = model.Entity(); review.ToTable("requirement_reviews", table => { diff --git a/app/api/GitHub/EvidenceChangeEndpoints.cs b/app/api/GitHub/EvidenceChangeEndpoints.cs new file mode 100644 index 0000000..b980ce2 --- /dev/null +++ b/app/api/GitHub/EvidenceChangeEndpoints.cs @@ -0,0 +1,368 @@ +using System.Security.Claims; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; +using SpecThread.Api.Data; +using SpecThread.Api.Projects; +using SpecThread.Api.Requirements; + +namespace SpecThread.Api.GitHub; + +// Suggests pull requests that say they close a linked issue, which a person confirms or rejects, +// and moves a link to another requirement of the project (ADR-056). A suggestion is not evidence +// until it is confirmed (ADR-036). +internal static partial class EvidenceEndpoints +{ + public const int MaxSuggestions = 50; + + private static void MapSuggestions(RouteGroupBuilder evidence) + { + evidence.MapGet("/suggestions", ListSuggestions).WithName("ListEvidenceSuggestions"); + evidence.MapPost("/suggestions/scan", ScanSuggestions).WithName("ScanEvidenceSuggestions") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); + evidence.MapPost("/suggestions/{suggestionId:guid}/confirm", ConfirmSuggestion).WithName("ConfirmEvidenceSuggestion") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); + evidence.MapPost("/suggestions/{suggestionId:guid}/reject", RejectSuggestion).WithName("RejectEvidenceSuggestion") + .ProducesProblem(StatusCodes.Status409Conflict); + } + + // Undecided suggestions from the connected repository, by number. + private static async Task>, NotFound>> ListSuggestions( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var scope = await FindAsync(db, user.CallerId(), requirementId, cancel); + if (scope is null) return TypedResults.NotFound(); + return TypedResults.Ok(await ReadSuggestionsAsync(db, requirementId, scope.ProjectId, cancel)); + } + + private static async Task>, NotFound, ProblemHttpResult>> ScanSuggestions( + Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel) + { + var userId = user.CallerId(); + var scope = await FindAsync(db, userId, requirementId, cancel); + if (scope is null) return TypedResults.NotFound(); + if (scope.Archived) return ProjectEndpoints.Archived(); + var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); + if (repository is null) return NoRepository(); + + var links = await db.RequirementEvidence.AsNoTracking() + .Where(e => e.RequirementId == requirementId && e.RepositoryId == repository.RepositoryId).ToListAsync(cancel); + var mentions = await ReadMentionsAsync(github, repository, links, cancel); + if (mentions.Failure != GitHubFailure.None) return Failed(mentions.Failure, $"{repository.Owner}/{repository.Name}"); + + // GitHub was read without holding the team lock. Take it now and check again what the reads relied on. + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + switch (await LockAndRecheckAsync(db, userId, requirementId, repository, cancel)) + { + case Recheck.NotFound: return TypedResults.NotFound(); + case Recheck.Archived: return ProjectEndpoints.Archived(); + case Recheck.Changed: return Changed(); + } + await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, Clock.UtcNow(), cancel); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + return TypedResults.Ok(await ReadSuggestionsAsync(db, requirementId, scope.ProjectId, cancel)); + } + + // Links the pull request as GitHub reports it now, like any link, marked as a confirmed suggestion. + private static async Task, NotFound, ProblemHttpResult>> ConfirmSuggestion( + Guid requirementId, Guid suggestionId, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel) + { + var userId = user.CallerId(); + var scope = await FindAsync(db, userId, requirementId, cancel); + if (scope is null) return TypedResults.NotFound(); + if (scope.Archived) return ProjectEndpoints.Archived(); + var suggestion = await db.RequirementEvidenceSuggestions.AsNoTracking() + .SingleOrDefaultAsync(s => s.Id == suggestionId && s.RequirementId == requirementId, cancel); + if (suggestion is null) return TypedResults.NotFound(); + if (suggestion.Status != "pending") return AlreadyDecided(); + var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); + if (repository is null) return NoRepository(); + if (repository.RepositoryId != suggestion.RepositoryId) return Changed(); + + // Tracked, because linking a change updates what the linked releases contain. + var existing = await db.RequirementEvidence.Where(e => e.RequirementId == requirementId).ToListAsync(cancel); + var here = existing.FindAll(e => e.RepositoryId == repository.RepositoryId); + if (here.Exists(e => e.Number == suggestion.Number)) return AlreadyLinked($"#{suggestion.Number}"); + if (existing.Count >= MaxEvidence) return LimitReached(); + + var found = await github.GetItemAsync(repository.InstallationId, repository.RepositoryId, suggestion.Number, cancel); + if (found.Failure == GitHubFailure.NotFound) + { + return TypedResults.Problem($"GitHub no longer has #{suggestion.Number} in {repository.Owner}/{repository.Name}.", statusCode: StatusCodes.Status409Conflict); + } + if (found.Failure != GitHubFailure.None) return Failed(found.Failure, $"{repository.Owner}/{repository.Name}"); + + var saved = await SaveLinkAsync(db, github, repository, requirementId, userId, here, found.Value!, "suggested", "confirmed", async (_, now) => + { + var pending = await db.RequirementEvidenceSuggestions.SingleOrDefaultAsync(s => s.Id == suggestionId && s.Status == "pending", cancel); + if (pending is null) return AlreadyDecided(); + (pending.Status, pending.DecidedBy, pending.DecidedAt) = ("confirmed", userId, now); + return null; + }, cancel); + if (saved.Problem is not null) return saved.Problem; + if (saved.Evidence is null) return TypedResults.NotFound(); + return TypedResults.Created($"/requirements/{requirementId}/evidence/{saved.Evidence.Id}", ToResponse(saved.Evidence)); + } + + // The row stays, so the pull request is not suggested for this requirement again. It can still be linked by hand. + private static async Task> RejectSuggestion( + Guid requirementId, Guid suggestionId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) + { + var userId = user.CallerId(); + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var found = await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel); + if (found is null) return TypedResults.NotFound(); + if (found.Archived) return ProjectEndpoints.Archived(); + var suggestion = await db.RequirementEvidenceSuggestions.SingleOrDefaultAsync(s => s.Id == suggestionId && s.RequirementId == requirementId, cancel); + if (suggestion is null) return TypedResults.NotFound(); + if (suggestion.Status != "pending") return AlreadyDecided(); + + var now = Clock.UtcNow(); + (suggestion.Status, suggestion.DecidedBy, suggestion.DecidedAt) = ("rejected", userId, now); + db.RequirementEvidenceEvents.Add(new RequirementEvidenceEvent + { + Id = Guid.NewGuid(), + RequirementId = requirementId, + EvidenceId = suggestion.Id, + Action = "rejected", + Kind = "pull_request", + Label = $"#{suggestion.Number}", + Title = suggestion.Title, + Actor = userId, + OccurredAt = now, + }); + await db.SaveChangesAsync(cancel); + await transaction.CommitAsync(cancel); + return TypedResults.NoContent(); + } + + // Moves a link to another active requirement of the project in one step, so it is never on + // both or neither. The link keeps its ID, so each requirement's decisions become outdated + // (ADR-038); both histories show the move. A pull request is not suggested again for the + // requirement it left, because a person has just said it does not belong there. + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Move( + Guid requirementId, Guid evidenceId, MoveEvidenceRequest request, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel) + { + if (request.TargetRequirementId is not Guid targetId || targetId == Guid.Empty) return InvalidTarget("Choose a requirement."); + if (targetId == requirementId) return InvalidTarget("This link is already on that requirement."); + + var userId = user.CallerId(); + var scope = await FindAsync(db, userId, requirementId, cancel); + if (scope is null) return TypedResults.NotFound(); + if (scope.Archived) return ProjectEndpoints.Archived(); + var evidence = await db.RequirementEvidence.SingleOrDefaultAsync(e => e.Id == evidenceId && e.RequirementId == requirementId, cancel); + if (evidence is null) return TypedResults.NotFound(); + var target = await db.Requirements.AsNoTracking().SingleOrDefaultAsync(r => r.Id == targetId && r.ProjectId == scope.ProjectId, cancel); + if (target is null) return InvalidTarget("Choose a requirement in this project."); + if (target.ArchivedAt is not null) return TypedResults.Problem("A link can't be moved to an archived requirement.", statusCode: StatusCodes.Status409Conflict); + + // Tracked, because the move updates what the target's releases contain. + var others = await db.RequirementEvidence.Where(e => e.RequirementId == targetId).ToListAsync(cancel); + if (others.Exists(e => SameItem(e, evidence))) return AlreadyLinkedThere(evidence); + if (others.Count >= MaxEvidence) return LimitReached(); + + // What a release contains was compared with the links of the requirement it was on. + if (evidence.Kind == "release") evidence.Contains = null; + var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); + if (repository is not null) + { + var contents = evidence.Kind == "release" + ? await ReadContentsAsync(github, repository, [evidence], others, cancel) + : await ReadContentsAsync(github, repository, others.FindAll(e => e.Kind == "release"), [evidence], cancel); + if (contents != GitHubFailure.None) return Failed(contents, $"{repository.Owner}/{repository.Name}"); + } + + // GitHub was read without holding the team lock. Take it now and check again what the reads relied on. + await using var transaction = await db.Database.BeginTransactionAsync(cancel); + var check = repository is not null + ? await LockAndRecheckAsync(db, userId, requirementId, repository, cancel) + : await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel) switch + { + null => Recheck.NotFound, + { Archived: true } => Recheck.Archived, + _ => Recheck.Ready, + }; + switch (check) + { + case Recheck.NotFound: return TypedResults.NotFound(); + case Recheck.Archived: return ProjectEndpoints.Archived(); + case Recheck.Changed: return Changed(); + } + if (!await db.Requirements.AnyAsync(r => r.Id == targetId && r.ProjectId == scope.ProjectId && r.ArchivedAt == null, cancel)) + { + return TypedResults.Problem("A link can't be moved to an archived requirement.", statusCode: StatusCodes.Status409Conflict); + } + if (await db.RequirementEvidence.CountAsync(e => e.RequirementId == targetId, cancel) >= MaxEvidence) return LimitReached(); + + var now = Clock.UtcNow(); + Record(db, evidence, "moved_out", userId, now, requirementId, targetId); + Record(db, evidence, "moved_in", userId, now, targetId, requirementId); + (evidence.RequirementId, evidence.LinkedBy, evidence.LinkedAt, evidence.Source) = (targetId, userId, now, "manual"); + if (evidence.Number is { } number) + { + await DropPendingAsync(db, targetId, evidence.RepositoryId, number, cancel); + if (evidence.Kind == "pull_request") await RefuseSuggestionAsync(db, requirementId, evidence, number, userId, now, cancel); + } + try + { + await db.SaveChangesAsync(cancel); + } + catch (DbUpdateConcurrencyException) + { + // The link was removed while GitHub was being read. + return Changed(); + } + catch (DbUpdateException) + { + // The same item was linked to the target at the same moment: a unique index refused this one. + return AlreadyLinkedThere(evidence); + } + await transaction.CommitAsync(cancel); + return TypedResults.Ok(ToResponse(evidence)); + } + + // Reads, for each linked issue, the pull requests that say they close it. Issues GitHub can't + // show are skipped, like items deleted there on refresh. Reads must not touch the DbContext. + private static async Task>> ReadMentionsAsync( + IGitHubClient github, ProjectRepository repository, List links, CancellationToken cancel) + { + var issues = links.FindAll(e => e.Kind == "issue" && e.Number is not null); + var reads = await ReadConcurrentlyAsync(issues, issue => github.ListMentionsAsync( + repository.InstallationId, repository.RepositoryId, repository.Owner, repository.Name, issue.Number!.Value, cancel), cancel); + var found = new List(); + for (var index = 0; index < issues.Count; index++) + { + if (reads[index].Failure == GitHubFailure.NotFound) continue; + if (reads[index].Failure != GitHubFailure.None) return GitHubResult>.Fail(reads[index].Failure); + foreach (var pull in reads[index].Value!) + { + if (ClosingKeyword(pull.Body, repository, issues[index].Number!.Value) is { } keyword) + { + found.Add(new EvidenceMention(pull, $"{keyword} #{issues[index].Number}")); + } + } + } + return GitHubResult>.Ok(found); + } + + // The keyword with which the text closes the issue, as GitHub reads closing references: "#N", + // "owner/name#N", or the issue's address. Null when it only mentions it. + private static string? ClosingKeyword(string text, ProjectRepository repository, int issue) + { + foreach (Match match in ClosingPattern().Matches(text)) + { + var (owner, name) = (match.Groups["owner"], match.Groups["name"]); + var here = !owner.Success || (string.Equals(owner.Value, repository.Owner, StringComparison.OrdinalIgnoreCase) && + string.Equals(name.Value, repository.Name, StringComparison.OrdinalIgnoreCase)); + if (here && int.TryParse(match.Groups["number"].ValueSpan, out var number) && number == issue) + { + var keyword = match.Groups["keyword"].Value.ToLowerInvariant(); + return char.ToUpperInvariant(keyword[0]) + keyword[1..]; + } + } + return null; + } + + // Brings the undecided suggestions in line with a complete read: new ones are added, ones no + // longer referenced go, and decided ones are left as they are. Call under the team lock. + private static async Task SaveSuggestionsAsync( + SpecThreadDbContext db, Guid requirementId, ProjectRepository repository, List mentions, DateTime now, CancellationToken cancel) + { + var known = await db.RequirementEvidenceSuggestions + .Where(s => s.RequirementId == requirementId && s.RepositoryId == repository.RepositoryId).ToListAsync(cancel); + var linked = (await db.RequirementEvidence.AsNoTracking() + .Where(e => e.RequirementId == requirementId && e.RepositoryId == repository.RepositoryId && e.Number != null) + .Select(e => e.Number!.Value).ToListAsync(cancel)) + .ToHashSet(); + var wanted = mentions.Where(m => !linked.Contains(m.Pull.Number)).DistinctBy(m => m.Pull.Number).ToList(); + db.RequirementEvidenceSuggestions.RemoveRange(known.Where(s => s.Status == "pending" && !wanted.Exists(m => m.Pull.Number == s.Number))); + var pending = known.Count(s => s.Status == "pending" && wanted.Exists(m => m.Pull.Number == s.Number)); + foreach (var (pull, reference) in wanted) + { + var row = known.Find(s => s.Number == pull.Number); + if (row is { Status: "pending" }) (row.Title, row.State, row.Author, row.Url, row.Reference) = (pull.Title, pull.State, pull.Author, pull.Url, reference); + if (row is not null || pending >= MaxSuggestions) continue; + pending++; + db.RequirementEvidenceSuggestions.Add(new RequirementEvidenceSuggestion + { + Id = Guid.NewGuid(), + RequirementId = requirementId, + RepositoryId = repository.RepositoryId, + Number = pull.Number, + Title = pull.Title, + State = pull.State, + Author = pull.Author, + Url = pull.Url, + Reference = reference, + FoundAt = now, + }); + } + } + + private static Task DropPendingAsync(SpecThreadDbContext db, Guid requirementId, long repositoryId, int number, CancellationToken cancel) => + db.RequirementEvidenceSuggestions + .Where(s => s.RequirementId == requirementId && s.RepositoryId == repositoryId && s.Number == number && s.Status == "pending") + .ExecuteDeleteAsync(cancel); + + // Records that a pull request moved away must not be suggested for this requirement again. + private static async Task RefuseSuggestionAsync( + SpecThreadDbContext db, Guid requirementId, RequirementEvidence evidence, int number, string userId, DateTime now, CancellationToken cancel) + { + var row = await db.RequirementEvidenceSuggestions + .SingleOrDefaultAsync(s => s.RequirementId == requirementId && s.RepositoryId == evidence.RepositoryId && s.Number == number, cancel); + if (row is null) + { + row = new RequirementEvidenceSuggestion + { + Id = Guid.NewGuid(), + RequirementId = requirementId, + RepositoryId = evidence.RepositoryId, + Number = number, + Title = evidence.Title, + State = evidence.State!, + Author = evidence.Author, + Url = evidence.Url, + Reference = "", + FoundAt = now, + }; + db.RequirementEvidenceSuggestions.Add(row); + } + (row.Status, row.DecidedBy, row.DecidedAt) = ("rejected", userId, now); + } + + private static async Task> ReadSuggestionsAsync( + SpecThreadDbContext db, Guid requirementId, Guid projectId, CancellationToken cancel) => + await db.RequirementEvidenceSuggestions.AsNoTracking() + .Where(s => s.RequirementId == requirementId && s.Status == "pending" && + db.ProjectRepositories.Any(r => r.ProjectId == projectId && r.RepositoryId == s.RepositoryId)) + .OrderBy(s => s.Number) + .Select(s => new EvidenceSuggestionResponse(s.Id, s.Number, s.Title, s.State, s.Author, s.Url, s.Reference, s.FoundAt)) + .ToListAsync(cancel); + + private static bool SameItem(RequirementEvidence a, RequirementEvidence b) => + a.RepositoryId == b.RepositoryId && a.Kind == b.Kind && a.Number == b.Number && a.Tag == b.Tag && (a.Number is not null || a.Tag is not null || a.Sha == b.Sha); + + private static ProblemHttpResult AlreadyDecided() => + TypedResults.Problem("Someone has already decided on this suggestion. Reload to see it.", statusCode: StatusCodes.Status409Conflict); + + private static ProblemHttpResult AlreadyLinkedThere(RequirementEvidence evidence) => + TypedResults.Problem($"{ReviewEndpoints.Label(evidence)} is already linked to that requirement.", statusCode: StatusCodes.Status409Conflict); + + private static ValidationProblem InvalidTarget(string message) => + TypedResults.ValidationProblem(new Dictionary { ["targetRequirementId"] = [message] }); + + // GitHub's closing keywords before "#N", "owner/name#N", or an issue's address. + [GeneratedRegex(@"\b(?close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b:?\s+(?:https://github\.com/(?[\w.-]+)/(?[\w.-]+)/issues/|(?:(?[\w.-]+)/(?[\w.-]+))?#)(?\d{1,9})\b", RegexOptions.IgnoreCase)] + private static partial Regex ClosingPattern(); +} + +internal sealed record EvidenceMention(GitHubMention Pull, string Reference); + +internal sealed record MoveEvidenceRequest(Guid? TargetRequirementId); + +// Reference is what was found, such as "Closes #184". +internal sealed record EvidenceSuggestionResponse( + Guid Id, int Number, string Title, string State, string? Author, string Url, string Reference, DateTime FoundAt); diff --git a/app/api/GitHub/EvidenceEndpoints.cs b/app/api/GitHub/EvidenceEndpoints.cs index 224b49b..45baa2c 100644 --- a/app/api/GitHub/EvidenceEndpoints.cs +++ b/app/api/GitHub/EvidenceEndpoints.cs @@ -32,6 +32,7 @@ public static void MapEvidenceEndpoints(this IEndpointRouteBuilder app) .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); evidence.MapGet("/history", History).WithName("ListEvidenceHistory"); + MapSuggestions(evidence); evidence.MapPost("/", Link).WithName("LinkEvidence") .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); @@ -40,6 +41,9 @@ public static void MapEvidenceEndpoints(this IEndpointRouteBuilder app) .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); evidence.MapDelete("/{evidenceId:guid}", Unlink).WithName("UnlinkEvidence") .ProducesProblem(StatusCodes.Status409Conflict); + evidence.MapPost("/{evidenceId:guid}/move", Move).WithName("MoveEvidence") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); } private static async Task>, NotFound>> List( @@ -78,14 +82,15 @@ private static async Task, ValidationProblem, new EvidenceSearchItem(item.Number, item.Kind, item.Title, item.State, item.Author, item.Url, linked.Contains(item.Number))))); } - // Every link added to or removed from the requirement, newest first (ADR-054). + // Every change to the requirement's links, newest first (ADR-054, ADR-056). private static async Task>, NotFound>> History( Guid requirementId, ClaimsPrincipal user, SpecThreadDbContext db, CancellationToken cancel) { if (await FindAsync(db, user.CallerId(), requirementId, cancel) is null) return TypedResults.NotFound(); var events = await db.RequirementEvidenceEvents.AsNoTracking().Where(e => e.RequirementId == requirementId) .OrderByDescending(e => e.OccurredAt).ThenByDescending(e => e.Id) - .Select(e => new EvidenceEventResponse(e.Id, e.EvidenceId, e.Action, e.Kind, e.Label, e.Title, e.Actor, e.OccurredAt)) + .Select(e => new EvidenceEventResponse(e.Id, e.EvidenceId, e.Action, e.Kind, e.Label, e.Title, e.Actor, e.OccurredAt, e.OtherRequirementId, + db.Requirements.Where(r => r.Id == e.OtherRequirementId).Select(r => r.Title).FirstOrDefault())) .ToListAsync(cancel); return TypedResults.Ok(events); } @@ -143,47 +148,67 @@ private static async Task, ValidationProblem, if (found.Value!.Kind == "commit" && here.Exists(e => e.Kind == "commit" && e.Sha == found.Value.Sha)) return AlreadyLinked(Label(found.Value)); if (found.Value.Kind == "release" && here.Exists(e => e.Kind == "release" && e.Tag == found.Value.Tag)) return AlreadyLinked(Label(found.Value)); - var checks = await ReadChecksAsync(github, repository, found.Value.Kind, found.Value.Sha, cancel); - if (checks.Failure is not (GitHubFailure.None or GitHubFailure.NotFound)) return Failed(checks.Failure, fullName); + var saved = await SaveLinkAsync(db, github, repository, requirementId, userId, here, found.Value, "manual", "added", async (_, _) => + { + // Linked by hand: there is nothing left to suggest. + if (found.Value.Number is { } linked) await DropPendingAsync(db, requirementId, repository.RepositoryId, linked, cancel); + return null; + }, cancel); + if (saved.Problem is not null) return saved.Problem; + if (saved.Evidence is null) return TypedResults.NotFound(); + return TypedResults.Created($"/requirements/{requirementId}/evidence/{saved.Evidence.Id}", ToResponse(saved.Evidence)); + } + + // Reads what the link needs from GitHub, then saves it with its history under the team lock. + // `underLock` runs there first and may refuse. Evidence and Problem are both null when the + // requirement is gone. + private static async Task SaveLinkAsync( + SpecThreadDbContext db, IGitHubClient github, ProjectRepository repository, Guid requirementId, string userId, List here, + GitHubItem found, string source, string action, Func> underLock, CancellationToken cancel) + { + var fullName = $"{repository.Owner}/{repository.Name}"; + var checks = await ReadChecksAsync(github, repository, found.Kind, found.Sha, cancel); + if (checks.Failure is not (GitHubFailure.None or GitHubFailure.NotFound)) return new(null, Failed(checks.Failure, fullName)); var now = Clock.UtcNow(); var evidence = new RequirementEvidence { Id = Guid.NewGuid(), RequirementId = requirementId, - Kind = found.Value.Kind, + Kind = found.Kind, RepositoryId = repository.RepositoryId, RepositoryOwner = repository.Owner, RepositoryName = repository.Name, - Number = found.Value.Number, + Number = found.Number, Title = "", Url = "", LinkedBy = userId, LinkedAt = now, - Source = "manual", + Source = source, }; - Apply(evidence, found.Value, now); + Apply(evidence, found, now); ApplyChecks(evidence, checks, now); // A new release is compared with every linked change; a new change with every linked release. var contents = evidence.Kind == "release" ? await ReadContentsAsync(github, repository, [evidence], here, cancel) : await ReadContentsAsync(github, repository, here.FindAll(e => e.Kind == "release"), [evidence], cancel); - if (contents != GitHubFailure.None) return Failed(contents, fullName); + if (contents != GitHubFailure.None) return new(null, Failed(contents, fullName)); // GitHub was read without holding the team lock. Take it now and check again what the reads relied on. await using var transaction = await db.Database.BeginTransactionAsync(cancel); switch (await LockAndRecheckAsync(db, userId, requirementId, repository, cancel)) { - case Recheck.NotFound: return TypedResults.NotFound(); - case Recheck.Archived: return ProjectEndpoints.Archived(); - case Recheck.Changed: return Changed(); + case Recheck.NotFound: return new(null, null); + case Recheck.Archived: return new(null, ProjectEndpoints.Archived()); + case Recheck.Changed: return new(null, Changed()); } // Counted again under the lock, so links added at the same time cannot pass the limit together. - if (await db.RequirementEvidence.CountAsync(e => e.RequirementId == requirementId, cancel) >= MaxEvidence) return LimitReached(); + if (await db.RequirementEvidence.CountAsync(e => e.RequirementId == requirementId, cancel) >= MaxEvidence) return new(null, LimitReached()); + if (await underLock(evidence, now) is { } refused) return new(null, refused); db.RequirementEvidence.Add(evidence); - Record(db, evidence, "added", userId, now); + Record(db, evidence, action, userId, now); try { await db.SaveChangesAsync(cancel); @@ -191,15 +216,15 @@ private static async Task, ValidationProblem, catch (DbUpdateConcurrencyException) { // A linked release this link updates was removed meanwhile. - return Changed(); + return new(null, Changed()); } catch (DbUpdateException) { // Two links of the same item at once: a unique index refused this one. - return AlreadyLinked(Label(found.Value)); + return new(null, AlreadyLinked(Label(found))); } await transaction.CommitAsync(cancel); - return TypedResults.Created($"/requirements/{requirementId}/evidence/{evidence.Id}", ToResponse(evidence)); + return new(evidence, null); } // Reads every linked issue, pull request, and release of the connected repository again, the @@ -249,6 +274,9 @@ private static async Task>, NotFound, ProblemH (release, change) => before[release.Id].Sha == release.Sha && before[change.Id].Change == ChangeSha(change) && before[release.Id].Contains.GetValueOrDefault(change.Id)); if (contents != GitHubFailure.None) return Failed(contents, fullName); + // Refreshing also looks for pull requests that close the linked issues (ADR-056). + var mentions = await ReadMentionsAsync(github, repository, items, cancel); + if (mentions.Failure != GitHubFailure.None) return Failed(mentions.Failure, fullName); // GitHub was read without holding the team lock, which can take a while. Take it now and // check again what the reads relied on. @@ -259,6 +287,7 @@ private static async Task>, NotFound, ProblemH case Recheck.Archived: return ProjectEndpoints.Archived(); case Recheck.Changed: return Changed(); } + await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, now, cancel); try { await db.SaveChangesAsync(cancel); @@ -291,11 +320,12 @@ private static async Task> Unlin return TypedResults.NoContent(); } - private static void Record(SpecThreadDbContext db, RequirementEvidence evidence, string action, string userId, DateTime at) => + private static void Record( + SpecThreadDbContext db, RequirementEvidence evidence, string action, string userId, DateTime at, Guid? requirementId = null, Guid? otherRequirementId = null) => db.RequirementEvidenceEvents.Add(new RequirementEvidenceEvent { Id = Guid.NewGuid(), - RequirementId = evidence.RequirementId, + RequirementId = requirementId ?? evidence.RequirementId, EvidenceId = evidence.Id, Action = action, Kind = evidence.Kind, @@ -303,6 +333,7 @@ private static void Record(SpecThreadDbContext db, RequirementEvidence evidence, Title = evidence.Title, Actor = userId, OccurredAt = at, + OtherRequirementId = otherRequirementId, }); // Null when the requirement does not exist or the caller is not a member of its project. @@ -556,6 +587,8 @@ private static ProblemHttpResult Failed(GitHubFailure failure, string fullName) e.Source, e.LinkedBy, e.LinkedAt, e.RefreshedAt); private sealed record Scope(Guid ProjectId, bool Archived); + + private sealed record SavedLink(RequirementEvidence? Evidence, ProblemHttpResult? Problem); } internal sealed record EvidenceReference(int? Number, string? Sha, string? Tag, string? Repository); @@ -566,9 +599,10 @@ internal sealed record EvidenceSearchItem(int Number, string Kind, string Title, internal sealed record EvidenceSearchResponse(List Items); -// Action is "added" or "removed"; Kind, Label, and Title are the link's as it was then. +// Kind, Label, and Title are the link's as it was then. A move names the requirement on its other side. internal sealed record EvidenceEventResponse( - Guid Id, Guid EvidenceId, string Action, string Kind, string Label, string Title, string Actor, DateTime OccurredAt); + Guid Id, Guid EvidenceId, string Action, string Kind, string Label, string Title, string Actor, DateTime OccurredAt, + Guid? OtherRequirementId, string? OtherRequirementTitle); internal sealed record EvidenceCommit(string Sha, string Message, string? Author, DateTime Date, string Url); diff --git a/app/api/GitHub/GitHubClient.cs b/app/api/GitHub/GitHubClient.cs index 1e08375..65bf1be 100644 --- a/app/api/GitHub/GitHubClient.cs +++ b/app/api/GitHub/GitHubClient.cs @@ -40,6 +40,10 @@ internal sealed record GitHubItem( // An issue or pull request found by searching titles. State is "open", "closed", or "merged". internal sealed record GitHubSearchItem(int Number, string Kind, string Title, string State, string? Author, string Url); +// A pull request of the repository that mentions an issue. Body is its description, for finding +// the reference in; it is read for this request only and is never stored or logged. +internal sealed record GitHubMention(int Number, string Title, string State, string? Author, string Url, string Body); + // One automated check on a commit. Result is "passed", "failed", "running", "skipped", // "cancelled", or "neutral". Kind is "check" (a check run) or "status" (an older commit status). internal sealed record GitHubCheck(string Name, string Result, string? Url, DateTime? CompletedAt, string Kind); @@ -106,6 +110,11 @@ internal interface IGitHubClient // of best match, at most MaxSearchResults of them (ADR-054). Task>> SearchItemsAsync( long installationId, string owner, string name, IReadOnlyList words, CancellationToken cancel); + + // Pull requests of owner/name that mention the issue, from its timeline (ADR-056). NotFound + // when the issue can't be read. + Task>> ListMentionsAsync( + long installationId, long repositoryId, string owner, string name, int issueNumber, CancellationToken cancel); } internal sealed class GitHubClient(HttpClient http, IConfiguration configuration, ILogger logger) : IGitHubClient @@ -549,6 +558,52 @@ public async Task>> SearchItemsAsync( } } + public async Task>> ListMentionsAsync( + long installationId, long repositoryId, string owner, string name, int issueNumber, CancellationToken cancel) + { + var token = await CreateInstallationTokenAsync(installationId, cancel); + if (token.Failure != GitHubFailure.None) return GitHubResult>.Fail(token.Failure); + + var expected = $"/repos/{owner}/{name}"; + var found = new Dictionary(); + for (var page = 1; page <= MaxListPages; page++) + { + var (failure, body) = await SendAsync( + HttpMethod.Get, $"repositories/{repositoryId}/issues/{issueNumber}/timeline?per_page={PageSize}&page={page}", token.Value!, cancel); + if (failure != GitHubFailure.None) return GitHubResult>.Fail(AsInstallation(failure)); + using (body) + { + var root = body!.RootElement; + if (root.ValueKind != JsonValueKind.Array) return GitHubResult>.Fail(GitHubFailure.Unavailable); + foreach (var entry in root.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object || !entry.TryGetProperty("event", out var kind) || kind.GetStringOrNull() != "cross-referenced" || + !entry.TryGetProperty("source", out var source) || source.ValueKind != JsonValueKind.Object || + !source.TryGetProperty("issue", out var item) || item.ValueKind != JsonValueKind.Object || + !item.TryGetProperty("pull_request", out var marker) || marker.ValueKind != JsonValueKind.Object) + { + continue; + } + if (!item.TryGetProperty("number", out var number) || number.ValueKind != JsonValueKind.Number || !number.TryGetInt32(out var read) || read < 1 || + !item.TryGetProperty("title", out var title) || title.ValueKind != JsonValueKind.String || + !item.TryGetProperty("state", out var state) || state.GetStringOrNull() is not ("open" or "closed") || + !item.TryGetProperty("html_url", out var url) || !IsGitHubLink(url.GetStringOrNull()) || + !item.TryGetProperty("repository_url", out var repository) || repository.GetStringOrNull() is not { } repositoryUrl) + { + return GitHubResult>.Fail(GitHubFailure.Unavailable); + } + // Pull requests of other repositories can mention the issue too; only this one's count. + if (!repositoryUrl.EndsWith(expected, StringComparison.OrdinalIgnoreCase)) continue; + var merged = marker.TryGetProperty("merged_at", out var mergedAt) && mergedAt.ValueKind == JsonValueKind.String; + var text = item.TryGetProperty("body", out var description) ? description.GetStringOrNull() ?? "" : ""; + found[read] = new GitHubMention(read, title.GetString()!, merged ? "merged" : state.GetString()!, ReadLogin(item, "user"), url.GetString()!, text); + } + if (root.GetArrayLength() < PageSize) break; + } + } + return GitHubResult>.Ok([.. found.Values]); + } + // An installation token GitHub just issued and then rejects is GitHub's problem, not the user's. private static GitHubFailure AsInstallation(GitHubFailure failure) => failure == GitHubFailure.TokenRejected ? GitHubFailure.Unavailable : failure; diff --git a/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.Designer.cs b/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.Designer.cs new file mode 100644 index 0000000..5f4ea3c --- /dev/null +++ b/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.Designer.cs @@ -0,0 +1,1644 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpecThread.Api.Data; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + [DbContext(typeof(SpecThreadDbContext))] + [Migration("20261106010723_RequirementEvidenceSuggestions")] + partial class RequirementEvidenceSuggestions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("RequirementId", "Position") + .IsUnique(); + + b.ToTable("acceptance_criteria", "public", t => + { + t.HasCheckConstraint("ck_criteria_position", "position >= 0"); + + t.HasCheckConstraint("ck_criteria_text", "length(btrim(text)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasColumnType("text") + .HasColumnName("accessToken"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accessTokenExpiresAt"); + + b.Property("AccountId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("accountId"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("IdToken") + .HasColumnType("text") + .HasColumnName("idToken"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("providerId"); + + b.Property("RefreshToken") + .HasColumnType("text") + .HasColumnName("refreshToken"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshTokenExpiresAt"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ProviderId", "AccountId") + .IsUnique(); + + b.ToTable("account", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthRateLimit", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .HasColumnType("integer") + .HasColumnName("count"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("LastRequest") + .HasColumnType("bigint") + .HasColumnName("lastRequest"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("LastRequest"); + + b.ToTable("rateLimit", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("IpAddress") + .HasColumnType("text") + .HasColumnName("ipAddress"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("UserAgent") + .HasColumnType("text") + .HasColumnName("userAgent"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("session", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSigningKey", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Alg") + .HasColumnType("text") + .HasColumnName("alg"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Crv") + .HasColumnType("text") + .HasColumnName("crv"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("privateKey"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("publicKey"); + + b.HasKey("Id"); + + b.ToTable("jwks", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("emailVerified"); + + b.Property("Image") + .HasColumnType("text") + .HasColumnName("image"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("user", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthVerification", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("createdAt"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiresAt"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text") + .HasColumnName("identifier"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updatedAt"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id"); + + b.HasIndex("Identifier"); + + b.ToTable("verification", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_type"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("file_name"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UploadedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at") + .HasDefaultValueSql("now()"); + + b.Property("UploadedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("uploaded_by"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UploadedBy"); + + b.ToTable("documents", "public", t => + { + t.HasCheckConstraint("ck_documents_content_type", "content_type IN ('application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')"); + + t.HasCheckConstraint("ck_documents_file_name", "length(btrim(file_name)) > 0"); + + t.HasCheckConstraint("ck_documents_size", "size_bytes BETWEEN 1 AND 4194304"); + + t.HasCheckConstraint("ck_documents_title", "length(btrim(title)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("content"); + + b.HasKey("DocumentId"); + + b.ToTable("document_contents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("TeamId"); + + b.ToTable("projects", "public", t => + { + t.HasCheckConstraint("ck_projects_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.HasKey("ProjectId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("project_members", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("ConnectedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("connected_at") + .HasDefaultValueSql("now()"); + + b.Property("ConnectedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("connected_by"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("IsPrivate") + .HasColumnType("boolean") + .HasColumnName("is_private"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("ProjectId"); + + b.HasIndex("ConnectedBy"); + + b.ToTable("project_repositories", "public", t => + { + t.HasCheckConstraint("ck_project_repositories_ids", "installation_id > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_project_repositories_name", "length(btrim(owner)) > 0 AND length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Milestone") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("milestone"); + + b.Property("ProjectId") + .HasColumnType("uuid") + .HasColumnName("project_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("version"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("requirements", "public", t => + { + t.HasCheckConstraint("ck_requirements_milestone", "milestone IS NULL OR (btrim(milestone) = milestone AND length(milestone) BETWEEN 1 AND 100)"); + + t.HasCheckConstraint("ck_requirements_title", "length(btrim(title)) > 0"); + + t.HasCheckConstraint("ck_requirements_version", "version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Criteria") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("criteria"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("RequirementId", "Number") + .IsUnique(); + + b.ToTable("requirement_criteria_versions", "public", t => + { + t.HasCheckConstraint("ck_requirement_criteria_versions_criteria", "jsonb_typeof(criteria) = 'array' AND NOT jsonb_path_exists(criteria, 'strict $[*] ? (@.type() != \"string\")')"); + + t.HasCheckConstraint("ck_requirement_criteria_versions_number", "number > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("DocumentId") + .HasColumnType("uuid") + .HasColumnName("document_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "DocumentId"); + + b.HasIndex("DocumentId"); + + b.HasIndex("LinkedBy"); + + b.ToTable("requirement_documents", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Additions") + .HasColumnType("integer") + .HasColumnName("additions"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("ChangedFiles") + .HasColumnType("integer") + .HasColumnName("changed_files"); + + b.Property("CheckCount") + .HasColumnType("integer") + .HasColumnName("check_count"); + + b.Property("Checks") + .HasColumnType("jsonb") + .HasColumnName("checks"); + + b.Property("ChecksReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checks_read_at"); + + b.Property("CommitCount") + .HasColumnType("integer") + .HasColumnName("commit_count"); + + b.Property("Commits") + .HasColumnType("jsonb") + .HasColumnName("commits"); + + b.Property("Contains") + .HasColumnType("jsonb") + .HasColumnName("contains"); + + b.Property("Deletions") + .HasColumnType("integer") + .HasColumnName("deletions"); + + b.Property("GitHubClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_closed_at"); + + b.Property("GitHubCreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_created_at"); + + b.Property("GitHubUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("github_updated_at"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.Property("MergeSha") + .HasColumnType("text") + .HasColumnName("merge_sha"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Prerelease") + .HasColumnType("boolean") + .HasColumnName("prerelease"); + + b.Property("RefreshedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RepositoryName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_name"); + + b.Property("RepositoryOwner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_owner"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Sha") + .HasColumnType("text") + .HasColumnName("sha"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("manual") + .HasColumnName("source"); + + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Tag") + .HasColumnType("text") + .HasColumnName("tag"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.HasIndex("RequirementId", "RepositoryId", "Sha") + .IsUnique() + .HasFilter("kind = 'commit'"); + + b.HasIndex("RequirementId", "RepositoryId", "Tag") + .IsUnique() + .HasFilter("kind = 'release'"); + + b.ToTable("requirement_evidence", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_changes", "COALESCE(additions, 0) >= 0 AND COALESCE(deletions, 0) >= 0 AND COALESCE(changed_files, 0) >= 0 AND COALESCE(commit_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_checks", "COALESCE(check_count, 0) >= 0"); + + t.HasCheckConstraint("ck_requirement_evidence_identity", "(kind = 'commit' AND sha IS NOT NULL AND sha ~ '^[0-9a-f]{40}$' AND number IS NULL AND state IS NULL AND tag IS NULL) OR (kind IN ('issue', 'pull_request') AND number IS NOT NULL AND number > 0 AND state IS NOT NULL AND tag IS NULL) OR (kind = 'release' AND tag IS NOT NULL AND length(btrim(tag)) > 0 AND number IS NULL AND state IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_repository", "repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_source", "source IN ('manual', 'suggested')"); + + t.HasCheckConstraint("ck_requirement_evidence_state", "state IS NULL OR state IN ('open', 'closed', 'merged')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("EvidenceId") + .HasColumnType("uuid") + .HasColumnName("evidence_id"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text") + .HasColumnName("label"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("now()"); + + b.Property("OtherRequirementId") + .HasColumnType("uuid") + .HasColumnName("other_requirement_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + b.HasIndex("OtherRequirementId"); + + b.HasIndex("RequirementId", "OccurredAt"); + + b.ToTable("requirement_evidence_events", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_events_action", "action IN ('added', 'removed', 'confirmed', 'rejected', 'moved_in', 'moved_out')"); + + t.HasCheckConstraint("ck_requirement_evidence_events_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_events_move", "(action IN ('moved_in', 'moved_out')) = (other_requirement_id IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecidedBy") + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("FoundAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("found_at") + .HasDefaultValueSql("now()"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.ToTable("requirement_evidence_suggestions", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_suggestions_decision", "(status = 'pending') = (decided_by IS NULL) AND (decided_by IS NULL) = (decided_at IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_number", "number > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_state", "state IN ('open', 'closed', 'merged')"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_status", "status IN ('pending', 'confirmed', 'rejected')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("LinkedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("linked_at") + .HasDefaultValueSql("now()"); + + b.Property("LinkedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("linked_by"); + + b.HasKey("RequirementId", "OriginalRequirementId"); + + b.HasIndex("LinkedBy"); + + b.HasIndex("OriginalRequirementId"); + + b.ToTable("requirement_follow_ups", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_ups_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action"); + + b.Property("Actor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actor"); + + b.Property("OccurredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at") + .HasDefaultValueSql("now()"); + + b.Property("OriginalRequirementId") + .HasColumnType("uuid") + .HasColumnName("original_requirement_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + b.HasIndex("OriginalRequirementId"); + + b.HasIndex("RequirementId"); + + b.ToTable("requirement_follow_up_events", "public", t => + { + t.HasCheckConstraint("ck_requirement_follow_up_events_action", "action IN ('added', 'removed')"); + + t.HasCheckConstraint("ck_requirement_follow_up_events_distinct", "requirement_id <> original_requirement_id"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CriteriaVersion") + .HasColumnType("integer") + .HasColumnName("criteria_version"); + + b.Property("DecidedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at") + .HasDefaultValueSql("now()"); + + b.Property("DecidedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("text") + .HasColumnName("decision"); + + b.Property("Evidence") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("evidence"); + + b.Property("Note") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("note"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("RequirementVersion") + .HasColumnType("integer") + .HasColumnName("requirement_version"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "DecidedAt"); + + b.ToTable("requirement_reviews", "public", t => + { + t.HasCheckConstraint("ck_requirement_reviews_criteria_version", "criteria_version IS NULL OR criteria_version > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_decision", "decision IN ('accepted', 'rejected', 'more_evidence')"); + + t.HasCheckConstraint("ck_requirement_reviews_evidence", "jsonb_typeof(evidence) = 'array'"); + + t.HasCheckConstraint("ck_requirement_reviews_note", "decision = 'accepted' OR length(btrim(note)) > 0"); + + t.HasCheckConstraint("ck_requirement_reviews_version", "requirement_version > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner_user_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("teams", "public", t => + { + t.HasCheckConstraint("ck_teams_name", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("accepted_at"); + + b.Property("AcceptedBy") + .HasColumnType("text") + .HasColumnName("accepted_by"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)") + .HasColumnName("email"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("InvitedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("invited_by"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedBy"); + + b.HasIndex("InvitedBy"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("TeamId", "Email") + .IsUnique() + .HasFilter("accepted_at IS NULL AND revoked_at IS NULL"); + + b.ToTable("team_invitations", "public", t => + { + t.HasCheckConstraint("ck_team_invitations_expiry", "expires_at > issued_at"); + + t.HasCheckConstraint("ck_team_invitations_resolution", "accepted_at IS NULL OR revoked_at IS NULL"); + + t.HasCheckConstraint("ck_team_invitations_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("IsExpanded") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_expanded"); + + b.Property("IsFavorite") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_favorite"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("member") + .HasColumnName("role"); + + b.HasKey("TeamId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("team_members", "public", t => + { + t.HasCheckConstraint("ck_team_members_role", "role IN ('admin', 'member')"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.HasKey("UserId"); + + b.ToTable("user_onboarding", "public"); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AcceptanceCriterion", b => + { + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthAccount", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.AuthSession", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Document", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UploadedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.DocumentContent", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.DocumentContent", "DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Project", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectMember", b => + { + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.ProjectRepository", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("ConnectedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithOne() + .HasForeignKey("SpecThread.Api.Data.ProjectRepository", "ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Requirement", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementCriteriaVersion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementDocument", b => + { + b.HasOne("SpecThread.Api.Data.Document", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidence", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceEvent", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("Actor") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OtherRequirementId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceSuggestion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("LinkedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUpEvent", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("Actor") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OriginalRequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementReview", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.Team", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamInvitation", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("AcceptedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("InvitedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.TeamMember", b => + { + b.HasOne("SpecThread.Api.Data.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.UserOnboarding", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.cs b/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.cs new file mode 100644 index 0000000..342ad43 --- /dev/null +++ b/app/api/Migrations/20261106010723_RequirementEvidenceSuggestions.cs @@ -0,0 +1,169 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementEvidenceSuggestions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_evidence_events_action", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.AddColumn( + name: "other_requirement_id", + schema: "public", + table: "requirement_evidence_events", + type: "uuid", + nullable: true); + + migrationBuilder.CreateTable( + name: "requirement_evidence_suggestions", + schema: "public", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + requirement_id = table.Column(type: "uuid", nullable: false), + repository_id = table.Column(type: "bigint", nullable: false), + number = table.Column(type: "integer", nullable: false), + title = table.Column(type: "text", nullable: false), + state = table.Column(type: "text", nullable: false), + author = table.Column(type: "text", nullable: true), + url = table.Column(type: "text", nullable: false), + reference = table.Column(type: "text", nullable: false), + status = table.Column(type: "text", nullable: false, defaultValue: "pending"), + found_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + decided_by = table.Column(type: "text", nullable: true), + decided_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_requirement_evidence_suggestions", x => x.id); + table.CheckConstraint("ck_requirement_evidence_suggestions_decision", "(status = 'pending') = (decided_by IS NULL) AND (decided_by IS NULL) = (decided_at IS NULL)"); + table.CheckConstraint("ck_requirement_evidence_suggestions_number", "number > 0 AND repository_id > 0"); + table.CheckConstraint("ck_requirement_evidence_suggestions_state", "state IN ('open', 'closed', 'merged')"); + table.CheckConstraint("ck_requirement_evidence_suggestions_status", "status IN ('pending', 'confirmed', 'rejected')"); + table.ForeignKey( + name: "FK_requirement_evidence_suggestions_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_evidence_suggestions_user_decided_by", + column: x => x.decided_by, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_requirement_evidence_events_other_requirement_id", + schema: "public", + table: "requirement_evidence_events", + column: "other_requirement_id"); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_evidence_events_action", + schema: "public", + table: "requirement_evidence_events", + sql: "action IN ('added', 'removed', 'confirmed', 'rejected', 'moved_in', 'moved_out')"); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_evidence_events_move", + schema: "public", + table: "requirement_evidence_events", + sql: "(action IN ('moved_in', 'moved_out')) = (other_requirement_id IS NOT NULL)"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_evidence_suggestions_decided_by", + schema: "public", + table: "requirement_evidence_suggestions", + column: "decided_by"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_evidence_suggestions_requirement_id_repository_~", + schema: "public", + table: "requirement_evidence_suggestions", + columns: new[] { "requirement_id", "repository_id", "number" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_requirement_evidence_events_requirements_other_requirement_~", + schema: "public", + table: "requirement_evidence_events", + column: "other_requirement_id", + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_evidence_suggestions" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM authenticated; + END IF; + END $security$; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_requirement_evidence_events_requirements_other_requirement_~", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.DropTable( + name: "requirement_evidence_suggestions", + schema: "public"); + + migrationBuilder.DropIndex( + name: "IX_requirement_evidence_events_other_requirement_id", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_evidence_events_action", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.DropCheckConstraint( + name: "ck_requirement_evidence_events_move", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.DropColumn( + name: "other_requirement_id", + schema: "public", + table: "requirement_evidence_events"); + + migrationBuilder.Sql(""" + DELETE FROM public.requirement_evidence_events WHERE action = 'rejected'; + UPDATE public.requirement_evidence_events SET action = 'added' WHERE action IN ('confirmed', 'moved_in'); + UPDATE public.requirement_evidence_events SET action = 'removed' WHERE action = 'moved_out'; + """); + + migrationBuilder.AddCheckConstraint( + name: "ck_requirement_evidence_events_action", + schema: "public", + table: "requirement_evidence_events", + sql: "action IN ('added', 'removed')"); + } + } +} diff --git a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 9e06769..59356e3 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -877,6 +877,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("occurred_at") .HasDefaultValueSql("now()"); + b.Property("OtherRequirementId") + .HasColumnType("uuid") + .HasColumnName("other_requirement_id"); + b.Property("RequirementId") .HasColumnType("uuid") .HasColumnName("requirement_id"); @@ -890,15 +894,103 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Actor"); + b.HasIndex("OtherRequirementId"); + b.HasIndex("RequirementId", "OccurredAt"); b.ToTable("requirement_evidence_events", "public", t => { - t.HasCheckConstraint("ck_requirement_evidence_events_action", "action IN ('added', 'removed')"); + t.HasCheckConstraint("ck_requirement_evidence_events_action", "action IN ('added', 'removed', 'confirmed', 'rejected', 'moved_in', 'moved_out')"); t.HasCheckConstraint("ck_requirement_evidence_events_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); t.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_events_move", "(action IN ('moved_in', 'moved_out')) = (other_requirement_id IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Author") + .HasColumnType("text") + .HasColumnName("author"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("decided_at"); + + b.Property("DecidedBy") + .HasColumnType("text") + .HasColumnName("decided_by"); + + b.Property("FoundAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("found_at") + .HasDefaultValueSql("now()"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reference"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text") + .HasColumnName("url"); + + b.HasKey("Id"); + + b.HasIndex("DecidedBy"); + + b.HasIndex("RequirementId", "RepositoryId", "Number") + .IsUnique(); + + b.ToTable("requirement_evidence_suggestions", "public", t => + { + t.HasCheckConstraint("ck_requirement_evidence_suggestions_decision", "(status = 'pending') = (decided_by IS NULL) AND (decided_by IS NULL) = (decided_at IS NULL)"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_number", "number > 0 AND repository_id > 0"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_state", "state IN ('open', 'closed', 'merged')"); + + t.HasCheckConstraint("ck_requirement_evidence_suggestions_status", "status IN ('pending', 'confirmed', 'rejected')"); }); }); @@ -1408,6 +1500,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("OtherRequirementId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SpecThread.Api.Data.Requirement", null) + .WithMany() + .HasForeignKey("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SpecThread.Api.Data.RequirementEvidenceSuggestion", b => + { + b.HasOne("SpecThread.Api.Data.AuthUser", null) + .WithMany() + .HasForeignKey("DecidedBy") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("SpecThread.Api.Data.Requirement", null) .WithMany() .HasForeignKey("RequirementId") diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index ece8ace..3579857 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -483,3 +483,10 @@ a.row:focus-visible { outline-offset: -2px; } /* Finding evidence by title (ADR-054) */ .evidence-link + .evidence-link { margin-top: 12px; } .evidence-link + .evidence-rows, .evidence-link + .muted { margin-top: 8px; } +/* Suggested links and moving a link (ADR-056) */ +.evidence-suggestions { margin-top: 12px; } +.app-main .badge.is-suggested { border-style: dashed; border-color: var(--app-warn); color: var(--app-warn); } +.evidence-rows .row .is-suggested { margin-left: auto; } +.suggestion-actions { display: flex; flex: none; gap: 6px; } +.evidence-rows li > .evidence-move { margin: 8px 14px 10px 44px; } +.evidence-move .muted { margin: 6px 0 0; font-size: 13px; } 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 93f049a..10edffd 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx @@ -9,7 +9,7 @@ import { ReviewPanel } from "@/components/review-panel"; import { documentTypeLabel } from "@/lib/documents"; import { getProjectRepository } from "@/lib/github-data"; import { - listEvidence, listEvidenceHistory, listFollowUps, listMembers, listMilestones, listRequirements, listReviews, requireProject, requireRequirement, viewerId, + listEvidence, listEvidenceHistory, listEvidenceSuggestions, listFollowUps, listMembers, listMilestones, listRequirements, listReviews, requireProject, requireRequirement, viewerId, } from "@/lib/project-data"; import { formatDate } from "@/lib/projects"; @@ -32,10 +32,11 @@ export default async function Page({ params }: Props) { const { projectId, requirementId } = await params; // One round of parallel reads. Each is authorized by the API, and requireRequirement still // shows the not-found page before anything renders when the requirement is in another project. - const [project, requirement, members, repository, evidence, reviews, viewer, followUps, requirements, milestones, evidenceHistory] = await Promise.all([ + const [project, requirement, members, repository, evidence, reviews, viewer, followUps, requirements, milestones, evidenceHistory, suggestions] = await Promise.all([ requireProject(projectId), requireRequirement(projectId, requirementId), listMembers(projectId), getProjectRepository(projectId), listEvidence(requirementId), listReviews(requirementId), viewerId(), listFollowUps(requirementId), listRequirements(projectId), listMilestones(projectId), listEvidenceHistory(requirementId), + listEvidenceSuggestions(requirementId), ]); const people = Object.fromEntries(members.map(member => [member.userId, member.name])); const nameOf = (userId: string) => people[userId] ?? "a former member"; @@ -44,6 +45,7 @@ export default async function Page({ params }: Props) { // Requirements this one could follow up: not itself, and not already linked either way. const linked = new Set([requirement.id, ...[...followUps.followUpOf, ...followUps.followedUpBy].map(link => link.requirementId)]); const candidates = requirements.filter(other => !linked.has(other.id)).map(({ id, title }) => ({ id, title })); + const moveTargets = requirements.filter(other => other.id !== requirement.id).map(({ id, title }) => ({ id, title })); return (
@@ -114,6 +116,7 @@ export default async function Page({ params }: Props) {

Evidence

{repository || evidence.length > 0 ? ( ) : ( diff --git a/app/web/src/app/projects/evidence-actions.ts b/app/web/src/app/projects/evidence-actions.ts index 171b5c1..df0ee13 100644 --- a/app/web/src/app/projects/evidence-actions.ts +++ b/app/web/src/app/projects/evidence-actions.ts @@ -3,7 +3,8 @@ import { revalidatePath } from "next/cache"; import { apiFetch } from "@/lib/api"; import { - EVIDENCE_SEARCH_MAX, evidenceReferenceError, parseEvidence, parseEvidenceList, parseEvidenceSearch, problemDetail, type EvidenceSearchItem, + EVIDENCE_SEARCH_MAX, evidenceReferenceError, parseEvidence, parseEvidenceList, parseEvidenceSearch, parseEvidenceSuggestions, problemDetail, + type EvidenceSearchItem, } from "@/lib/evidence"; import { fieldError, isUuid } from "@/lib/projects"; @@ -84,6 +85,78 @@ export async function searchEvidence( } } +/** Looks for pull requests that say they close the linked issues (ADR-056). What it finds is suggested, never linked. */ +export async function findSuggestions(projectId: string, requirementId: string): Promise { + const failed = "Suggestions could not be found. Please try again."; + if (!isUuid(projectId) || !isUuid(requirementId)) return { error: failed }; + try { + const response = await apiFetch(`/requirements/${requirementId}/evidence/suggestions/scan`, { method: "POST", timeoutMs: GITHUB_WRITE_TIMEOUT_MS }); + if (response.status === 409) return { error: problemDetail(await response.json()) ?? failed }; + if (response.status === 502) return { error: GITHUB_DOWN }; + if (response.status === 503) return { error: GITHUB_NOT_SET_UP }; + if (!response.ok) throw new Error(`Find suggestions returned ${response.status}.`); + parseEvidenceSuggestions(await response.json()); + } catch (error) { + console.error("Find suggestions failed", error); + return { error: failed }; + } + refresh(projectId, requirementId); + return { error: null }; +} + +/** Confirms or rejects one suggestion, by the form's `suggestionId` and the button's `decision`. Both are recorded with who and when. */ +export async function decideSuggestion( + projectId: string, requirementId: string, _previous: EvidenceActionState, formData: FormData, +): Promise { + const failed = "The suggestion could not be saved. Please try again."; + const suggestionId = String(formData.get("suggestionId") ?? ""); + const decision = formData.get("decision"); + if (!isUuid(projectId) || !isUuid(requirementId) || !isUuid(suggestionId) || (decision !== "confirm" && decision !== "reject")) return { error: failed }; + try { + const response = await apiFetch(`/requirements/${requirementId}/evidence/suggestions/${suggestionId}/${decision}`, { + method: "POST", timeoutMs: GITHUB_WRITE_TIMEOUT_MS, + }); + // 409 explains itself: decided already, linked already, gone from GitHub, the limit, or archived. + if (response.status === 409) return { error: problemDetail(await response.json()) ?? failed }; + if (response.status === 502) return { error: GITHUB_DOWN }; + if (response.status === 503) return { error: GITHUB_NOT_SET_UP }; + if (!response.ok) throw new Error(`Decide suggestion returned ${response.status}.`); + } catch (error) { + console.error("Decide suggestion failed", error); + return { error: failed }; + } + refresh(projectId, requirementId); + return { error: null }; +} + +/** Moves a link to another requirement of the project in one step (ADR-056). Both requirements' histories show it. */ +export async function moveEvidence( + projectId: string, requirementId: string, _previous: EvidenceActionState, formData: FormData, +): Promise { + const failed = "The link could not be moved. Please try again."; + const evidenceId = String(formData.get("evidenceId") ?? ""); + const targetRequirementId = String(formData.get("targetRequirementId") ?? ""); + if (!targetRequirementId) return { error: "Choose the requirement to move this link to." }; + if (!isUuid(projectId) || !isUuid(requirementId) || !isUuid(evidenceId) || !isUuid(targetRequirementId)) return { error: failed }; + try { + const response = await apiFetch(`/requirements/${requirementId}/evidence/${evidenceId}/move`, { + method: "POST", body: JSON.stringify({ targetRequirementId }), timeoutMs: GITHUB_WRITE_TIMEOUT_MS, + }); + if (response.status === 400) return { error: fieldError(await response.json(), "targetRequirementId") ?? failed }; + if (response.status === 409) return { error: problemDetail(await response.json()) ?? failed }; + if (response.status === 502) return { error: GITHUB_DOWN }; + if (response.status === 503) return { error: GITHUB_NOT_SET_UP }; + if (!response.ok) throw new Error(`Move evidence returned ${response.status}.`); + parseEvidence(await response.json()); + } catch (error) { + console.error("Move evidence failed", error); + return { error: failed }; + } + // Both requirements change, along with the counts on the project's lists. + revalidatePath(`/projects/${projectId}`, "layout"); + return { error: null }; +} + /** Reads every linked item from GitHub again and stores what it reports now. */ export async function refreshEvidence(projectId: string, requirementId: string): Promise { const failed = "The evidence could not be refreshed. Please try again."; diff --git a/app/web/src/components/evidence-panel.tsx b/app/web/src/components/evidence-panel.tsx index d0d997c..e84e7cc 100644 --- a/app/web/src/components/evidence-panel.tsx +++ b/app/web/src/components/evidence-panel.tsx @@ -2,10 +2,12 @@ import { useActionState, useState } from "react"; import { CircleDot, GitCommitHorizontal, GitPullRequest, RefreshCw, Tag, X } from "lucide-react"; -import { linkEvidence, refreshEvidence, searchEvidence, unlinkEvidence } from "@/app/projects/evidence-actions"; +import { + decideSuggestion, findSuggestions, linkEvidence, moveEvidence, refreshEvidence, searchEvidence, unlinkEvidence, +} from "@/app/projects/evidence-actions"; import { checkSummary, EVIDENCE_REFERENCE_MAX, EVIDENCE_SEARCH_MAX, evidenceChanges, evidenceEventText, evidenceLabel, releaseContents, releasesContaining, - type Evidence, type EvidenceEvent, + type Evidence, type EvidenceEvent, type EvidenceSuggestion, } from "@/lib/evidence"; import { formatDate } from "@/lib/projects"; @@ -23,13 +25,17 @@ const external = { target: "_blank", rel: "noreferrer" } as const; * include them; a pull request lists its commits; a release lists which linked changes it contains. Each * link names who added it and when (for a suggestion, who confirmed it). While the requirement can * change, members add a link by reference or by searching titles, remove one, and read everything - * from GitHub again. `history` lists every link added or removed (ADR-054); null when it can't be read. + * from GitHub again. `history` lists every change to the links (ADR-054); null when it can't be read. + * `suggestions` are pull requests that say they close a linked issue, shown apart from the + * evidence, which members confirm or reject; `moveTargets` are the requirements a link can move to (ADR-056). */ -export function EvidencePanel({ projectId, requirementId, evidence, history = null, repository, people, editable }: { +export function EvidencePanel({ projectId, requirementId, evidence, history = null, suggestions = [], moveTargets = [], repository, people, editable }: { projectId: string; requirementId: string; evidence: Evidence[]; history?: EvidenceEvent[] | null; + suggestions?: EvidenceSuggestion[]; + moveTargets?: { id: string; title: string }[]; repository: string; /** Names of the project's members by user ID, for naming who added each link. */ people: Record; @@ -38,6 +44,8 @@ export function EvidencePanel({ projectId, requirementId, evidence, history = nu const [link, linkAction, linking] = useActionState(linkEvidence.bind(null, projectId, requirementId), { reference: "", error: null, linked: 0 }); const [refreshed, refreshAction, refreshing] = useActionState(refreshEvidence.bind(null, projectId, requirementId), { error: null }); const [unlinked, unlinkAction, unlinking] = useActionState(unlinkEvidence.bind(null, projectId, requirementId), { error: null }); + const [moved, moveAction, moving] = useActionState(moveEvidence.bind(null, projectId, requirementId), { error: null }); + const [movingId, setMovingId] = useState(null); const lastChecked = evidence.map(item => item.refreshedAt).sort().at(-1); return ( @@ -67,6 +75,10 @@ export function EvidencePanel({ projectId, requirementId, evidence, history = nu {item.author ? `${item.author} · ` : ""} + {editable && moveTargets.length > 0 && ( + + )} {editable && (
@@ -85,12 +97,17 @@ export function EvidencePanel({ projectId, requirementId, evidence, history = nu {item.source === "suggested" ? "Suggested, confirmed" : "Linked"} by {people[item.linkedBy] ?? "a former member"} on{" "}

+ {movingId === item.id && ( + setMovingId(null)} /> + )} ); })} )} {unlinked.error &&

{unlinked.error}

} + {moved.error &&

{moved.error}

} {evidence.length > 0 && ( @@ -104,6 +121,7 @@ export function EvidencePanel({ projectId, requirementId, evidence, history = nu )} {refreshed.error &&

{refreshed.error}

} + {editable && } {editable && ( void; + pending: boolean; + onCancel: () => void; +}) { + return ( +
+ +
+ +
+ + + +
+
+

Decisions on both requirements will need a new review, because their evidence changes.

+
+ ); +} + +/** + * Renders suggested links apart from the evidence, each labeled "Suggested" with the reference it + * came from, for members to confirm or reject. They count as evidence only once confirmed. + */ +function Suggestions({ projectId, requirementId, suggestions, editable }: { + projectId: string; + requirementId: string; + suggestions: EvidenceSuggestion[]; + editable: boolean; +}) { + const [found, findAction, finding] = useActionState(findSuggestions.bind(null, projectId, requirementId), { error: null }); + const [decided, decideAction, deciding] = useActionState(decideSuggestion.bind(null, projectId, requirementId), { error: null }); + if (!editable && suggestions.length === 0) return null; + return ( +
+ {suggestions.length > 0 && ( +
    + {suggestions.map(item => ( +
  • +
    +
    +

    Its description says “{item.reference}”. Not evidence until confirmed.

    +
  • + ))} +
+ )} + {decided.error &&

{decided.error}

} + {editable && ( +
+ Finds pull requests that say they close a linked issue. + +
+ )} + {found.error &&

{found.error}

} +
+ ); +} + +/** Lists changes to the evidence links, newest first, with who made each change and when. */ export function EvidenceHistory({ history, people }: { history: EvidenceEvent[]; people: Record }) { return (
    diff --git a/app/web/src/lib/evidence.ts b/app/web/src/lib/evidence.ts index 7c4e6b4..ea736bf 100644 --- a/app/web/src/lib/evidence.ts +++ b/app/web/src/lib/evidence.ts @@ -225,31 +225,79 @@ export function parseEvidenceSearch(value: unknown): EvidenceSearchItem[] { }); } -/** A link added to or removed from a requirement, with what it was then (ADR-054). */ +const EVENT_ACTIONS = ["added", "removed", "confirmed", "rejected", "moved_in", "moved_out"] as const; + +/** + * A change to a requirement's links, with what the link was then (ADR-054, ADR-056). A move names + * the requirement on its other side, which is null for every other change. + */ export type EvidenceEvent = { - id: string; evidenceId: string; action: "added" | "removed"; kind: Evidence["kind"]; label: string; title: string; - actor: string; occurredAt: string; + id: string; evidenceId: string; action: (typeof EVENT_ACTIONS)[number]; kind: Evidence["kind"]; label: string; title: string; + actor: string; occurredAt: string; otherRequirementId: string | null; otherRequirementTitle: string | null; }; -/** Validates a requirement's evidence history from an API response, newest first. */ +/** + * Validates a requirement's evidence history from an API response, newest first. The previous API + * (ADR-044) sends no other requirement; its events have none. + */ export function parseEvidenceHistory(value: unknown): EvidenceEvent[] { if (!Array.isArray(value)) throw new Error("The API returned an unexpected evidence history."); return value.map((entry: unknown) => { const item = entry as Record | null; + const otherId = item?.otherRequirementId === undefined ? null : item.otherRequirementId; + const otherTitle = item?.otherRequirementTitle === undefined ? null : item.otherRequirementTitle; if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.evidenceId !== "string" || - (item.action !== "added" && item.action !== "removed") || + !EVENT_ACTIONS.includes(item.action as EvidenceEvent["action"]) || + (otherId !== null && typeof otherId !== "string") || (otherTitle !== null && typeof otherTitle !== "string") || (item.kind !== "issue" && item.kind !== "pull_request" && item.kind !== "commit" && item.kind !== "release") || typeof item.label !== "string" || typeof item.title !== "string" || typeof item.actor !== "string" || !isDate(item.occurredAt)) { throw new Error("The API returned an unexpected evidence history."); } return { - id: item.id, evidenceId: item.evidenceId, action: item.action, kind: item.kind, label: item.label, title: item.title, - actor: item.actor, occurredAt: item.occurredAt, + id: item.id, evidenceId: item.evidenceId, action: item.action as EvidenceEvent["action"], kind: item.kind, label: item.label, title: item.title, + actor: item.actor, occurredAt: item.occurredAt, otherRequirementId: otherId, otherRequirementTitle: otherTitle, }; }); } -/** Describes one change to the links, such as "Linked #12 “Guests cannot pay”" or "Removed 4f2a9c1 “Fix totals”". */ -export function evidenceEventText(event: Pick): string { - return `${event.action === "added" ? "Linked" : "Removed"} ${event.label} “${event.title}”`; +/** Describes one change to the links, such as "Linked #12 “Guests cannot pay”" or "Moved #15 “Add checkout” to “Guest checkout”". */ +export function evidenceEventText(event: Pick): string { + const item = `${event.label} “${event.title}”`; + const other = `“${event.otherRequirementTitle ?? "another requirement"}”`; + switch (event.action) { + case "added": return `Linked ${item}`; + case "removed": return `Removed ${item}`; + case "confirmed": return `Confirmed suggestion ${item}`; + case "rejected": return `Rejected suggestion ${item}`; + case "moved_in": return `Moved ${item} here from ${other}`; + case "moved_out": return `Moved ${item} to ${other}`; + } +} + +/** + * A pull request that says it closes an issue linked to the requirement (ADR-056). It is not + * evidence until a person confirms it. `reference` is what was found, such as "Closes #184". + */ +export type EvidenceSuggestion = { + id: string; number: number; title: string; state: "open" | "closed" | "merged"; author: string | null; url: string; + reference: string; foundAt: string; +}; + +/** Validates a list of suggestions from an API response. Throws when the shape is not the documented one. */ +export function parseEvidenceSuggestions(value: unknown): EvidenceSuggestion[] { + if (!Array.isArray(value)) throw new Error("The API returned unexpected suggestions."); + return value.map((entry: unknown) => { + const item = entry as Record | null; + if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.number !== "number" || + !Number.isInteger(item.number) || item.number < 1 || typeof item.title !== "string" || + (item.state !== "open" && item.state !== "closed" && item.state !== "merged") || + (item.author !== null && typeof item.author !== "string") || !isGitHubLink(item.url) || + typeof item.reference !== "string" || !isDate(item.foundAt)) { + throw new Error("The API returned unexpected suggestions."); + } + return { + id: item.id, number: item.number, title: item.title, state: item.state, author: item.author as string | null, + url: item.url as string, reference: item.reference, foundAt: item.foundAt, + }; + }); } diff --git a/app/web/src/lib/project-data.ts b/app/web/src/lib/project-data.ts index 9208ddd..09c6f00 100644 --- a/app/web/src/lib/project-data.ts +++ b/app/web/src/lib/project-data.ts @@ -10,7 +10,9 @@ import { import { parseCriteriaVersion, parseCriteriaVersions, type CriteriaVersion } from "./criteria-versions"; import { parseFollowUps, type FollowUps } from "./follow-ups"; import { parseDocument, parseDocuments, type ProjectDocument } from "./documents"; -import { parseEvidenceHistory, parseEvidenceList, type Evidence, type EvidenceEvent } from "./evidence"; +import { + parseEvidenceHistory, parseEvidenceList, parseEvidenceSuggestions, type Evidence, type EvidenceEvent, type EvidenceSuggestion, +} from "./evidence"; import { parseRequirement, type Requirement } from "./requirements"; import { parseReviews, type Review } from "./reviews"; @@ -97,6 +99,15 @@ export const listEvidenceHistory = cache(async (requirementId: string): Promise< return body === null ? null : parseEvidenceHistory(body); }); +/** + * Lists a requirement's undecided link suggestions (ADR-056). Call after `requireRequirement`. + * The previous API (ADR-044) answers 404, which reads as none. + */ +export const listEvidenceSuggestions = cache(async (requirementId: string): Promise => { + const body = isUuid(requirementId) ? await read(`/requirements/${requirementId}/evidence/suggestions`) : null; + return body === null ? [] : parseEvidenceSuggestions(body); +}); + /** Lists the decisions recorded on a requirement, newest first. Call after `requireRequirement`. */ export const listReviews = cache(async (requirementId: string): Promise => { const body = isUuid(requirementId) ? await read(`/requirements/${requirementId}/reviews`) : null; diff --git a/docs/API.md b/docs/API.md index c6d15e3..b807d01 100644 --- a/docs/API.md +++ b/docs/API.md @@ -67,6 +67,11 @@ to the project's team; non-members get 404 as if the project did not exist. | GET | `/requirements/{requirementId}/evidence` | member | | 200 `Evidence[]` (oldest first by GitHub's created time) | | GET | `/requirements/{requirementId}/evidence/search` | member | query `q`: words from a title | 200 `{ items: EvidenceSearchItem[] }` (at most 20) | | GET | `/requirements/{requirementId}/evidence/history` | member | | 200 `EvidenceEvent[]` (newest first) | +| GET | `/requirements/{requirementId}/evidence/suggestions` | member | | 200 `EvidenceSuggestion[]` (undecided, by number) | +| POST | `/requirements/{requirementId}/evidence/suggestions/scan` | member | | 200 `EvidenceSuggestion[]` | +| POST | `/requirements/{requirementId}/evidence/suggestions/{suggestionId}/confirm` | member | | 201 `Evidence` (`source: "suggested"`) | +| POST | `/requirements/{requirementId}/evidence/suggestions/{suggestionId}/reject` | member | | 204 | +| POST | `/requirements/{requirementId}/evidence/{evidenceId}/move` | member | `{ targetRequirementId }` | 200 `Evidence` | | POST | `/requirements/{requirementId}/evidence` | member | `{ reference }` | 201 `Evidence` | | POST | `/requirements/{requirementId}/evidence/refresh` | member | | 200 `Evidence[]` | | DELETE | `/requirements/{requirementId}/evidence/{evidenceId}` | member | | 204 | @@ -110,6 +115,23 @@ other repository are dropped. It links nothing: link a result by its number. An GitHub's search rate limit. Linking and unlinking each add an `EvidenceEvent`, which stays after the link is removed; refresh adds none. +Suggestions (ADR-056) are pull requests of the connected repository whose description +closes an issue linked to the requirement (close, fix, or resolve in GitHub's forms, +before `#N`, `owner/name#N`, or the issue's address). Scan and refresh bring the undecided +ones up to date. They are not evidence: no evidence list, count, review, or Evidence +complete includes them. Confirm links the pull request as GitHub reports it now; reject +keeps it from being suggested again, though it can still be linked by hand. Either adds an +`EvidenceEvent`. A suggestion decided already, or a pull request gone from GitHub, is +409, as are no repository connected and an archived requirement or project; 502 and 503 +as above. Linking a suggested pull request by hand removes its suggestion. + +Move takes a link to another active requirement of the project in one step. The link +keeps its ID, `linkedBy` and `linkedAt` become the mover and now, and `source` becomes +`manual`. Each requirement gets an `EvidenceEvent`, and decisions on both become +`outdated`. A target that is missing, the same requirement, or in another project is 400 on +`targetRequirementId`. An archived target, the item already linked there, or 50 links +there is 409. A pull request moved away is not suggested again for that requirement. + Link and refresh read GitHub first and only then take the team lock and save, so a large refresh may take tens of seconds; the web app allows these two calls 55 seconds (ADR-048). Every evidence, review, and repository write serializes with archiving @@ -248,10 +270,17 @@ type EvidenceSearchItem = { linked: boolean; // linked to this requirement when the search ran }; type EvidenceEvent = { - id: string; evidenceId: string; action: "added" | "removed"; + id: string; evidenceId: string; // for a rejection, the suggestion's ID + action: "added" | "removed" | "confirmed" | "rejected" | "moved_in" | "moved_out"; // The link as it was then: label is "#9", a release tag, or a short SHA. kind: Evidence["kind"]; label: string; title: string; actor: string; occurredAt: string; + otherRequirementId: string | null; otherRequirementTitle: string | null; // the other side of a move +}; +type EvidenceSuggestion = { + id: string; number: number; title: string; state: "open" | "closed" | "merged"; author: string | null; url: string; + reference: string; // what was found, such as "Closes #184" + foundAt: string; }; ``` @@ -349,7 +378,7 @@ remain 401, and an account without a user row remains 403. Project access follow Not yet available: activity and audit history. The only change histories kept are criteria versions (ADR-051), follow-up link changes (ADR-052), and evidence link -changes (ADR-054). +changes (ADR-054, ADR-056). Team management uses authenticated server actions. Role changes require explicit diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab92058..5a34aa8 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). 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 eighteen 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, 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. 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/DATABASE.md b/docs/DATABASE.md index af0de1c..525ab71 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -13,11 +13,11 @@ dotnet tool restore dotnet build app/api --configuration Release ``` -`SpecThreadDbContext` maps six Better Auth tables and eighteen product tables in +`SpecThreadDbContext` maps six Better Auth tables and nineteen product tables in `public`, with the migrations listed under "Combined migration order" below, ending with RequirementDocuments, RequirementUpdatedBy, RequirementCriteriaVersions, -RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, and -RequirementEvidenceEvents. Startup never calls `EnsureCreated`, `Migrate`, or a database +RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, +RequirementEvidenceEvents, and RequirementEvidenceSuggestions. Startup never calls `EnsureCreated`, `Migrate`, or a database query. `/health` checks process liveness only. Resolving the context without `ConnectionStrings:Database` fails explicitly. Playwright verifies this failure and provider initialization with dummy credentials, without a database connection. @@ -329,8 +329,8 @@ Roll back by script (docs/schema/*-rollback.sql) for one migration at a time: that come later in time. scripts/test-database.mjs applies the teams scripts first and then the evidence and review scripts. ProjectDocuments comes after all of them, then RequirementDocuments, RequirementUpdatedBy, RequirementCriteriaVersions, -RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, and -RequirementEvidenceEvents. +RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, +RequirementEvidenceEvents, and RequirementEvidenceSuggestions. ## Project documents migration @@ -505,6 +505,31 @@ removes until the new API is deployed are not recorded. Rollback: `dotnet ef dat update RequirementCriteriaVersionTexts --project app/api`, or the rollback script; it drops only the history, losing every recorded addition and removal; links stay. +## Evidence suggestions migration + +RequirementEvidenceSuggestions (ADR-056) adds `requirement_evidence_suggestions`: one row +per requirement and pull request (unique on requirement_id, repository_id, number) with +the item's `title`, `state`, `author`, and `url`, the `reference` found, `status` +(`pending`, `confirmed`, or `rejected`), `found_at`, and `decided_by` and `decided_at`, +which are set exactly when it is decided. It references requirements and users with +restrict deletes, has RLS enabled with no allow policies, and revokes PUBLIC, anon, and +authenticated grants. It also widens `requirement_evidence_events.action` to `confirmed`, +`rejected`, `moved_in`, and `moved_out`, and adds `other_requirement_id` (nullable, foreign +key to requirements, set exactly on moves). + +```sh +dotnet ef migrations add RequirementEvidenceSuggestions --project app/api --configuration Release +node scripts/generate-requirement-evidence-suggestions.mjs +``` + +The second command adds the RLS, grant, and rollback statements and generates +docs/schema/requirement-evidence-suggestions.sql and -rollback.sql. CI applies it after +the merge; the running API only writes `added` and `removed`, which stay valid. Rollback: +`dotnet ef database update RequirementEvidenceEvents --project app/api`, or the rollback +script. It drops the table, losing every suggestion and which were rejected. Events stay +in the older form: `moved_out` becomes `removed`, `moved_in` and `confirmed` become +`added`, and `rejected` events are deleted. Links stay. + ## Supabase tooling ```sh diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f7fee62..539eeb2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -737,7 +737,8 @@ migration EvidenceChecks). `linked_by` is always a person: for a suggested link, person who confirmed it. The link endpoint ignores any `source` a caller sends and writes `manual`; only a future "confirm a suggestion" endpoint will write `suggested`. The requirement page names who confirmed a suggested link (ADR-054 later -names who added every link, and records additions and removals). Rules for +names who added every link, and records additions and removals; ADR-056 adds the first +suggestions, from explicit references rather than AI). Rules for the AI work that follows: suggestions are kept in their own store and enter `requirement_evidence` only when a project member confirms them; AI never records a review decision; every core flow works with AI unavailable or switched off; and AI @@ -1440,6 +1441,58 @@ leaked OTLP token can only write telemetry. The browser suite now runs with tele against the fake receiver, so instrumentation problems surface in tests. Rollback: delete `OTEL_EXPORTER_OTLP_ENDPOINT` on Vercel and Render and redeploy; no code or migration change. +ADR-056: Links are suggested from closing references, and a link can move between requirements + +Status: Accepted + +Context: Two stories for Danielle (Tech Lead). From "Design the Box" F2 and the MVP scope: +suggest links from explicit references such as "Closes #184", labeled "Suggested" and not +counted as evidence until confirmed, with each confirmation or rejection recorded by name +and time. From the scenario "Changing a requirement after its code was merged": move a +wrongly linked pull request to the correct requirement, with both timelines showing the +change, who made it, and when. ADR-036 reserved `source = suggested` and said suggestions +live apart from evidence. The user chose scanning on request over webhooks, rejections +that stay rejected, and moving any linked item rather than only pull requests. + +Decision: Suggestions are deterministic; no AI is involved. For each issue linked to a +requirement, the API reads the issue's GitHub timeline for cross-references from pull +requests of the connected repository, and keeps those whose description uses a closing +keyword for that issue: close, fix, or resolve in GitHub's forms, before `#N`, +`owner/name#N`, or the issue's address. A plain mention is not enough. This runs on +`POST /requirements/{id}/evidence/suggestions/scan` ("Find suggestions") and on Refresh. +No webhook is added, so ADR-032 stands. + +`requirement_evidence_suggestions` holds them, one row per requirement and pull request, +with the item as read and the reference found ("Closes #184"); the description itself +is neither stored nor logged. Nothing that counts evidence reads this table: the +evidence list, `evidenceCount`, Evidence complete (ADR-053), review snapshots, and +"outdated" (ADR-038) all see only `requirement_evidence`. Any member confirms or rejects. +Confirming reads the pull request from GitHub and links it like any link, with +`source = suggested`. Each decision stays on the row with who and when, and as a +`confirmed` or `rejected` event in the history (ADR-054). A decided row is never suggested +again; a rejected pull request can still be linked by hand. Undecided rows that a +complete read no longer finds are dropped, and linking the pull request by hand drops its +suggestion. + +`POST /requirements/{id}/evidence/{evidenceId}/move` moves any link to another active +requirement of the project, in one transaction under the team lock. The row keeps its +ID, so the decisions on both requirements become outdated; `linked_by` and `linked_at` +become the mover and the time, and `source` becomes `manual`. Each history gets an event, +`moved_out` and `moved_in`, naming the other requirement. A moved change is compared +with the target's releases, and a moved release with the target's changes. A pull +request moved away is recorded as rejected for the requirement it left, or the same +closing reference would suggest it straight back. + +Consequences: Suggestions appear only when someone asks or refreshes, so a pull request +opened later waits for that. Only pull request descriptions are read: commit messages, +comments, and references without a closing keyword are not, nor are pull requests that +reference an issue not linked to the requirement. The timeline is read to 500 events per +issue. Refresh makes one more GitHub call per linked issue and fails if that read fails. +Migration RequirementEvidenceSuggestions adds the table (RLS on, browser grants revoked), +widens the event actions, and adds `other_requirement_id`. Its rollback drops the table, +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-NNN: Title Status: Proposed, Accepted, Superseded, or Rejected diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8cc239c..03a5ae8 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,54 @@ # Shared handoff -## Current task: Observability Phase 1, instrumentation off by default (2026-10-30) +## Current 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 + references such as "Closes #184" ("Design the Box" F2 / MVP scope), and move a wrongly + linked pull request to the correct requirement ("Changing a requirement after its code + was merged"). +- User decisions: one branch; scan on request and on Refresh, not webhooks; a rejection + stays rejected; any linked item can move, not only pull requests. +- Completed: + - Suggestions: pull requests of the connected repository whose description closes a + linked issue, read from the issue's GitHub timeline. "Find suggestions" and Refresh + update them. They are listed apart from the evidence, labeled "Suggested" with the + reference found, and count as nothing until confirmed. Confirm links the pull request + (`source = suggested`); reject keeps it from coming back. Both are recorded with who + and when, on the row and in the link history. + - Moves: a Move control on each link, in one transaction, keeping the link's ID. Both + requirements' link histories name the other. Decisions on both become outdated, release + comparisons follow the link, and a pull request moved away isn't suggested back. +- Changed files: API `GitHub/EvidenceChangeEndpoints.cs` (new), `GitHub/EvidenceEndpoints.cs` + (`SaveLinkAsync` split out of Link so confirming reuses it; history, refresh), + `GitHub/GitHubClient.cs` (`ListMentionsAsync`), `Data/ProductModels.cs`, + `Data/SpecThreadDbContext.cs`, migration `RequirementEvidenceSuggestions` and the + snapshot; `scripts/generate-requirement-evidence-suggestions.mjs`, generated + `docs/schema/requirement-evidence-suggestions*.sql`, `scripts/test-database.mjs`, + `scripts/test-github.mjs` (timeline); web `lib/evidence.ts`, `lib/project-data.ts`, + `app/projects/evidence-actions.ts`, `components/evidence-panel.tsx`, the requirement + page, `app-shell.css`; tests: new `tests/schema/evidence-suggestions.spec.ts` (5130, + 5131), four tests in `tests/e2e/evidence.spec.ts`, parser tests in + `tests/api/project-data.spec.ts`, `tests/support/github.ts`, migration counts up by one in + `reviews.spec.ts`, `evidence.spec.ts`, `github-repository.spec.ts`; docs DECISIONS + (ADR-056, ADR-036 note), API, DATABASE, ARCHITECTURE (nineteen product tables), PROJECT, + TESTING. +- Known issues and risks: + - Suggestions wait for someone to press Find suggestions or Refresh; there is no webhook. + - Only pull request descriptions are read, not commit messages or comments. + - Refresh makes one more GitHub call per linked issue and fails if that read fails. + - The real GitHub timeline format is not exercised by tests; try it once on a real + repository before relying on it. + - The scaffold Suggestions page stays a placeholder; suggestions are on the requirement page. + - CI applies the migration on merge. Don't apply it to Supabase by hand first. + - Rolling the migration back deletes rejections, so rejected pull requests can be + suggested again. +- Checks: `npm test` 407 passed (it builds the web app and the Release API). + `npm run lint`, `npm run typecheck`, `dotnet format app/api --verify-no-changes`, + `has-pending-model-changes` (none), and `git diff --check` passed. +- Next step: review, then open a PR to `main`. + +## Previous task: Observability Phase 1, instrumentation off by default (2026-10-30) - Branch: `feature/observability`, from `main` at `ba2013f` (PR #24 merged). - Scope: OpenTelemetry in the API and web app, exported to Grafana Cloud in deployment and diff --git a/docs/PROJECT.md b/docs/PROJECT.md index e30c689..04ffd61 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -98,6 +98,28 @@ Each link records who created it and when. A link can be removed, and the removal is recorded. +Story: suggest links from explicit references + +As a tech lead (Danielle), I want SpecThread to suggest links from explicit references such +as "Closes #184" so that I don't have to link every pull request by hand. Source: "Design +the Box" F2 / MVP scope. Acceptance criteria (ADR-056): + +Suggestions are clearly labeled "Suggested" and are not counted as evidence until confirmed. + +A user can confirm or reject each suggestion. + +Confirming or rejecting is recorded with name and timestamp. + +Story: move a wrongly linked pull request + +As a tech lead (Danielle), I want to move a wrongly linked pull request to the correct +requirement so that a developer's mistaken issue reference doesn't misstate the evidence. +Source: scenario "Changing a requirement after its code was merged". Acceptance criteria (ADR-056): + +A user can unlink a pull request from one requirement and link it to another. + +Both requirements' timelines show the change, who made it, and when. + MVP scope The first usable version should support: diff --git a/docs/TESTING.md b/docs/TESTING.md index c8195c1..2a03f32 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -69,7 +69,7 @@ web app at 127.0.0.1:3100, a test-only JWKS issuer (scripts/start-test-jwks.mjs) at 127.0.0.1:5101, and the API in Development at 127.0.0.1:5100 trusting that issuer. Auth tests also start extra API instances on 5102-5104, and product API tests on 5105. The Teams API tests use 5116, team management 5117, invitations 5118, documents 5119, requirement documents 5120, criteria -versions 5121, follow-ups 5122, milestones 5123, and evidence search and history 5124 (fake GitHub) and 5125 +versions 5121, follow-ups 5122, milestones 5123, evidence search and history 5124 (fake GitHub) and 5125, and evidence suggestions 5130 (fake GitHub) and 5131 (requirement documents moved from 5107 to 5109 when the branches were merged, because the proxy and GitHub stand-ins below already use those). @@ -182,6 +182,20 @@ currently require network access during the web build. a result, "Linked by" on the link, the result marked Linked and offered again after removal, the change list on the page and the history page, no matches, and a search failing, refused, or answered wrongly. +- Suggestions and moves (ADR-056). tests/schema/evidence-suggestions.spec.ts (fake GitHub + on 5130, API on 5131): every form of closing reference, and plain mentions, other + repositories, longer numbers, and unlinked issues not matching; descriptions not + stored; a pending suggestion missing from the evidence list, count, review snapshot, and + Evidence complete; confirm and reject with who and when, decided ones refused and never + suggested again; linking by hand, edited references, refresh, and items gone from + GitHub; members only, archived (also while waiting for the team lock), disconnected, + and GitHub failures; moves with both histories, outdated decisions, no re-suggestion, + and moving back; refusals for the target, a duplicate, and the limit; release + comparison after a move; and the migration's constraints, RLS, and rollback. Browser + (tests/e2e/evidence.spec.ts): finding, the label and reference, not counted, confirm, + reject, the change list, staying rejected, a failed scan and a refused decision; moving + with cancel, both requirements' changes and the history page, and a refused move. Not + exercised: the real GitHub timeline, and timelines over 100 events. - Evidence (ADR-033). Browser: the hint when no repository is connected; linking by number, `#number`, and address; timeline order, state, and author; refused references with what was typed kept; refresh picking up closed and merged items and diff --git a/docs/schema/requirement-evidence-suggestions-rollback.sql b/docs/schema/requirement-evidence-suggestions-rollback.sql new file mode 100644 index 0000000..e056ec2 --- /dev/null +++ b/docs/schema/requirement-evidence-suggestions-rollback.sql @@ -0,0 +1,24 @@ +START TRANSACTION; +ALTER TABLE public.requirement_evidence_events DROP CONSTRAINT "FK_requirement_evidence_events_requirements_other_requirement_~"; + +DROP TABLE public.requirement_evidence_suggestions; + +DROP INDEX public."IX_requirement_evidence_events_other_requirement_id"; + +ALTER TABLE public.requirement_evidence_events DROP CONSTRAINT ck_requirement_evidence_events_action; + +ALTER TABLE public.requirement_evidence_events DROP CONSTRAINT ck_requirement_evidence_events_move; + +ALTER TABLE public.requirement_evidence_events DROP COLUMN other_requirement_id; + +DELETE FROM public.requirement_evidence_events WHERE action = 'rejected'; +UPDATE public.requirement_evidence_events SET action = 'added' WHERE action IN ('confirmed', 'moved_in'); +UPDATE public.requirement_evidence_events SET action = 'removed' WHERE action = 'moved_out'; + +ALTER TABLE public.requirement_evidence_events ADD CONSTRAINT ck_requirement_evidence_events_action CHECK (action IN ('added', 'removed')); + +DELETE FROM "__EFMigrationsHistory" +WHERE "MigrationId" = '20261106010723_RequirementEvidenceSuggestions'; + +COMMIT; + diff --git a/docs/schema/requirement-evidence-suggestions.sql b/docs/schema/requirement-evidence-suggestions.sql new file mode 100644 index 0000000..c702b92 --- /dev/null +++ b/docs/schema/requirement-evidence-suggestions.sql @@ -0,0 +1,57 @@ +START TRANSACTION; +ALTER TABLE public.requirement_evidence_events DROP CONSTRAINT ck_requirement_evidence_events_action; + +ALTER TABLE public.requirement_evidence_events ADD other_requirement_id uuid; + +CREATE TABLE public.requirement_evidence_suggestions ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + requirement_id uuid NOT NULL, + repository_id bigint NOT NULL, + number integer NOT NULL, + title text NOT NULL, + state text NOT NULL, + author text, + url text NOT NULL, + reference text NOT NULL, + status text NOT NULL DEFAULT 'pending', + found_at timestamp with time zone NOT NULL DEFAULT (now()), + decided_by text, + decided_at timestamp with time zone, + CONSTRAINT "PK_requirement_evidence_suggestions" PRIMARY KEY (id), + CONSTRAINT ck_requirement_evidence_suggestions_decision CHECK ((status = 'pending') = (decided_by IS NULL) AND (decided_by IS NULL) = (decided_at IS NULL)), + CONSTRAINT ck_requirement_evidence_suggestions_number CHECK (number > 0 AND repository_id > 0), + CONSTRAINT ck_requirement_evidence_suggestions_state CHECK (state IN ('open', 'closed', 'merged')), + CONSTRAINT ck_requirement_evidence_suggestions_status CHECK (status IN ('pending', 'confirmed', 'rejected')), + CONSTRAINT "FK_requirement_evidence_suggestions_requirements_requirement_id" FOREIGN KEY (requirement_id) REFERENCES public.requirements (id) ON DELETE RESTRICT, + CONSTRAINT "FK_requirement_evidence_suggestions_user_decided_by" FOREIGN KEY (decided_by) REFERENCES public."user" (id) ON DELETE RESTRICT +); + +CREATE INDEX "IX_requirement_evidence_events_other_requirement_id" ON public.requirement_evidence_events (other_requirement_id); + +ALTER TABLE public.requirement_evidence_events ADD CONSTRAINT ck_requirement_evidence_events_action CHECK (action IN ('added', 'removed', 'confirmed', 'rejected', 'moved_in', 'moved_out')); + +ALTER TABLE public.requirement_evidence_events ADD CONSTRAINT ck_requirement_evidence_events_move CHECK ((action IN ('moved_in', 'moved_out')) = (other_requirement_id IS NOT NULL)); + +CREATE INDEX "IX_requirement_evidence_suggestions_decided_by" ON public.requirement_evidence_suggestions (decided_by); + +CREATE UNIQUE INDEX "IX_requirement_evidence_suggestions_requirement_id_repository_~" ON public.requirement_evidence_suggestions (requirement_id, repository_id, number); + +ALTER TABLE public.requirement_evidence_events ADD CONSTRAINT "FK_requirement_evidence_events_requirements_other_requirement_~" FOREIGN KEY (other_requirement_id) REFERENCES public.requirements (id) ON DELETE RESTRICT; + +ALTER TABLE public."requirement_evidence_suggestions" ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM PUBLIC; +DO $security$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_evidence_suggestions" FROM authenticated; + END IF; +END $security$; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261106010723_RequirementEvidenceSuggestions', '10.0.12'); + +COMMIT; + diff --git a/scripts/generate-requirement-evidence-suggestions.mjs b/scripts/generate-requirement-evidence-suggestions.mjs new file mode 100644 index 0000000..6c189de --- /dev/null +++ b/scripts/generate-requirement-evidence-suggestions.mjs @@ -0,0 +1,51 @@ +// Run after dotnet ef migrations add RequirementEvidenceSuggestions --project app/api. No database connection is opened. +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; + +const directory = "app/api/Migrations"; +const file = `${directory}/${readdirSync(directory).find(name => name.endsWith("_RequirementEvidenceSuggestions.cs"))}`; +let source = readFileSync(file, "utf8"); +if (!source.includes("ENABLE ROW LEVEL SECURITY")) { + const position = source.lastIndexOf(" }", source.indexOf("protected override void Down")); + if (position < 0) throw new Error("Unexpected migration format"); + // RLS is not expressible in EF's relational model, so it is added to the migration here. + const table = "requirement_evidence_suggestions"; + const security = ` + migrationBuilder.Sql(""" + ALTER TABLE public."${table}" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."${table}" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."${table}" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."${table}" FROM authenticated; + END IF; + END $security$; + """); +`; + source = source.slice(0, position) + security + source.slice(position); + // The earlier check allows only added and removed. A move is one of each and a confirmation + // an addition, so they stay in the history; a rejection has no earlier form and goes. + const down = source.indexOf("protected override void Down"); + const check = source.indexOf("migrationBuilder.AddCheckConstraint(", down); + if (check < 0) throw new Error("Unexpected migration format"); + const keep = `migrationBuilder.Sql(""" + DELETE FROM public.requirement_evidence_events WHERE action = 'rejected'; + UPDATE public.requirement_evidence_events SET action = 'added' WHERE action IN ('confirmed', 'moved_in'); + UPDATE public.requirement_evidence_events SET action = 'removed' WHERE action = 'moved_out'; + """); + + `; + source = source.slice(0, check) + keep + source.slice(check); + writeFileSync(file, source); +} +const env = { ...process.env, ConnectionStrings__Database: "Host=127.0.0.1;Port=1;Database=offline;Username=placeholder;Password=placeholder" }; +for (const [from, to, output] of [ + ["RequirementEvidenceEvents", "RequirementEvidenceSuggestions", "docs/schema/requirement-evidence-suggestions.sql"], + ["RequirementEvidenceSuggestions", "RequirementEvidenceEvents", "docs/schema/requirement-evidence-suggestions-rollback.sql"], +]) { + execFileSync("dotnet", ["ef", "migrations", "script", from, to, "--project", "app/api", "--configuration", "Release", "--output", output], { env, stdio: "inherit" }); + writeFileSync(output, readFileSync(output, "utf8").replace(/^/, ""), "utf8"); +} diff --git a/scripts/test-database.mjs b/scripts/test-database.mjs index 9c87c8d..6307327 100644 --- a/scripts/test-database.mjs +++ b/scripts/test-database.mjs @@ -61,6 +61,7 @@ export async function startTestDatabase() { await pool.query(await readSql("docs/schema/requirement-follow-ups.sql")); await pool.query(await readSql("docs/schema/requirement-criteria-version-texts.sql")); await pool.query(await readSql("docs/schema/requirement-evidence-events.sql")); + await pool.query(await readSql("docs/schema/requirement-evidence-suggestions.sql")); return { name, connectionString, pool, stop }; } catch (error) { await stop(); diff --git a/scripts/test-github.mjs b/scripts/test-github.mjs index bf01b4f..7665565 100644 --- a/scripts/test-github.mjs +++ b/scripts/test-github.mjs @@ -206,6 +206,26 @@ export function startFakeGitHub({ port, appId, publicKey }) { }); } + // An issue's timeline: a cross-reference from each pull request whose description mentions it. + const timeline = /^\/repositories\/(\d+)\/issues\/(\d+)\/timeline$/.exec(url.pathname); + if (timeline && request.method === "GET") { + if (bearer !== installationToken) return send(response, 401, { message: "Bad credentials" }); + const repositoryId = Number(timeline[1]); + const listed = items.get(repositoryId) ?? []; + if (!listed.some(entry => entry.number === Number(timeline[2]))) return send(response, 404, { message: "Not Found" }); + const repository = [...users.values()].flat().flatMap(installation => installation.repositories).find(entry => entry.id === repositoryId); + const mentions = listed.filter(entry => entry.pull && new RegExp(`#${timeline[2]}\\b|/issues/${timeline[2]}\\b`).test(entry.body ?? "")); + return send(response, 200, page([{ event: "labeled" }, ...mentions.map(entry => ({ + event: "cross-referenced", + source: { type: "issue", issue: { + number: entry.number, title: entry.title, state: entry.state, body: entry.body, user: entry.user === null ? null : { login: entry.user ?? "octocat" }, + html_url: `https://github.com/${repository.owner}/${repository.name}/pull/${entry.number}`, + repository_url: `https://api.github.com/repos/${repository.owner}/${repository.name}`, + pull_request: { merged_at: entry.merged ? entry.closed_at ?? entry.updated_at : null }, + } }, + }))], url)); + } + const item = /^\/repositories\/(\d+)\/(issues|pulls)\/(\d+)$/.exec(url.pathname); if (item && request.method === "GET") { if (bearer !== installationToken) return send(response, 401, { message: "Bad credentials" }); diff --git a/tests/api/project-data.spec.ts b/tests/api/project-data.spec.ts index fda5a1b..f4a86bf 100644 --- a/tests/api/project-data.spec.ts +++ b/tests/api/project-data.spec.ts @@ -10,7 +10,7 @@ import { criteriaVersionText, parseCriteriaVersion, parseCriteriaVersions } from import { followUpEventText, parseFollowUps } from "../../app/web/src/lib/follow-ups"; import { checkSummary, evidenceChanges, evidenceCountText, evidenceEventText, evidenceLabel, evidenceReferenceError, parseEvidence, parseEvidenceHistory, - parseEvidenceList, parseEvidenceSearch, problemDetail, + parseEvidenceList, parseEvidenceSearch, parseEvidenceSuggestions, problemDetail, releaseContents, releasesContaining, } from "../../app/web/src/lib/evidence"; import { parseAvailableRepositories, parseProjectRepository, parseRepositoryChoice } from "../../app/web/src/lib/repositories"; @@ -458,11 +458,31 @@ test("title search results and the evidence history are accepted only in the doc const event = { id: "v1", evidenceId: "e1", action: "removed" as const, kind: "commit", label: "4f2a9c1", title: "Fix totals", actor: "u1", occurredAt: "2026-10-30T10:00:00Z" }; - expect(parseEvidenceHistory([{ ...event, extra: 1 }])).toEqual([event]); + expect(parseEvidenceHistory([{ ...event, extra: 1 }])).toEqual([{ ...event, otherRequirementId: null, otherRequirementTitle: null }]); for (const value of [null, {}, [{ ...event, action: "renamed" }], [{ ...event, kind: "deployment" }], [{ ...event, label: 1 }], [{ ...event, actor: null }], [{ ...event, occurredAt: "soon" }]]) { expect(() => parseEvidenceHistory(value), JSON.stringify(value)).toThrow("unexpected evidence history"); } - expect(evidenceEventText(event)).toBe("Removed 4f2a9c1 “Fix totals”"); - expect(evidenceEventText({ ...event, action: "added" as const, label: "#15", title: "Add guest checkout" })).toBe("Linked #15 “Add guest checkout”"); + // The previous API sends no other requirement. + const change = { action: "removed" as const, label: "4f2a9c1", title: "Fix totals", otherRequirementTitle: null }; + expect(parseEvidenceHistory([event])[0]).toMatchObject({ otherRequirementId: null, otherRequirementTitle: null }); + expect(parseEvidenceHistory([{ ...event, action: "moved_in", otherRequirementId: "r2", otherRequirementTitle: "Saved carts" }])[0]) + .toMatchObject({ action: "moved_in", otherRequirementId: "r2", otherRequirementTitle: "Saved carts" }); + expect(() => parseEvidenceHistory([{ ...event, otherRequirementId: 1 }])).toThrow("unexpected evidence history"); + expect(() => parseEvidenceHistory([{ ...event, otherRequirementTitle: 1 }])).toThrow("unexpected evidence history"); + expect(evidenceEventText(change)).toBe("Removed 4f2a9c1 “Fix totals”"); + const pull = { label: "#15", title: "Add checkout", otherRequirementTitle: null }; + expect(evidenceEventText({ ...pull, action: "added" })).toBe("Linked #15 “Add checkout”"); + expect(evidenceEventText({ ...pull, action: "confirmed" })).toBe("Confirmed suggestion #15 “Add checkout”"); + expect(evidenceEventText({ ...pull, action: "rejected" })).toBe("Rejected suggestion #15 “Add checkout”"); + expect(evidenceEventText({ ...pull, action: "moved_out", otherRequirementTitle: "Saved carts" })).toBe("Moved #15 “Add checkout” to “Saved carts”"); + expect(evidenceEventText({ ...pull, action: "moved_in", otherRequirementTitle: "Guest checkout" })).toBe("Moved #15 “Add checkout” here from “Guest checkout”"); + + const suggestion = { id: "s1", number: 15, title: "Add checkout", state: "open", author: null, url: "https://github.com/acme/web/pull/15", + reference: "Closes #184", foundAt: "2026-11-05T10:00:00Z" }; + expect(parseEvidenceSuggestions([{ ...suggestion, extra: 1 }])).toEqual([suggestion]); + for (const value of [null, {}, [null], [{ ...suggestion, number: 0 }], [{ ...suggestion, state: "draft" }], [{ ...suggestion, author: 1 }], + [{ ...suggestion, url: "https://evil.example/pull/15" }], [{ ...suggestion, reference: null }], [{ ...suggestion, foundAt: "soon" }]]) { + expect(() => parseEvidenceSuggestions(value), JSON.stringify(value)).toThrow("unexpected suggestions"); + } }); diff --git a/tests/e2e/evidence.spec.ts b/tests/e2e/evidence.spec.ts index a4dcf84..fb53a40 100644 --- a/tests/e2e/evidence.spec.ts +++ b/tests/e2e/evidence.spec.ts @@ -691,3 +691,122 @@ test("a search GitHub can't answer, or the API refuses, is explained and links n await expect(page.getByRole("list", { name: "Search results" })).toHaveCount(0); await expect(rows(page)).toHaveCount(0); }); + +/** Adds a second requirement to the project and returns its address. */ +async function addRequirement(page: Page, projectId: string, title: string) { + await page.goto(`/projects/${projectId}/requirements/new`); + await page.getByLabel("Title").fill(title); + await page.getByLabel("Criterion 1", { exact: true }).fill("Done"); + await page.getByRole("button", { name: "Create requirement" }).click(); + await expect(page).toHaveURL(/\/requirements\/[0-9a-f-]{36}$/); + return page.url(); +} + +test("pull requests that close a linked issue are suggested apart from the evidence, then confirmed or rejected", async ({ page }, testInfo) => { + const { projectId, url } = await createRequirement(page); + await connect(projectId, [ + issue(12, "Guests cannot pay"), + issue(15, "Add guest checkout", { pull: true, user: "grace", body: "Closes #12" }), + issue(16, "Unrelated cleanup", { pull: true, body: "Fixes #12" }), + issue(17, "Only mentions it", { pull: true, body: "See #12" }), + ]); + await page.reload(); + await link(page, "12"); + await expect(rows(page)).toHaveCount(1); + await page.getByRole("button", { name: "Find suggestions" }).click(); + + const suggested = page.locator('ul[aria-label="Suggested links"] > li'); + await expect(suggested).toHaveCount(2); + await expect(suggested.nth(0)).toContainText("Suggested"); + await expect(suggested.nth(0)).toContainText("Its description says “Closes #12”. Not evidence until confirmed."); + await expect(suggested.nth(0)).toContainText("grace"); + // Not evidence: the linked list and the requirement list don't count them. + await expect(rows(page)).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath("evidence-suggestions.png"), fullPage: true }); + await page.goto(`/projects/${projectId}/requirements`); + await expect(page.getByRole("list", { name: "Requirements" }).getByRole("listitem")).toContainText("1 evidence link"); + await page.goto(url); + + await page.getByRole("button", { name: "Confirm #15" }).click(); + await expect(rows(page)).toHaveCount(2); + await expect(rows(page).filter({ hasText: "#15" })).toContainText("Suggested, confirmed by Evidence tester on"); + await page.getByRole("button", { name: "Reject #16" }).click(); + await expect(suggested).toHaveCount(0); + await expect(rows(page)).toHaveCount(2); + + await page.getByText("3 link changes").click(); + const changes = page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem"); + await expect(changes.nth(0)).toContainText("Rejected suggestion #16 “Unrelated cleanup” by Evidence tester"); + await expect(changes.nth(1)).toContainText("Confirmed suggestion #15 “Add guest checkout” by Evidence tester"); + + // A rejected suggestion stays rejected. + await page.getByRole("button", { name: "Find suggestions" }).click(); + await expect(page.getByRole("button", { name: "Find suggestions" })).toBeEnabled(); + await expect(suggested).toHaveCount(0); +}); + +test("a failed search for suggestions, or a refused decision, is explained and changes nothing", async ({ page }) => { + const { projectId, requirementId } = await createRequirement(page); + await connect(projectId, [issue(12, "Guests cannot pay"), issue(15, "Add guest checkout", { pull: true, body: "Closes #12" })]); + await page.reload(); + await link(page, "12"); + const scan = `^/requirements/${requirementId}/evidence/suggestions/scan$`; + await failApi(owner.userId, { method: "POST", path: scan, status: 502, body: { status: 502 } }); + await page.getByRole("button", { name: "Find suggestions" }).click(); + await expect(page.getByRole("alert").filter({ hasText: "GitHub could not be reached. Try again in a moment." })).toBeVisible(); + await clearApiFaults(owner.userId); + + await page.getByRole("button", { name: "Find suggestions" }).click(); + await expect(page.getByRole("button", { name: "Confirm #15" })).toBeVisible(); + await failApi(owner.userId, { method: "POST", path: `^/requirements/${requirementId}/evidence/suggestions/.*/confirm$`, status: 409, body: { detail: "Someone has already decided on this suggestion. Reload to see it." } }); + await page.getByRole("button", { name: "Confirm #15" }).click(); + await expect(page.getByRole("alert").filter({ hasText: "Someone has already decided on this suggestion." })).toBeVisible(); + await expect(rows(page)).toHaveCount(1); +}); + +test("a wrongly linked pull request moves to the right requirement, and both show who moved it and when", async ({ page }, testInfo) => { + const { projectId, url } = await createRequirement(page); + await connect(projectId, [issue(15, "Add saved carts", { pull: true })]); + const other = await addRequirement(page, projectId, "Saved carts"); + await page.goto(url); + await link(page, "15"); + await expect(rows(page)).toHaveCount(1); + + // Cancelling moves nothing. + await page.getByRole("button", { name: "Move link to #15", exact: true }).click(); + await page.getByRole("form", { name: "Move #15" }).getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("form", { name: "Move #15" })).toHaveCount(0); + + await page.getByRole("button", { name: "Move link to #15", exact: true }).click(); + const form = page.getByRole("form", { name: "Move #15" }); + await form.getByLabel("Move #15 to").selectOption({ label: "Saved carts" }); + await page.screenshot({ path: testInfo.outputPath("evidence-move.png"), fullPage: true }); + await form.getByRole("button", { name: "Move link" }).click(); + await expect(rows(page)).toHaveCount(0); + await page.getByText("2 link changes").click(); + await expect(page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem").nth(0)) + .toContainText("Moved #15 “Add saved carts” to “Saved carts” by Evidence tester"); + + await page.goto(other); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page)).toContainText("Linked by Evidence tester on"); + await page.getByText("1 link change").click(); + await expect(page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem")) + .toContainText("Moved #15 “Add saved carts” here from “Guest checkout” by Evidence tester"); + await page.goto(`${other}/history`); + await expect(page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem")).toContainText("here from “Guest checkout”"); +}); + +test("a refused move is explained, and the link stays where it was", async ({ page }) => { + const { projectId, requirementId, url } = await createRequirement(page); + await connect(projectId, [issue(15, "Add saved carts", { pull: true })]); + await addRequirement(page, projectId, "Saved carts"); + await page.goto(url); + await link(page, "15"); + await failApi(owner.userId, { method: "POST", path: `^/requirements/${requirementId}/evidence/.*/move$`, status: 409, body: { detail: "#15 is already linked to that requirement." } }); + await page.getByRole("button", { name: "Move link to #15", exact: true }).click(); + await page.getByLabel("Move #15 to").selectOption({ label: "Saved carts" }); + await page.getByRole("button", { name: "Move link", exact: true }).click(); + await expect(page.getByRole("alert").filter({ hasText: "#15 is already linked to that requirement." })).toBeVisible(); + await expect(rows(page)).toHaveCount(1); +}); diff --git a/tests/schema/evidence-suggestions.spec.ts b/tests/schema/evidence-suggestions.spec.ts new file mode 100644 index 0000000..120c3f3 --- /dev/null +++ b/tests/schema/evidence-suggestions.spec.ts @@ -0,0 +1,365 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { generateKeyPairSync } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { Server } from "node:http"; +import { startTestDatabase } from "../../scripts/test-database.mjs"; +import { startFakeGitHub } from "../../scripts/test-github.mjs"; +import { startApi } from "../support/api-process"; +import { stillWaiting, withTeamLock } from "../support/team-lock"; + +// Suggested links from explicit references, and moving a link between requirements (ADR-056), +// against a disposable PostgreSQL database and a local stand-in for GitHub. +const issuer = "http://127.0.0.1:5101"; +const gitHub = "http://127.0.0.1:5130"; +const key = generateKeyPairSync("rsa", { modulusLength: 2048 }); +let database: Awaited>; +let api: Awaited>; +let fake: Server; +let nextId = 5000; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(async () => { + test.setTimeout(240_000); + database = await startTestDatabase(); + await database.pool.query(`INSERT INTO public."user" (id,name,email,"emailVerified","createdAt","updatedAt") VALUES + ('owner','Owner','owner@example.invalid',true,now(),now()), + ('member','Member','member@example.invalid',true,now(),now()), + ('outsider','Outsider','outsider@example.invalid',true,now(),now())`); + await database.pool.query(`INSERT INTO public.user_onboarding (user_id,completed_at) SELECT id,now() FROM public."user"`); + const url = new URL(database.connectionString); + fake = await startFakeGitHub({ port: 5130, appId: "321", publicKey: key.publicKey }); + api = await startApi(5131, { + ConnectionStrings__Database: `Host=${url.hostname};Port=${url.port};Database=postgres;Username=${url.username};Password=${url.password}`, + Auth__Issuer: issuer, GitHub__ApiBaseUrl: gitHub, GitHub__AppId: "321", + GitHub__PrivateKey: String(key.privateKey.export({ type: "pkcs1", format: "pem" })), + }); +}); +test.afterAll(async () => { + api?.stop(); + await new Promise(resolve => fake ? fake.close(resolve) : resolve(undefined)); + await database?.stop(); +}); + +async function as(request: APIRequestContext, sub: string) { + const minted = await request.post(`${issuer}/sign`, { data: { claims: { sub } } }); + const headers = { authorization: `Bearer ${(await minted.json()).token}` }; + const url = (path: string) => `${api.baseURL}${path}`; + return { + get: (path: string) => request.get(url(path), { headers }), + post: (path: string, data?: object) => request.post(url(path), { headers, data }), + delete: (path: string) => request.delete(url(path), { headers }), + }; +} + +type Item = { number: number; pull?: boolean; merged?: boolean; title: string; state: string; user?: string | null; body?: string; created_at: string; updated_at: string; closed_at?: string }; +const item = (number: number, title: string, extra: Partial = {}): Item => + ({ number, title, state: "open", user: "ada", created_at: "2026-09-01T10:00:00Z", updated_at: "2026-10-01T10:00:00Z", ...extra }); +const pull = (number: number, title: string, body: string, extra: Partial = {}) => item(number, title, { pull: true, body, ...extra }); + +/** A project with a member, requirements of the given titles, and a connected repository GitHub knows with these items. */ +async function setUp(request: APIRequestContext, items: Item[] | ((name: string) => Item[]), titles = ["Guest checkout", "Saved carts"], connected = true) { + const owner = await as(request, "owner"); + const team = await (await owner.post("/teams", { name: "Product team" })).json(); + const project = await (await owner.post("/projects", { name: "Checkout", teamId: team.id })).json(); + // Fixture-only membership. In the product a person joins a team by accepting an invitation. + await database.pool.query("INSERT INTO public.team_members (team_id,user_id) VALUES ($1,'member')", [team.id]); + const ids: string[] = []; + for (const title of titles) ids.push((await (await owner.post(`/projects/${project.id}/requirements`, { title, acceptanceCriteria: ["Done"] })).json()).id); + const [installationId, repositoryId] = [nextId++, nextId++]; + const name = `web-${repositoryId}`; + await request.put(`${gitHub}/__github/users/seed-${installationId}`, { data: { installations: [{ id: installationId, repositories: [{ id: repositoryId, owner: "acme", name }] }] } }); + await request.put(`${gitHub}/__github/repositories/${repositoryId}/items`, { data: { items: typeof items === "function" ? items(name) : items } }); + if (connected) { + await database.pool.query(`INSERT INTO public.project_repositories (project_id,installation_id,repository_id,owner,name,is_private,connected_by) + VALUES ($1,$2,$3,'acme',$4,false,'owner')`, [project.id, installationId, repositoryId, name]); + } + return { projectId: project.id as string, ids, paths: ids.map(id => `/requirements/${id}/evidence`), name, repositoryId }; +} + +type Suggestion = { id: string; number: number; title: string; reference: string; state: string; author: string | null }; +type Event = { action: string; label: string; actor: string; otherRequirementId: string | null; otherRequirementTitle: string | null }; +const scan = async (actor: Awaited>, path: string): Promise => { + const response = await actor.post(`${path}/suggestions/scan`); + expect(response.status()).toBe(200); + return response.json(); +}; + +test("pull requests that close a linked issue are suggested, and only those", async ({ request }) => { + const { paths: [path], name: repository } = await setUp(request, name => [ + item(184, "Guests cannot pay"), + pull(201, "Add guest checkout", "This work.\n\nCloses #184", { user: "grace" }), + pull(202, "Fix the cart", "fixes: #184 and tidies up", { merged: true, state: "closed", closed_at: "2026-09-04T10:00:00Z" }), + pull(203, "Address form", `Resolved https://github.com/acme/${name}/issues/184`), + pull(204, "Also by name", `closed acme/${name}#184`), + pull(205, "Only mentions it", "See #184 for background"), + pull(206, "Another repository's issue", "Closes acme/other#184"), + pull(207, "A longer number", "Closes #1840"), + item(185, "Linked issue with nothing"), + item(300, "Unlinked issue"), + pull(301, "Closes an unlinked issue", "Closes #300"), + ]); + const member = await as(request, "member"); + expect((await member.post(path, { reference: "184" })).status()).toBe(201); + expect((await member.post(path, { reference: "185" })).status()).toBe(201); + expect(await (await member.get(`${path}/suggestions`)).json()).toEqual([]); + + const found = await scan(member, path); + expect(found.map(s => [s.number, s.reference, s.state])).toEqual([ + [201, "Closes #184", "open"], [202, "Fixes #184", "merged"], [203, "Resolved #184", "open"], [204, "Closed #184", "open"], + ]); + expect(found[0]).toMatchObject({ title: "Add guest checkout", author: "grace", url: `https://github.com/acme/${repository}/pull/201` }); + expect(await (await member.get(`${path}/suggestions`)).json()).toEqual(found); + // Scanning again changes nothing, and the description itself is not kept. + expect(await scan(member, path)).toEqual(found); + const { rows } = await database.pool.query("SELECT * FROM public.requirement_evidence_suggestions"); + expect(JSON.stringify(rows)).not.toContain("tidies up"); +}); + +test("a suggestion is not evidence until it is confirmed, and confirming and rejecting are recorded", async ({ request }) => { + const { projectId, ids: [id], paths: [path] } = await setUp(request, [ + item(1, "Guests cannot pay"), pull(2, "Add guest checkout", "Closes #1"), pull(3, "Wrong one", "Fixes #1"), + ]); + const [owner, member] = [await as(request, "owner"), await as(request, "member")]; + expect((await owner.post(path, { reference: "1" })).status()).toBe(201); + const [first, second] = await scan(owner, path); + const summary = async () => (await (await owner.get(`/projects/${projectId}/requirements`)).json()).find((r: { id: string }) => r.id === id); + + // Pending: no link, not counted, not in what a reviewer sees, and no Evidence complete. + expect((await (await owner.get(path)).json()).map((e: { number: number }) => e.number)).toEqual([1]); + expect(await summary()).toMatchObject({ evidenceCount: 1, evidenceComplete: false }); + const reviewed = await (await member.post(`/requirements/${id}/reviews`, { decision: "accepted", version: 1 })).json(); + expect(reviewed.evidence.map((e: { label: string }) => e.label)).toEqual(["#1"]); + expect((await summary()).review.outdated).toBe(false); + + const confirmed = await member.post(`${path}/suggestions/${first.id}/confirm`); + expect(confirmed.status()).toBe(201); + expect(await confirmed.json()).toMatchObject({ number: 2, kind: "pull_request", source: "suggested", linkedBy: "member" }); + expect((await member.post(`${path}/suggestions/${first.id}/confirm`)).status()).toBe(409); + expect((await member.post(`${path}/suggestions/${first.id}/reject`)).status()).toBe(409); + expect(await summary()).toMatchObject({ evidenceCount: 2 }); + expect((await summary()).review.outdated).toBe(true); + + expect((await owner.post(`${path}/suggestions/${second.id}/reject`)).status()).toBe(204); + expect((await owner.post(`${path}/suggestions/${second.id}/confirm`)).status()).toBe(409); + expect(await (await owner.get(`${path}/suggestions`)).json()).toEqual([]); + expect((await (await owner.get(path)).json())).toHaveLength(2); + + const history: Event[] = await (await owner.get(`${path}/history`)).json(); + expect(history.map(e => [e.action, e.label, e.actor])).toEqual([["rejected", "#3", "owner"], ["confirmed", "#2", "member"], ["added", "#1", "owner"]]); + const { rows } = await database.pool.query("SELECT number, status, decided_by, decided_at FROM public.requirement_evidence_suggestions WHERE requirement_id = $1 ORDER BY number", [id]); + expect(rows.map(r => [r.number, r.status, r.decided_by])).toEqual([[2, "confirmed", "member"], [3, "rejected", "owner"]]); + expect(rows.every(r => r.decided_at !== null)).toBe(true); + + // A decision stands: neither comes back, though the rejected one can still be linked by hand. + expect(await scan(owner, path)).toEqual([]); + expect((await owner.post(path, { reference: "3" })).status()).toBe(201); +}); + +test("a suggestion follows what GitHub and people do next", async ({ request }) => { + const { ids: [, otherId], paths: [path], repositoryId } = await setUp(request, [ + item(1, "Guests cannot pay"), pull(2, "By hand", "Closes #1"), pull(3, "Edited later", "Closes #1"), pull(4, "Deleted later", "Closes #1"), + ]); + const owner = await as(request, "owner"); + expect((await owner.post(path, { reference: "1" })).status()).toBe(201); + const found = await scan(owner, path); + expect(found).toHaveLength(3); + + // Linked by hand: nothing left to decide. + expect((await owner.post(path, { reference: "2" })).status()).toBe(201); + expect((await (await owner.get(`${path}/suggestions`)).json()).map((s: Suggestion) => s.number)).toEqual([3, 4]); + expect((await owner.post(`${path}/suggestions/${found[0].id}/confirm`)).status()).toBe(404); + + // A reference that was edited out goes on the next read, which a refresh also does. + await request.put(`${gitHub}/__github/repositories/${repositoryId}/items`, { data: { items: [ + item(1, "Guests cannot pay"), pull(2, "By hand", "Closes #1"), pull(3, "Edited later", "No longer related"), pull(5, "Opened later", "Closes #1"), + ] } }); + expect((await owner.post(`${path}/refresh`)).status()).toBe(200); + const fresh: Suggestion[] = await (await owner.get(`${path}/suggestions`)).json(); + expect(fresh.map(s => s.number)).toEqual([5]); + + // Gone from GitHub before anyone confirmed it. + await request.put(`${gitHub}/__github/repositories/${repositoryId}/items`, { data: { items: [item(1, "Guests cannot pay")] } }); + const gone = await owner.post(`${path}/suggestions/${fresh[0].id}/confirm`); + expect(gone.status()).toBe(409); + expect((await gone.json()).detail).toContain("GitHub no longer has #5"); + + // Another requirement's suggestion can't be decided through this one. + expect((await owner.post(`/requirements/${otherId}/evidence/suggestions/${fresh[0].id}/reject`)).status()).toBe(404); +}); + +test("suggestions need a member, an active requirement, a repository, and GitHub", async ({ request }) => { + const { projectId, paths: [path], repositoryId } = await setUp(request, [item(1, "Guests cannot pay"), pull(2, "Add it", "Closes #1")]); + const owner = await as(request, "owner"); + expect((await owner.post(path, { reference: "1" })).status()).toBe(201); + const [found] = await scan(owner, path); + + const outsider = await as(request, "outsider"); + for (const response of [await outsider.get(`${path}/suggestions`), await outsider.post(`${path}/suggestions/scan`), + await outsider.post(`${path}/suggestions/${found.id}/confirm`), await outsider.post(`${path}/suggestions/${found.id}/reject`)]) { + expect(response.status()).toBe(404); + } + expect((await request.get(`${api.baseURL}${path}/suggestions`)).status()).toBe(401); + + const fault = (data: object) => request.post(`${gitHub}/__github/faults`, { data: { bearer: "installation", path: `^/repositories/${repositoryId}/issues/1/timeline$`, ...data } }); + const clear = () => request.delete(`${gitHub}/__github/faults?bearer=installation`); + for (const data of [{ status: 500 }, { status: 200, body: { not: "a list" } }, { close: true }]) { + await fault(data); + expect((await owner.post(`${path}/suggestions/scan`)).status(), JSON.stringify(data)).toBe(502); + await clear(); + } + // Pull requests of another repository that mention the issue are not this repository's. + await fault({ status: 200, times: 1, body: [{ event: "cross-referenced", source: { type: "issue", issue: { + number: 9, title: "Elsewhere", state: "open", body: "Closes #1", user: null, html_url: "https://github.com/acme/secret/pull/9", + repository_url: "https://api.github.com/repos/acme/secret", pull_request: {}, + } } }] }); + expect(await scan(owner, path)).toEqual([]); + expect((await scan(owner, path)).map(s => s.number)).toEqual([2]); + const [again] = await (await owner.get(`${path}/suggestions`)).json(); + + // A decision waits for a concurrent team write, then sees the project it archived. + const pending = await withTeamLock(database.pool, projectId, async client => { + const response = owner.post(`${path}/suggestions/${again.id}/reject`); + expect(await stillWaiting(response)).toBe(true); + await client.query("UPDATE public.projects SET archived_at = now() WHERE id = $1", [projectId]); + return { response }; + }); + expect((await pending.response).status()).toBe(409); + for (const response of [await owner.post(`${path}/suggestions/scan`), await owner.post(`${path}/suggestions/${again.id}/confirm`)]) { + expect(response.status()).toBe(409); + } + await database.pool.query("UPDATE public.projects SET archived_at = NULL WHERE id = $1", [projectId]); + + // Disconnected: nothing is offered, and nothing can be found. + await database.pool.query("DELETE FROM public.project_repositories WHERE project_id = $1", [projectId]); + expect(await (await owner.get(`${path}/suggestions`)).json()).toEqual([]); + expect((await owner.post(`${path}/suggestions/scan`)).status()).toBe(409); + expect((await owner.post(`${path}/suggestions/${again.id}/confirm`)).status()).toBe(409); +}); + +test("a link moves to another requirement in one step, and both histories show it", async ({ request }) => { + const { projectId, ids: [fromId, toId], paths: [from, to] } = await setUp(request, [ + item(1, "Guests cannot pay"), pull(2, "Add guest checkout", "Closes #1"), + ]); + const [owner, member] = [await as(request, "owner"), await as(request, "member")]; + expect((await owner.post(from, { reference: "1" })).status()).toBe(201); + const link = await (await owner.post(from, { reference: "2" })).json(); + const decided = await (await member.post(`/requirements/${fromId}/reviews`, { decision: "accepted", version: 1 })).json(); + expect(decided.evidence).toHaveLength(2); + + const moved = await member.post(`${from}/${link.id}/move`, { targetRequirementId: toId }); + expect(moved.status()).toBe(200); + expect(await moved.json()).toMatchObject({ id: link.id, requirementId: toId, number: 2, linkedBy: "member", source: "manual" }); + expect((await (await owner.get(from)).json()).map((e: { number: number }) => e.number)).toEqual([1]); + expect((await (await owner.get(to)).json()).map((e: { number: number }) => e.number)).toEqual([2]); + + const [left, arrived]: Event[][] = [await (await owner.get(`${from}/history`)).json(), await (await owner.get(`${to}/history`)).json()]; + expect(left[0]).toMatchObject({ action: "moved_out", label: "#2", actor: "member", otherRequirementId: toId, otherRequirementTitle: "Saved carts" }); + expect(arrived).toHaveLength(1); + expect(arrived[0]).toMatchObject({ action: "moved_in", label: "#2", actor: "member", otherRequirementId: fromId, otherRequirementTitle: "Guest checkout" }); + + // The decision on the requirement it left no longer describes its evidence. + const listed = await (await owner.get(`/projects/${projectId}/requirements`)).json(); + expect(listed.find((r: { id: string }) => r.id === fromId).review.outdated).toBe(true); + // A person said it doesn't belong there, so it isn't suggested back, even though it closes a linked issue. + expect(await scan(owner, from)).toEqual([]); + + // It can go back, which is also recorded. + expect((await owner.post(`${to}/${link.id}/move`, { targetRequirementId: fromId })).status()).toBe(200); + expect(((await (await owner.get(`${to}/history`)).json()) as Event[]).map(e => e.action)).toEqual(["moved_out", "moved_in"]); +}); + +test("moves stay within active requirements of the project and never duplicate a link", async ({ request }) => { + const { projectId, ids: [, toId, archivedId], paths: [from, to] } = await setUp(request, [item(1, "One"), item(2, "Two")], ["From", "To", "Old"]); + const { ids: [elsewhere] } = await setUp(request, [], ["Other project"]); + const owner = await as(request, "owner"); + const link = await (await owner.post(from, { reference: "1" })).json(); + expect((await owner.post(to, { reference: "1" })).status()).toBe(201); + const move = (targetRequirementId: unknown) => owner.post(`${from}/${link.id}/move`, { targetRequirementId }); + + for (const [target, message] of [[null, "Choose a requirement."], [link.requirementId, "This link is already on that requirement."], + [elsewhere, "Choose a requirement in this project."], ["00000000-0000-4000-8000-000000000000", "Choose a requirement in this project."]]) { + const refused = await move(target); + expect(refused.status(), String(target)).toBe(400); + expect((await refused.json()).errors.targetRequirementId).toEqual([message]); + } + const duplicate = await move(toId); + expect(duplicate.status()).toBe(409); + expect((await duplicate.json()).detail).toBe("#1 is already linked to that requirement."); + + expect((await owner.post(`/requirements/${archivedId}/archive`)).status()).toBe(200); + expect((await move(archivedId)).status()).toBe(409); + expect((await (await as(request, "outsider")).post(`${from}/${link.id}/move`, { targetRequirementId: toId })).status()).toBe(404); + expect((await owner.post(`${from}/00000000-0000-4000-8000-000000000000/move`, { targetRequirementId: toId })).status()).toBe(404); + expect(await (await owner.get(from)).json()).toHaveLength(1); + + // A full target refuses, and so does an archived project. + await database.pool.query(`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) + SELECT $1,'issue',1,'acme','other',n,'open','t','https://github.com/acme/other/issues/1',now(),now(),'owner' FROM generate_series(100,148) n`, [toId]); + const full = await (await owner.post(from, { reference: "2" })).json(); + const refused = await owner.post(`${from}/${full.id}/move`, { targetRequirementId: toId }); + expect(refused.status()).toBe(409); + expect((await refused.json()).detail).toBe("A requirement can have at most 50 evidence links."); + await database.pool.query("UPDATE public.projects SET archived_at = now() WHERE id = $1", [projectId]); + expect((await owner.post(`${from}/${full.id}/move`, { targetRequirementId: toId })).status()).toBe(409); +}); + +test("a moved change is compared with the releases it joins, and a moved release with the changes it joins", async ({ request }) => { + const [merged, tagged] = ["abc1234".padEnd(40, "0"), "1111111".padEnd(40, "0")]; + const { ids: [, toId], paths: [from, to], repositoryId } = await setUp(request, [pull(9, "Add checkout", "", { merged: true, state: "closed", merge_sha: merged } as Partial)]); + await request.put(`${gitHub}/__github/repositories/${repositoryId}/releases`, { data: { releases: [{ tag: "v1", published_at: "2026-09-25T10:00:00Z", sha: tagged, contains: [merged] }] } }); + const owner = await as(request, "owner"); + const change = await (await owner.post(from, { reference: "9" })).json(); + const release = await (await owner.post(to, { reference: "v1" })).json(); + const contains = async (path: string) => (await (await owner.get(path)).json()).find((e: { kind: string }) => e.kind === "release").contains; + expect(await contains(to)).toEqual({}); + + expect((await owner.post(`${from}/${change.id}/move`, { targetRequirementId: toId })).status()).toBe(200); + expect(await contains(to)).toEqual({ [change.id]: true }); + + // Back apart, then the release follows the change and is compared afresh. + const fromId = from.split("/")[2]; + expect((await owner.post(`${to}/${change.id}/move`, { targetRequirementId: fromId })).status()).toBe(200); + expect((await owner.post(`${to}/${release.id}/move`, { targetRequirementId: fromId })).status()).toBe(200); + expect(await contains(from)).toEqual({ [change.id]: true }); +}); + +test("the migration protects suggestions, constrains them and the history, and its rollback keeps what the old history can hold", async ({ request }) => { + const { ids: [fromId, toId], paths: [from] } = await setUp(request, [item(1, "One"), pull(2, "Two", "Closes #1"), pull(3, "Three", "Closes #1")]); + const owner = await as(request, "owner"); + expect((await owner.post(from, { reference: "1" })).status()).toBe(201); + const [confirm, reject] = await scan(owner, from); + expect((await owner.post(`${from}/suggestions/${confirm.id}/confirm`)).status()).toBe(201); + expect((await owner.post(`${from}/suggestions/${reject.id}/reject`)).status()).toBe(204); + const [link] = await (await owner.get(from)).json(); + expect((await owner.post(`${from}/${link.id}/move`, { targetRequirementId: toId })).status()).toBe(200); + + const count = async (sql: string, values: unknown[] = []) => (await database.pool.query(sql, values)).rows[0].count; + const table = "requirement_evidence_suggestions"; + expect(await count(`SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='${table}' AND rowsecurity`)).toBe(1); + expect(await count(`SELECT count(*)::int AS count FROM information_schema.role_table_grants WHERE table_name='${table}' AND grantee IN ('anon','authenticated','PUBLIC')`)).toBe(0); + const insert = (values: Record) => { + const row = { requirement_id: fromId, repository_id: 1, number: 900, title: "t", state: "open", url: "https://github.com/a/b/pull/1", reference: "Closes #1", ...values }; + const names = Object.keys(row); + return database.pool.query(`INSERT INTO public.${table} (${names.join(",")}) VALUES (${names.map((_, i) => `$${i + 1}`).join(",")})`, Object.values(row)); + }; + await insert({}); + await expect(insert({})).rejects.toThrow(/duplicate key/); + for (const values of [{ status: "maybe" }, { state: "draft" }, { number: 0 }, { status: "confirmed" }, { decided_by: "owner" }, { title: null }]) { + await expect(insert({ number: 901, ...values }), JSON.stringify(values)).rejects.toThrow(/check constraint|not-null/); + } + await expect(insert({ number: 902, status: "rejected", decided_by: "nobody", decided_at: new Date() })).rejects.toThrow(/foreign key/); + await expect(database.pool.query(`INSERT INTO public.requirement_evidence_events (requirement_id,evidence_id,action,kind,label,title,actor) + VALUES ($1,gen_random_uuid(),'moved_in','issue','#1','t','owner')`, [fromId])).rejects.toThrow(/check constraint/); + + const history = await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`); + const links = await count("SELECT count(*)::int AS count FROM public.requirement_evidence"); + await database.pool.query(await readFile("docs/schema/requirement-evidence-suggestions-rollback.sql", "utf8")); + expect(await count(`SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='${table}'`)).toBe(0); + expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence")).toBe(links); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(history - 1); + // A move stays as a removal and an addition, a confirmation as an addition; a rejection has no earlier form. + const actions = async (id: string) => (await database.pool.query("SELECT action FROM public.requirement_evidence_events WHERE requirement_id = $1 ORDER BY occurred_at, action", [id])).rows.map(r => r.action); + expect(await actions(fromId)).toEqual(["added", "added", "removed"]); + expect(await actions(toId)).toEqual(["added"]); +}); diff --git a/tests/schema/evidence.spec.ts b/tests/schema/evidence.spec.ts index e522f54..4354d93 100644 --- a/tests/schema/evidence.spec.ts +++ b/tests/schema/evidence.spec.ts @@ -580,7 +580,7 @@ test("the release migration constrains what a release row can be, and its rollba expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE kind = 'release'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence")).toBe(others); expect(await count(columns)).toBe(0); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(19); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(20); }); test("the check migration defaults every link to manual, constrains the source, and rolls back without losing links", async () => { @@ -604,7 +604,7 @@ test("the check migration defaults every link to manual, constrains the source, await database.pool.query(await readFile("docs/schema/evidence-checks-rollback.sql", "utf8")); expect(await count(columns)).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence")).toBe(before); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(18); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(19); }); test("the commit migration constrains what a row can be, and its rollback removes commit links only", async () => { @@ -630,7 +630,7 @@ test("the commit migration constrains what a row can be, and its rollback remove expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE kind = 'commit'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence WHERE repository_id = 515151")).toBe(2); expect(await count("SELECT count(*)::int AS count FROM information_schema.columns WHERE table_name='requirement_evidence' AND column_name IN ('sha','commits','additions')")).toBe(0); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(17); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(18); }); test("the migration protects the new table and its rollback removes only that table", async () => { @@ -652,5 +652,5 @@ test("the migration protects the new table and its rollback removes only that ta await database.pool.query(await readFile("docs/schema/requirement-evidence-rollback.sql", "utf8")); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_evidence'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('project_repositories','requirements')")).toBe(2); - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(16); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(17); }); diff --git a/tests/schema/github-repository.spec.ts b/tests/schema/github-repository.spec.ts index 09b97fe..57bf840 100644 --- a/tests/schema/github-repository.spec.ts +++ b/tests/schema/github-repository.spec.ts @@ -293,5 +293,5 @@ test("the migration protects the new table and its rollback removes only that ta expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='project_repositories'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('projects','requirements','rateLimit')")).toBe(3); // Everything else remains: the two initial migrations, four for teams, four later ones for evidence, RequirementReviews, ProjectDocuments, RequirementDocuments, and RequirementUpdatedBy. - expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(19); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(20); }); diff --git a/tests/schema/reviews.spec.ts b/tests/schema/reviews.spec.ts index 92b0d2e..a7c077e 100644 --- a/tests/schema/reviews.spec.ts +++ b/tests/schema/reviews.spec.ts @@ -301,5 +301,5 @@ test("the migration protects the new table, constrains its rows, and its rollbac expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_reviews'")).toBe(0); expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename IN ('requirements','requirement_evidence','project_repositories')")).toBe(3); expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(history - 1); - expect(history).toBe(20); // Every migration through RequirementEvidenceEvents. + expect(history).toBe(21); // Every migration through RequirementEvidenceSuggestions. }); diff --git a/tests/support/github.ts b/tests/support/github.ts index 83f5099..5890b52 100644 --- a/tests/support/github.ts +++ b/tests/support/github.ts @@ -63,6 +63,8 @@ export type FakeItem = { number: number; pull?: boolean; merged?: boolean; title: string; state: "open" | "closed"; /** GitHub login; null for a deleted account. */ user?: string | null; + /** A pull request's description, where a reference such as "Closes #184" is found. */ + body?: string; created_at: string; updated_at: string; closed_at?: string | null; /** For pull requests: totals, and the commits GitHub lists for it. */ additions?: number; deletions?: number; changed_files?: number; commit_count?: number; commits?: FakeCommit[]; From 2c54812b3708396da4728ce470d81cb35c5ae117 Mon Sep 17 00:00:00 2001 From: sormond319 Date: Thu, 5 Nov 2026 20:49:43 -0500 Subject: [PATCH 2/2] fix: guard suggestion scans and evidence moves against concurrent changes - A scan or refresh saves nothing if the linked issues changed while GitHub was read, and a timeline cut off at the page limit drops no suggestions. - A move needs a connected repository and, under the team lock, confirms the link is still on the source and the target's links are the ones compared. - A move leaves an earlier decision on a suggestion as it was made. Co-Authored-By: Claude Opus 5.5 --- app/api/GitHub/EvidenceChangeEndpoints.cs | 91 ++++++++++++++--------- app/api/GitHub/EvidenceEndpoints.cs | 2 +- app/api/GitHub/GitHubClient.cs | 20 +++-- docs/API.md | 8 +- docs/DECISIONS.md | 12 ++- docs/HANDOFF.md | 6 +- docs/TESTING.md | 8 +- tests/schema/evidence-suggestions.spec.ts | 73 ++++++++++++++++++ 8 files changed, 165 insertions(+), 55 deletions(-) diff --git a/app/api/GitHub/EvidenceChangeEndpoints.cs b/app/api/GitHub/EvidenceChangeEndpoints.cs index b980ce2..774889c 100644 --- a/app/api/GitHub/EvidenceChangeEndpoints.cs +++ b/app/api/GitHub/EvidenceChangeEndpoints.cs @@ -60,7 +60,7 @@ private static async Task>, NotFound case Recheck.Archived: return ProjectEndpoints.Archived(); case Recheck.Changed: return Changed(); } - await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, Clock.UtcNow(), cancel); + if (!await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, Clock.UtcNow(), cancel)) return Changed(); await db.SaveChangesAsync(cancel); await transaction.CommitAsync(cancel); return TypedResults.Ok(await ReadSuggestionsAsync(db, requirementId, scope.ProjectId, cancel)); @@ -163,29 +163,22 @@ private static async Task, ValidationProblem, NotFo var others = await db.RequirementEvidence.Where(e => e.RequirementId == targetId).ToListAsync(cancel); if (others.Exists(e => SameItem(e, evidence))) return AlreadyLinkedThere(evidence); if (others.Count >= MaxEvidence) return LimitReached(); + // Without a repository, links can't be changed, and nothing could compare the move with releases. + var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); + if (repository is null) return NoRepository(); + // What the comparison below relies on, taken before it changes the tracked releases. + var compared = Comparable(others); // What a release contains was compared with the links of the requirement it was on. if (evidence.Kind == "release") evidence.Contains = null; - var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); - if (repository is not null) - { - var contents = evidence.Kind == "release" - ? await ReadContentsAsync(github, repository, [evidence], others, cancel) - : await ReadContentsAsync(github, repository, others.FindAll(e => e.Kind == "release"), [evidence], cancel); - if (contents != GitHubFailure.None) return Failed(contents, $"{repository.Owner}/{repository.Name}"); - } + var contents = evidence.Kind == "release" + ? await ReadContentsAsync(github, repository, [evidence], others, cancel) + : await ReadContentsAsync(github, repository, others.FindAll(e => e.Kind == "release"), [evidence], cancel); + if (contents != GitHubFailure.None) return Failed(contents, $"{repository.Owner}/{repository.Name}"); // GitHub was read without holding the team lock. Take it now and check again what the reads relied on. await using var transaction = await db.Database.BeginTransactionAsync(cancel); - var check = repository is not null - ? await LockAndRecheckAsync(db, userId, requirementId, repository, cancel) - : await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel) switch - { - null => Recheck.NotFound, - { Archived: true } => Recheck.Archived, - _ => Recheck.Ready, - }; - switch (check) + switch (await LockAndRecheckAsync(db, userId, requirementId, repository, cancel)) { case Recheck.NotFound: return TypedResults.NotFound(); case Recheck.Archived: return ProjectEndpoints.Archived(); @@ -195,7 +188,12 @@ private static async Task, ValidationProblem, NotFo { return TypedResults.Problem("A link can't be moved to an archived requirement.", statusCode: StatusCodes.Status409Conflict); } - if (await db.RequirementEvidence.CountAsync(e => e.RequirementId == targetId, cancel) >= MaxEvidence) return LimitReached(); + // Moved or removed by someone else meanwhile: this move would write over that. + if (!await db.RequirementEvidence.AsNoTracking().AnyAsync(e => e.Id == evidenceId && e.RequirementId == requirementId, cancel)) return Changed(); + // The target's links changed meanwhile: the comparison is out of date, and saving it would + // overwrite newer answers. An unchanged target also still has room for this link. + var current = await db.RequirementEvidence.AsNoTracking().Where(e => e.RequirementId == targetId).ToListAsync(cancel); + if (!Comparable(current).SequenceEqual(compared)) return Changed(); var now = Clock.UtcNow(); Record(db, evidence, "moved_out", userId, now, requirementId, targetId); @@ -226,18 +224,20 @@ private static async Task, ValidationProblem, NotFo // Reads, for each linked issue, the pull requests that say they close it. Issues GitHub can't // show are skipped, like items deleted there on refresh. Reads must not touch the DbContext. - private static async Task>> ReadMentionsAsync( + private static async Task> ReadMentionsAsync( IGitHubClient github, ProjectRepository repository, List links, CancellationToken cancel) { var issues = links.FindAll(e => e.Kind == "issue" && e.Number is not null); var reads = await ReadConcurrentlyAsync(issues, issue => github.ListMentionsAsync( repository.InstallationId, repository.RepositoryId, repository.Owner, repository.Name, issue.Number!.Value, cancel), cancel); var found = new List(); + var complete = true; for (var index = 0; index < issues.Count; index++) { if (reads[index].Failure == GitHubFailure.NotFound) continue; - if (reads[index].Failure != GitHubFailure.None) return GitHubResult>.Fail(reads[index].Failure); - foreach (var pull in reads[index].Value!) + if (reads[index].Failure != GitHubFailure.None) return GitHubResult.Fail(reads[index].Failure); + complete &= !reads[index].Value!.Truncated; + foreach (var pull in reads[index].Value!.Items) { if (ClosingKeyword(pull.Body, repository, issues[index].Number!.Value) is { } keyword) { @@ -245,7 +245,7 @@ private static async Task>> ReadMentionsAsync } } } - return GitHubResult>.Ok(found); + return GitHubResult.Ok(new EvidenceMentions(found, complete, [.. issues.Select(issue => issue.Number!.Value)])); } // The keyword with which the text closes the issue, as GitHub reads closing references: "#N", @@ -266,20 +266,29 @@ private static async Task>> ReadMentionsAsync return null; } - // Brings the undecided suggestions in line with a complete read: new ones are added, ones no - // longer referenced go, and decided ones are left as they are. Call under the team lock. - private static async Task SaveSuggestionsAsync( - SpecThreadDbContext db, Guid requirementId, ProjectRepository repository, List mentions, DateTime now, CancellationToken cancel) + // Brings the undecided suggestions in line with a read: new ones are added, and decided ones + // are left as they are. Ones no longer referenced go, unless the read stopped early and so can't + // tell. Saves nothing and returns false when the linked issues are not the ones read, because + // the read then answers for other links. Call under the team lock. + private static async Task SaveSuggestionsAsync( + SpecThreadDbContext db, Guid requirementId, ProjectRepository repository, EvidenceMentions mentions, DateTime now, CancellationToken cancel) { var known = await db.RequirementEvidenceSuggestions .Where(s => s.RequirementId == requirementId && s.RepositoryId == repository.RepositoryId).ToListAsync(cancel); - var linked = (await db.RequirementEvidence.AsNoTracking() - .Where(e => e.RequirementId == requirementId && e.RepositoryId == repository.RepositoryId && e.Number != null) - .Select(e => e.Number!.Value).ToListAsync(cancel)) - .ToHashSet(); - var wanted = mentions.Where(m => !linked.Contains(m.Pull.Number)).DistinctBy(m => m.Pull.Number).ToList(); - db.RequirementEvidenceSuggestions.RemoveRange(known.Where(s => s.Status == "pending" && !wanted.Exists(m => m.Pull.Number == s.Number))); - var pending = known.Count(s => s.Status == "pending" && wanted.Exists(m => m.Pull.Number == s.Number)); + var links = await db.RequirementEvidence.AsNoTracking() + .Where(e => e.RequirementId == requirementId && e.RepositoryId == repository.RepositoryId && e.Number != null) + .Select(e => new { e.Kind, Number = e.Number!.Value }).ToListAsync(cancel); + if (!links.Where(e => e.Kind == "issue").Select(e => e.Number).ToHashSet().SetEquals(mentions.Issues)) return false; + + var linked = links.Select(e => e.Number).ToHashSet(); + var wanted = mentions.Items.Where(m => !linked.Contains(m.Pull.Number)).DistinctBy(m => m.Pull.Number).ToList(); + if (mentions.Complete) + { + var stale = known.FindAll(s => s.Status == "pending" && !wanted.Exists(m => m.Pull.Number == s.Number)); + db.RequirementEvidenceSuggestions.RemoveRange(stale); + known.RemoveAll(stale.Contains); + } + var pending = known.Count(s => s.Status == "pending"); foreach (var (pull, reference) in wanted) { var row = known.Find(s => s.Number == pull.Number); @@ -300,6 +309,7 @@ private static async Task SaveSuggestionsAsync( FoundAt = now, }); } + return true; } private static Task DropPendingAsync(SpecThreadDbContext db, Guid requirementId, long repositoryId, int number, CancellationToken cancel) => @@ -307,7 +317,9 @@ private static Task DropPendingAsync(SpecThreadDbContext db, Guid requireme .Where(s => s.RequirementId == requirementId && s.RepositoryId == repositoryId && s.Number == number && s.Status == "pending") .ExecuteDeleteAsync(cancel); - // Records that a pull request moved away must not be suggested for this requirement again. + // Records that a pull request moved away must not be suggested for this requirement again. A + // decided row already keeps it from coming back, so that decision stays as it was made; the + // move itself is in the history. private static async Task RefuseSuggestionAsync( SpecThreadDbContext db, Guid requirementId, RequirementEvidence evidence, int number, string userId, DateTime now, CancellationToken cancel) { @@ -330,7 +342,7 @@ private static async Task RefuseSuggestionAsync( }; db.RequirementEvidenceSuggestions.Add(row); } - (row.Status, row.DecidedBy, row.DecidedAt) = ("rejected", userId, now); + if (row.Status == "pending") (row.Status, row.DecidedBy, row.DecidedAt) = ("rejected", userId, now); } private static async Task> ReadSuggestionsAsync( @@ -342,6 +354,10 @@ await db.RequirementEvidenceSuggestions.AsNoTracking() .Select(s => new EvidenceSuggestionResponse(s.Id, s.Number, s.Title, s.State, s.Author, s.Url, s.Reference, s.FoundAt)) .ToListAsync(cancel); + // What a comparison with releases depends on, in a stable order. + private static List<(Guid Id, string? Sha, string? Change, string? Contains)> Comparable(List links) => + [.. links.Select(e => (e.Id, e.Sha, ChangeSha(e), e.Contains)).OrderBy(e => e.Id)]; + private static bool SameItem(RequirementEvidence a, RequirementEvidence b) => a.RepositoryId == b.RepositoryId && a.Kind == b.Kind && a.Number == b.Number && a.Tag == b.Tag && (a.Number is not null || a.Tag is not null || a.Sha == b.Sha); @@ -361,6 +377,9 @@ private static ValidationProblem InvalidTarget(string message) => internal sealed record EvidenceMention(GitHubMention Pull, string Reference); +// What a read found. Complete is false when a timeline stopped early; Issues are the linked issues it read. +internal sealed record EvidenceMentions(List Items, bool Complete, List Issues); + internal sealed record MoveEvidenceRequest(Guid? TargetRequirementId); // Reference is what was found, such as "Closes #184". diff --git a/app/api/GitHub/EvidenceEndpoints.cs b/app/api/GitHub/EvidenceEndpoints.cs index 45baa2c..e880f27 100644 --- a/app/api/GitHub/EvidenceEndpoints.cs +++ b/app/api/GitHub/EvidenceEndpoints.cs @@ -287,7 +287,7 @@ private static async Task>, NotFound, ProblemH case Recheck.Archived: return ProjectEndpoints.Archived(); case Recheck.Changed: return Changed(); } - await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, now, cancel); + if (!await SaveSuggestionsAsync(db, requirementId, repository, mentions.Value!, now, cancel)) return Changed(); try { await db.SaveChangesAsync(cancel); diff --git a/app/api/GitHub/GitHubClient.cs b/app/api/GitHub/GitHubClient.cs index 65bf1be..6a4c091 100644 --- a/app/api/GitHub/GitHubClient.cs +++ b/app/api/GitHub/GitHubClient.cs @@ -44,6 +44,10 @@ internal sealed record GitHubSearchItem(int Number, string Kind, string Title, s // the reference in; it is read for this request only and is never stored or logged. internal sealed record GitHubMention(int Number, string Title, string State, string? Author, string Url, string Body); +// The pull requests found in an issue's timeline. Truncated means reading stopped at the page +// limit, so one that is not listed may still mention the issue. +internal sealed record GitHubMentions(List Items, bool Truncated); + // One automated check on a commit. Result is "passed", "failed", "running", "skipped", // "cancelled", or "neutral". Kind is "check" (a check run) or "status" (an older commit status). internal sealed record GitHubCheck(string Name, string Result, string? Url, DateTime? CompletedAt, string Kind); @@ -113,7 +117,7 @@ Task>> SearchItemsAsync( // Pull requests of owner/name that mention the issue, from its timeline (ADR-056). NotFound // when the issue can't be read. - Task>> ListMentionsAsync( + Task> ListMentionsAsync( long installationId, long repositoryId, string owner, string name, int issueNumber, CancellationToken cancel); } @@ -558,11 +562,11 @@ public async Task>> SearchItemsAsync( } } - public async Task>> ListMentionsAsync( + public async Task> ListMentionsAsync( long installationId, long repositoryId, string owner, string name, int issueNumber, CancellationToken cancel) { var token = await CreateInstallationTokenAsync(installationId, cancel); - if (token.Failure != GitHubFailure.None) return GitHubResult>.Fail(token.Failure); + if (token.Failure != GitHubFailure.None) return GitHubResult.Fail(token.Failure); var expected = $"/repos/{owner}/{name}"; var found = new Dictionary(); @@ -570,11 +574,11 @@ public async Task>> ListMentionsAsync( { var (failure, body) = await SendAsync( HttpMethod.Get, $"repositories/{repositoryId}/issues/{issueNumber}/timeline?per_page={PageSize}&page={page}", token.Value!, cancel); - if (failure != GitHubFailure.None) return GitHubResult>.Fail(AsInstallation(failure)); + if (failure != GitHubFailure.None) return GitHubResult.Fail(AsInstallation(failure)); using (body) { var root = body!.RootElement; - if (root.ValueKind != JsonValueKind.Array) return GitHubResult>.Fail(GitHubFailure.Unavailable); + if (root.ValueKind != JsonValueKind.Array) return GitHubResult.Fail(GitHubFailure.Unavailable); foreach (var entry in root.EnumerateArray()) { if (entry.ValueKind != JsonValueKind.Object || !entry.TryGetProperty("event", out var kind) || kind.GetStringOrNull() != "cross-referenced" || @@ -590,7 +594,7 @@ public async Task>> ListMentionsAsync( !item.TryGetProperty("html_url", out var url) || !IsGitHubLink(url.GetStringOrNull()) || !item.TryGetProperty("repository_url", out var repository) || repository.GetStringOrNull() is not { } repositoryUrl) { - return GitHubResult>.Fail(GitHubFailure.Unavailable); + return GitHubResult.Fail(GitHubFailure.Unavailable); } // Pull requests of other repositories can mention the issue too; only this one's count. if (!repositoryUrl.EndsWith(expected, StringComparison.OrdinalIgnoreCase)) continue; @@ -598,10 +602,10 @@ public async Task>> ListMentionsAsync( var text = item.TryGetProperty("body", out var description) ? description.GetStringOrNull() ?? "" : ""; found[read] = new GitHubMention(read, title.GetString()!, merged ? "merged" : state.GetString()!, ReadLogin(item, "user"), url.GetString()!, text); } - if (root.GetArrayLength() < PageSize) break; + if (root.GetArrayLength() < PageSize) return GitHubResult.Ok(new([.. found.Values], false)); } } - return GitHubResult>.Ok([.. found.Values]); + return GitHubResult.Ok(new([.. found.Values], true)); } // An installation token GitHub just issued and then rejects is GitHub's problem, not the user's. diff --git a/docs/API.md b/docs/API.md index b807d01..4c128ec 100644 --- a/docs/API.md +++ b/docs/API.md @@ -118,7 +118,7 @@ stays after the link is removed; refresh adds none. Suggestions (ADR-056) are pull requests of the connected repository whose description closes an issue linked to the requirement (close, fix, or resolve in GitHub's forms, before `#N`, `owner/name#N`, or the issue's address). Scan and refresh bring the undecided -ones up to date. They are not evidence: no evidence list, count, review, or Evidence +ones up to date, and answer 409 if the linked issues change while GitHub is read. They are not evidence: no evidence list, count, review, or Evidence complete includes them. Confirm links the pull request as GitHub reports it now; reject keeps it from being suggested again, though it can still be linked by hand. Either adds an `EvidenceEvent`. A suggestion decided already, or a pull request gone from GitHub, is @@ -129,8 +129,10 @@ Move takes a link to another active requirement of the project in one step. The keeps its ID, `linkedBy` and `linkedAt` become the mover and now, and `source` becomes `manual`. Each requirement gets an `EvidenceEvent`, and decisions on both become `outdated`. A target that is missing, the same requirement, or in another project is 400 on -`targetRequirementId`. An archived target, the item already linked there, or 50 links -there is 409. A pull request moved away is not suggested again for that requirement. +`targetRequirementId`. An archived target, the item already linked there, 50 links there, +no repository connected, or the link or the target's links changing during the move is +409. A pull request moved away is not suggested again for that requirement; a decision +already made on it stays as it was. Link and refresh read GitHub first and only then take the team lock and save, so a large refresh may take tens of seconds; the web app allows these two calls 55 seconds diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 539eeb2..affcdcf 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1472,16 +1472,22 @@ Confirming reads the pull request from GitHub and links it like any link, with `confirmed` or `rejected` event in the history (ADR-054). A decided row is never suggested again; a rejected pull request can still be linked by hand. Undecided rows that a complete read no longer finds are dropped, and linking the pull request by hand drops its -suggestion. +suggestion. A read that stopped at the timeline limit drops nothing, because it can't tell. +Because GitHub is read outside the team lock, nothing is saved if the linked issues +changed meanwhile (409). `POST /requirements/{id}/evidence/{evidenceId}/move` moves any link to another active -requirement of the project, in one transaction under the team lock. The row keeps its +requirement of the project, in one transaction under the team lock. It needs a connected +repository, as the page already says of changing links. Under the lock it confirms that +the link is still on the source and the target's links are the ones compared, and +answers 409 otherwise, so a concurrent change is not overwritten. The row keeps its ID, so the decisions on both requirements become outdated; `linked_by` and `linked_at` become the mover and the time, and `source` becomes `manual`. Each history gets an event, `moved_out` and `moved_in`, naming the other requirement. A moved change is compared with the target's releases, and a moved release with the target's changes. A pull request moved away is recorded as rejected for the requirement it left, or the same -closing reference would suggest it straight back. +closing reference would suggest it straight back. An earlier decision on it stays as it +was made, and already prevents that. Consequences: Suggestions appear only when someone asks or refreshes, so a pull request opened later waits for that. Only pull request descriptions are read: commit messages, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 03a5ae8..42b224a 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -43,7 +43,11 @@ - CI applies the migration on merge. Don't apply it to Supabase by hand first. - Rolling the migration back deletes rejections, so rejected pull requests can be suggested again. -- Checks: `npm test` 407 passed (it builds the web app and the Release API). +- Review follow-up (six comments, all valid): a scan or refresh saves nothing if the linked + issues changed while GitHub was read; a truncated timeline drops no suggestions; a move + needs a connected repository, rechecks under the lock that the link is still on the + source and the target's links are unchanged, and leaves an earlier decision as it was. +- Checks: `npm test` 409 passed (it builds the web app and the Release API). `npm run lint`, `npm run typecheck`, `dotnet format app/api --verify-no-changes`, `has-pending-model-changes` (none), and `git diff --check` passed. - Next step: review, then open a PR to `main`. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2a03f32..82cbd36 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -189,9 +189,11 @@ currently require network access during the web build. Evidence complete; confirm and reject with who and when, decided ones refused and never suggested again; linking by hand, edited references, refresh, and items gone from GitHub; members only, archived (also while waiting for the team lock), disconnected, - and GitHub failures; moves with both histories, outdated decisions, no re-suggestion, - and moving back; refusals for the target, a duplicate, and the limit; release - comparison after a move; and the migration's constraints, RLS, and rollback. Browser + and GitHub failures; a linked issue changing during a scan, and a truncated timeline + dropping nothing; moves with both histories, outdated decisions, no re-suggestion, and + moving back; refusals for the target, a duplicate, the limit, and no repository; the + link or the target's links changing while a move waits for the lock; an earlier + decision surviving a move; release comparison after a move; and the migration's constraints, RLS, and rollback. Browser (tests/e2e/evidence.spec.ts): finding, the label and reference, not counted, confirm, reject, the change list, staying rejected, a failed scan and a refused decision; moving with cancel, both requirements' changes and the history page, and a refused move. Not diff --git a/tests/schema/evidence-suggestions.spec.ts b/tests/schema/evidence-suggestions.spec.ts index 120c3f3..b6ff0b9 100644 --- a/tests/schema/evidence-suggestions.spec.ts +++ b/tests/schema/evidence-suggestions.spec.ts @@ -324,6 +324,79 @@ test("a moved change is compared with the releases it joins, and a moved release expect(await contains(from)).toEqual({ [change.id]: true }); }); +test("a scan saves nothing if the linked issues changed while GitHub was read, and a truncated read drops nothing", async ({ request }) => { + const { projectId, ids: [id], paths: [path], repositoryId } = await setUp(request, [item(1, "Guests cannot pay"), pull(2, "Add it", "Closes #1")]); + const owner = await as(request, "owner"); + expect((await owner.post(path, { reference: "1" })).status()).toBe(201); + + const pending = await withTeamLock(database.pool, projectId, async client => { + const response = owner.post(`${path}/suggestions/scan`); + expect(await stillWaiting(response)).toBe(true); + await client.query("DELETE FROM public.requirement_evidence WHERE requirement_id = $1", [id]); + return { response }; + }); + expect((await pending.response).status()).toBe(409); + expect(await (await owner.get(`${path}/suggestions`)).json()).toEqual([]); + + expect((await owner.post(path, { reference: "1" })).status()).toBe(201); + expect(await scan(owner, path)).toHaveLength(1); + // Every page full and the reference not among them: GitHub stopped being read, not referencing it. + const full = Array.from({ length: 100 }, () => ({ event: "labeled" })); + const timeline = `^/repositories/${repositoryId}/issues/1/timeline$`; + await request.post(`${gitHub}/__github/faults`, { data: { bearer: "installation", path: timeline, status: 200, body: full } }); + expect(await scan(owner, path)).toHaveLength(1); + await request.delete(`${gitHub}/__github/faults?bearer=installation`); + // A complete read without it still drops it. + await request.post(`${gitHub}/__github/faults`, { data: { bearer: "installation", path: timeline, status: 200, times: 1, body: [] } }); + expect(await scan(owner, path)).toEqual([]); +}); + +test("a move refuses without a repository, or when the link or the target changed while it waited, and keeps earlier decisions", async ({ request }) => { + const { projectId, ids: [fromId, toId, thirdId], paths: [from, to] } = await setUp(request, + [item(1, "Guests cannot pay"), pull(2, "Add it", "Closes #1"), item(3, "Other")], ["From", "To", "Third"]); + const [owner, member] = [await as(request, "owner"), await as(request, "member")]; + expect((await owner.post(from, { reference: "1" })).status()).toBe(201); + const [suggestion] = await scan(owner, from); + const link = await (await member.post(`${from}/suggestions/${suggestion.id}/confirm`)).json(); + const body = { targetRequirementId: toId }; + const history = async (id: string) => (await database.pool.query("SELECT count(*)::int AS count FROM public.requirement_evidence_events WHERE requirement_id = $1", [id])).rows[0].count; + const [fromEvents, toEvents] = [await history(fromId), await history(toId)]; + + // Moved elsewhere while this move waited: it is not moved again, and no history is written. + const moved = await withTeamLock(database.pool, projectId, async client => { + const response = owner.post(`${from}/${link.id}/move`, body); + expect(await stillWaiting(response)).toBe(true); + await client.query("UPDATE public.requirement_evidence SET requirement_id = $1 WHERE id = $2", [thirdId, link.id]); + return { response }; + }); + expect((await moved.response).status()).toBe(409); + expect((await database.pool.query("SELECT requirement_id FROM public.requirement_evidence WHERE id = $1", [link.id])).rows[0].requirement_id).toBe(thirdId); + expect([await history(fromId), await history(toId)]).toEqual([fromEvents, toEvents]); + await database.pool.query("UPDATE public.requirement_evidence SET requirement_id = $1 WHERE id = $2", [fromId, link.id]); + + // The target's links changed while this move waited: what was compared is out of date. + const added = await (await owner.post(to, { reference: "3" })).json(); + const changed = await withTeamLock(database.pool, projectId, async client => { + const response = owner.post(`${from}/${link.id}/move`, body); + expect(await stillWaiting(response)).toBe(true); + await client.query("DELETE FROM public.requirement_evidence WHERE id = $1", [added.id]); + return { response }; + }); + expect((await changed.response).status()).toBe(409); + + // The decision made on the suggestion stays as it was, and still keeps it from coming back. + expect((await owner.post(`${from}/${link.id}/move`, body)).status()).toBe(200); + const { rows: [row] } = await database.pool.query("SELECT status, decided_by FROM public.requirement_evidence_suggestions WHERE id = $1", [suggestion.id]); + expect(row).toEqual({ status: "confirmed", decided_by: "member" }); + expect(await scan(owner, from)).toEqual([]); + + await database.pool.query("DELETE FROM public.project_repositories WHERE project_id = $1", [projectId]); + const refused = await owner.post(`${to}/${link.id}/move`, { targetRequirementId: fromId }); + expect(refused.status()).toBe(409); + expect((await refused.json()).detail).toBe("Connect a GitHub repository to this project first."); + expect((await database.pool.query("SELECT requirement_id FROM public.requirement_evidence WHERE id = $1", [link.id])).rows[0].requirement_id).toBe(toId); +}); + test("the migration protects suggestions, constrains them and the history, and its rollback keeps what the old history can hold", async ({ request }) => { const { ids: [fromId, toId], paths: [from] } = await setUp(request, [item(1, "One"), pull(2, "Two", "Closes #1"), pull(3, "Three", "Closes #1")]); const owner = await as(request, "owner");