From 0d78f527ec8bcb168bfc020cce501f3f31ac5af8 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 11 Oct 2026 21:23:47 -0400 Subject: [PATCH] feat(board): derive board refs, PR gates and stacks from stored rows (RIG-5171) DeriveRefs resolves each task ref against stored pull request and issue rows in one batched read per kind, with no forge calls. PrGate applies the gate order (merged, closed, queue states, draft, conflicts, checks, changes requested, awaiting review, awaiting enqueue). An approval on an older commit is stale, and an unresolved thread holds a PR at awaiting review. StackOf orders an agent's open PRs by base/head ref. Issue reads now carry forge_updated_at for observed_at. Ledger-impact: none Co-authored-by: Matt Wilkinson --- go/internal/board/pr_gate.go | 143 ++++++ go/internal/board/pr_gate_test.go | 268 +++++++++++ go/internal/board/refs_derive.go | 381 ++++++++++++++++ go/internal/board/refs_derive_pgtest_test.go | 100 ++++ go/internal/board/refs_derive_test.go | 431 ++++++++++++++++++ go/internal/board/stack.go | 84 ++++ go/internal/board/stack_test.go | 68 +++ go/internal/store/agent_boards.go | 42 +- go/internal/store/agent_boards_pgtest_test.go | 19 + go/internal/store/db/agent_boards.sql.go | 102 ++++- go/internal/store/db/issues.sql.go | 114 ++--- go/internal/store/db/querier.go | 1 + go/internal/store/issues.go | 45 +- go/internal/store/queries/agent_boards.sql | 17 +- go/internal/store/queries/issues.sql | 6 +- 15 files changed, 1723 insertions(+), 98 deletions(-) create mode 100644 go/internal/board/pr_gate.go create mode 100644 go/internal/board/pr_gate_test.go create mode 100644 go/internal/board/refs_derive.go create mode 100644 go/internal/board/refs_derive_pgtest_test.go create mode 100644 go/internal/board/refs_derive_test.go create mode 100644 go/internal/board/stack.go create mode 100644 go/internal/board/stack_test.go 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