diff --git a/go/internal/board/pr_gate.go b/go/internal/board/pr_gate.go new file mode 100644 index 000000000..88462302a --- /dev/null +++ b/go/internal/board/pr_gate.go @@ -0,0 +1,143 @@ +//go:build unix + +package board + +import ( + "strings" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +// derivePRState derives a gate and stale-approval flag from one review pass. +func derivePRState(pr *compassv1.PullRequest) (compassv1.BoardPrGate, bool) { + if pr == nil { + return compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED, false + } + reviews := reviewState(pr) + return prGate(pr, reviews), reviews.staleApproval +} + +// PrGate derives the first unmet pull-request gate from its stored facts. +func PrGate(pr *compassv1.PullRequest) compassv1.BoardPrGate { + gate, _ := derivePRState(pr) + return gate +} + +func prGate(pr *compassv1.PullRequest, reviews reviewStateResult) compassv1.BoardPrGate { + switch { + case strings.EqualFold(pr.GetForgeState(), "merged"), strings.EqualFold(pr.GetMergeQueue().GetState(), "merged"): + return compassv1.BoardPrGate_BOARD_PR_GATE_MERGED + case strings.EqualFold(pr.GetForgeState(), "closed"): + return compassv1.BoardPrGate_BOARD_PR_GATE_CLOSED + case queueGate(pr.GetMergeQueue().GetState()) != compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED: + return queueGate(pr.GetMergeQueue().GetState()) + case pr.GetDraft(): + return compassv1.BoardPrGate_BOARD_PR_GATE_DRAFT + case strings.EqualFold(pr.GetMergeableState(), "dirty"): + return compassv1.BoardPrGate_BOARD_PR_GATE_CONFLICTS + } + + if gate := checksGate(pr.GetChecks()); gate != compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED { + return gate + } + if reviews.changesRequested { + return compassv1.BoardPrGate_BOARD_PR_GATE_CHANGES_REQUESTED + } + if !reviews.approved { + return compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW + } + for _, thread := range pr.GetThreads() { + if thread != nil && !thread.GetResolved() { + return compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW + } + } + return compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE +} + +func queueGate(state string) compassv1.BoardPrGate { + switch strings.ToLower(state) { + case "queued", "testing": + return compassv1.BoardPrGate_BOARD_PR_GATE_QUEUED + case "failed": + return compassv1.BoardPrGate_BOARD_PR_GATE_QUEUE_FAILED + case "removed": + return compassv1.BoardPrGate_BOARD_PR_GATE_QUEUE_REMOVED + default: + return compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED + } +} + +func checksGate(checks *compassv1.ChecksSummary) compassv1.BoardPrGate { + if checks == nil { + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING + } + hasRequired := false + for _, check := range checks.GetChecks() { + if check == nil || !check.GetRequired() { + continue + } + hasRequired = true + if checkStateGate(check.GetState()) == compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED { + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED + } + } + if !hasRequired { + if strings.TrimSpace(checks.GetState()) == "" { + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING + } + return checkStateGate(checks.GetState()) + } + for _, check := range checks.GetChecks() { + if check != nil && check.GetRequired() && + checkStateGate(check.GetState()) == compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING { + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING + } + } + return compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED +} + +func checkStateGate(state string) compassv1.BoardPrGate { + switch strings.ToLower(state) { + case "failure", "cancelled": + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED + case "success", "neutral", "": + return compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED + default: + return compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING + } +} + +type reviewStateResult struct { + changesRequested bool + approved bool + staleApproval bool +} + +func reviewState(pr *compassv1.PullRequest) reviewStateResult { + latest := make(map[string]*compassv1.Review, len(pr.GetReviews())) + for _, review := range pr.GetReviews() { + if review == nil || review.GetIsBot() || review.GetAuthor() == "" { + continue + } + switch strings.ToLower(review.GetVerdict()) { + case "approved", "changes_requested": + latest[review.GetAuthor()] = review + case "dismissed": + delete(latest, review.GetAuthor()) + } + } + var result reviewStateResult + for _, review := range latest { + switch strings.ToLower(review.GetVerdict()) { + case "changes_requested": + result.changesRequested = true + case "approved": + if review.GetCommitId() != "" && review.GetCommitId() != pr.GetHeadSha() { + result.staleApproval = true + } else { + result.approved = true + } + } + } + return result +} diff --git a/go/internal/board/pr_gate_test.go b/go/internal/board/pr_gate_test.go new file mode 100644 index 000000000..65749993d --- /dev/null +++ b/go/internal/board/pr_gate_test.go @@ -0,0 +1,268 @@ +//go:build unix + +package board + +import ( + "testing" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +func TestPrGate(t *testing.T) { + for _, tt := range prGateCases { + t.Run(tt.name, func(t *testing.T) { + if got := PrGate(tt.pr); got != tt.want { + t.Errorf("PrGate() = %v, want %v", got, tt.want) + } + }) + } +} + +var prGateCases = []struct { + name string + pr *compassv1.PullRequest + want compassv1.BoardPrGate +}{ + { + name: "nil pull request", + want: compassv1.BoardPrGate_BOARD_PR_GATE_UNSPECIFIED, + }, + { + name: "merged", + pr: &compassv1.PullRequest{ForgeState: "merged"}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_MERGED, + }, + { + name: "merged queue entry", + pr: &compassv1.PullRequest{MergeQueue: &compassv1.MergeQueueEntry{State: "merged"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_MERGED, + }, + { + name: "closed", + pr: &compassv1.PullRequest{ForgeState: "closed"}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CLOSED, + }, + { + name: "queued", + pr: &compassv1.PullRequest{MergeQueue: &compassv1.MergeQueueEntry{State: "queued"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_QUEUED, + }, + { + name: "testing", + pr: &compassv1.PullRequest{MergeQueue: &compassv1.MergeQueueEntry{State: "testing"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_QUEUED, + }, + { + name: "queue failed", + pr: &compassv1.PullRequest{MergeQueue: &compassv1.MergeQueueEntry{State: "failed"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_QUEUE_FAILED, + }, + { + name: "queue removed", + pr: &compassv1.PullRequest{MergeQueue: &compassv1.MergeQueueEntry{State: "removed"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_QUEUE_REMOVED, + }, + { + name: "draft takes precedence over conflicts", + pr: &compassv1.PullRequest{Draft: true, MergeableState: "dirty"}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_DRAFT, + }, + { + name: "conflicts take precedence over failed checks", + pr: &compassv1.PullRequest{MergeableState: "dirty", Checks: &compassv1.ChecksSummary{State: "failure"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CONFLICTS, + }, + { + name: "failed required check takes precedence over changes requested", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{Checks: []*compassv1.Check{{Required: true, State: "failure"}}}, + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "changes_requested"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED, + }, + { + name: "queued takes precedence over failed checks", + pr: &compassv1.PullRequest{ + MergeQueue: &compassv1.MergeQueueEntry{State: "queued"}, + Checks: &compassv1.ChecksSummary{State: "failure"}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_QUEUED, + }, + { + name: "required success overrides failed rollup", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{ + State: "failure", Checks: []*compassv1.Check{{Required: true, State: "success"}}, + }}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "required check failed", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{ + Checks: []*compassv1.Check{{Required: true, State: "failure"}}, + }}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED, + }, + { + name: "failed non-required check does not block", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{ + State: "success", Checks: []*compassv1.Check{{Required: false, State: "failure"}}, + }}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "required check pending", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{ + Checks: []*compassv1.Check{{Required: true, State: "in_progress"}}, + }}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING, + }, + { + name: "rollup failure without required checks", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{State: "failure"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_FAILED, + }, + { + name: "rollup pending without required checks", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{State: "pending"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING, + }, + { + name: "missing checks is pending", + pr: &compassv1.PullRequest{Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved"}}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING, + }, + { + name: "empty rollup is pending", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{}, + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHECKS_PENDING, + }, + { + name: "latest review per author", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "approved"}, + {Author: "reviewer", Verdict: "changes_requested"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHANGES_REQUESTED, + }, + { + name: "approval survives a later comment", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "approved"}, + {Author: "reviewer", Verdict: "commented"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE, + }, + { + name: "changes requested survives a later comment", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "changes_requested"}, + {Author: "reviewer", Verdict: "commented"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHANGES_REQUESTED, + }, + { + name: "approval by another author does not erase changes requested", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "changes_requested"}, + {Author: "other", Verdict: "approved"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_CHANGES_REQUESTED, + }, + { + name: "dismissed review clears that author's approval", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "approved"}, + {Author: "reviewer", Verdict: "dismissed"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "dismissed review clears changes requested without erasing another approval", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{ + {Author: "reviewer", Verdict: "changes_requested"}, + {Author: "other", Verdict: "approved"}, + {Author: "reviewer", Verdict: "dismissed"}, + }, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE, + }, + + { + name: "bot approval is ignored", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{{Author: "bot", IsBot: true, Verdict: "approved"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "no approval", + pr: &compassv1.PullRequest{Checks: &compassv1.ChecksSummary{State: "success"}}, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "stale approval awaits review", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + HeadSha: "new", + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved", CommitId: "old"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "approval without commit id", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + HeadSha: "new", + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE, + }, + { + name: "approval for head", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + HeadSha: "head", + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved", CommitId: "head"}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE, + }, + { + name: "unresolved thread waits for review", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved"}}, + Threads: []*compassv1.ReviewThread{{Resolved: false}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW, + }, + { + name: "resolved thread allows enqueue", + pr: &compassv1.PullRequest{ + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved"}}, + Threads: []*compassv1.ReviewThread{{Resolved: true}}, + }, + want: compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_ENQUEUE, + }, +} diff --git a/go/internal/board/refs_derive.go b/go/internal/board/refs_derive.go new file mode 100644 index 000000000..442af8747 --- /dev/null +++ b/go/internal/board/refs_derive.go @@ -0,0 +1,381 @@ +//go:build unix + +package board + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/refs" + "github.com/RigelBuild/compass/go/internal/store" + "google.golang.org/protobuf/encoding/protojson" +) + +// RefResolver is the stored-row read surface used to derive board references. +type RefResolver interface { + PullRequestsByCoords(ctx context.Context, coords []store.ForgeCoord) ([]store.PullRequestRow, error) + IssuesByCoords(ctx context.Context, coords []store.ForgeCoord) ([]store.Issue, error) + AuthoredCoordsForAgent(ctx context.Context, agent store.AccountID, coords []store.ForgeCoord) ([]store.AuthoredRef, error) + OpenAuthoredRefs(ctx context.Context, agent store.AccountID) ([]store.AuthoredRef, error) +} + +// Forges maps parsed references to the configured forge hosts and Linear teams. +type Forges struct { + GitHubHost string + LinearHost string + LinearTeams map[string]bool +} + +type artifactKey struct { + coord store.ForgeCoord + kind compassv1.BoardRefKind +} + +type parsedBoardRef struct { + ref refs.Ref + coord store.ForgeCoord + kind compassv1.BoardRefKind +} +type stackGroup struct { + provider store.ForgeProvider + host string +} + +type storedBoardPR struct { + row store.PullRequestRow + pr *compassv1.PullRequest +} + +// DeriveRefs joins parsed task refs to stored forge rows without contacting a provider. +func DeriveRefs( + ctx context.Context, + resolver RefResolver, + forges Forges, + agent store.AccountID, + refsIn []string, +) ([]*compassv1.BoardRef, []string, error) { + derived := make([]*compassv1.BoardRef, len(refsIn)) + parsed := make([]*parsedBoardRef, len(refsIn)) + coords := make([]store.ForgeCoord, 0, len(refsIn)) + coordSet := make(map[store.ForgeCoord]struct{}, len(refsIn)) + taskRefs := make(map[artifactKey]struct{}, len(refsIn)) + for i, text := range refsIn { + ref, err := refs.Parse(text) + if err != nil { + derived[i] = &compassv1.BoardRef{Ref: text} + continue + } + item, base := parseBoardRef(ref, forges) + derived[i] = base + if item == nil { + addTaskRefCoords(ref, nil, taskRefs) + continue + } + parsed[i] = item + addCoord(item.coord, coordSet, &coords) + addTaskRefCoords(ref, item, taskRefs) + } + openAuthored, err := resolver.OpenAuthoredRefs(ctx, agent) + if err != nil { + return nil, nil, fmt.Errorf("board: list open authored refs: %w", err) + } + openAuthoredSet := make(map[artifactKey]struct{}, len(openAuthored)) + for _, ref := range openAuthored { + coord := ref.Coord.Normalized() + openAuthoredSet[artifactKey{coord: coord, kind: boardRefKind(ref.Kind)}] = struct{}{} + addCoord(coord, coordSet, &coords) + } + + authored, err := resolver.AuthoredCoordsForAgent(ctx, agent, coords) + if err != nil { + return nil, nil, fmt.Errorf("board: list authored coordinates: %w", err) + } + authoredSet := make(map[artifactKey]struct{}, len(authored)) + for _, ref := range authored { + authoredSet[artifactKey{coord: ref.Coord.Normalized(), kind: boardRefKind(ref.Kind)}] = struct{}{} + } + + data, err := loadStoredRows(ctx, resolver, coords, openAuthoredSet) + if err != nil { + return nil, nil, err + } + prsByCoord, issuesByCoord, stacksByGroup := data.prs, data.issues, data.stacks + + for i, item := range parsed { + if item == nil { + continue + } + base := derived[i] + switch item.kind { + case compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST: + if stored, ok := prsByCoord[item.coord]; ok { + _, base.Owned = authoredSet[artifactKey{coord: item.coord, kind: item.kind}] + group := stackGroup{provider: item.coord.Provider, host: strings.ToLower(item.coord.Host)} + fillPullRequestRef(base, stored, item, stacksByGroup[group]) + continue + } + if item.ref.Kind != refs.KindForge { + continue + } + issue, ok := issuesByCoord[item.coord] + if !ok { + continue + } + fillIssueDerivedRef(base, item, issue, authoredSet) + case compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE: + issue, ok := issuesByCoord[item.coord] + if !ok { + continue + } + fillIssueDerivedRef(base, item, issue, authoredSet) + case compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED: + continue + } + } + + untracked := make([]string, 0, len(openAuthored)) + for _, ref := range openAuthored { + if _, named := taskRefs[artifactKey{coord: ref.Coord.Normalized(), kind: boardRefKind(ref.Kind)}]; named { + continue + } + untracked = append(untracked, authoredRefString(ref)) + } + return derived, untracked, nil +} + +type storedRows struct { + prs map[store.ForgeCoord]storedBoardPR + issues map[store.ForgeCoord]store.Issue + stacks map[stackGroup]map[string]Stack +} + +func loadStoredRows( + ctx context.Context, + resolver RefResolver, + coords []store.ForgeCoord, + openAuthored map[artifactKey]struct{}, +) (storedRows, error) { + prRows, err := resolver.PullRequestsByCoords(ctx, coords) + if err != nil { + return storedRows{}, fmt.Errorf("board: load pull requests: %w", err) + } + issueRows, err := resolver.IssuesByCoords(ctx, coords) + if err != nil { + return storedRows{}, fmt.Errorf("board: load issues: %w", err) + } + + prsByCoord := make(map[store.ForgeCoord]storedBoardPR, len(prRows)) + openAuthoredPRs := make(map[stackGroup][]*compassv1.PullRequest) + for _, row := range prRows { + if err := storePRRow(row, prsByCoord); err != nil { + return storedRows{}, err + } + coord := row.Coord.Normalized() + key := artifactKey{coord: coord, kind: compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST} + if row.State != "open" { + continue + } + if _, owned := openAuthored[key]; !owned { + continue + } + group := stackGroup{provider: coord.Provider, host: strings.ToLower(coord.Host)} + openAuthoredPRs[group] = append(openAuthoredPRs[group], prsByCoord[coord].pr) + } + + stacksByGroup := make(map[stackGroup]map[string]Stack, len(openAuthoredPRs)) + for group, prs := range openAuthoredPRs { + stacksByGroup[group] = StackOf(prs) + } + + issuesByCoord := make(map[store.ForgeCoord]store.Issue, len(issueRows)) + for _, issue := range issueRows { + issuesByCoord[issueCoord(issue)] = issue + } + return storedRows{prs: prsByCoord, issues: issuesByCoord, stacks: stacksByGroup}, nil +} + +func storePRRow(row store.PullRequestRow, byCoord map[store.ForgeCoord]storedBoardPR) error { + pr := &compassv1.PullRequest{} + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(row.PR, pr); err != nil { + return fmt.Errorf("board: decode stored pull request %v: %w", row.Coord, err) + } + byCoord[row.Coord.Normalized()] = storedBoardPR{row: row, pr: pr} + return nil +} + +func parseBoardRef(parsed refs.Ref, forges Forges) (*parsedBoardRef, *compassv1.BoardRef) { + coord, kind, ok := configuredCoord(parsed, forges) + if !ok { + return nil, &compassv1.BoardRef{Ref: parsed.String()} + } + return &parsedBoardRef{ref: parsed, coord: coord, kind: kind}, &compassv1.BoardRef{ + Ref: parsed.String(), Url: unknownRefURL(parsed, forges), + } +} + +func addCoord(coord store.ForgeCoord, seen map[store.ForgeCoord]struct{}, coords *[]store.ForgeCoord) { + if _, exists := seen[coord]; exists { + return + } + seen[coord] = struct{}{} + *coords = append(*coords, coord) +} + +func fillIssueDerivedRef(base *compassv1.BoardRef, item *parsedBoardRef, issue store.Issue, authored map[artifactKey]struct{}) { + base.Kind = compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE + _, base.Owned = authored[artifactKey{coord: item.coord, kind: compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE}] + fillIssueRef(base, issue) +} + +func boardRefKind(kind store.ForgeArtifactKind) compassv1.BoardRefKind { + switch kind { + case store.ForgeArtifactKindIssue: + return compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE + case store.ForgeArtifactKindPullRequest: + return compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST + default: + return compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED + } +} + +func configuredCoord(ref refs.Ref, forges Forges) (store.ForgeCoord, compassv1.BoardRefKind, bool) { + if ref.Number == 0 { + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } + switch ref.Kind { + case refs.KindForge: + if forges.GitHubHost == "" { + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } + return store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, + Host: forges.GitHubHost, + Repo: ref.Owner + "/" + ref.Repo, + Number: ref.Number, + }.Normalized(), compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST, true + case refs.KindTracker: + if forges.LinearHost == "" || !forges.LinearTeams[ref.Key] { + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } + return store.ForgeCoord{ + Provider: store.ForgeProviderLinear, + Host: forges.LinearHost, + Repo: ref.Key, + Number: ref.Number, + }.Normalized(), compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE, true + default: + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } +} + +func taskRefCoord(ref refs.Ref) (store.ForgeCoord, compassv1.BoardRefKind, bool) { + if ref.Number == 0 { + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } + switch ref.Kind { + case refs.KindForge: + return store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, Host: "github.com", + Repo: ref.Owner + "/" + ref.Repo, Number: ref.Number, + }.Normalized(), compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST, true + case refs.KindTracker: + return store.ForgeCoord{ + Provider: store.ForgeProviderLinear, Host: "linear.app", + Repo: ref.Key, Number: ref.Number, + }.Normalized(), compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE, true + default: + return store.ForgeCoord{}, compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED, false + } +} + +func addTaskRefCoords(ref refs.Ref, item *parsedBoardRef, taskRefs map[artifactKey]struct{}) { + coord, kind, ok := taskRefCoord(ref) + if !ok { + return + } + if item != nil { + coord, kind = item.coord, item.kind + } + taskRefs[artifactKey{coord: coord, kind: kind}] = struct{}{} + if ref.Kind == refs.KindForge { + taskRefs[artifactKey{coord: coord, kind: compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE}] = struct{}{} + } +} + +func issueCoord(issue store.Issue) store.ForgeCoord { + return store.ForgeCoord{ + Provider: issue.ForgeProvider, + Host: issue.ForgeHost, + Repo: issue.Repo, + Number: uint64(issue.Number), + }.Normalized() +} + +func unknownRefURL(ref refs.Ref, forges Forges) string { + switch ref.Kind { + case refs.KindForge: + if forges.GitHubHost == "" { + return "" + } + return "https://" + forges.GitHubHost + "/" + ref.Owner + "/" + ref.Repo + "/issues/" + strconv.FormatUint(ref.Number, 10) + case refs.KindTracker: + if forges.LinearHost == "" || !forges.LinearTeams[ref.Key] { + return "" + } + return "https://" + forges.LinearHost + "/browse/" + ref.Key + "-" + strconv.FormatUint(ref.Number, 10) + default: + return "" + } +} + +func authoredRefString(ref store.AuthoredRef) string { + coord := ref.Coord.Normalized() + switch ref.Kind { + case store.ForgeArtifactKindPullRequest, store.ForgeArtifactKindIssue: + if coord.Provider == store.ForgeProviderLinear { + return coord.Repo + "-" + strconv.FormatUint(coord.Number, 10) + } + return strings.ToLower(coord.Repo) + "#" + strconv.FormatUint(coord.Number, 10) + default: + return "" + } +} + +func boardRefKey(repo string, number uint64) string { + return repo + "#" + strconv.FormatUint(number, 10) +} + +func fillPullRequestRef(base *compassv1.BoardRef, stored storedBoardPR, item *parsedBoardRef, stacks map[string]Stack) { + pr := stored.pr + base.Kind = compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST + base.Title = pr.GetTitle() + base.Url = pr.GetUrl() + base.Draft = pr.GetDraft() + base.ChecksState = pr.GetChecks().GetState() + base.Gate, base.StaleApproval = derivePRState(pr) + base.MergeQueueState = pr.GetMergeQueue().GetState() + base.ObservedAtUnixMs = unixMilli(stored.row.UpdatedAt) + if stack, ok := stacks[boardRefKey(item.ref.Owner+"/"+item.ref.Repo, item.ref.Number)]; ok { + base.StackPosition = stack.Position + base.StackTipRef = stack.Tip + } +} + +func fillIssueRef(base *compassv1.BoardRef, issue store.Issue) { + base.Title = issue.Title + base.Url = issue.URL + base.ForgeState = issue.ForgeState + base.IssueState = compassv1.IssueState(issue.State) + base.ObservedAtUnixMs = unixMilli(issue.ForgeUpdatedAt) +} + +func unixMilli(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} diff --git a/go/internal/board/refs_derive_pgtest_test.go b/go/internal/board/refs_derive_pgtest_test.go new file mode 100644 index 000000000..53eb92af3 --- /dev/null +++ b/go/internal/board/refs_derive_pgtest_test.go @@ -0,0 +1,100 @@ +//go:build pgtest && unix + +package board + +import ( + "context" + "testing" + "time" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/pgtest" + "github.com/RigelBuild/compass/go/internal/store" + "google.golang.org/protobuf/encoding/protojson" +) + +func TestDeriveRefsFromStore(t *testing.T) { + ctx := context.Background() + dsn := pgtest.RequireDSN(t) + st, err := store.Open(ctx, dsn) + if err != nil { + t.Fatalf("store Open: %v", err) + } + t.Cleanup(st.Close) + + owner, err := st.CreateUser(ctx, store.NewUser{Handle: "derive-owner", DisplayName: "Owner"}) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + agent, err := st.CreateAgent(ctx, owner.ID, store.NewAgent{Handle: "derive-agent", DisplayName: "Agent"}) + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + + updated := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + coord := store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 22} + if _, err := st.UpsertIssueForgeFields(ctx, store.IssueForgeFields{ + ForgeProvider: coord.Provider, + ForgeHost: coord.Host, + Repo: coord.Repo, + Number: uint32(coord.Number), + Title: "stored issue", + ForgeState: "open", + ForgeUpdatedAt: updated, + }); err != nil { + t.Fatalf("UpsertIssueForgeFields: %v", err) + } + if err := st.RecordAuthoredArtifact(ctx, store.AuthoredArtifact{ + Provider: coord.Provider, Host: coord.Host, Repo: coord.Repo, + Kind: store.ForgeArtifactKindIssue, Number: coord.Number, + AgentAccountID: agent.ID, OwnerUserID: owner.ID, + SessionID: "session", CreatedAtUnixMS: updated.UnixMilli(), + }); err != nil { + t.Fatalf("RecordAuthoredArtifact: %v", err) + } + + prCoord := store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 23} + if err := st.EnsureForgeRepoSubscription(ctx, store.ForgeRepoSubscription{Provider: prCoord.Provider, Host: prCoord.Host, Repo: prCoord.Repo, Enabled: true}); err != nil { + t.Fatalf("EnsureForgeRepoSubscription: %v", err) + } + pr := &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: prCoord.Host}, + Repo: prCoord.Repo, + Number: uint32(prCoord.Number), + Title: "stored PR", + ForgeState: "open", + Url: "https://github.com/owner/repo/pull/23", + HeadRef: "feature", + BaseRef: "main", + Checks: &compassv1.ChecksSummary{State: "success"}, + } + prJSON, err := protojson.Marshal(pr) + if err != nil { + t.Fatalf("marshal PR: %v", err) + } + if err := st.CreatePullRequestWithLink(ctx, store.AuthoredArtifact{ + Provider: prCoord.Provider, Host: prCoord.Host, Repo: prCoord.Repo, + Kind: store.ForgeArtifactKindPullRequest, Number: prCoord.Number, + AgentAccountID: agent.ID, OwnerUserID: owner.ID, + SessionID: "session", CreatedAtUnixMS: updated.UnixMilli(), + }, store.PullRequestRow{Coord: prCoord, State: "open", CreatedAt: updated, UpdatedAt: updated, PR: prJSON}, nil); err != nil { + t.Fatalf("CreatePullRequestWithLink: %v", err) + } + + got, untracked, err := DeriveRefs(ctx, st, Forges{GitHubHost: "github.com"}, agent.ID, []string{"owner/repo#22", "owner/repo#23"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 2 { + t.Fatalf("derived refs = %d, want 2", len(got)) + } + if got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE || got[0].GetTitle() != "stored issue" || got[0].GetObservedAtUnixMs() != updated.UnixMilli() { + t.Errorf("issue ref = %+v, want stored row and timestamp %d", got[0], updated.UnixMilli()) + } + if got[1].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST || got[1].GetGate() != compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW { + t.Errorf("PR ref = %+v, want open PR awaiting review", got[1]) + } + if len(untracked) != 0 { + t.Errorf("untracked = %v, want empty", untracked) + } +} diff --git a/go/internal/board/refs_derive_test.go b/go/internal/board/refs_derive_test.go new file mode 100644 index 000000000..c5aee1407 --- /dev/null +++ b/go/internal/board/refs_derive_test.go @@ -0,0 +1,431 @@ +//go:build unix + +package board + +import ( + "context" + "slices" + "testing" + "time" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/store" + "google.golang.org/protobuf/encoding/protojson" +) + +var _ RefResolver = (*store.Store)(nil) + +type fakeRefResolver struct { + prs []store.PullRequestRow + issues []store.Issue + authored []store.AuthoredRef + owned []store.AuthoredRef + calls []string + coords [][]store.ForgeCoord + agentSeen store.AccountID +} + +func (f *fakeRefResolver) AuthoredCoordsForAgent(_ context.Context, agent store.AccountID, coords []store.ForgeCoord) ([]store.AuthoredRef, error) { + f.calls = append(f.calls, "owned") + f.agentSeen = agent + f.coords = append(f.coords, slices.Clone(coords)) + return f.owned, nil +} + +func (f *fakeRefResolver) PullRequestsByCoords(_ context.Context, coords []store.ForgeCoord) ([]store.PullRequestRow, error) { + f.calls = append(f.calls, "prs") + f.coords = append(f.coords, slices.Clone(coords)) + return f.prs, nil +} + +func (f *fakeRefResolver) IssuesByCoords(_ context.Context, coords []store.ForgeCoord) ([]store.Issue, error) { + f.calls = append(f.calls, "issues") + f.coords = append(f.coords, slices.Clone(coords)) + return f.issues, nil +} + +func (f *fakeRefResolver) OpenAuthoredRefs(_ context.Context, agent store.AccountID) ([]store.AuthoredRef, error) { + f.calls = append(f.calls, "authored") + f.agentSeen = agent + return f.authored, nil +} + +func TestDeriveRefs(t *testing.T) { + pr := &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: "github.com"}, + Repo: "owner/repo", + Number: 42, + Title: "stored PR", + ForgeState: "open", + Url: "https://github.com/owner/repo/pull/42", + HeadSha: "new-head", + Checks: &compassv1.ChecksSummary{State: "success"}, + Reviews: []*compassv1.Review{{Author: "reviewer", Verdict: "approved", CommitId: "old-head"}}, + } + prJSON, err := protojson.Marshal(pr) + if err != nil { + t.Fatal(err) + } + updated := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + fake := &fakeRefResolver{ + prs: []store.PullRequestRow{{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 42}, + State: "open", UpdatedAt: updated, PR: prJSON, + }}, + issues: []store.Issue{{ + ForgeProvider: store.ForgeProviderGitHub, ForgeHost: "github.com", Repo: "owner/repo", Number: 9, + Title: "stored issue", ForgeState: "open", State: store.IssueStateInProgress, ForgeUpdatedAt: updated, + }}, + authored: []store.AuthoredRef{ + {Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 77}, Kind: store.ForgeArtifactKindPullRequest}, + {Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 42}, Kind: store.ForgeArtifactKindPullRequest}, + }, + } + forges := Forges{GitHubHost: "github.com", LinearHost: "linear.app", LinearTeams: map[string]bool{"TEAM": true}} + + t.Run("stored rows, stale approval, and untracked ref", func(t *testing.T) { + got, untracked, err := DeriveRefs(context.Background(), fake, forges, "agent-1", []string{"owner/repo#42", "owner/repo#9"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 2 || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST || got[0].GetGate() != compassv1.BoardPrGate_BOARD_PR_GATE_AWAITING_REVIEW || !got[0].GetStaleApproval() { + t.Errorf("PR derivation = %+v, want pull request with stale approval and awaiting-review gate", got) + } + if got[1].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE || got[1].GetTitle() != "stored issue" || got[1].GetObservedAtUnixMs() != updated.UnixMilli() { + t.Errorf("GitHub issue ref = %+v, want stored issue and timestamp %d", got[1], updated.UnixMilli()) + } + if !slices.Equal(untracked, []string{"owner/repo#77"}) { + t.Errorf("untracked = %v, want [owner/repo#77]", untracked) + } + }) + + t.Run("unknown and unconfigured references stay unspecified", func(t *testing.T) { + unknownResolver := &fakeRefResolver{} + got, _, err := DeriveRefs(context.Background(), unknownResolver, forges, "agent-1", []string{"other/repo#1", "TEAM-1", "BAD"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 3 || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED || got[1].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED || got[2].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED { + t.Errorf("unknown refs = %+v, want unspecified kinds", got) + } + }) + + t.Run("one authored query and one batched query per row type", func(t *testing.T) { + resolver := &fakeRefResolver{ + authored: []store.AuthoredRef{ + {Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 77}, Kind: store.ForgeArtifactKindPullRequest}, + {Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 42}, Kind: store.ForgeArtifactKindPullRequest}, + }, + } + _, _, err := DeriveRefs(context.Background(), resolver, forges, "agent-1", []string{"owner/repo#42", "owner/repo#9"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if !slices.Equal(resolver.calls, []string{"authored", "owned", "prs", "issues"}) { + t.Errorf("resolver calls = %v", resolver.calls) + } + if resolver.agentSeen != "agent-1" { + t.Errorf("authored refs agent = %q, want agent-1", resolver.agentSeen) + } + if len(resolver.coords) != 3 || !slices.Equal(resolver.coords[0], resolver.coords[1]) || !slices.Equal(resolver.coords[1], resolver.coords[2]) || len(resolver.coords[0]) != 3 { + t.Errorf("coordinate batches = %+v, want identical batch of three unique coordinates", resolver.coords) + } + }) +} + +func TestDeriveRefsPreservesMalformedRefs(t *testing.T) { + resolver := &fakeRefResolver{} + got, _, err := DeriveRefs(context.Background(), resolver, Forges{}, "agent-1", []string{"broken", "owner/repo#2"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if got[0].GetRef() != "broken" || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED { + t.Errorf("malformed ref = %+v", got[0]) + } + if got[1].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED { + t.Errorf("unconfigured ref = %+v", got[1]) + } +} + +func TestDeriveRefsZeroNumberStaysUnknown(t *testing.T) { + resolver := &fakeRefResolver{} + got, _, err := DeriveRefs(context.Background(), resolver, Forges{GitHubHost: "github.com"}, "agent-1", []string{"owner/repo#0"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 1 || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_UNSPECIFIED { + t.Fatalf("zero-number ref = %+v, want unspecified", got) + } + if !slices.Equal(resolver.calls, []string{"authored", "owned", "prs", "issues"}) { + t.Fatalf("resolver calls = %v, want one call per read method", resolver.calls) + } + if len(resolver.coords) != 3 || len(resolver.coords[0]) != 0 || len(resolver.coords[1]) != 0 || len(resolver.coords[2]) != 0 { + t.Fatalf("coordinates = %v, want empty batches", resolver.coords) + } +} + +func TestDeriveRefsCoordinatesForGitHubAndLinear(t *testing.T) { + resolver := &fakeRefResolver{ + prs: []store.PullRequestRow{{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 5}, + PR: []byte(`{"forge":{"provider":"FORGE_PROVIDER_GITHUB","host":"github.com"},"repo":"owner/repo","number":5,"title":"GitHub PR","forgeState":"open"}`), + }}, + issues: []store.Issue{{ + ForgeProvider: store.ForgeProviderLinear, + ForgeHost: "linear.app", + Repo: "KEY", + Number: 7, + Title: "Linear issue", + }}, + } + forges := Forges{GitHubHost: "github.com", LinearHost: "linear.app", LinearTeams: map[string]bool{"KEY": true}} + got, _, err := DeriveRefs(context.Background(), resolver, forges, "agent-1", []string{"OwNeR/RePo#5", "KEY-7"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 2 || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST || got[0].GetTitle() != "GitHub PR" || got[1].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_ISSUE || got[1].GetTitle() != "Linear issue" { + t.Fatalf("derived refs = %+v, want stored GitHub PR and Linear issue", got) + } + want := []store.ForgeCoord{ + {Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 5}, + {Provider: store.ForgeProviderLinear, Host: "linear.app", Repo: "KEY", Number: 7}, + } + if len(resolver.coords) != 3 || !slices.Equal(resolver.coords[0], want) || !slices.Equal(resolver.coords[1], want) || !slices.Equal(resolver.coords[2], want) { + t.Fatalf("coordinate batches = %v, want %v for all batched reads", resolver.coords, want) + } +} + +func TestDeriveRefsPrefersPullRequestOverIssueAtSameCoordinate(t *testing.T) { + coord := store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 15, + } + pr := &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{ + Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, + Host: coord.Host, + }, + Repo: coord.Repo, Number: uint32(coord.Number), Title: "stored PR", ForgeState: "open", + } + prJSON, err := protojson.Marshal(pr) + if err != nil { + t.Fatalf("marshal PR: %v", err) + } + resolver := &fakeRefResolver{ + prs: []store.PullRequestRow{{Coord: coord, State: "open", PR: prJSON}}, + issues: []store.Issue{{ + ForgeProvider: coord.Provider, ForgeHost: coord.Host, Repo: coord.Repo, + Number: uint32(coord.Number), Title: "stored issue", ForgeState: "open", + }}, + } + got, _, err := DeriveRefs( + context.Background(), resolver, Forges{GitHubHost: coord.Host}, "agent-1", []string{"owner/repo#15"}, + ) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 1 || got[0].GetKind() != compassv1.BoardRefKind_BOARD_REF_KIND_PULL_REQUEST || got[0].GetTitle() != "stored PR" { + t.Fatalf("derived ref = %+v, want stored PR to win over issue at the same coordinate", got) + } +} + +func TestDeriveRefsOwnershipIncludesMergedAuthoredPR(t *testing.T) { + coord := store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: "github.com", Repo: "owner/repo", Number: 12} + resolver := &fakeRefResolver{ + prs: []store.PullRequestRow{{ + Coord: coord, State: "merged", + PR: []byte(`{"forge":{"provider":"FORGE_PROVIDER_GITHUB","host":"github.com"},"repo":"owner/repo","number":12,"title":"merged","forgeState":"merged"}`), + }}, + owned: []store.AuthoredRef{{Coord: coord, Kind: store.ForgeArtifactKindPullRequest}}, + } + got, untracked, err := DeriveRefs(context.Background(), resolver, Forges{GitHubHost: "github.com"}, "agent-1", []string{"owner/repo#12"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 1 || !got[0].GetOwned() || got[0].GetGate() != compassv1.BoardPrGate_BOARD_PR_GATE_MERGED { + t.Fatalf("merged authored PR = %+v, want owned merged PR", got) + } + if len(untracked) != 0 { + t.Errorf("untracked = %v, want empty for merged PR", untracked) + } +} + +func TestDeriveRefsUnconfiguredGitHubRefSuppressesAuthoredIssue(t *testing.T) { + resolver := &fakeRefResolver{ + authored: []store.AuthoredRef{{ + Coord: store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, Host: "github.com", + Repo: "owner/repo", Number: 7, + }, + Kind: store.ForgeArtifactKindIssue, + }}, + } + _, untracked, err := DeriveRefs( + context.Background(), resolver, Forges{}, "agent-1", []string{"owner/repo#7"}, + ) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(untracked) != 0 { + t.Fatalf("untracked = %v, want empty because the task names the authored issue", untracked) + } +} + +func TestDeriveRefsConfiguredGitHubHostSuppressesAuthoredIssue(t *testing.T) { + resolver := &fakeRefResolver{ + authored: []store.AuthoredRef{{ + Coord: store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, Host: "github.com", + Repo: "owner/repo", Number: 7, + }, + Kind: store.ForgeArtifactKindIssue, + }}, + } + _, untracked, err := DeriveRefs( + context.Background(), resolver, Forges{GitHubHost: "github.com"}, + "agent-1", []string{"owner/repo#7"}, + ) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(untracked) != 0 { + t.Fatalf("untracked = %v, want empty because the task names the authored issue", untracked) + } +} + +func TestDeriveRefsUnconfiguredTrackerRefSuppressesUntracked(t *testing.T) { + resolver := &fakeRefResolver{ + authored: []store.AuthoredRef{{ + Coord: store.ForgeCoord{Provider: store.ForgeProviderLinear, Host: "linear.app", Repo: "KEY", Number: 7}, + Kind: store.ForgeArtifactKindIssue, + }}, + } + _, untracked, err := DeriveRefs(context.Background(), resolver, Forges{}, "agent-1", []string{"KEY-7"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(untracked) != 0 { + t.Fatalf("untracked = %v, want empty because the task names KEY-7", untracked) + } +} + +func TestDeriveRefsSeparatesStacksByForgeHost(t *testing.T) { + firstHost := "github.com" + secondHost := "github.enterprise" + makePR := func(host string, number uint32, base, head string) *compassv1.PullRequest { + return &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{Provider: compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, Host: host}, + Repo: "owner/repo", Number: number, ForgeState: "open", BaseRef: base, HeadRef: head, + } + } + prs := []*compassv1.PullRequest{ + makePR(firstHost, 1, "main", "feature"), + makePR(firstHost, 2, "feature", "tip"), + makePR(secondHost, 1, "main", "feature"), + makePR(secondHost, 2, "feature", "tip"), + makePR(secondHost, 3, "tip", "long-tip"), + } + resolver := &fakeRefResolver{} + for _, pr := range prs { + data, err := protojson.Marshal(pr) + if err != nil { + t.Fatal(err) + } + coord := store.ForgeCoord{Provider: store.ForgeProviderGitHub, Host: pr.GetForge().GetHost(), Repo: pr.GetRepo(), Number: uint64(pr.GetNumber())} + resolver.prs = append(resolver.prs, store.PullRequestRow{Coord: coord, State: "open", PR: data}) + resolver.authored = append(resolver.authored, store.AuthoredRef{Coord: coord, Kind: store.ForgeArtifactKindPullRequest}) + } + got, _, err := DeriveRefs(context.Background(), resolver, Forges{GitHubHost: firstHost}, "agent-1", []string{"owner/repo#1"}) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 1 || got[0].GetStackPosition() != 1 || got[0].GetStackTipRef() != "owner/repo#2" { + t.Fatalf("first-host stack = %+v, want position 1 and owner/repo#2 tip", got) + } +} + +func TestDeriveRefsUntrackedSuppressionMatchesFullCoordinate(t *testing.T) { + tests := []struct { + name string + coord store.ForgeCoord + }{ + { + name: "same repo and number on another host", + coord: store.ForgeCoord{ + Provider: store.ForgeProviderGitHub, Host: "github.enterprise", + Repo: "owner/repo", Number: 5, + }, + }, + { + name: "same repo and number from another provider", + coord: store.ForgeCoord{ + Provider: store.ForgeProviderGitLab, Host: "github.com", + Repo: "owner/repo", Number: 5, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := &fakeRefResolver{ + authored: []store.AuthoredRef{{ + Coord: tt.coord, Kind: store.ForgeArtifactKindPullRequest, + }}, + } + _, untracked, err := DeriveRefs( + context.Background(), resolver, Forges{GitHubHost: "github.com"}, + "agent-1", []string{"owner/repo#5"}, + ) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if !slices.Equal(untracked, []string{"owner/repo#5"}) { + t.Fatalf("untracked = %v, want authored artifact at a distinct coordinate", untracked) + } + }) + } +} + +func TestDeriveRefsSeparatesStacksByForgeProvider(t *testing.T) { + const host = "forge.example.com" + tests := []struct { + provider store.ForgeProvider + wire compassv1.ForgeProvider + number uint32 + base string + head string + }{ + {store.ForgeProviderGitHub, compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, 1, "main", "feature"}, + {store.ForgeProviderGitHub, compassv1.ForgeProvider_FORGE_PROVIDER_GITHUB, 2, "feature", "tip"}, + {store.ForgeProviderGitLab, compassv1.ForgeProvider_FORGE_PROVIDER_GITLAB, 3, "main", "feature"}, + {store.ForgeProviderGitLab, compassv1.ForgeProvider_FORGE_PROVIDER_GITLAB, 4, "feature", "middle"}, + {store.ForgeProviderGitLab, compassv1.ForgeProvider_FORGE_PROVIDER_GITLAB, 5, "middle", "long-tip"}, + } + resolver := &fakeRefResolver{} + for _, tt := range tests { + pr := &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{Provider: tt.wire, Host: host}, + Repo: "owner/repo", Number: tt.number, ForgeState: "open", + BaseRef: tt.base, HeadRef: tt.head, + } + data, err := protojson.Marshal(pr) + if err != nil { + t.Fatal(err) + } + coord := store.ForgeCoord{ + Provider: tt.provider, Host: host, Repo: "owner/repo", Number: uint64(tt.number), + } + resolver.prs = append(resolver.prs, store.PullRequestRow{Coord: coord, State: "open", PR: data}) + resolver.authored = append(resolver.authored, store.AuthoredRef{ + Coord: coord, Kind: store.ForgeArtifactKindPullRequest, + }) + } + got, _, err := DeriveRefs( + context.Background(), resolver, Forges{GitHubHost: host}, + "agent-1", []string{"owner/repo#1"}, + ) + if err != nil { + t.Fatalf("DeriveRefs: %v", err) + } + if len(got) != 1 || got[0].GetStackPosition() != 1 || got[0].GetStackTipRef() != "owner/repo#2" { + t.Fatalf("GitHub stack = %+v, want position 1 and owner/repo#2 tip", got) + } +} diff --git a/go/internal/board/stack.go b/go/internal/board/stack.go new file mode 100644 index 000000000..530b69f92 --- /dev/null +++ b/go/internal/board/stack.go @@ -0,0 +1,84 @@ +//go:build unix + +package board + +import ( + "math" + "slices" + "sort" + "strings" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +func stackPosition(length int) uint32 { + if length > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(length) //nolint:gosec // length was checked against the wire field's maximum. +} + +// Stack locates a pull request in its stack and identifies that stack's tip. +type Stack struct { + Position uint32 + Tip string +} + +// StackOf derives positions and tips from the supplied open authored pull requests. +func StackOf(open []*compassv1.PullRequest) map[string]Stack { + byRepo := make(map[string][]*compassv1.PullRequest) + for _, pr := range open { + if pr == nil || pr.GetForgeState() != "open" || pr.GetRepo() == "" || pr.GetNumber() == 0 { + continue + } + repo := strings.ToLower(pr.GetRepo()) + byRepo[repo] = append(byRepo[repo], pr) + } + + out := make(map[string]Stack) + for repo, prs := range byRepo { + byHead := make(map[string]*compassv1.PullRequest, len(prs)) + children := make(map[string][]*compassv1.PullRequest, len(prs)) + for _, pr := range prs { + byHead[pr.GetHeadRef()] = pr + } + for _, pr := range prs { + if parent := byHead[pr.GetBaseRef()]; parent != nil && parent != pr { + parentKey := boardRefKey(repo, uint64(parent.GetNumber())) + children[parentKey] = append(children[parentKey], pr) + } + } + + var tips []*compassv1.PullRequest + for _, pr := range prs { + if len(children[boardRefKey(repo, uint64(pr.GetNumber()))]) == 0 { + tips = append(tips, pr) + } + } + sort.Slice(tips, func(i, j int) bool { + return boardRefKey(repo, uint64(tips[i].GetNumber())) < boardRefKey(repo, uint64(tips[j].GetNumber())) + }) + // Shared ancestors use the lexically first tip's ref. + for _, tip := range tips { + var chain []*compassv1.PullRequest + current := tip + seen := make(map[uint32]struct{}, len(prs)) + for current != nil { + if _, ok := seen[current.GetNumber()]; ok { + break + } + seen[current.GetNumber()] = struct{}{} + chain = append(chain, current) + current = byHead[current.GetBaseRef()] + } + for i, pr := range slices.Backward(chain) { + key := boardRefKey(repo, uint64(pr.GetNumber())) + if _, assigned := out[key]; !assigned { + position := stackPosition(len(chain) - i) + out[key] = Stack{Position: position, Tip: boardRefKey(repo, uint64(tip.GetNumber()))} + } + } + } + } + return out +} diff --git a/go/internal/board/stack_test.go b/go/internal/board/stack_test.go new file mode 100644 index 000000000..dc23c4cff --- /dev/null +++ b/go/internal/board/stack_test.go @@ -0,0 +1,68 @@ +//go:build unix + +package board + +import ( + "testing" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +func TestStackOf(t *testing.T) { + tests := []struct { + name string + prs []*compassv1.PullRequest + want map[string]Stack + }{ + { + name: "three pull request chain", + prs: []*compassv1.PullRequest{ + stackTestPR(1, "main", "one"), + stackTestPR(2, "one", "two"), + stackTestPR(3, "two", "three"), + }, + want: map[string]Stack{ + "owner/repo#1": {Position: 1, Tip: "owner/repo#3"}, + "owner/repo#2": {Position: 2, Tip: "owner/repo#3"}, + "owner/repo#3": {Position: 3, Tip: "owner/repo#3"}, + }, + }, + { + name: "fork has two tips", + prs: []*compassv1.PullRequest{ + stackTestPR(1, "main", "one"), + stackTestPR(2, "one", "two"), + stackTestPR(3, "one", "three"), + }, + want: map[string]Stack{ + "owner/repo#1": {Position: 1, Tip: "owner/repo#2"}, + "owner/repo#2": {Position: 2, Tip: "owner/repo#2"}, + "owner/repo#3": {Position: 2, Tip: "owner/repo#3"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StackOf(tt.prs) + if len(got) != len(tt.want) { + t.Fatalf("stack size = %d, want %d: %v", len(got), len(tt.want), got) + } + for key, want := range tt.want { + if got[key] != want { + t.Errorf("stack[%q] = %+v, want %+v", key, got[key], want) + } + } + }) + } +} + +func stackTestPR(number uint32, base, head string) *compassv1.PullRequest { + return &compassv1.PullRequest{ + Forge: &compassv1.ForgeRef{Host: "github.com"}, + Repo: "owner/repo", + Number: number, + ForgeState: "open", + BaseRef: base, + HeadRef: head, + } +} diff --git a/go/internal/store/agent_boards.go b/go/internal/store/agent_boards.go index 2c262028e..5554ba26a 100644 --- a/go/internal/store/agent_boards.go +++ b/go/internal/store/agent_boards.go @@ -775,6 +775,46 @@ func (s *Store) AsksByAuthor(ctx context.Context, viewer, author AccountID, answ return out, nil } +// AuthoredCoordsForAgent returns authored coordinates that intersect a batch. +func (s *Store) AuthoredCoordsForAgent(ctx context.Context, agent AccountID, coords []ForgeCoord) ([]AuthoredRef, error) { + if agent == "" { + return nil, fmt.Errorf("%w: agent account id is required", ErrInvalidArgument) + } + want, err := normalizeCoords(coords) + if err != nil { + return nil, err + } + if len(want) == 0 { + return nil, nil + } + arg := db.AuthoredCoordsForAgentParams{ + AgentAccountID: string(agent), + ForgeProviders: make([]int16, 0, len(want)), + ForgeHosts: make([]string, 0, len(want)), + ForgeRepos: make([]string, 0, len(want)), + ForgeNumbers: make([]int64, 0, len(want)), + } + for _, coord := range want { + value := coord.db() + arg.ForgeProviders = append(arg.ForgeProviders, value.provider) + arg.ForgeHosts = append(arg.ForgeHosts, value.host) + arg.ForgeRepos = append(arg.ForgeRepos, value.repo) + arg.ForgeNumbers = append(arg.ForgeNumbers, value.number) + } + rows, err := s.q.AuthoredCoordsForAgent(ctx, arg) + if err != nil { + return nil, fmt.Errorf("store: authored coordinates for agent: %w", err) + } + out := make([]AuthoredRef, 0, len(rows)) + for _, row := range rows { + out = append(out, AuthoredRef{ + Coord: coordFromDB(row.ForgeProvider, row.ForgeHost, row.Repo, row.Number), + Kind: ForgeArtifactKind(row.Kind), + }) + } + return out, nil +} + // OpenAuthoredRefs returns the agent's authored issues and PRs that remain open. func (s *Store) OpenAuthoredRefs(ctx context.Context, agent AccountID) ([]AuthoredRef, error) { if agent == "" { @@ -858,7 +898,7 @@ func (s *Store) IssuesByCoords(ctx context.Context, coords []ForgeCoord) ([]Issu out = append(out, issueFromColumns( row.ID, row.ForgeProvider, row.ForgeHost, row.Repo, row.Number, row.Title, row.Body, row.ForgeState, row.Url, row.ForgeAccount, row.Labels, row.AgentHandle, - row.State, row.Priority, row.Assignee, row.Summary, row.Branch, + row.ForgeUpdatedAt, row.State, row.Priority, row.Assignee, row.Summary, row.Branch, )) } return out, nil diff --git a/go/internal/store/agent_boards_pgtest_test.go b/go/internal/store/agent_boards_pgtest_test.go index f365ca7ad..e0a73bbdb 100644 --- a/go/internal/store/agent_boards_pgtest_test.go +++ b/go/internal/store/agent_boards_pgtest_test.go @@ -670,6 +670,25 @@ func TestBoardCoordinateReadsAndOpenAuthoredRefs(t *testing.T) { open[1].Coord.Number != openPR.Number || open[1].Kind != ForgeArtifactKindPullRequest { t.Fatalf("open authored refs = %+v, want open issue and PR only", open) } + owned, err := s.AuthoredCoordsForAgent(t.Context(), agent, []ForgeCoord{openIssue, closedIssue, openPR, closedPR, mergedPR}) + if err != nil { + t.Fatalf("AuthoredCoordsForAgent: %v", err) + } + ownedByArtifact := make(map[uint64]ForgeArtifactKind, len(owned)) + for _, ref := range owned { + coord := ref.Coord.Normalized() + ownedByArtifact[coord.Number] = ref.Kind + } + for _, number := range []uint64{openIssue.Number, closedIssue.Number} { + if ownedByArtifact[number] != ForgeArtifactKindIssue { + t.Errorf("authored issue %d = %v, want ownership regardless of forge state", number, ownedByArtifact[number]) + } + } + for _, number := range []uint64{openPR.Number, closedPR.Number, mergedPR.Number} { + if ownedByArtifact[number] != ForgeArtifactKindPullRequest { + t.Errorf("authored PR %d = %v, want ownership regardless of forge state", number, ownedByArtifact[number]) + } + } mixedCasePRs, err := s.PullRequestsByCoords(t.Context(), []ForgeCoord{ghCoord("OWNER/REPO", openPR.Number)}) if err != nil { t.Fatalf("PullRequestsByCoords(mixed-case repo): %v", err) diff --git a/go/internal/store/db/agent_boards.sql.go b/go/internal/store/db/agent_boards.sql.go index a0354af76..4b1df7003 100644 --- a/go/internal/store/db/agent_boards.sql.go +++ b/go/internal/store/db/agent_boards.sql.go @@ -11,6 +11,70 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const authoredCoordsForAgent = `-- name: AuthoredCoordsForAgent :many +WITH want AS ( + SELECT unnest($2::smallint[]) AS provider, + unnest($3::text[]) AS host, + unnest($4::text[]) AS repo, + unnest($5::bigint[]) AS number +) +SELECT a.forge_provider, a.forge_host, a.repo, a.kind, a.number + FROM forge_authored_artifacts a + JOIN want w ON a.forge_provider = w.provider AND a.forge_host = w.host + AND (CASE WHEN a.forge_provider = 1 THEN lower(a.repo) ELSE a.repo END) = w.repo + AND a.number = w.number + WHERE a.agent_account_id = $1 + ORDER BY a.forge_provider, a.forge_host, a.repo, a.kind, a.number +` + +type AuthoredCoordsForAgentParams struct { + AgentAccountID string + ForgeProviders []int16 + ForgeHosts []string + ForgeRepos []string + ForgeNumbers []int64 +} + +type AuthoredCoordsForAgentRow struct { + ForgeProvider int16 + ForgeHost string + Repo string + Kind int16 + Number int64 +} + +func (q *Queries) AuthoredCoordsForAgent(ctx context.Context, arg AuthoredCoordsForAgentParams) ([]AuthoredCoordsForAgentRow, error) { + rows, err := q.db.Query(ctx, authoredCoordsForAgent, + arg.AgentAccountID, + arg.ForgeProviders, + arg.ForgeHosts, + arg.ForgeRepos, + arg.ForgeNumbers, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuthoredCoordsForAgentRow + for rows.Next() { + var i AuthoredCoordsForAgentRow + if err := rows.Scan( + &i.ForgeProvider, + &i.ForgeHost, + &i.Repo, + &i.Kind, + &i.Number, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const deleteAgentBoardTasks = `-- name: DeleteAgentBoardTasks :exec DELETE FROM agent_board_tasks WHERE agent_account_id = $1 AND task_key = ANY($2::text[]) @@ -134,7 +198,7 @@ WITH want AS ( ) SELECT i.id, i.forge_provider, i.forge_host, i.repo, i.number, i.title, i.body, i.forge_state, i.url, i.forge_account, i.labels, i.agent_handle, - i.state, i.priority, i.assignee, i.summary, i.branch + i.forge_updated_at, i.state, i.priority, i.assignee, i.summary, i.branch FROM issues i JOIN want w ON i.forge_provider = w.provider AND i.forge_host = w.host AND (CASE WHEN i.forge_provider = 1 THEN lower(i.repo) ELSE i.repo END) = w.repo @@ -150,23 +214,24 @@ type IssuesByCoordsParams struct { } type IssuesByCoordsRow struct { - ID string - ForgeProvider int16 - ForgeHost string - Repo string - Number int64 - Title string - Body string - ForgeState string - Url string - ForgeAccount string - Labels []string - AgentHandle string - State int16 - Priority string - Assignee string - Summary string - Branch string + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + ForgeUpdatedAt pgtype.Timestamptz + State int16 + Priority string + Assignee string + Summary string + Branch string } func (q *Queries) IssuesByCoords(ctx context.Context, arg IssuesByCoordsParams) ([]IssuesByCoordsRow, error) { @@ -196,6 +261,7 @@ func (q *Queries) IssuesByCoords(ctx context.Context, arg IssuesByCoordsParams) &i.ForgeAccount, &i.Labels, &i.AgentHandle, + &i.ForgeUpdatedAt, &i.State, &i.Priority, &i.Assignee, diff --git a/go/internal/store/db/issues.sql.go b/go/internal/store/db/issues.sql.go index 774e9890c..2c8182c28 100644 --- a/go/internal/store/db/issues.sql.go +++ b/go/internal/store/db/issues.sql.go @@ -14,29 +14,30 @@ import ( const getIssue = `-- name: GetIssue :one SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues WHERE id = $1 ` type GetIssueRow struct { - ID string - ForgeProvider int16 - ForgeHost string - Repo string - Number int64 - Title string - Body string - ForgeState string - Url string - ForgeAccount string - Labels []string - AgentHandle string - State int16 - Priority string - Assignee string - Summary string - Branch string + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + ForgeUpdatedAt pgtype.Timestamptz + State int16 + Priority string + Assignee string + Summary string + Branch string } func (q *Queries) GetIssue(ctx context.Context, id string) (GetIssueRow, error) { @@ -55,6 +56,7 @@ func (q *Queries) GetIssue(ctx context.Context, id string) (GetIssueRow, error) &i.ForgeAccount, &i.Labels, &i.AgentHandle, + &i.ForgeUpdatedAt, &i.State, &i.Priority, &i.Assignee, @@ -67,29 +69,30 @@ func (q *Queries) GetIssue(ctx context.Context, id string) (GetIssueRow, error) const listIssues = `-- name: ListIssues :many SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues ORDER BY id ` type ListIssuesRow struct { - ID string - ForgeProvider int16 - ForgeHost string - Repo string - Number int64 - Title string - Body string - ForgeState string - Url string - ForgeAccount string - Labels []string - AgentHandle string - State int16 - Priority string - Assignee string - Summary string - Branch string + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + ForgeUpdatedAt pgtype.Timestamptz + State int16 + Priority string + Assignee string + Summary string + Branch string } func (q *Queries) ListIssues(ctx context.Context) ([]ListIssuesRow, error) { @@ -114,6 +117,7 @@ func (q *Queries) ListIssues(ctx context.Context) ([]ListIssuesRow, error) { &i.ForgeAccount, &i.Labels, &i.AgentHandle, + &i.ForgeUpdatedAt, &i.State, &i.Priority, &i.Assignee, @@ -133,7 +137,7 @@ func (q *Queries) ListIssues(ctx context.Context) ([]ListIssuesRow, error) { const searchIssues = `-- name: SearchIssues :many SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues WHERE search_tsv @@ websearch_to_tsquery('english', $1) ORDER BY ts_rank('{0.1,0.2,0.4,1.0}'::real[], search_tsv, websearch_to_tsquery('english', $1)) DESC, number DESC @@ -146,23 +150,24 @@ type SearchIssuesParams struct { } type SearchIssuesRow struct { - ID string - ForgeProvider int16 - ForgeHost string - Repo string - Number int64 - Title string - Body string - ForgeState string - Url string - ForgeAccount string - Labels []string - AgentHandle string - State int16 - Priority string - Assignee string - Summary string - Branch string + ID string + ForgeProvider int16 + ForgeHost string + Repo string + Number int64 + Title string + Body string + ForgeState string + Url string + ForgeAccount string + Labels []string + AgentHandle string + ForgeUpdatedAt pgtype.Timestamptz + State int16 + Priority string + Assignee string + Summary string + Branch string } func (q *Queries) SearchIssues(ctx context.Context, arg SearchIssuesParams) ([]SearchIssuesRow, error) { @@ -187,6 +192,7 @@ func (q *Queries) SearchIssues(ctx context.Context, arg SearchIssuesParams) ([]S &i.ForgeAccount, &i.Labels, &i.AgentHandle, + &i.ForgeUpdatedAt, &i.State, &i.Priority, &i.Assignee, diff --git a/go/internal/store/db/querier.go b/go/internal/store/db/querier.go index 0a1a6f541..8a2e13f22 100644 --- a/go/internal/store/db/querier.go +++ b/go/internal/store/db/querier.go @@ -73,6 +73,7 @@ type Querier interface { AppendTokenUsageEvents(ctx context.Context, arg AppendTokenUsageEventsParams) error AuthoredArtifactByCoordinate(ctx context.Context, arg AuthoredArtifactByCoordinateParams) (ForgeAuthoredArtifact, error) AuthoredArtifactByRequestID(ctx context.Context, arg AuthoredArtifactByRequestIDParams) (ForgeAuthoredArtifact, error) + AuthoredCoordsForAgent(ctx context.Context, arg AuthoredCoordsForAgentParams) ([]AuthoredCoordsForAgentRow, error) // Agent-transcript queries (sqlc adoption T5, RIG-3034). These replace the // inline SQL literals in internal/store/agent_transcripts.go; the hand-written // Store methods keep their exact signatures and own the two-tier flush diff --git a/go/internal/store/issues.go b/go/internal/store/issues.go index 8ba56643f..56be4a42f 100644 --- a/go/internal/store/issues.go +++ b/go/internal/store/issues.go @@ -66,6 +66,8 @@ type Issue struct { ForgeAccount string // the native forge login; always set Labels []string AgentHandle string // parsed Compass attribution; "" = non-Compass (human) author + // ForgeUpdatedAt is zero when the forge row has no update timestamp. + ForgeUpdatedAt time.Time // Compass machinery (Compass-owned; not on the forge). State is set by the // write path (part 5); the other four by their own producers (assignee = @@ -244,7 +246,7 @@ func (s *Store) SearchIssues(ctx context.Context, query string, limit uint32) ([ func issueFromGetRow(r db.GetIssueRow) Issue { return issueFromColumns(r.ID, r.ForgeProvider, r.ForgeHost, r.Repo, r.Number, r.Title, r.Body, r.ForgeState, r.Url, r.ForgeAccount, r.Labels, r.AgentHandle, - r.State, r.Priority, r.Assignee, r.Summary, r.Branch) + r.ForgeUpdatedAt, r.State, r.Priority, r.Assignee, r.Summary, r.Branch) } // issueFromListRow maps a generated ListIssues row into a domain Issue (identical @@ -252,14 +254,14 @@ func issueFromGetRow(r db.GetIssueRow) Issue { func issueFromListRow(r db.ListIssuesRow) Issue { return issueFromColumns(r.ID, r.ForgeProvider, r.ForgeHost, r.Repo, r.Number, r.Title, r.Body, r.ForgeState, r.Url, r.ForgeAccount, r.Labels, r.AgentHandle, - r.State, r.Priority, r.Assignee, r.Summary, r.Branch) + r.ForgeUpdatedAt, r.State, r.Priority, r.Assignee, r.Summary, r.Branch) } // issueFromSearchRow maps a generated search result to the store issue model. func issueFromSearchRow(r db.SearchIssuesRow) Issue { return issueFromColumns(r.ID, r.ForgeProvider, r.ForgeHost, r.Repo, r.Number, r.Title, r.Body, r.ForgeState, r.Url, r.ForgeAccount, r.Labels, r.AgentHandle, - r.State, r.Priority, r.Assignee, r.Summary, r.Branch) + r.ForgeUpdatedAt, r.State, r.Priority, r.Assignee, r.Summary, r.Branch) } // issueFromColumns builds an Issue from the shared issue projection both reads @@ -268,26 +270,27 @@ func issueFromSearchRow(r db.SearchIssuesRow) Issue { func issueFromColumns( id string, forgeProvider int16, forgeHost, repo string, number int64, title, body, forgeState, url, forgeAccount string, labels []string, agentHandle string, - state int16, priority, assignee, summary, branch string, + forgeUpdatedAt pgtype.Timestamptz, state int16, priority, assignee, summary, branch string, ) Issue { iss := Issue{ - ID: id, - ForgeProvider: ForgeProvider(forgeProvider), - ForgeHost: forgeHost, - Repo: repo, - Number: uint32(number), //nolint:gosec // G115: number is a BIGINT written only from a canonical uint32 (UpsertIssueForgeFields narrows in.Number), so it is always within the uint32 domain - Title: title, - Body: body, - ForgeState: forgeState, - URL: url, - ForgeAccount: forgeAccount, - Labels: labels, - AgentHandle: agentHandle, - State: IssueState(state), - Priority: priority, - Assignee: assignee, - Summary: summary, - Branch: branch, + ID: id, + ForgeProvider: ForgeProvider(forgeProvider), + ForgeHost: forgeHost, + Repo: repo, + Number: uint32(number), //nolint:gosec // G115: number is a BIGINT written only from a canonical uint32 (UpsertIssueForgeFields narrows in.Number), so it is always within the uint32 domain + Title: title, + Body: body, + ForgeState: forgeState, + URL: url, + ForgeAccount: forgeAccount, + Labels: labels, + AgentHandle: agentHandle, + ForgeUpdatedAt: forgeUpdatedAt.Time, + State: IssueState(state), + Priority: priority, + Assignee: assignee, + Summary: summary, + Branch: branch, } if len(iss.Labels) == 0 { iss.Labels = nil diff --git a/go/internal/store/queries/agent_boards.sql b/go/internal/store/queries/agent_boards.sql index a5aa442d0..90b9416d6 100644 --- a/go/internal/store/queries/agent_boards.sql +++ b/go/internal/store/queries/agent_boards.sql @@ -199,6 +199,21 @@ SELECT a.forge_provider, a.forge_host, a.repo, a.kind, a.number (CASE WHEN a.forge_provider = 1 THEN lower(a.repo) ELSE a.repo END) AND i.number = a.number AND i.forge_state = 'open'))) ORDER BY a.created_at_unix_ms, a.forge_provider, a.forge_host, a.repo, a.kind, a.number; +-- name: AuthoredCoordsForAgent :many +WITH want AS ( + SELECT unnest(@forge_providers::smallint[]) AS provider, + unnest(@forge_hosts::text[]) AS host, + unnest(@forge_repos::text[]) AS repo, + unnest(@forge_numbers::bigint[]) AS number +) +SELECT a.forge_provider, a.forge_host, a.repo, a.kind, a.number + FROM forge_authored_artifacts a + JOIN want w ON a.forge_provider = w.provider AND a.forge_host = w.host + AND (CASE WHEN a.forge_provider = 1 THEN lower(a.repo) ELSE a.repo END) = w.repo + AND a.number = w.number + WHERE a.agent_account_id = @agent_account_id + ORDER BY a.forge_provider, a.forge_host, a.repo, a.kind, a.number; + -- name: PullRequestsByCoords :many WITH want AS ( @@ -224,7 +239,7 @@ WITH want AS ( ) SELECT i.id, i.forge_provider, i.forge_host, i.repo, i.number, i.title, i.body, i.forge_state, i.url, i.forge_account, i.labels, i.agent_handle, - i.state, i.priority, i.assignee, i.summary, i.branch + i.forge_updated_at, i.state, i.priority, i.assignee, i.summary, i.branch FROM issues i JOIN want w ON i.forge_provider = w.provider AND i.forge_host = w.host AND (CASE WHEN i.forge_provider = 1 THEN lower(i.repo) ELSE i.repo END) = w.repo diff --git a/go/internal/store/queries/issues.sql b/go/internal/store/queries/issues.sql index 9a4e4efd9..438cc8c32 100644 --- a/go/internal/store/queries/issues.sql +++ b/go/internal/store/queries/issues.sql @@ -40,21 +40,21 @@ UPDATE issues SET state = $2 WHERE id = $1; -- name: GetIssue :one SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues WHERE id = $1; -- name: ListIssues :many SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues ORDER BY id; -- name: SearchIssues :many SELECT id, forge_provider, forge_host, repo, number, title, body, forge_state, url, forge_account, labels, agent_handle, - state, priority, assignee, summary, branch + forge_updated_at, state, priority, assignee, summary, branch FROM issues WHERE search_tsv @@ websearch_to_tsquery('english', $1) ORDER BY ts_rank('{0.1,0.2,0.4,1.0}'::real[], search_tsv, websearch_to_tsquery('english', $1)) DESC, number DESC