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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/api/Data/ProductModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions app/api/Data/SpecThreadDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class SpecThreadDbContext(DbContextOptions<SpecThreadDbContext> op
public DbSet<ProjectRepository> ProjectRepositories => Set<ProjectRepository>();
public DbSet<RequirementEvidence> RequirementEvidence => Set<RequirementEvidence>();
public DbSet<RequirementReview> RequirementReviews => Set<RequirementReview>();
public DbSet<RequirementEvidenceEvent> RequirementEvidenceEvents => Set<RequirementEvidenceEvent>();
public DbSet<Team> Teams => Set<Team>();
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
public DbSet<UserOnboarding> UserOnboardings => Set<UserOnboarding>();
Expand Down Expand Up @@ -262,6 +263,27 @@ protected override void OnModelCreating(ModelBuilder model)
evidence.HasOne<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
evidence.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.LinkedBy).OnDelete(DeleteBehavior.Restrict);

var evidenceEvent = model.Entity<RequirementEvidenceEvent>();
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<Requirement>().WithMany().HasForeignKey(x => x.RequirementId).OnDelete(DeleteBehavior.Restrict);
evidenceEvent.HasOne<AuthUser>().WithMany().HasForeignKey(x => x.Actor).OnDelete(DeleteBehavior.Restrict);

var review = model.Entity<RequirementReview>();
review.ToTable("requirement_reviews", table =>
{
Expand Down
82 changes: 79 additions & 3 deletions app/api/GitHub/EvidenceEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@ 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)
{
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);
Expand All @@ -43,6 +49,47 @@ private static async Task<Results<Ok<List<EvidenceResponse>>, 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<Results<Ok<EvidenceSearchResponse>, 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<Results<Ok<List<EvidenceEventResponse>>, 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<Results<Created<EvidenceResponse>, ValidationProblem, NotFound, ProblemHttpResult>> Link(
Guid requirementId, LinkEvidenceRequest request, ClaimsPrincipal user, SpecThreadDbContext db, IGitHubClient github, CancellationToken cancel)
{
Expand Down Expand Up @@ -136,6 +183,7 @@ private static async Task<Results<Created<EvidenceResponse>, 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);
Expand Down Expand Up @@ -229,14 +277,34 @@ private static async Task<Results<NoContent, NotFound, ProblemHttpResult>> 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<Scope?> FindAsync(SpecThreadDbContext db, string userId, Guid requirementId, CancellationToken cancel) =>
db.Requirements.AsNoTracking().Where(r => r.Id == requirementId)
Expand Down Expand Up @@ -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<EvidenceSearchItem> 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);
Expand Down
57 changes: 57 additions & 0 deletions app/api/GitHub/GitHubClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<GitHubResult<GitHubChecks>> 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<GitHubResult<List<GitHubSearchItem>>> SearchItemsAsync(
long installationId, string owner, string name, IReadOnlyList<string> words, CancellationToken cancel);
}

internal sealed class GitHubClient(HttpClient http, IConfiguration configuration, ILogger<GitHubClient> logger) : IGitHubClient
Expand All @@ -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;
Expand Down Expand Up @@ -492,6 +501,54 @@ private async Task<GitHubResult<string>> CreateInstallationTokenAsync(long insta
}
}

public async Task<GitHubResult<List<GitHubSearchItem>>> SearchItemsAsync(
long installationId, string owner, string name, IReadOnlyList<string> words, CancellationToken cancel)
{
var token = await CreateInstallationTokenAsync(installationId, cancel);
if (token.Failure != GitHubFailure.None) return GitHubResult<List<GitHubSearchItem>>.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<List<GitHubSearchItem>>.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<List<GitHubSearchItem>>.Ok([]);
if (failure != GitHubFailure.None) return GitHubResult<List<GitHubSearchItem>>.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<List<GitHubSearchItem>>.Fail(GitHubFailure.Unavailable);
}
var expected = $"/repos/{owner}/{name}";
var found = new List<GitHubSearchItem>();
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<List<GitHubSearchItem>>.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<List<GitHubSearchItem>>.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;
Expand Down
Loading
Loading