diff --git a/app/api/Data/ProductModels.cs b/app/api/Data/ProductModels.cs index 2261eff..f2f6bb8 100644 --- a/app/api/Data/ProductModels.cs +++ b/app/api/Data/ProductModels.cs @@ -137,6 +137,22 @@ 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. +public sealed class RequirementEvidenceEvent +{ + public Guid Id { get; set; } + public Guid RequirementId { get; set; } + public Guid EvidenceId { get; set; } + public required string Action { get; set; } + public required string Kind { get; set; } + public required string Label { get; set; } + public required string Title { get; set; } + public required string Actor { get; set; } + public DateTime OccurredAt { get; set; } +} + // A person's decision on a requirement (ADR-038). Evidence is a JSON array of the evidence links // as they were when the decision was made. Rows are only ever added. public sealed class RequirementReview diff --git a/app/api/Data/SpecThreadDbContext.cs b/app/api/Data/SpecThreadDbContext.cs index d865d17..41de101 100644 --- a/app/api/Data/SpecThreadDbContext.cs +++ b/app/api/Data/SpecThreadDbContext.cs @@ -13,6 +13,7 @@ public sealed class SpecThreadDbContext(DbContextOptions op public DbSet ProjectRepositories => Set(); public DbSet RequirementEvidence => Set(); public DbSet RequirementReviews => Set(); + public DbSet RequirementEvidenceEvents => Set(); public DbSet Teams => Set(); public DbSet TeamMembers => Set(); public DbSet UserOnboardings => Set(); @@ -262,6 +263,27 @@ protected override void OnModelCreating(ModelBuilder model) evidence.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); evidence.HasOne().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict); + 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_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + table.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + }); + evidenceEvent.HasKey(x => x.Id); + evidenceEvent.Property(x => x.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()"); + evidenceEvent.Property(x => x.RequirementId).HasColumnName("requirement_id"); + evidenceEvent.Property(x => x.EvidenceId).HasColumnName("evidence_id"); + evidenceEvent.Property(x => x.Action).HasColumnName("action"); + evidenceEvent.Property(x => x.Kind).HasColumnName("kind"); + evidenceEvent.Property(x => x.Label).HasColumnName("label"); + 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.HasIndex(x => new { x.RequirementId, x.OccurredAt }); + evidenceEvent.HasOne().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict); + evidenceEvent.HasOne().WithMany().HasForeignKey(x => x.Actor).OnDelete(DeleteBehavior.Restrict); + var review = model.Entity(); review.ToTable("requirement_reviews", table => { diff --git a/app/api/GitHub/EvidenceEndpoints.cs b/app/api/GitHub/EvidenceEndpoints.cs index e8c86e1..224b49b 100644 --- a/app/api/GitHub/EvidenceEndpoints.cs +++ b/app/api/GitHub/EvidenceEndpoints.cs @@ -19,6 +19,8 @@ internal static partial class EvidenceEndpoints public const int MaxEvidence = 50; private const int MaxReferenceLength = 300; private const int MaxTagLength = 100; + private const int MaxSearchLength = 200; + private const int MaxSearchWords = 10; private static readonly JsonSerializerOptions CommitJson = new(JsonSerializerDefaults.Web); public static void MapEvidenceEndpoints(this IEndpointRouteBuilder app) @@ -26,6 +28,10 @@ public static void MapEvidenceEndpoints(this IEndpointRouteBuilder app) var evidence = app.MapGroup("/requirements/{requirementId:guid}/evidence").WithTags("Evidence") .WithMetadata(ProjectEndpoints.Unauthorized); evidence.MapGet("/", List).WithName("ListEvidence"); + evidence.MapGet("/search", Search).WithName("SearchEvidence") + .ProducesProblem(StatusCodes.Status409Conflict) + .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); + evidence.MapGet("/history", History).WithName("ListEvidenceHistory"); evidence.MapPost("/", Link).WithName("LinkEvidence") .ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status502BadGateway).ProducesProblem(StatusCodes.Status503ServiceUnavailable); @@ -43,6 +49,47 @@ private static async Task>, NotFound>> List( return TypedResults.Ok(await ReadAsync(db, requirementId, cancel)); } + // Finds issues and pull requests of the connected repository by words in their titles + // (ADR-054), saying which are linked already. Nothing is linked or stored. + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Search( + Guid requirementId, string? q, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel) + { + var errors = new InputErrors(); + var text = errors.Required("q", q, MaxSearchLength); + var words = (text ?? "").Replace("\"", " ", StringComparison.Ordinal) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).ToList(); + if (text is not null && words.Count == 0) errors.Add("q", "Enter words from the title."); + // Refused rather than cut short: a shorter search would find titles without every word. + else if (words.Count > MaxSearchWords) errors.Add("q", $"Use at most {MaxSearchWords} words."); + if (errors.Any) return TypedResults.ValidationProblem(errors.ToDictionary()); + + var scope = await FindAsync(db, user.CallerId(), requirementId, cancel); + if (scope is null) return TypedResults.NotFound(); + var repository = await db.ProjectRepositories.AsNoTracking().SingleOrDefaultAsync(r => r.ProjectId == scope.ProjectId, cancel); + if (repository is null) return NoRepository(); + + var found = await github.SearchItemsAsync(repository.InstallationId, repository.Owner, repository.Name, words, cancel); + if (found.Failure != GitHubFailure.None) return Failed(found.Failure, $"{repository.Owner}/{repository.Name}"); + 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(); + return TypedResults.Ok(new EvidenceSearchResponse(found.Value!.ConvertAll(item => + 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). + 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)) + .ToListAsync(cancel); + return TypedResults.Ok(events); + } + private static async Task, ValidationProblem, NotFound, ProblemHttpResult>> Link( Guid requirementId, LinkEvidenceRequest request, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel) { @@ -136,6 +183,7 @@ private static async Task, ValidationProblem, if (await db.RequirementEvidence.CountAsync(e => e.RequirementId == requirementId, cancel) >= MaxEvidence) return LimitReached(); db.RequirementEvidence.Add(evidence); + Record(db, evidence, "added", userId, now); try { await db.SaveChangesAsync(cancel); @@ -229,14 +277,34 @@ private static async Task> Unlin { // Serialize with archiving and membership changes in this project's team. await using var transaction = await db.Database.BeginTransactionAsync(cancel); - var found = await RequirementEndpoints.LockForMember(db, user.CallerId(), requirementId, cancel); + var userId = user.CallerId(); + var found = await RequirementEndpoints.LockForMember(db, userId, requirementId, cancel); if (found is null) return TypedResults.NotFound(); if (found.Archived) return ProjectEndpoints.Archived(); - var deleted = await db.RequirementEvidence.Where(e => e.Id == evidenceId && e.RequirementId == requirementId).ExecuteDeleteAsync(cancel); + var evidence = await db.RequirementEvidence.SingleOrDefaultAsync(e => e.Id == evidenceId && e.RequirementId == requirementId, cancel); + if (evidence is null) return TypedResults.NotFound(); + // The link goes; who removed it, and what it was, stay in the history (ADR-054). + db.RequirementEvidence.Remove(evidence); + Record(db, evidence, "removed", userId, Clock.UtcNow()); + await db.SaveChangesAsync(cancel); await transaction.CommitAsync(cancel); - return deleted == 0 ? TypedResults.NotFound() : TypedResults.NoContent(); + return TypedResults.NoContent(); } + private static void Record(SpecThreadDbContext db, RequirementEvidence evidence, string action, string userId, DateTime at) => + db.RequirementEvidenceEvents.Add(new RequirementEvidenceEvent + { + Id = Guid.NewGuid(), + RequirementId = evidence.RequirementId, + EvidenceId = evidence.Id, + Action = action, + Kind = evidence.Kind, + Label = ReviewEndpoints.Label(evidence), + Title = evidence.Title, + Actor = userId, + OccurredAt = at, + }); + // Null when the requirement does not exist or the caller is not a member of its project. private static Task FindAsync(SpecThreadDbContext db, string userId, Guid requirementId, CancellationToken cancel) => db.Requirements.AsNoTracking().Where(r => r.Id == requirementId) @@ -494,6 +562,14 @@ internal sealed record EvidenceReference(int? Number, string? Sha, string? Tag, internal sealed record LinkEvidenceRequest(string? Reference); +internal sealed record EvidenceSearchItem(int Number, string Kind, string Title, string State, string? Author, string Url, bool Linked); + +internal sealed record EvidenceSearchResponse(List Items); + +// Action is "added" or "removed"; Kind, Label, and Title are the link's as it was then. +internal sealed record EvidenceEventResponse( + Guid Id, Guid EvidenceId, string Action, string Kind, string Label, string Title, string Actor, DateTime OccurredAt); + internal sealed record EvidenceCommit(string Sha, string Message, string? Author, DateTime Date, string Url); internal sealed record EvidenceCheck(string Name, string Result, string? Url, DateTime? CompletedAt, string Kind); diff --git a/app/api/GitHub/GitHubClient.cs b/app/api/GitHub/GitHubClient.cs index 42ec915..1e08375 100644 --- a/app/api/GitHub/GitHubClient.cs +++ b/app/api/GitHub/GitHubClient.cs @@ -37,6 +37,9 @@ internal sealed record GitHubItem( public bool? Prerelease { get; init; } } +// 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); + // 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); @@ -98,6 +101,11 @@ internal interface IGitHubClient // Reads the check runs and commit statuses of a commit, acting as the app. NotFound means // GitHub does not let the app read them, for example because a permission was not granted. Task> GetChecksAsync(long installationId, long repositoryId, string sha, CancellationToken cancel); + + // Issues and pull requests of owner/name whose titles contain every word, in GitHub's order + // of best match, at most MaxSearchResults of them (ADR-054). + Task>> SearchItemsAsync( + long installationId, string owner, string name, IReadOnlyList words, CancellationToken cancel); } internal sealed class GitHubClient(HttpClient http, IConfiguration configuration, ILogger logger) : IGitHubClient @@ -106,6 +114,7 @@ internal sealed class GitHubClient(HttpClient http, IConfiguration configuration private const int MaxListPages = 5; private const int MaxListedRepositories = 500; private const int MaxSearchPages = 30; + public const int MaxSearchResults = 20; private const int MaxCommitMessageLength = 300; private const int MaxCheckNameLength = 200; private const int MaxReleaseNameLength = 300; @@ -492,6 +501,54 @@ private async Task> CreateInstallationTokenAsync(long insta } } + public async Task>> SearchItemsAsync( + long installationId, string owner, string name, IReadOnlyList words, CancellationToken cancel) + { + var token = await CreateInstallationTokenAsync(installationId, cancel); + if (token.Failure != GitHubFailure.None) return GitHubResult>.Fail(token.Failure); + + // Each word is quoted, so text such as "repo:other/x" is searched for rather than read as + // a qualifier that could reach another repository of the installation. + var terms = string.Join(' ', words.Select(word => word.Replace("\"", "", StringComparison.Ordinal).Trim()) + .Where(word => word.Length > 0).Select(word => $"\"{word}\"")); + if (terms.Length == 0) return GitHubResult>.Ok([]); + var query = Uri.EscapeDataString($"{terms} repo:{owner}/{name} in:title"); + var (failure, body) = await SendAsync(HttpMethod.Get, $"search/issues?q={query}&per_page={MaxSearchResults}", token.Value!, cancel); + // 422: GitHub can't search that repository, for example after a rename. Nothing is found. + if (failure == GitHubFailure.NotFound) return GitHubResult>.Ok([]); + if (failure != GitHubFailure.None) return GitHubResult>.Fail(AsInstallation(failure)); + using (body) + { + var root = body!.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("items", out var entries) || entries.ValueKind != JsonValueKind.Array) + { + return GitHubResult>.Fail(GitHubFailure.Unavailable); + } + var expected = $"/repos/{owner}/{name}"; + var found = new List(); + foreach (var entry in entries.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object || + !entry.TryGetProperty("number", out var number) || number.ValueKind != JsonValueKind.Number || !number.TryGetInt32(out var read) || read < 1 || + !entry.TryGetProperty("title", out var title) || title.ValueKind != JsonValueKind.String || + !entry.TryGetProperty("state", out var state) || state.GetStringOrNull() is not ("open" or "closed") || + !entry.TryGetProperty("html_url", out var url) || !IsGitHubLink(url.GetStringOrNull()) || + !entry.TryGetProperty("repository_url", out var repository) || repository.GetStringOrNull() is not { } repositoryUrl) + { + return GitHubResult>.Fail(GitHubFailure.Unavailable); + } + // Only the connected repository, whatever GitHub returns. + if (!repositoryUrl.EndsWith(expected, StringComparison.OrdinalIgnoreCase)) continue; + var pull = entry.TryGetProperty("pull_request", out var marker) && marker.ValueKind == JsonValueKind.Object; + var merged = pull && marker.TryGetProperty("merged_at", out var mergedAt) && mergedAt.ValueKind == JsonValueKind.String; + found.Add(new GitHubSearchItem(read, pull ? "pull_request" : "issue", title.GetString()!, + merged ? "merged" : state.GetString()!, ReadLogin(entry, "user"), url.GetString()!)); + if (found.Count == MaxSearchResults) break; + } + return GitHubResult>.Ok(found); + } + } + // 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/20261030130820_RequirementEvidenceEvents.Designer.cs b/app/api/Migrations/20261030130820_RequirementEvidenceEvents.Designer.cs new file mode 100644 index 0000000..5e122fd --- /dev/null +++ b/app/api/Migrations/20261030130820_RequirementEvidenceEvents.Designer.cs @@ -0,0 +1,1533 @@ +// +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("20261030130820_RequirementEvidenceEvents")] + partial class RequirementEvidenceEvents + { + /// + 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("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + 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_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + }); + }); + + 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("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/20261030130820_RequirementEvidenceEvents.cs b/app/api/Migrations/20261030130820_RequirementEvidenceEvents.cs new file mode 100644 index 0000000..5566847 --- /dev/null +++ b/app/api/Migrations/20261030130820_RequirementEvidenceEvents.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpecThread.Api.Migrations +{ + /// + public partial class RequirementEvidenceEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "requirement_evidence_events", + schema: "public", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + requirement_id = table.Column(type: "uuid", nullable: false), + evidence_id = table.Column(type: "uuid", nullable: false), + action = table.Column(type: "text", nullable: false), + kind = table.Column(type: "text", nullable: false), + label = table.Column(type: "text", nullable: false), + title = table.Column(type: "text", nullable: false), + actor = table.Column(type: "text", nullable: false), + occurred_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_requirement_evidence_events", x => x.id); + table.CheckConstraint("ck_requirement_evidence_events_action", "action IN ('added', 'removed')"); + table.CheckConstraint("ck_requirement_evidence_events_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + table.CheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + table.ForeignKey( + name: "FK_requirement_evidence_events_requirements_requirement_id", + column: x => x.requirement_id, + principalSchema: "public", + principalTable: "requirements", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requirement_evidence_events_user_actor", + column: x => x.actor, + principalSchema: "public", + principalTable: "user", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_requirement_evidence_events_actor", + schema: "public", + table: "requirement_evidence_events", + column: "actor"); + + migrationBuilder.CreateIndex( + name: "IX_requirement_evidence_events_requirement_id_occurred_at", + schema: "public", + table: "requirement_evidence_events", + columns: new[] { "requirement_id", "occurred_at" }); + + migrationBuilder.Sql(""" + ALTER TABLE public."requirement_evidence_events" ENABLE ROW LEVEL SECURITY; + REVOKE ALL ON TABLE public."requirement_evidence_events" FROM PUBLIC; + DO $security$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_evidence_events" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_evidence_events" FROM authenticated; + END IF; + END $security$; + INSERT INTO public.requirement_evidence_events (requirement_id, evidence_id, action, kind, label, title, actor, occurred_at) + SELECT e.requirement_id, e.id, 'added', e.kind, + CASE WHEN e.number IS NOT NULL THEN '#' || e.number WHEN e.tag IS NOT NULL THEN e.tag ELSE left(e.sha, 7) END, + e.title, e.linked_by, e.linked_at + FROM public.requirement_evidence e; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "requirement_evidence_events", + schema: "public"); + } + } +} diff --git a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs index 5733137..9e06769 100644 --- a/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs +++ b/app/api/Migrations/SpecThreadDbContextModelSnapshot.cs @@ -839,6 +839,69 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + 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("RequirementId") + .HasColumnType("uuid") + .HasColumnName("requirement_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("Actor"); + + 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_kind", "kind IN ('issue', 'pull_request', 'commit', 'release')"); + + t.HasCheckConstraint("ck_requirement_evidence_events_label", "length(btrim(label)) > 0"); + }); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => { b.Property("RequirementId") @@ -1337,6 +1400,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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("RequirementId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SpecThread.Api.Data.RequirementFollowUp", b => { b.HasOne("SpecThread.Api.Data.AuthUser", null) diff --git a/app/api/Requirements/ReviewEndpoints.cs b/app/api/Requirements/ReviewEndpoints.cs index 581d4b8..ea4dca0 100644 --- a/app/api/Requirements/ReviewEndpoints.cs +++ b/app/api/Requirements/ReviewEndpoints.cs @@ -99,7 +99,8 @@ public static ReviewSummary Summarize(RequirementReview review, int currentVersi private static List ReadSnapshot(RequirementReview review) => JsonSerializer.Deserialize>(review.Evidence, SnapshotJson) ?? []; - private static string Label(RequirementEvidence evidence) => evidence switch + // How a link is named in snapshots and history: "#9", a release tag, or a short SHA. + internal static string Label(RequirementEvidence evidence) => evidence switch { { Number: { } number } => $"#{number}", { Tag: { } tag } => tag, diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index d77178b..2873ff2 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -477,3 +477,6 @@ a.row:focus-visible { outline-offset: -2px; } .history-list a { color: var(--app-text); text-decoration: underline; text-underline-offset: 3px; } .history-decisions { margin: 0; padding-left: 18px; color: var(--app-text-2); font-size: 13px; } .follow-up-history li { display: flex; flex-wrap: wrap; gap: 4px 8px; } +/* Finding evidence by title (ADR-054) */ +.evidence-link + .evidence-link { margin-top: 12px; } +.evidence-link + .evidence-rows, .evidence-link + .muted { margin-top: 8px; } diff --git a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx index d778eb8..1aed58d 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/history/page.tsx @@ -1,8 +1,11 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; +import { EvidenceHistory } from "@/components/evidence-panel"; import { FollowUpHistory } from "@/components/requirement-follow-ups"; -import { listCriteriaVersions, listFollowUps, listMembers, listReviews, requireProject, requireRequirement } from "@/lib/project-data"; +import { + listCriteriaVersions, listEvidenceHistory, listFollowUps, listMembers, listReviews, requireProject, requireRequirement, +} from "@/lib/project-data"; import { formatDate } from "@/lib/projects"; import { decisionText } from "@/lib/reviews"; @@ -10,14 +13,14 @@ export const metadata: Metadata = { title: "Requirement history" }; /** * Shows how a requirement changed: every version of its acceptance criteria with who saved it, - * when, and the decisions made against it (ADR-051), and every follow-up link added or removed - * (ADR-052). Earlier versions open read-only. + * when, and the decisions made against it (ADR-051), every follow-up link added or removed + * (ADR-052), and every evidence link added or removed (ADR-054). Earlier versions open read-only. */ export default async function Page({ params }: PageProps<"/projects/[projectId]/requirements/[requirementId]/history">) { const { projectId, requirementId } = await params; - const [project, requirement, versions, followUps, reviews, members] = await Promise.all([ + const [project, requirement, versions, followUps, reviews, members, evidenceHistory] = await Promise.all([ requireProject(projectId), requireRequirement(projectId, requirementId), listCriteriaVersions(requirementId), - listFollowUps(requirementId), listReviews(requirementId), listMembers(projectId), + listFollowUps(requirementId), listReviews(requirementId), listMembers(projectId), listEvidenceHistory(requirementId), ]); const people = Object.fromEntries(members.map(member => [member.userId, member.name])); const nameOf = (userId: string) => people[userId] ?? "a former member"; @@ -65,6 +68,14 @@ export default async function Page({ params }: PageProps<"/projects/[projectId]/ )} +
+

Evidence links

+ {evidenceHistory === null + ?

Evidence link changes can't be shown right now. Try again in a few minutes.

+ : evidenceHistory.length === 0 + ?

No evidence links have been added or removed.

+ :
} +

Follow-up links

{followUps.history.length === 0 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 35fac92..93f049a 100644 --- a/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx +++ b/app/web/src/app/projects/[projectId]/requirements/[requirementId]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowLeft, FileText, Pencil } from "lucide-react"; -import { EvidencePanel } from "@/components/evidence-panel"; +import { EvidenceHistory, EvidencePanel } from "@/components/evidence-panel"; import { RequirementArchive, RequirementRestore } from "@/components/requirement-archive"; import { RequirementFollowUps } from "@/components/requirement-follow-ups"; import { RequirementMilestone } from "@/components/requirement-milestone"; @@ -9,7 +9,7 @@ import { ReviewPanel } from "@/components/review-panel"; import { documentTypeLabel } from "@/lib/documents"; import { getProjectRepository } from "@/lib/github-data"; import { - listEvidence, listFollowUps, listMembers, listMilestones, listRequirements, listReviews, requireProject, requireRequirement, viewerId, + listEvidence, listEvidenceHistory, listFollowUps, listMembers, listMilestones, listRequirements, listReviews, requireProject, requireRequirement, viewerId, } from "@/lib/project-data"; import { formatDate } from "@/lib/projects"; @@ -32,10 +32,10 @@ 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] = await Promise.all([ + const [project, requirement, members, repository, evidence, reviews, viewer, followUps, requirements, milestones, evidenceHistory] = await Promise.all([ requireProject(projectId), requireRequirement(projectId, requirementId), listMembers(projectId), getProjectRepository(projectId), listEvidence(requirementId), listReviews(requirementId), viewerId(), - listFollowUps(requirementId), listRequirements(projectId), listMilestones(projectId), + listFollowUps(requirementId), listRequirements(projectId), listMilestones(projectId), listEvidenceHistory(requirementId), ]); const people = Object.fromEntries(members.map(member => [member.userId, member.name])); const nameOf = (userId: string) => people[userId] ?? "a former member"; @@ -113,14 +113,23 @@ export default async function Page({ params }: Props) {

Evidence

{repository || evidence.length > 0 ? ( - ) : ( -

- Connect a GitHub repository in project settings to - link issues, pull requests, commits, and releases. -

+ <> +

+ Connect a GitHub repository in project settings to + link issues, pull requests, commits, and releases. +

+ {/* Links removed before the repository was disconnected stay in the record (ADR-054). */} + {evidenceHistory && evidenceHistory.length > 0 && ( +
+ {evidenceHistory.length === 1 ? "1 link change" : `${evidenceHistory.length} link changes`} + +
+ )} + )} {repository === null && evidence.length > 0 && (

diff --git a/app/web/src/app/projects/evidence-actions.ts b/app/web/src/app/projects/evidence-actions.ts index b4c4bd7..171b5c1 100644 --- a/app/web/src/app/projects/evidence-actions.ts +++ b/app/web/src/app/projects/evidence-actions.ts @@ -2,11 +2,15 @@ import { revalidatePath } from "next/cache"; import { apiFetch } from "@/lib/api"; -import { evidenceReferenceError, parseEvidence, parseEvidenceList, problemDetail } from "@/lib/evidence"; +import { + EVIDENCE_SEARCH_MAX, evidenceReferenceError, parseEvidence, parseEvidenceList, parseEvidenceSearch, problemDetail, type EvidenceSearchItem, +} from "@/lib/evidence"; import { fieldError, isUuid } from "@/lib/projects"; export type EvidenceLinkState = { reference: string; error: string | null; linked: number }; export type EvidenceActionState = { error: string | null }; +/** `items` is null until a search has been answered. */ +export type EvidenceSearchState = { query: string; items: EvidenceSearchItem[] | null; error: string | null }; const GITHUB_DOWN = "GitHub could not be reached. Try again in a moment."; const GITHUB_NOT_SET_UP = "GitHub is not set up for this SpecThread deployment yet."; @@ -51,6 +55,35 @@ export async function linkEvidence( return { reference: "", error: null, linked: previous.linked + 1 }; } +/** + * Finds issues and pull requests of the project's connected repository by words in their titles + * (ADR-054). Nothing is linked: each result is linked with `linkEvidence` by its number. + */ +export async function searchEvidence( + projectId: string, requirementId: string, _previous: EvidenceSearchState, formData: FormData, +): Promise { + const failed = "GitHub could not be searched. Please try again."; + const entry = formData.get("q"); + const query = typeof entry === "string" ? entry.trim() : ""; + const fail = (error: string): EvidenceSearchState => ({ query, items: null, error }); + if (!query) return fail("Enter words from the title of an issue or pull request."); + if (query.length > EVIDENCE_SEARCH_MAX) return fail(`Use ${EVIDENCE_SEARCH_MAX} characters or fewer.`); + if (!isUuid(projectId) || !isUuid(requirementId)) return fail(failed); + + try { + const response = await apiFetch(`/requirements/${requirementId}/evidence/search?${new URLSearchParams({ q: query })}`); + if (response.status === 400) return fail(fieldError(await response.json(), "q") ?? failed); + if (response.status === 409) return fail(problemDetail(await response.json()) ?? failed); + if (response.status === 502) return fail(GITHUB_DOWN); + if (response.status === 503) return fail(GITHUB_NOT_SET_UP); + if (!response.ok) throw new Error(`Search evidence returned ${response.status}.`); + return { query, items: parseEvidenceSearch(await response.json()), error: null }; + } catch (error) { + console.error("Search evidence failed", error); + return fail(failed); + } +} + /** 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 5c27868..d0d997c 100644 --- a/app/web/src/components/evidence-panel.tsx +++ b/app/web/src/components/evidence-panel.tsx @@ -2,9 +2,10 @@ import { useActionState, useState } from "react"; import { CircleDot, GitCommitHorizontal, GitPullRequest, RefreshCw, Tag, X } from "lucide-react"; -import { linkEvidence, refreshEvidence, unlinkEvidence } from "@/app/projects/evidence-actions"; +import { linkEvidence, refreshEvidence, searchEvidence, unlinkEvidence } from "@/app/projects/evidence-actions"; import { - checkSummary, EVIDENCE_REFERENCE_MAX, evidenceChanges, evidenceLabel, releaseContents, releasesContaining, type Evidence, + checkSummary, EVIDENCE_REFERENCE_MAX, EVIDENCE_SEARCH_MAX, evidenceChanges, evidenceEventText, evidenceLabel, releaseContents, releasesContaining, + type Evidence, type EvidenceEvent, } from "@/lib/evidence"; import { formatDate } from "@/lib/projects"; @@ -19,16 +20,18 @@ const external = { target: "_blank", rel: "noreferrer" } as const; * Renders a requirement's linked issues, pull requests, commits, and releases as a timeline, * oldest first, with what GitHub reported when they were last read. Pull requests and commits * show what changed, the results of their automated checks, and the linked releases that - * include them; a pull request lists its commits; a release lists which linked changes it contains. A - * link that began as a suggestion names the person who confirmed it. While the requirement can - * change, members add a link, remove one, and read everything from GitHub again. + * 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. */ -export function EvidencePanel({ projectId, requirementId, evidence, repository, people, editable }: { +export function EvidencePanel({ projectId, requirementId, evidence, history = null, repository, people, editable }: { projectId: string; requirementId: string; evidence: Evidence[]; + history?: EvidenceEvent[] | null; repository: string; - /** Names of the project's members by user ID, for naming who confirmed a suggested link. */ + /** Names of the project's members by user ID, for naming who added each link. */ people: Record; editable: boolean; }) { @@ -78,9 +81,10 @@ export function EvidencePanel({ projectId, requirementId, evidence, repository, {item.kind === "release" && } {inReleases.length > 0 &&

In {inReleases.length === 1 ? "release" : "releases"} {inReleases.join(", ")}

} - {item.source === "suggested" && ( -

Suggested, confirmed by {people[item.linkedBy] ?? "a former member"}

- )} +

+ {item.source === "suggested" ? "Suggested, confirmed" : "Linked"} by {people[item.linkedBy] ?? "a former member"} on{" "} + +

); })} @@ -101,6 +105,99 @@ export function EvidencePanel({ projectId, requirementId, evidence, repository, )} {refreshed.error &&

{refreshed.error}

} {editable && } + {editable && ( + + )} + {history && history.length > 0 && ( +
+ {history.length === 1 ? "1 link change" : `${history.length} link changes`} + +
+ )} + + ); +} + +/** Lists evidence links added and removed, newest first, with who made each change and when. */ +export function EvidenceHistory({ history, people }: { history: EvidenceEvent[]; people: Record }) { + return ( +
    + {history.map(event => ( +
  1. + {evidenceEventText(event)}{" "} + + by {people[event.actor] ?? "a former member"} on + +
  2. + ))} +
+ ); +} + +/** + * Renders the search for issues and pull requests by words in their titles, and its results with + * each one's state and author. A result is linked by its number through the same action as the + * link field, so its messages appear there; one linked already says so. + */ +function SearchForm({ projectId, requirementId, repository, evidence, linkAction, linking }: { + projectId: string; + requirementId: string; + repository: string; + evidence: Evidence[]; + linkAction: (formData: FormData) => void; + linking: boolean; +}) { + const [search, searchAction, searching] = useActionState(searchEvidence.bind(null, projectId, requirementId), { query: "", items: null, error: null }); + // What is linked now, so results stay right after a link is added or removed without searching again. + const linked = new Set(evidence.map(item => item.number).filter(number => number !== null)); + return ( + <> +
+
+ +
+ + +
+ {search.error && } +
+
+ {search.items && (search.items.length === 0 ? ( +

No issue or pull request in {repository} has those words in its title.

+ ) : ( +
    + {search.items.map(item => { + const Icon = icons[item.kind]; + const label = `#${item.number}`; + return ( +
  • +
    +
    +
  • + ); + })} +
+ ))} ); } diff --git a/app/web/src/lib/evidence.ts b/app/web/src/lib/evidence.ts index c177be6..7c4e6b4 100644 --- a/app/web/src/lib/evidence.ts +++ b/app/web/src/lib/evidence.ts @@ -197,3 +197,59 @@ export function problemDetail(problem: unknown): string | null { export function evidenceCountText(count: number): string { return count === 0 ? "No evidence" : count === 1 ? "1 evidence link" : `${count} evidence links`; } + +export const EVIDENCE_SEARCH_MAX = 200; + +/** An issue or pull request found by words in its title (ADR-054). `linked` says it is linked to this requirement already. */ +export type EvidenceSearchItem = { + number: number; kind: "issue" | "pull_request"; title: string; state: "open" | "closed" | "merged"; + author: string | null; url: string; linked: boolean; +}; + +/** Validates title search results from an API response. Throws when the shape is not the documented one. */ +export function parseEvidenceSearch(value: unknown): EvidenceSearchItem[] { + const items = (value as { items?: unknown } | null)?.items; + if (!Array.isArray(items)) throw new Error("The API returned unexpected search results."); + return items.map((entry: unknown) => { + const item = entry as Record | null; + if (typeof item !== "object" || item === null || typeof item.number !== "number" || !Number.isInteger(item.number) || item.number < 1 || + (item.kind !== "issue" && item.kind !== "pull_request") || 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.linked !== "boolean") { + throw new Error("The API returned unexpected search results."); + } + return { + number: item.number, kind: item.kind, title: item.title, state: item.state, author: item.author as string | null, + url: item.url as string, linked: item.linked, + }; + }); +} + +/** A link added to or removed from a requirement, with what it was then (ADR-054). */ +export type EvidenceEvent = { + id: string; evidenceId: string; action: "added" | "removed"; kind: Evidence["kind"]; label: string; title: string; + actor: string; occurredAt: string; +}; + +/** Validates a requirement's evidence history from an API response, newest first. */ +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; + if (typeof item !== "object" || item === null || typeof item.id !== "string" || typeof item.evidenceId !== "string" || + (item.action !== "added" && item.action !== "removed") || + (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, + }; + }); +} + +/** 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}”`; +} diff --git a/app/web/src/lib/project-data.ts b/app/web/src/lib/project-data.ts index 299f3e2..9208ddd 100644 --- a/app/web/src/lib/project-data.ts +++ b/app/web/src/lib/project-data.ts @@ -10,7 +10,7 @@ import { import { parseCriteriaVersion, parseCriteriaVersions, type CriteriaVersion } from "./criteria-versions"; import { parseFollowUps, type FollowUps } from "./follow-ups"; import { parseDocument, parseDocuments, type ProjectDocument } from "./documents"; -import { parseEvidenceList, type Evidence } from "./evidence"; +import { parseEvidenceHistory, parseEvidenceList, type Evidence, type EvidenceEvent } from "./evidence"; import { parseRequirement, type Requirement } from "./requirements"; import { parseReviews, type Review } from "./reviews"; @@ -88,6 +88,15 @@ export const listEvidence = cache(async (requirementId: string): Promise => { + const body = isUuid(requirementId) ? await read(`/requirements/${requirementId}/evidence/history`) : null; + return body === null ? null : parseEvidenceHistory(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 ae4456e..c6d15e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -65,6 +65,8 @@ to the project's team; non-members get 404 as if the project did not exist. | DELETE | `/projects/{projectId}/repository` | team Owner/Admin | | 204 (idempotent) | | 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) | | POST | `/requirements/{requirementId}/evidence` | member | `{ reference }` | 201 `Evidence` | | POST | `/requirements/{requirementId}/evidence/refresh` | member | | 200 `Evidence[]` | | DELETE | `/requirements/{requirementId}/evidence/{evidenceId}` | member | | 204 | @@ -99,6 +101,15 @@ connected, the app uninstalled, an archived requirement or project, or the evide or connected repository changed while GitHub was being read (reload and retry). 502 and 503 mean the same as for repositories. +Search (ADR-054) finds issues and pull requests of the connected repository whose +titles contain every word of `q` (1 to 200 characters, at most 10 words; quotes are +ignored), through GitHub's search API. Each word is searched for as text, so a +qualifier such as `repo:` in `q` cannot reach another repository; results from any +other repository are dropped. It links nothing: link a result by its number. An empty +`q`, or one of more than 10 words, is 400 on `q` (words are never dropped); no repository connected is 409; 502 and 503 as above, including +GitHub's search rate limit. Linking and unlinking each add an `EvidenceEvent`, which +stays after the link is removed; refresh adds none. + 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 @@ -231,6 +242,17 @@ type Evidence = { githubCreatedAt: string; githubUpdatedAt: string; githubClosedAt: string | null; linkedBy: string; linkedAt: string; refreshedAt: string; }; +type EvidenceSearchItem = { + number: number; kind: "issue" | "pull_request"; title: string; state: "open" | "closed" | "merged"; + author: string | null; url: string; + linked: boolean; // linked to this requirement when the search ran +}; +type EvidenceEvent = { + id: string; evidenceId: string; action: "added" | "removed"; + // 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; +}; ``` Review decisions (ADR-038): `note` may have up to 2,000 characters and is required @@ -326,7 +348,8 @@ from protected pages to /onboarding, and POST /projects returns 409 with remain 401, and an account without a user row remains 403. Project access follows the containing team as described above. Not yet available: activity and audit history. The only change histories kept are -criteria versions (ADR-051) and follow-up link changes (ADR-052). +criteria versions (ADR-051), follow-up link changes (ADR-052), and evidence link +changes (ADR-054). Team management uses authenticated server actions. Role changes require explicit diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 160bf47..258d537 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -161,7 +161,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). 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 seventeen 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). 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. 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 16d325b..af0de1c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -13,10 +13,11 @@ dotnet tool restore dotnet build app/api --configuration Release ``` -`SpecThreadDbContext` maps six Better Auth tables and seventeen product tables in +`SpecThreadDbContext` maps six Better Auth tables and eighteen product tables in `public`, with the migrations listed under "Combined migration order" below, ending with RequirementDocuments, RequirementUpdatedBy, RequirementCriteriaVersions, -RequirementMilestone, RequirementFollowUps, and RequirementCriteriaVersionTexts. Startup never calls `EnsureCreated`, `Migrate`, or a database +RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, and +RequirementEvidenceEvents. 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. @@ -328,7 +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, and RequirementCriteriaVersionTexts. +RequirementMilestone, RequirementFollowUps, RequirementCriteriaVersionTexts, and +RequirementEvidenceEvents. ## Project documents migration @@ -479,6 +481,30 @@ The second command generates docs/schema/requirement-criteria-version-texts.sql ef database update RequirementFollowUps --project app/api`, or the rollback script; it restores the array-only check and loses no data. +## Evidence link history migration + +RequirementEvidenceEvents (ADR-054) adds `requirement_evidence_events`: one row per +evidence link added or removed, with `requirement_id`, `evidence_id` (the link's ID, +without a foreign key, because a removal deletes the link), `action` (`added` or +`removed`), the link's `kind`, `label`, and `title` as they were, `actor`, and +`occurred_at`. It references requirements and users with restrict deletes, is indexed by +(requirement_id, occurred_at), has RLS enabled with no allow policies, and revokes +PUBLIC, anon, and authenticated grants. It starts the history with an `added` row for +every existing link, from its `linked_by` and `linked_at`, labelled as the API labels +links (`#` and the number, the tag, or the first 7 characters of the SHA). + +```sh +dotnet ef migrations add RequirementEvidenceEvents --project app/api --configuration Release +node scripts/generate-requirement-evidence-events.mjs +``` + +The second command adds the RLS, grant, and backfill statements and generates +docs/schema/requirement-evidence-events.sql and -rollback.sql. CI applies it after the +merge; it only adds a table, so the running API is unaffected, though links it makes or +removes until the new API is deployed are not recorded. Rollback: `dotnet ef database +update RequirementCriteriaVersionTexts --project app/api`, or the rollback script; it +drops only the history, losing every recorded addition and removal; links stay. + ## Supabase tooling ```sh diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 89b8bfb..c7ea7a5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -626,7 +626,8 @@ by creating an installation token. All GitHub calls go through `IGitHubClient` (app/api/GitHub). New settings: `GitHub:AppId`, `GitHub:PrivateKey` (PEM; literal `\n` accepted), `GitHub:AppSlug`, and optional `GitHub:ApiBaseUrl` for tests. No Setup URL or webhook is used. Members read the connection; only the owner connects -or disconnects; archived projects cannot change it. GitHub being unreachable is 502, +or disconnects (ADR-041 later changes this to the team's Owner or an Admin); archived +projects cannot change it. GitHub being unreachable is 502, missing or rejected app credentials 503, and a token or repository GitHub does not accept 400 on the field. @@ -735,7 +736,8 @@ Decision: `requirement_evidence.source` is `manual` or `suggested` (default `man migration EvidenceChecks). `linked_by` is always a person: for a suggested link, the 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. Rules for +`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 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 @@ -1353,6 +1355,48 @@ evidence policy exists, and check results are as fresh as the last GitHub refres Migration RequirementMilestone adds the column and its check; its rollback drops it, losing every label. +ADR-054: Evidence links are found by title and every change to them is recorded + +Status: Accepted + +Context: The story "link GitHub issues and pull requests to a requirement" (Danielle, +Tech Lead; "Design the Box" F2) asks that an issue or pull request can be found by number +or title; that each link shows the item's title, state, and author; that each link +records who created it and when; and that a link can be removed with the removal +recorded. Links were made by number, SHA, tag, or address only (ADR-033), who linked +an item was shown only for confirmed suggestions (ADR-036), and removing a link deleted +it without a trace. The user chose GitHub's search over filtering recent items, and a +history of both additions and removals over removals only. + +Decision: `GET /requirements/{id}/evidence/search?q=` asks GitHub's search API +(`/search/issues`, through `IGitHubClient`) with the installation token for issues and +pull requests of the connected repository whose titles contain every word, open or +closed, at most 20, with number, kind, title, state (merged from the pull request's +`merged_at`), author, and whether each is linked. Every word is quoted, so text such as +`repo:other/x` is searched for rather than read as a qualifier that could reach +another repository of the installation, and results whose repository is not the +connected one are dropped. Nothing is stored; a result is linked through the existing +link endpoint by its number, which reads the item from GitHub as before. On the +requirement page a "Find by title" field lists the results with a Link button, or +"Linked" for one that is. + +`requirement_evidence_events` records each link added and removed with the person, +the time, and the link's kind, label ("#9", a tag, or a short SHA), and title as they +were, so the history outlives the link; rows are only added. Linking and unlinking +write it under the team lock in the same transaction as the link; refreshing does not. +`GET /requirements/{id}/evidence/history` lists it newest first. Each linked item now +shows "Linked by on " ("Suggested, confirmed by" for a suggestion), and the +changes appear under the evidence and on the Requirement history page. + +Consequences: Search uses the repository name saved at connection (ADR-032), so after +a rename it finds nothing until the repository is connected again; GitHub's search +also lags new items by a short time and is rate limited (about 30 searches a minute per +installation), which shows as 502. Search matches words, not exact phrases. Links made +or removed by the previous API during a deploy are not recorded. Migration +RequirementEvidenceEvents adds the table (RLS on, browser grants revoked) and starts it +with an "added" event for every existing link, from its `linked_by` and `linked_at`; its +rollback drops the table and loses the history, including every removal. + ADR-NNN: Title Status: Proposed, Accepted, Superseded, or Rejected diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e114055..0c4dc95 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,71 @@ # Shared handoff -## Current task: Criteria versions, accepted-criteria warning, follow-ups, and milestones (2026-10-29) +## Current task: Connect a GitHub repository story (2026-10-30) + +- Branch: `feature/repository-connection-story`, from `main` at `09848a9` (PR #23 merged). +- Scope: the story "connect one GitHub repository to a project" (Danielle, Tech Lead; + source "Design the Box" F2). All three criteria were already met by ADR-032 and + ADR-041; this task verifies them and closes the one test gap. + - Uses the SpecThread GitHub App: the repository is chosen from the installations the + user's GitHub token shows, and the API confirms the app can act there + (`app/api/GitHub/RepositoryEndpoints.cs`). + - Team Owner or Admin only: `CanManageTeam`, checked again under the team lock; a + Member gets 403, a non-member 404. + - At most one repository: `project_repositories` is keyed by project, so connecting + again replaces it. The name shows in the project's Properties, in Settings, and on + requirement pages. +- Completed: an Admin who is not the Owner is now tested connecting and disconnecting, + through the API and the browser (the Owner sees the name on the project, then "Not + connected"). Test names that said "owner" for the Owner-or-Admin rule were corrected. + The story is recorded in docs/PROJECT.md, and ADR-032 notes that ADR-041 replaced its + owner-only rule. +- Changed files: `tests/schema/github-repository.spec.ts`, `tests/e2e/repository.spec.ts`, + docs PROJECT, DECISIONS (ADR-032 note), TESTING, HANDOFF. No product code or migration. +- Checks: `npx playwright test tests/schema/github-repository.spec.ts` (8 passed) and + `tests/e2e/repository.spec.ts` (26 passed); `npm run lint`, `npm run typecheck`, + `git diff --check` passed. +- Known issues and risks: none new. As before (ADR-032), the repository name is a + snapshot from connection time, and the real GitHub is not exercised by tests. +- Part 2, same branch at the user's request: the story "link GitHub issues and pull + requests to a requirement" (Danielle; "Design the Box" F2), ADR-054. + - Already met: linking by number or address (ADR-033); each link shows title, state + (open/closed/merged), and author; `linked_by`/`linked_at` were stored. + - Completed: + - Find by title: `GET /requirements/{id}/evidence/search?q=` asks GitHub's search API + for issues and pull requests whose titles contain every word, at most 20, with + state, author, and whether linked. Words are quoted, and results from another + repository are dropped. A "Find by title" field lists results with Link buttons. + - Every link now shows "Linked by on ". + - `requirement_evidence_events` records each link added and removed (who, when, and + the link's kind, label, and title), shown under the evidence and on the Requirement + history page; `GET /requirements/{id}/evidence/history`. + - Decisions (the user's): GitHub search over filtering recent items; record additions + and removals, not removals only; stay on this branch. + - Changed files: API `GitHub/GitHubClient.cs` (`SearchItemsAsync`), + `GitHub/EvidenceEndpoints.cs` (search, history, events on link and unlink), + `Requirements/ReviewEndpoints.cs` (shared `Label`), `Data/ProductModels.cs`, + `Data/SpecThreadDbContext.cs`, migration `20261030130820_RequirementEvidenceEvents` + and the snapshot; `scripts/generate-requirement-evidence-events.mjs`, generated + `docs/schema/requirement-evidence-events*.sql`, `scripts/test-database.mjs`, + `scripts/test-github.mjs` (search); web `lib/evidence.ts`, `lib/project-data.ts`, + `app/projects/evidence-actions.ts`, `components/evidence-panel.tsx`, the requirement + and history pages, `app-shell.css`; tests: new `tests/schema/evidence-history.spec.ts` + (5124, 5125), two tests in `tests/e2e/evidence.spec.ts`, parser tests in + `tests/api/project-data.spec.ts`, migration counts up by one in `reviews.spec.ts`, + `evidence.spec.ts`, `github-repository.spec.ts`; docs DECISIONS (ADR-054, ADR-036 + note), API, DATABASE, ARCHITECTURE (eighteen product tables), PROJECT, TESTING. + - Risks: search uses the repository name saved at connection, so after a rename it + finds nothing until reconnected; GitHub search is rate limited (about 30 a minute per + installation; shown as 502) and lags new items briefly; links changed by the previous + API during the deploy are not recorded. CI applies the migration on merge. + - Checks: `npm test` 389 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), `git diff --check` passed. The migration was + applied to the local Docker database with `dotnet ef database update`. Not exercised: + the real GitHub search API. +- Next step: review, then open a PR to `main` carrying both stories and the migration. + +## Previous task: Criteria versions, accepted-criteria warning, follow-ups, and milestones (2026-10-29, merged as PR #23) - Branch: `feature/requirement-change-tracking`, from `main` at `8555d75` (PR #22 merged). - Scope: four stories from the scenarios "Changing a requirement after its code was @@ -93,10 +158,10 @@ - Playwright prints a pg "terminating connection due to administrator command" stack from the shared web test database when the run ends; it happens on `main` too and does not fail tests. -- Next step: review the diff, then commit (with the user's approval) and open a PR to - `main`. A local Docker database for manual testing (`specthread-local-db`, port 54329) - has every migration applied with `dotnet ef database update`. Possible follow-ups: a Milestones page built on the labels, an edit history - for titles and descriptions, and a configurable evidence policy. +- Outcome: merged to `main` as PR #23 (`09848a9`). A local Docker database for manual + testing (`specthread-local-db`, port 54329) has every migration applied with + `dotnet ef database update`. Possible follow-ups: a Milestones page built on the + labels, an edit history for titles and descriptions, and a configurable evidence policy. ## Previous task: Requirement criteria rule, creation date, linked documents, last editor, and restore (2026-10-23, merged as PR #22) diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 11a3211..e30c689 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -72,6 +72,32 @@ Archived requirements are hidden from the default list but can still be opened r Archived requirements can't be edited or reviewed until restored. +Story: connect a GitHub repository + +As a tech lead (Danielle), I want to connect one GitHub repository to a project so that +SpecThread can read its issues, pull requests, commits, and check results. Source: "Design +the Box" F2. Acceptance criteria (ADR-032, ADR-041): + +Connection uses the SpecThread GitHub App. + +Only the team Owner or an Admin can connect or disconnect the repository. + +A project connects to at most one repository; the connected repository's name is shown on the project. + +Story: link issues and pull requests + +As a tech lead (Danielle), I want to link GitHub issues and pull requests to a requirement +so that the work that implements it is visible in one place. Source: "Design the Box" F2. +Acceptance criteria (ADR-033, ADR-054): + +Users can find an issue or pull request by number or title. + +Each link shows the item's title, state (open/closed/merged), and author. + +Each link records who created it and when. + +A link can be removed, and the removal is recorded. + MVP scope The first usable version should support: diff --git a/docs/TESTING.md b/docs/TESTING.md index 512d0cb..5d84cf1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -69,7 +69,8 @@ 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, and milestones 5123 (requirement documents moved from +versions 5121, follow-ups 5122, milestones 5123, and evidence search and history 5124 (fake GitHub) and 5125 +(requirement documents moved from 5107 to 5109 when the branches were merged, because the proxy and GitHub stand-ins below already use those). scripts/start-test-web.mjs also starts the API on 5106 for browser tests. That instance uses @@ -152,15 +153,29 @@ currently require network access during the web build. - GitHub repository connection (ADR-032). Browser: connecting, seeing the repository across the project, and disconnecting after confirmation; no linked GitHub account; a rejected GitHub sign-in; no installation; the choice kept after a refused attempt; - a repository or installation that disappeared; non-owners and archived projects - seeing it read-only; GitHub down, rate limiting, dropping connections, or answering + a repository or installation that disappeared; a team Admin who is not the Owner + connecting and disconnecting while the Owner sees the name on the project; Members + and archived projects seeing it read-only; GitHub down, rate limiting, dropping connections, or answering wrongly while listing and while connecting; API failures while connecting and disconnecting; and the error page inside the project frame. API (schema suite): - owner-only changes, 404 for non-members, refusing other users' installations and + changes by the team Owner or an Admin only (a Member gets 403), 404 for non-members, refusing other users' installations and repositories and mismatched pairs, input validation, paging past 100 repositories and the 500 limit, 502 and 503 mapping, missing app credentials, anonymous 401, the user's token never being stored, and the migration's RLS, constraints, and rollback. Not exercised: the real GitHub, and an expired GitHub token being refreshed. +- Evidence search and history (ADR-054). tests/schema/evidence-history.spec.ts (fake + GitHub on 5124, API on 5125): additions and removals recorded newest first with who, + when, and what the link was, outliving it; nothing recorded for a refused removal; + title search by every word, case-insensitive, with state (merged too), author, and + whether linked; a `repo:` qualifier in the words finding nothing; results from + another repository dropped; validation of `q`; 409 without a repository; 404, 401; + GitHub failures and rate limiting as 502; and the table's constraints, RLS, backfill + from existing links, and rollback. Each test repository has its own name, because the + fake GitHub finds a repository for search by name, as GitHub does. Browser + (tests/e2e/evidence.spec.ts): searching, the results' state and author, linking from + 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. - 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-events-rollback.sql b/docs/schema/requirement-evidence-events-rollback.sql new file mode 100644 index 0000000..10d56e5 --- /dev/null +++ b/docs/schema/requirement-evidence-events-rollback.sql @@ -0,0 +1,8 @@ +START TRANSACTION; +DROP TABLE public.requirement_evidence_events; + +DELETE FROM "__EFMigrationsHistory" +WHERE "MigrationId" = '20261030130820_RequirementEvidenceEvents'; + +COMMIT; + diff --git a/docs/schema/requirement-evidence-events.sql b/docs/schema/requirement-evidence-events.sql new file mode 100644 index 0000000..6dcdb8e --- /dev/null +++ b/docs/schema/requirement-evidence-events.sql @@ -0,0 +1,45 @@ +START TRANSACTION; +CREATE TABLE public.requirement_evidence_events ( + id uuid NOT NULL DEFAULT (gen_random_uuid()), + requirement_id uuid NOT NULL, + evidence_id uuid NOT NULL, + action text NOT NULL, + kind text NOT NULL, + label text NOT NULL, + title text NOT NULL, + actor text NOT NULL, + occurred_at timestamp with time zone NOT NULL DEFAULT (now()), + CONSTRAINT "PK_requirement_evidence_events" PRIMARY KEY (id), + CONSTRAINT ck_requirement_evidence_events_action CHECK (action IN ('added', 'removed')), + CONSTRAINT ck_requirement_evidence_events_kind CHECK (kind IN ('issue', 'pull_request', 'commit', 'release')), + CONSTRAINT ck_requirement_evidence_events_label CHECK (length(btrim(label)) > 0), + CONSTRAINT "FK_requirement_evidence_events_requirements_requirement_id" FOREIGN KEY (requirement_id) REFERENCES public.requirements (id) ON DELETE RESTRICT, + CONSTRAINT "FK_requirement_evidence_events_user_actor" FOREIGN KEY (actor) REFERENCES public."user" (id) ON DELETE RESTRICT +); + +CREATE INDEX "IX_requirement_evidence_events_actor" ON public.requirement_evidence_events (actor); + +CREATE INDEX "IX_requirement_evidence_events_requirement_id_occurred_at" ON public.requirement_evidence_events (requirement_id, occurred_at); + +ALTER TABLE public."requirement_evidence_events" ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON TABLE public."requirement_evidence_events" FROM PUBLIC; +DO $security$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + REVOKE ALL ON TABLE public."requirement_evidence_events" FROM anon; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + REVOKE ALL ON TABLE public."requirement_evidence_events" FROM authenticated; + END IF; +END $security$; +INSERT INTO public.requirement_evidence_events (requirement_id, evidence_id, action, kind, label, title, actor, occurred_at) +SELECT e.requirement_id, e.id, 'added', e.kind, + CASE WHEN e.number IS NOT NULL THEN '#' || e.number WHEN e.tag IS NOT NULL THEN e.tag ELSE left(e.sha, 7) END, + e.title, e.linked_by, e.linked_at +FROM public.requirement_evidence e; + +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") +VALUES ('20261030130820_RequirementEvidenceEvents', '10.0.12'); + +COMMIT; + diff --git a/scripts/generate-requirement-evidence-events.mjs b/scripts/generate-requirement-evidence-events.mjs new file mode 100644 index 0000000..1abe3c5 --- /dev/null +++ b/scripts/generate-requirement-evidence-events.mjs @@ -0,0 +1,44 @@ +// Run after dotnet ef migrations add RequirementEvidenceEvents --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("_RequirementEvidenceEvents.cs"))}`; +const 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. Every existing link already records who + // added it and when, so each starts the history with that addition (ADR-054). The label is + // the one the API shows: #number, the release tag, or the short SHA. + const table = "requirement_evidence_events"; + const sql = ` + 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$; + INSERT INTO public.requirement_evidence_events (requirement_id, evidence_id, action, kind, label, title, actor, occurred_at) + SELECT e.requirement_id, e.id, 'added', e.kind, + CASE WHEN e.number IS NOT NULL THEN '#' || e.number WHEN e.tag IS NOT NULL THEN e.tag ELSE left(e.sha, 7) END, + e.title, e.linked_by, e.linked_at + FROM public.requirement_evidence e; + """); +`; + writeFileSync(file, source.slice(0, position) + sql + source.slice(position)); +} +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 [ + ["RequirementCriteriaVersionTexts", "RequirementEvidenceEvents", "docs/schema/requirement-evidence-events.sql"], + ["RequirementEvidenceEvents", "RequirementCriteriaVersionTexts", "docs/schema/requirement-evidence-events-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 b0db4d1..9c87c8d 100644 --- a/scripts/test-database.mjs +++ b/scripts/test-database.mjs @@ -60,6 +60,7 @@ export async function startTestDatabase() { await pool.query(await readSql("docs/schema/requirement-milestone.sql")); 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")); return { name, connectionString, pool, stop }; } catch (error) { await stop(); diff --git a/scripts/test-github.mjs b/scripts/test-github.mjs index 41d25f7..bf01b4f 100644 --- a/scripts/test-github.mjs +++ b/scripts/test-github.mjs @@ -185,6 +185,27 @@ export function startFakeGitHub({ port, appId, publicKey }) { return found ? send(response, 200, page(found.commits ?? [], url).map(commitJson)) : send(response, 404, { message: "Not Found" }); } + // Title search: quoted words, all required, in the one repository named by "repo:". + if (request.method === "GET" && url.pathname === "/search/issues") { + if (bearer !== installationToken) return send(response, 401, { message: "Bad credentials" }); + const query = url.searchParams.get("q") ?? ""; + const named = /(?:^| )repo:([^/ ]+)\/([^ ]+)(?: |$)/.exec(query); + const repository = named && [...users.values()].flat().flatMap(installation => installation.repositories) + .find(entry => entry.owner.toLowerCase() === named[1].toLowerCase() && entry.name.toLowerCase() === named[2].toLowerCase()); + if (!repository) return send(response, 422, { message: "Validation Failed" }); + const words = [...query.matchAll(/"([^"]*)"/g)].map(match => match[1].toLowerCase()); + const found = (items.get(repository.id) ?? []).filter(entry => words.every(word => entry.title.toLowerCase().includes(word))); + return send(response, 200, { + total_count: found.length, incomplete_results: false, + items: page(found, url).map(entry => ({ + number: entry.number, title: entry.title, state: entry.state, user: entry.user === null ? null : { login: entry.user ?? "octocat" }, + html_url: `https://github.com/${repository.owner}/${repository.name}/${entry.pull ? "pull" : "issues"}/${entry.number}`, + repository_url: `https://api.github.com/repos/${repository.owner}/${repository.name}`, + ...(entry.pull ? { pull_request: { merged_at: entry.merged ? entry.closed_at ?? entry.updated_at : null } } : {}), + })), + }); + } + 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 44958de..fda5a1b 100644 --- a/tests/api/project-data.spec.ts +++ b/tests/api/project-data.spec.ts @@ -9,7 +9,8 @@ import { import { criteriaVersionText, parseCriteriaVersion, parseCriteriaVersions } from "../../app/web/src/lib/criteria-versions"; import { followUpEventText, parseFollowUps } from "../../app/web/src/lib/follow-ups"; import { - checkSummary, evidenceChanges, evidenceCountText, evidenceLabel, evidenceReferenceError, parseEvidence, parseEvidenceList, problemDetail, + checkSummary, evidenceChanges, evidenceCountText, evidenceEventText, evidenceLabel, evidenceReferenceError, parseEvidence, parseEvidenceHistory, + parseEvidenceList, parseEvidenceSearch, problemDetail, releaseContents, releasesContaining, } from "../../app/web/src/lib/evidence"; import { parseAvailableRepositories, parseProjectRepository, parseRepositoryChoice } from "../../app/web/src/lib/repositories"; @@ -443,3 +444,25 @@ test("criteria versions and follow-ups are accepted only in the documented shape expect(followUpEventText(parseFollowUps(followUps).history[0], "r1")).toBe("Marked as a follow-up of “Box opens”"); expect(followUpEventText(parseFollowUps(followUps).history[1], "r2")).toBe("“Box opens faster” was unmarked as a follow-up of this requirement"); }); + +test("title search results and the evidence history are accepted only in the documented shape", () => { + const result = { number: 15, kind: "pull_request", title: "Add guest checkout", state: "merged", author: "grace", + url: "https://github.com/acme/web/pull/15", linked: false }; + expect(parseEvidenceSearch({ items: [{ ...result, extra: 1 }, { ...result, number: 3, author: null }] })).toEqual([result, { ...result, number: 3, author: null }]); + expect(parseEvidenceSearch({ items: [] })).toEqual([]); + for (const value of [null, [], { items: null }, { items: [{ ...result, number: 0 }] }, { items: [{ ...result, kind: "commit" }] }, + { items: [{ ...result, state: "draft" }] }, { items: [{ ...result, url: "https://evil.example/acme/web/pull/15" }] }, + { items: [{ ...result, linked: "no" }] }, { items: [{ ...result, author: 1 }] }]) { + expect(() => parseEvidenceSearch(value), JSON.stringify(value)).toThrow("unexpected search results"); + } + + 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]); + 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”"); +}); diff --git a/tests/e2e/evidence.spec.ts b/tests/e2e/evidence.spec.ts index 243dece..a4dcf84 100644 --- a/tests/e2e/evidence.spec.ts +++ b/tests/e2e/evidence.spec.ts @@ -40,13 +40,16 @@ async function createRequirement(page: Page) { return { projectId, requirementId: page.url().split("/").at(-1)!, url: page.url() }; } -/** Connects a repository to the project directly in the database, and makes GitHub know its installation. */ -async function connect(projectId: string, items: FakeItem[], appInstalled = true) { +/** + * Connects a repository to the project directly in the database, and makes GitHub know its + * installation. Searching finds a repository by name, so a test that searches passes its own. + */ +async function connect(projectId: string, items: FakeItem[], appInstalled = true, name = "web") { const [installationId, repositoryId] = [githubId(), githubId()]; - await setGitHubUser(`seed-${installationId}`, [{ id: installationId, appInstalled, repositories: [{ id: repositoryId, owner: "acme", name: "web" }] }]); + await setGitHubUser(`seed-${installationId}`, [{ id: installationId, appInstalled, repositories: [{ id: repositoryId, owner: "acme", name }] }]); await setGitHubItems(repositoryId, items); await runTestSql(`INSERT INTO public.project_repositories (project_id,installation_id,repository_id,owner,name,is_private,connected_by) - VALUES ('${projectId}',${installationId},${repositoryId},'acme','web',false,'${owner.userId}')`); + VALUES ('${projectId}',${installationId},${repositoryId},'acme','${name}',false,'${owner.userId}')`); return { installationId, repositoryId }; } @@ -609,3 +612,82 @@ test("requirement lists say how many evidence links each requirement has", async await clearApiFaults(owner.userId); } }); + +test("a member finds issues and pull requests by title, links one from the results, and adding and removing are recorded", async ({ page }, testInfo) => { + const { projectId, url } = await createRequirement(page); + const name = `search-${githubId()}`; + await connect(projectId, [ + issue(12, "Guests cannot pay"), + issue(15, "Add guest checkout", { pull: true, user: "grace" }), + issue(3, "Guest spike", { pull: true, merged: true, state: "closed", closed_at: "2026-09-04T10:00:00Z", user: null }), + ], true, name); + await page.reload(); + const search = page.getByLabel(`Find an issue or pull request in acme/${name} by title`); + const results = page.getByRole("list", { name: "Search results" }).getByRole("listitem"); + + await search.fill("guest"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(results).toHaveCount(3); + await expect(results.filter({ hasText: "#15" })).toContainText("Open"); + await expect(results.filter({ hasText: "#15" })).toContainText("grace"); + await expect(results.filter({ hasText: "#3" })).toContainText("Merged"); + await expect(search).toHaveValue("guest"); + await page.screenshot({ path: testInfo.outputPath("evidence-search.png"), fullPage: true }); + + await page.getByRole("button", { name: "Link #15" }).click(); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page)).toContainText("Add guest checkout"); + await expect(rows(page)).toContainText("Linked by Evidence tester on"); + // The result now says it is linked instead of offering to link it again. + await expect(results.filter({ hasText: "#15" })).toContainText("Linked"); + await expect(page.getByRole("button", { name: "Link #15" })).toHaveCount(0); + + await page.getByRole("button", { name: "Remove link to #15" }).click(); + await expect(rows(page)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Link #15" })).toBeVisible(); + await page.getByText("2 link changes").click(); + const changes = page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem"); + await expect(changes.nth(0)).toContainText("Removed #15 “Add guest checkout” by Evidence tester"); + await expect(changes.nth(1)).toContainText("Linked #15 “Add guest checkout” by Evidence tester"); + + await search.fill("refund"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(page.getByText(`No issue or pull request in acme/${name} has those words in its title.`)).toBeVisible(); + + await page.goto(`${url}/history`); + await expect(page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem")).toHaveCount(2); + + // With no links left and the repository disconnected, the changes are still on the requirement. + await runTestSql(`DELETE FROM public.project_repositories WHERE project_id = '${projectId}'`); + await page.goto(url); + await expect(page.getByText("to link issues, pull requests, commits, and releases.")).toBeVisible(); + await page.getByText("2 link changes").click(); + await expect(page.getByRole("list", { name: "Evidence link changes" }).getByRole("listitem").nth(0)) + .toContainText("Removed #15 “Add guest checkout” by Evidence tester"); +}); + +test("a search GitHub can't answer, or the API refuses, is explained and links nothing", async ({ page }) => { + const { projectId, requirementId } = await createRequirement(page); + await connect(projectId, [issue(12, "Guests cannot pay")], true, `search-${githubId()}`); + await page.reload(); + const search = page.getByLabel(/Find an issue or pull request in .* by title/); + const alert = page.getByRole("form", { name: "Find by title" }).getByRole("alert"); + const path = `^/requirements/${requirementId}/evidence/search$`; + + await failApi(owner.userId, { method: "GET", path, status: 502, body: { status: 502 } }); + await search.fill("guest"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(alert).toHaveText("GitHub could not be reached. Try again in a moment."); + await clearApiFaults(owner.userId); + + await failApi(owner.userId, { method: "GET", path, status: 400, body: { errors: { q: ["Enter words from the title."] } } }); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(alert).toHaveText("Enter words from the title."); + await clearApiFaults(owner.userId); + + await failApi(owner.userId, { method: "GET", path, status: 200, body: { items: "nope" } }); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(alert).toHaveText("GitHub could not be searched. Please try again."); + await expect(page.getByRole("list", { name: "Search results" })).toHaveCount(0); + await expect(rows(page)).toHaveCount(0); +}); diff --git a/tests/e2e/repository.spec.ts b/tests/e2e/repository.spec.ts index 43c9878..eb093f1 100644 --- a/tests/e2e/repository.spec.ts +++ b/tests/e2e/repository.spec.ts @@ -108,7 +108,7 @@ test("with no installation the owner is told to install the app, and the list fi expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); }); -test("members who are not the owner, and archived projects, see the connection but cannot change it", async ({ page, browser }) => { +test("members who are not the team Owner or an Admin, and archived projects, see the connection but cannot change it", async ({ page, browser }) => { const projectId = await createProject(page, "Shared"); await gitHubWith([repo("web-shop")]); await page.goto(`/projects/${projectId}/settings/repository`); @@ -265,3 +265,31 @@ test("other users cannot see a project's repository page", async ({ page, browse await expect(otherPage.getByRole("heading", { name: "Page not found" })).toBeVisible(); await other.close(); }); + +test("a team Admin who is not the Owner connects and disconnects, and the project shows the repository's name", async ({ page, browser }) => { + const projectId = await createProject(page, "Box"); + const admin = await createTestSession("Danielle Admin"); + await addTestTeamMember(projectId, admin.userId, "admin"); + await setGitHubUser(await linkGitHubAccount(admin.userId), [{ id: githubId(), repositories: [repo("box")] }]); + const context = await browser.newContext(); + await context.addCookies([admin.cookie]); + const adminPage = await context.newPage(); + + await adminPage.goto(`/projects/${projectId}/settings/repository`); + await adminPage.getByRole("radio", { name: /acme\/box/ }).check(); + await adminPage.getByRole("button", { name: "Connect repository" }).click(); + await expect(adminPage.getByText("Connected by Danielle Admin on")).toBeVisible(); + // One repository per project: the picker is gone until it is disconnected. + await expect(adminPage.getByRole("radio")).toHaveCount(0); + + // Everyone in the project sees its name, including the Owner who did not connect it. + await page.goto(`/projects/${projectId}`); + await expect(page.getByRole("complementary", { name: "Project details" }).getByRole("link", { name: "acme/box" })).toBeVisible(); + + await adminPage.getByRole("button", { name: "Disconnect repository" }).click(); + await adminPage.getByRole("button", { name: "Yes, disconnect" }).click(); + await expect(adminPage.getByRole("radio")).toHaveCount(1); + await page.reload(); + await expect(page.getByRole("complementary", { name: "Project details" }).getByRole("link", { name: "Not connected" })).toBeVisible(); + await context.close(); +}); diff --git a/tests/schema/evidence-history.spec.ts b/tests/schema/evidence-history.spec.ts new file mode 100644 index 0000000..d62d2d6 --- /dev/null +++ b/tests/schema/evidence-history.spec.ts @@ -0,0 +1,203 @@ +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"; + +// Finding issues and pull requests by title, and the history of evidence links added and +// removed (ADR-054), 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:5124"; +const key = generateKeyPairSync("rsa", { modulusLength: 2048 }); +let database: Awaited>; +let api: Awaited>; +let fake: Server; +let nextId = 3000; + +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: 5124, appId: "999", publicKey: key.publicKey }); + api = await startApi(5125, { + ConnectionStrings__Database: `Host=${url.hostname};Port=${url.port};Database=postgres;Username=${url.username};Password=${url.password}`, + Auth__Issuer: issuer, GitHub__ApiBaseUrl: gitHub, GitHub__AppId: "999", + 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; 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 }); + +/** A project with a member, one requirement, and, unless told otherwise, a connected repository GitHub knows with these items. */ +async function setUp(request: APIRequestContext, items: Item[], 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 requirement = await (await owner.post(`/projects/${project.id}/requirements`, { title: "Guest checkout", acceptanceCriteria: ["Done"] })).json(); + const [installationId, repositoryId] = [nextId++, nextId++]; + // Each project gets its own repository name, as on GitHub, where search finds a repository by name. + 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 } }); + 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, path: `/requirements/${requirement.id}/evidence`, name }; +} + +type Event = { action: string; label: string; title: string; kind: string; actor: string; evidenceId: string; occurredAt: string }; + +test("adding and removing a link is recorded with who and when, newest first, and outlives the link", async ({ request }) => { + const { path } = await setUp(request, [item(20, "Guests cannot pay"), item(5, "Add guest checkout", { pull: true })]); + const [owner, member] = [await as(request, "owner"), await as(request, "member")]; + expect(await (await member.get(`${path}/history`)).json()).toEqual([]); + + const issue = await (await member.post(path, { reference: "#20" })).json(); + const pull = await (await owner.post(path, { reference: "5" })).json(); + expect((await owner.delete(`${path}/${issue.id}`)).status()).toBe(204); + expect((await owner.delete(`${path}/${issue.id}`)).status()).toBe(404); + + const history: Event[] = await (await member.get(`${path}/history`)).json(); + expect(history.map(e => [e.action, e.label, e.title, e.kind, e.actor])).toEqual([ + ["removed", "#20", "Guests cannot pay", "issue", "owner"], + ["added", "#5", "Add guest checkout", "pull_request", "owner"], + ["added", "#20", "Guests cannot pay", "issue", "member"], + ]); + expect(history[2]).toMatchObject({ evidenceId: issue.id, occurredAt: issue.linkedAt }); + expect(history[1]).toMatchObject({ evidenceId: pull.id, occurredAt: pull.linkedAt }); + expect(Date.parse(history[0].occurredAt)).toBeGreaterThanOrEqual(Date.parse(history[1].occurredAt)); + + // A refused removal records nothing. + const outsider = await as(request, "outsider"); + expect((await outsider.delete(`${path}/${pull.id}`)).status()).toBe(404); + expect((await outsider.get(`${path}/history`)).status()).toBe(404); + expect((await request.get(`${api.baseURL}${path}/history`)).status()).toBe(401); + expect(await (await member.get(`${path}/history`)).json()).toHaveLength(3); +}); + +test("issues and pull requests are found by words in their titles, with state, author, and whether they are linked", async ({ request }) => { + const { path, name } = await setUp(request, [ + item(20, "Guests cannot pay"), + item(5, "Add guest checkout", { pull: true, user: "grace" }), + item(2, "Guest spike", { pull: true, merged: true, state: "closed", closed_at: "2026-09-04T10:00:00Z", user: null }), + item(7, "Cart totals are wrong", { state: "closed" }), + ]); + const member = await as(request, "member"); + const search = async (q: string) => { + const response = await member.get(`${path}/search?${new URLSearchParams({ q })}`); + expect(response.status(), q).toBe(200); + return (await response.json()).items; + }; + expect((await member.post(path, { reference: "20" })).status()).toBe(201); + + expect(await search("guest")).toEqual([ + { number: 20, kind: "issue", title: "Guests cannot pay", state: "open", author: "ada", url: `https://github.com/acme/${name}/issues/20`, linked: true }, + { number: 5, kind: "pull_request", title: "Add guest checkout", state: "open", author: "grace", url: `https://github.com/acme/${name}/pull/5`, linked: false }, + { number: 2, kind: "pull_request", title: "Guest spike", state: "merged", author: null, url: `https://github.com/acme/${name}/pull/2`, linked: false }, + ]); + expect((await search(" guest PAY ")).map((r: { number: number }) => r.number)).toEqual([20]); + expect((await search("cart")).map((r: { state: string }) => r.state)).toEqual(["closed"]); + expect(await search("refund")).toEqual([]); + // Words are searched for, never read as qualifiers that could reach another repository. + expect(await search('guest repo:acme/other')).toEqual([]); + expect(await search('"guest" "pay"')).toHaveLength(1); + // Searching links nothing. + expect(await (await member.get(path)).json()).toHaveLength(1); +}); + +test("searches are validated, need a connected repository, stay in it, and report GitHub failures", async ({ request }) => { + const { path, name } = await setUp(request, [item(1, "Guests cannot pay")]); + const member = await as(request, "member"); + const words = (count: number) => Array.from({ length: count }, (_, i) => `w${i}`).join(" "); + for (const query of ["", "q=", "q=%20%20", `q=${'"'.repeat(3)}`, `q=${"x".repeat(201)}`, `q=${encodeURIComponent(words(11))}`]) { + const response = await member.get(`${path}/search?${query}`); + expect(response.status(), query).toBe(400); + expect((await response.json()).errors.q, query).toHaveLength(1); + } + // Ten words are searched in full; eleven are refused rather than cut to ten. + expect((await (await member.get(`${path}/search?q=${encodeURIComponent(words(11))}`)).json()).errors.q).toEqual(["Use at most 10 words."]); + expect((await member.get(`${path}/search?q=${encodeURIComponent(words(10))}`)).status()).toBe(200); + + const fault = (data: object) => request.post(`${gitHub}/__github/faults`, { data: { bearer: "installation", path: "^/search/issues$", ...data } }); + const clear = () => request.delete(`${gitHub}/__github/faults?bearer=installation`); + // Whatever GitHub answers, items of another repository are not shown. + await fault({ status: 200, times: 1, body: { items: [ + { number: 9, title: "Guest", state: "open", user: null, html_url: "https://github.com/acme/secret/issues/9", repository_url: "https://api.github.com/repos/acme/secret" }, + { number: 1, title: "Guests cannot pay", state: "open", user: null, html_url: `https://github.com/acme/${name}/issues/1`, repository_url: `https://api.github.com/repos/acme/${name}` }, + ] } }); + expect((await (await member.get(`${path}/search?q=guest`)).json()).items.map((r: { number: number }) => r.number)).toEqual([1]); + for (const [data, status] of [[{ status: 500 }, 502], [{ status: 403, headers: { "x-ratelimit-remaining": "0" } }, 502], [{ status: 200, body: { items: "nope" } }, 502]] as const) { + await fault(data); + expect((await member.get(`${path}/search?q=guest`)).status(), JSON.stringify(data)).toBe(status); + await clear(); + } + + const unconnected = await setUp(request, [], false); + const refused = await member.get(`${unconnected.path}/search?q=guest`); + expect(refused.status()).toBe(409); + expect((await refused.json()).detail).toBe("Connect a GitHub repository to this project first."); + expect((await (await as(request, "outsider")).get(`${path}/search?q=guest`)).status()).toBe(404); + expect((await request.get(`${api.baseURL}${path}/search?q=guest`)).status()).toBe(401); +}); + +test("the migration protects the history, starts it with every existing link, and its rollback removes only the history", async () => { + const count = async (sql: string) => (await database.pool.query(sql)).rows[0].count; + expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_evidence_events' AND rowsecurity")).toBe(1); + expect(await count("SELECT count(*)::int AS count FROM information_schema.role_table_grants WHERE table_name='requirement_evidence_events' AND grantee IN ('anon','authenticated','PUBLIC')")).toBe(0); + const { rows: [requirement] } = await database.pool.query("SELECT id FROM public.requirements LIMIT 1"); + const insert = (values: Record) => { + const row = { requirement_id: requirement.id, evidence_id: "00000000-0000-4000-8000-000000000001", action: "added", kind: "issue", label: "#1", title: "t", actor: "owner", ...values }; + const names = Object.keys(row); + return database.pool.query(`INSERT INTO public.requirement_evidence_events (${names.join(",")}) VALUES (${names.map((_, i) => `$${i + 1}`).join(",")})`, Object.values(row)); + }; + await insert({}); + for (const values of [{ action: "renamed" }, { kind: "deployment" }, { label: " " }, { title: null }, { actor: null }]) { + await expect(insert(values), JSON.stringify(values)).rejects.toThrow(/check constraint|not-null/); + } + await expect(insert({ actor: "nobody" })).rejects.toThrow(/foreign key/); + + 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-events-rollback.sql", "utf8")); + expect(await count("SELECT count(*)::int AS count FROM pg_tables WHERE schemaname='public' AND tablename='requirement_evidence_events'")).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); + + // Applied to existing links, each starts its history as added by whoever linked it, when they did. + await database.pool.query(await readFile("docs/schema/requirement-evidence-events.sql", "utf8")); + expect(await count("SELECT count(*)::int AS count FROM public.requirement_evidence_events")).toBe(links); + expect(await count(`SELECT count(*)::int AS count FROM public.requirement_evidence e JOIN public.requirement_evidence_events v + ON v.evidence_id = e.id AND v.action = 'added' AND v.kind = e.kind AND v.title = e.title AND v.actor = e.linked_by AND v.occurred_at = e.linked_at + AND v.label = '#' || e.number`)).toBe(links); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(history); +}); diff --git a/tests/schema/evidence.spec.ts b/tests/schema/evidence.spec.ts index 27398ee..e522f54 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(18); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(19); }); 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(17); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(18); }); 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(16); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(17); }); 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(15); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(16); }); diff --git a/tests/schema/github-repository.spec.ts b/tests/schema/github-repository.spec.ts index e3c71d5..09b97fe 100644 --- a/tests/schema/github-repository.spec.ts +++ b/tests/schema/github-repository.spec.ts @@ -23,6 +23,7 @@ test.beforeAll(async () => { 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()), + ('admin','Admin','admin@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"`); @@ -64,7 +65,7 @@ async function createProject(request: APIRequestContext) { 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) SELECT $1, unnest($2::text[])", [team.id, ["member"]]); + await database.pool.query("INSERT INTO public.team_members (team_id,user_id,role) VALUES ($1,'admin','admin'), ($1,'member','member')", [team.id]); return project.id as string; } @@ -96,9 +97,9 @@ test("the owner connects, replaces, and disconnects a repository GitHub shows th expect(await (await owner.get(`/projects/${projectId}/repository`)).json()).toEqual({ repository: null }); }); -test("only the owner changes the connection; members read it; others cannot tell the project exists", async ({ request }) => { +test("only the team Owner or an Admin changes the connection; members read it; others cannot tell the project exists", async ({ request }) => { const projectId = await createProject(request); - const [owner, member, outsider] = [await as(request, "owner"), await as(request, "member"), await as(request, "outsider")]; + const [owner, admin, member, outsider] = [await as(request, "owner"), await as(request, "admin"), await as(request, "member"), await as(request, "outsider")]; await gitHubShows(request, "shared-token", [{ id: 21, repositories: [{ id: 201, owner: "acme", name: "web" }] }]); const body = { installationId: 21, repositoryId: 201, githubToken: "shared-token" }; @@ -112,6 +113,13 @@ test("only the owner changes the connection; members read it; others cannot tell expect((await (await member.get(`/projects/${projectId}/repository`)).json()).repository.fullName).toBe("acme/web"); expect((await member.delete(`/projects/${projectId}/repository`)).status()).toBe(403); + // An Admin who is not the Owner disconnects and connects, as the Owner does (ADR-041). + expect((await admin.delete(`/projects/${projectId}/repository`)).status()).toBe(204); + expect(await (await member.get(`/projects/${projectId}/repository`)).json()).toEqual({ repository: null }); + const connected = await admin.put(`/projects/${projectId}/repository`, body); + expect(connected.status()).toBe(200); + expect((await connected.json()).repository).toMatchObject({ fullName: "acme/web", connectedBy: "admin" }); + expect((await owner.post(`/projects/${projectId}/archive`)).status()).toBe(200); expect((await owner.put(`/projects/${projectId}/repository`, body)).status()).toBe(409); expect((await owner.delete(`/projects/${projectId}/repository`)).status()).toBe(409); @@ -285,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(18); + expect(await count(`SELECT count(*)::int AS count FROM "__EFMigrationsHistory"`)).toBe(19); }); diff --git a/tests/schema/reviews.spec.ts b/tests/schema/reviews.spec.ts index 8d701bd..92b0d2e 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(19); // Every migration through RequirementCriteriaVersionTexts. + expect(history).toBe(20); // Every migration through RequirementEvidenceEvents. });