diff --git a/lab/internal/delivery/receipt.go b/lab/internal/delivery/receipt.go index 33c9412..0d16db4 100644 --- a/lab/internal/delivery/receipt.go +++ b/lab/internal/delivery/receipt.go @@ -18,6 +18,8 @@ type ReceiptStatus struct { Lifecycle string URL string MoleculeID string + // Commit is the commit Forgecell published, or "" before publication. + Commit string } // ReadReceiptStatus reads deliveries/.json for display without locking, @@ -25,7 +27,7 @@ type ReceiptStatus struct { // symlinked deliveries directory, a symlink, a non-regular or oversized file, a // receipt for another Molecule and a state other than prepared, committed or // published. The receipt state is reported exactly; publication is never -// inferred. Only the state, lifecycle state, URL and Molecule id are kept: the +// inferred. Only the state, lifecycle state, URL, commit and Molecule id are kept: the // JSON is streamed token by token, so the reviewed diff and every other field // are validated as JSON and discarded rather than decoded. func ReadReceiptStatus(lab, id string) (s ReceiptStatus, found bool, err error) { @@ -48,7 +50,7 @@ func ReadReceiptStatus(lab, id string) (s ReceiptStatus, found bool, err error) return s, true, nil } -// decodeStatus walks one top-level object, keeping state, url, lifecycle.state +// decodeStatus walks one top-level object, keeping state, url, commit, lifecycle.state // and preview.moleculeId, and requires nothing but whitespace after it. func decodeStatus(dec *json.Decoder) (ReceiptStatus, error) { var s ReceiptStatus @@ -58,6 +60,8 @@ func decodeStatus(dec *json.Decoder) (ReceiptStatus, error) { return dec.Decode(&s.State) case "url": return dec.Decode(&s.URL) + case "commit": + return dec.Decode(&s.Commit) case "lifecycle": return nullableObject(dec, func(key string) error { if key == "state" { diff --git a/lab/internal/forgesync/cache.go b/lab/internal/forgesync/cache.go new file mode 100644 index 0000000..1b200b9 --- /dev/null +++ b/lab/internal/forgesync/cache.go @@ -0,0 +1,201 @@ +package forgesync + +import ( + "context" + "sync" + "time" +) + +// TTL is how long one read of GitHub serves a server: at most one sync per TTL, +// failed or not, unless a caller asks for a refresh. +const TTL = 60 * time.Second + +// syncTimeout bounds one whole sync, every gh call in it included. +const syncTimeout = 2 * time.Minute + +// Snapshot is one successful sync of a repository. +type Snapshot struct { + Repository string + Listing Listing + // Issues holds every Issue read, listed open ones and those read by number. + Issues map[int64]Issue + PRs map[int64]PR + // StatesTruncated means some wanted numbers were past MaxStateNumbers and + // were not read. + StatesTruncated bool + FetchedAt time.Time +} + +// Issue is the read Issue n, or nil when it was not read. +func (s *Snapshot) Issue(n int64) *Issue { + if s == nil { + return nil + } + if i, ok := s.Issues[n]; ok { + return &i + } + return nil +} + +// Want is what one sync reads: the repository's open Issues, plus these Issues +// and pull requests by number. +type Want struct { + Repository string + Issues []int64 + PRs []int64 +} + +// Result is the cache's answer. Snapshot is the last good sync of the +// repository, or nil when there is none; Reason is the fixed reason the latest +// attempt failed, or "" when it succeeded. Age is the snapshot's age. +type Result struct { + Snapshot *Snapshot + Reason string + Age time.Duration +} + +// Cache serves one server's GitHub state. It reads GitHub at most once per TTL, +// a refresh bypasses that, and concurrent callers share one in-flight sync. A +// failed sync keeps the last good snapshot. +type Cache struct { + fetcher Fetcher + now func() time.Time + + mu sync.Mutex + snap *Snapshot + reason string + attempted time.Time + attemptedRepo string + flight *flight + // joined, when set by a test, receives once per caller that joins a flight: + // a readiness marker, so tests never poll. + joined chan struct{} +} + +type flight struct { + done chan struct{} + waiters int +} + +// NewCache returns a cache reading through fetcher; now is the clock, so tests +// never wait on the wall clock. +func NewCache(fetcher Fetcher, now func() time.Time) *Cache { + if now == nil { + now = time.Now + } + return &Cache{fetcher: fetcher, now: now} +} + +// Get returns GitHub state for want, syncing when the latest attempt for its +// repository is older than TTL or refresh is set. A caller arriving while a +// sync runs waits for it, or for ctx, instead of starting another. +func (c *Cache) Get(ctx context.Context, want Want, refresh bool) Result { + c.mu.Lock() + if f := c.flight; f != nil { + f.waiters++ + joined := c.joined + c.mu.Unlock() + if joined != nil { + joined <- struct{}{} + } + select { + case <-f.done: + case <-ctx.Done(): + } + c.mu.Lock() + defer c.mu.Unlock() + return c.result(want.Repository) + } + fresh := !c.attempted.IsZero() && c.attemptedRepo == want.Repository && c.now().Sub(c.attempted) < TTL + if fresh && !refresh { + defer c.mu.Unlock() + return c.result(want.Repository) + } + f := &flight{done: make(chan struct{})} + c.flight = f + c.mu.Unlock() + return c.run(want, f) +} + +// run is the one shared sync. Its deferred function always records the +// outcome, clears the in-flight entry and closes done, so the cache never +// wedges; a fetcher panic is recovered and recorded as the fixed ReasonFailed. +func (c *Cache) run(want Want, f *flight) (r Result) { + var snap *Snapshot + reason := ReasonFailed + defer func() { + if recover() != nil { + snap, reason = nil, ReasonFailed + } + c.mu.Lock() + c.attempted, c.attemptedRepo, c.reason = c.now(), want.Repository, reason + if reason == "" { + c.snap = snap + } else if c.snap != nil && c.snap.Repository != want.Repository { + c.snap = nil + } + c.flight = nil + r = c.result(want.Repository) + c.mu.Unlock() + close(f.done) + }() + snap, reason = c.sync(want) + return r +} + +// Current reports GitHub state fit to allow a start: the latest sync succeeded +// and its snapshot is at most twice the TTL old. Anything else fails closed. +func (r Result) Current() bool { + return r.Snapshot != nil && r.Reason == "" && r.Age <= 2*TTL +} + +// result is the answer for repository; the caller holds mu. +func (c *Cache) result(repository string) Result { + var r Result + if c.attemptedRepo == repository { + r.Reason = c.reason + } + if c.snap != nil && c.snap.Repository == repository { + r.Snapshot = c.snap + if age := c.now().Sub(c.snap.FetchedAt); age > 0 { + r.Age = age + } + } + return r +} + +// sync reads GitHub once. It runs on its own bounded context, so a caller that +// goes away does not cancel the sync the others share. +func (c *Cache) sync(want Want) (*Snapshot, string) { + ctx, cancel := context.WithTimeout(context.Background(), syncTimeout) + defer cancel() + listing, err := c.fetcher.OpenIssues(ctx, want.Repository) + if err != nil { + return nil, ReasonOf(err) + } + snap := &Snapshot{Repository: want.Repository, Listing: listing, Issues: map[int64]Issue{}, PRs: map[int64]PR{}} + for _, i := range listing.Issues { + snap.Issues[i.Number] = i + } + var need []int64 + for _, n := range want.Issues { + if _, listed := snap.Issues[n]; !listed { + need = append(need, n) + } + } + if len(need) > 0 || len(want.PRs) > 0 { + states, err := c.fetcher.States(ctx, want.Repository, need, want.PRs) + if err != nil { + return nil, ReasonOf(err) + } + for n, i := range states.Issues { + snap.Issues[n] = i + } + for n, p := range states.PRs { + snap.PRs[n] = p + } + snap.StatesTruncated = states.Truncated + } + snap.FetchedAt = c.now() + return snap, "" +} diff --git a/lab/internal/forgesync/cache_test.go b/lab/internal/forgesync/cache_test.go new file mode 100644 index 0000000..849849b --- /dev/null +++ b/lab/internal/forgesync/cache_test.go @@ -0,0 +1,285 @@ +package forgesync + +import ( + "context" + "slices" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeFetcher counts syncs; gate, when set, holds each listing until closed. +type fakeFetcher struct { + lists, states atomic.Int32 + started chan struct{} + gate chan struct{} + err error + gotIssues []int64 + gotPRs []int64 + mu sync.Mutex +} + +func (f *fakeFetcher) OpenIssues(ctx context.Context, repository string) (Listing, error) { + f.lists.Add(1) + if f.started != nil { + f.started <- struct{}{} + } + if f.gate != nil { + <-f.gate + } + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return Listing{}, f.err + } + return Listing{Issues: []Issue{{Number: 1, Found: true, State: IssueOpen, Labels: []string{}}}, Total: 1}, nil +} + +func (f *fakeFetcher) States(ctx context.Context, repository string, issues, prs []int64) (States, error) { + f.states.Add(1) + f.mu.Lock() + defer f.mu.Unlock() + f.gotIssues, f.gotPRs = issues, prs + out := States{Issues: map[int64]Issue{}, PRs: map[int64]PR{}} + for _, n := range issues { + out.Issues[n] = Issue{Number: n, Found: true, State: IssueClosed, Labels: []string{}} + } + for _, n := range prs { + out.PRs[n] = PR{Number: n, Found: true, State: PRMerged} + } + return out, nil +} + +func (f *fakeFetcher) fail(err error) { + f.mu.Lock() + f.err = err + f.mu.Unlock() +} + +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t } +func (c *clock) add(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +func newClock() *clock { return &clock{t: time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)} } + +func TestCacheHonoursTheTTLAndRefreshBypassesIt(t *testing.T) { + f, clk := &fakeFetcher{}, newClock() + c := NewCache(f, clk.now) + want := Want{Repository: "o/r", Issues: []int64{1, 2}, PRs: []int64{5}} + r := c.Get(context.Background(), want, false) + if r.Snapshot == nil || r.Reason != "" || f.lists.Load() != 1 { + t.Fatalf("first: %+v lists %d", r, f.lists.Load()) + } + // Listed Issues are not read again by number. + if !slices.Equal(f.gotIssues, []int64{2}) || !slices.Equal(f.gotPRs, []int64{5}) { + t.Fatalf("states asked for %v %v", f.gotIssues, f.gotPRs) + } + if r.Snapshot.Issue(1).State != IssueOpen || r.Snapshot.Issue(2).State != IssueClosed || r.Snapshot.PRs[5].State != PRMerged || r.Snapshot.Issue(3) != nil { + t.Fatalf("snapshot %+v", r.Snapshot) + } + clk.add(59 * time.Second) + if r = c.Get(context.Background(), want, false); f.lists.Load() != 1 || r.Age != 59*time.Second { + t.Fatalf("within the TTL: lists %d age %v", f.lists.Load(), r.Age) + } + if c.Get(context.Background(), want, true); f.lists.Load() != 2 { + t.Fatalf("refresh: lists %d", f.lists.Load()) + } + clk.add(TTL) + if r = c.Get(context.Background(), want, false); f.lists.Load() != 3 || r.Age != 0 { + t.Fatalf("after the TTL: lists %d age %v", f.lists.Load(), r.Age) + } +} + +// A failed sync keeps the last good snapshot and reports its age with the fixed +// reason; a failure is throttled by the TTL as well. +func TestCacheKeepsTheStaleSnapshotOnFailure(t *testing.T) { + f, clk := &fakeFetcher{}, newClock() + c := NewCache(f, clk.now) + want := Want{Repository: "o/r"} + good := c.Get(context.Background(), want, false).Snapshot + clk.add(4 * time.Minute) + f.fail(fail(ReasonRateLimited)) + r := c.Get(context.Background(), want, false) + if r.Snapshot != good || r.Reason != ReasonRateLimited || r.Age != 4*time.Minute { + t.Fatalf("stale: %+v", r) + } + clk.add(time.Second) + if r = c.Get(context.Background(), want, false); f.lists.Load() != 2 || r.Reason != ReasonRateLimited || r.Snapshot != good { + t.Fatalf("failure not throttled: lists %d %+v", f.lists.Load(), r) + } + f.fail(nil) + if r = c.Get(context.Background(), want, true); r.Reason != "" || r.Snapshot == good { + t.Fatalf("recovery: %+v", r) + } +} + +func TestCacheWithoutASnapshotReportsOnlyTheReason(t *testing.T) { + f, clk := &fakeFetcher{err: fail(ReasonNotAuthenticated)}, newClock() + r := NewCache(f, clk.now).Get(context.Background(), Want{Repository: "o/r"}, false) + if r.Snapshot != nil || r.Reason != ReasonNotAuthenticated { + t.Fatalf("%+v", r) + } +} + +// A snapshot of one repository is never served for another. +func TestCacheIsPerRepository(t *testing.T) { + f, clk := &fakeFetcher{}, newClock() + c := NewCache(f, clk.now) + c.Get(context.Background(), Want{Repository: "o/r"}, false) + f.fail(fail(ReasonWrongHost)) + r := c.Get(context.Background(), Want{Repository: "o/other"}, false) + if f.lists.Load() != 2 || r.Snapshot != nil || r.Reason != ReasonWrongHost { + t.Fatalf("other repository: lists %d %+v", f.lists.Load(), r) + } +} + +// Concurrent requests share one in-flight sync, refresh or not. +func TestCacheSingleFlight(t *testing.T) { + f := &fakeFetcher{started: make(chan struct{}, 1), gate: make(chan struct{})} + clk := newClock() + c := NewCache(f, clk.now) + const callers = 8 + c.joined = make(chan struct{}, callers) + results := make(chan Result, callers) + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); results <- c.Get(context.Background(), Want{Repository: "o/r"}, true) }() + <-f.started + for i := 1; i < callers; i++ { + wg.Add(1) + go func() { defer wg.Done(); results <- c.Get(context.Background(), Want{Repository: "o/r"}, true) }() + } + // Readiness marker: every other caller has joined the flight. The deadline + // only guards against a hang; the test never waits on it otherwise. + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + for i := 1; i < callers; i++ { + select { + case <-c.joined: + case <-ctx.Done(): + close(f.gate) + t.Fatalf("only %d of %d callers joined the flight", i-1, callers-1) + } + } + close(f.gate) + wg.Wait() + close(results) + for r := range results { + if r.Snapshot == nil { + t.Fatalf("a caller got %+v", r) + } + } + if n := f.lists.Load(); n != 1 { + t.Fatalf("%d syncs for %d concurrent callers", n, callers) + } +} + +// panicFetcher panics on its first listings, while a waiter has joined, then +// answers like fakeFetcher. +type panicFetcher struct { + fakeFetcher + panics atomic.Int32 +} + +func (p *panicFetcher) OpenIssues(ctx context.Context, repository string) (Listing, error) { + if p.panics.Add(-1) >= 0 { + p.lists.Add(1) + if p.started != nil { + p.started <- struct{}{} + } + if p.gate != nil { + <-p.gate + } + panic("fetcher exploded") + } + return p.fakeFetcher.OpenIssues(ctx, repository) +} + +// A fetcher panic never wedges the cache: the flight is cleared, its waiters are +// released with the fixed reason, and the next Get syncs again. +func TestCacheRecoversAFetcherPanic(t *testing.T) { + p := &panicFetcher{} + p.panics.Store(1) + p.started, p.gate = make(chan struct{}, 1), make(chan struct{}) + c := NewCache(p, newClock().now) + c.joined = make(chan struct{}, 1) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + first, waiter := make(chan Result, 1), make(chan Result, 1) + go func() { first <- c.Get(context.Background(), Want{Repository: "o/r"}, false) }() + <-p.started + go func() { waiter <- c.Get(ctx, Want{Repository: "o/r"}, false) }() + select { + case <-c.joined: + case <-ctx.Done(): + t.Fatal("the waiter never joined") + } + close(p.gate) + for _, ch := range []chan Result{first, waiter} { + select { + case r := <-ch: + if r.Snapshot != nil || r.Reason != ReasonFailed { + t.Fatalf("after a panic: %+v", r) + } + case <-ctx.Done(): + t.Fatal("the cache wedged after a fetcher panic") + } + } + c.mu.Lock() + wedged := c.flight != nil + c.mu.Unlock() + if wedged { + t.Fatal("the in-flight entry was not cleared") + } + p.started, p.gate = nil, nil + if r := c.Get(ctx, Want{Repository: "o/r"}, true); r.Snapshot == nil || r.Reason != "" || p.lists.Load() != 2 { + t.Fatalf("after recovery: %+v lists %d", r, p.lists.Load()) + } +} + +// Current fails closed on a failed sync and on a snapshot older than 2x TTL. +func TestResultCurrent(t *testing.T) { + s := &Snapshot{} + for _, c := range []struct { + r Result + want bool + }{ + {Result{Snapshot: s}, true}, + {Result{Snapshot: s, Age: 2 * TTL}, true}, + {Result{Snapshot: s, Age: 2*TTL + time.Second}, false}, + {Result{Snapshot: s, Reason: ReasonOffline}, false}, + {Result{Reason: ReasonOffline}, false}, + {Result{}, false}, + } { + if got := c.r.Current(); got != c.want { + t.Errorf("%+v: %v", c.r, got) + } + } +} + +// A caller that gives up while a sync runs returns without cancelling it. +func TestCacheWaiterHonoursItsContext(t *testing.T) { + f := &fakeFetcher{started: make(chan struct{}, 1), gate: make(chan struct{})} + c := NewCache(f, newClock().now) + done := make(chan Result, 1) + go func() { done <- c.Get(context.Background(), Want{Repository: "o/r"}, false) }() + <-f.started + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if r := c.Get(ctx, Want{Repository: "o/r"}, false); r.Snapshot != nil { + t.Fatalf("cancelled waiter: %+v", r) + } + close(f.gate) + if r := <-done; r.Snapshot == nil { + t.Fatalf("the shared sync was cancelled: %+v", r) + } +} diff --git a/lab/internal/forgesync/fetch.go b/lab/internal/forgesync/fetch.go new file mode 100644 index 0000000..e42dc27 --- /dev/null +++ b/lab/internal/forgesync/fetch.go @@ -0,0 +1,604 @@ +package forgesync + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/intake" + "github.com/DanielNetzer/forgecell/lab/internal/process" + "regexp" + "sort" + "strings" + "time" + "unicode" +) + +// Bounds of one sync. The open-Issue listing reads at most MaxListed Issues in +// pages of listPageSize; Issue and pull request state is read in GraphQL queries +// of at most MaxBatch numbers, for at most MaxStateNumbers numbers in all. +const ( + MaxListed = 500 + listPageSize = 100 + MaxBatch = 50 + MaxStateNumbers = 1000 + callTimeout = 20 * time.Second + maxCallBytes = 4_000_000 + maxTitleRunes = 300 + maxLabels = 20 + maxLabelRunes = 100 + maxMilestone = 200 + maxCursorBytes = 200 + maxSafeInteger = 9007199254740991 +) + +// Fixed failure reasons. A response never carries gh's own output, which can +// name local paths, hosts or accounts. +const ( + ReasonGHMissing = "GitHub CLI (gh) is not installed or not on PATH; install gh to check GitHub state." + ReasonNotAuthenticated = "gh is not signed in to github.com; run gh auth login, then Refresh." + ReasonRateLimited = "GitHub's API rate limit was reached; wait a few minutes, then Refresh." + ReasonOffline = "GitHub could not be reached; check the network connection, then Refresh." + ReasonWrongHost = "gh could not read this repository on github.com; check gh auth status and the active Formula's repository." + ReasonTimeout = "GitHub did not answer in time; Refresh to try again." + ReasonOversized = "GitHub's answer was larger than Forgecell reads; nothing from it is shown." + ReasonMalformed = "GitHub's answer was malformed or incomplete; nothing from it is shown." + ReasonFailed = "gh could not read GitHub state; run gh auth status, then Refresh." + ReasonRepository = "The active Formula names no valid GitHub repository." +) + +// Plain reasons for one Issue GitHub does not return. +const ( + UnavailableNotFound = "not found or transferred" + UnavailableNoAccess = "no access" +) + +// Failure is a sync that failed closed with one fixed Reason. +type Failure struct{ Reason string } + +func (f *Failure) Error() string { return f.Reason } + +func fail(reason string) error { return &Failure{Reason: reason} } + +// ReasonOf is the fixed reason of err; an error that is not a Failure never +// shows its own text. +func ReasonOf(err error) string { + var f *Failure + if errors.As(err, &f) { + return f.Reason + } + return ReasonFailed +} + +// Listing is the bounded open-Issue listing, oldest first. Total is GitHub's +// count of open Issues; Truncated means more exist than were read. +type Listing struct { + Issues []Issue + Total int + Truncated bool +} + +// States are the Issues and pull requests read by number. Truncated means more +// numbers were asked for than MaxStateNumbers; the rest were not read. +type States struct { + Issues map[int64]Issue + PRs map[int64]PR + Truncated bool +} + +// Fetcher reads GitHub state; tests replace it with a fake. +type Fetcher interface { + OpenIssues(ctx context.Context, repository string) (Listing, error) + States(ctx context.Context, repository string, issues, prs []int64) (States, error) +} + +// GH is the gh-backed Fetcher. Every call is one read-only `gh api graphql` with +// a timeout and a byte cap; Run is the process seam tests replace. +type GH struct { + Run func(context.Context, process.Options) process.Result + Timeout time.Duration + MaxBytes int +} + +// NewGH returns the Fetcher that runs gh. +func NewGH() *GH { return &GH{Run: process.Run, Timeout: callTimeout, MaxBytes: maxCallBytes} } + +// issueListFields reads the latest reopen as well: row 15 needs it to fall +// after the Forgecell merge. It is aliased so the closing timeline can be read +// beside it. +const issueListFields = `number state stateReason createdAt updatedAt title locked labels(first:20){nodes{name}} milestone{title} reopened:timelineItems(last:1,itemTypes:[REOPENED_EVENT]){nodes{... on ReopenedEvent{createdAt}}}` + +const issueStateFields = issueListFields + ` closedAt timelineItems(last:1,itemTypes:[CLOSED_EVENT]){nodes{... on ClosedEvent{closer{__typename ... on PullRequest{number} ... on Commit{oid}}}}}` + +const prFields = `number state isDraft mergedAt mergeCommit{oid} headRefOid headRef{name} reviewDecision commits(last:1){nodes{commit{statusCheckRollup{state}}}}` + +const listQuery = `query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(states:OPEN,first:100,after:$after,orderBy:{field:CREATED_AT,direction:ASC}){totalCount pageInfo{hasNextPage endCursor} nodes{` + issueListFields + `}}}}` + +type gqlError struct { + Type string `json:"type"` + Message string `json:"message"` + Path []any `json:"path"` +} + +type gqlResponse struct { + Data *struct { + Repository json.RawMessage `json:"repository"` + } `json:"data"` + Errors []gqlError `json:"errors"` +} + +type remoteIssue struct { + Number int64 `json:"number"` + State string `json:"state"` + StateReason *string `json:"stateReason"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ClosedAt *string `json:"closedAt"` + Title string `json:"title"` + Locked bool `json:"locked"` + Labels *struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + Milestone *struct { + Title string `json:"title"` + } `json:"milestone"` + Timeline *struct { + Nodes []struct { + Closer *struct { + Type string `json:"__typename"` + Number int64 `json:"number"` + OID string `json:"oid"` + } `json:"closer"` + } `json:"nodes"` + } `json:"timelineItems"` + Reopened *struct { + Nodes []struct { + CreatedAt *string `json:"createdAt"` + } `json:"nodes"` + } `json:"reopened"` +} + +type remotePR struct { + Number int64 `json:"number"` + State string `json:"state"` + IsDraft bool `json:"isDraft"` + MergedAt *string `json:"mergedAt"` + MergeCommit *struct { + OID string `json:"oid"` + } `json:"mergeCommit"` + HeadRefOid string `json:"headRefOid"` + HeadRef *struct { + Name string `json:"name"` + } `json:"headRef"` + ReviewDecision *string `json:"reviewDecision"` + Commits *struct { + Nodes []struct { + Commit struct { + Rollup *struct { + State string `json:"state"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` +} + +// bind validates the repository; its parts are passed to gh only as argv. +func bind(repository string) (owner, name string, err error) { + ref, e := intake.BindRepository(intake.IssueRef{Number: 1}, repository) + if e != nil { + return "", "", fail(ReasonRepository) + } + return ref.Owner, ref.Repo, nil +} + +// OpenIssues reads open Issues oldest first, at most MaxListed of them. +func (g *GH) OpenIssues(ctx context.Context, repository string) (Listing, error) { + owner, name, err := bind(repository) + if err != nil { + return Listing{}, err + } + base := "https://github.com/" + owner + "/" + name + var out Listing + seen := map[int64]bool{} + after := "" + for page := 0; page < MaxListed/listPageSize; page++ { + vars := map[string]string{"owner": owner, "name": name} + if after != "" { + vars["after"] = after + } + resp, err := g.query(ctx, listQuery, vars) + if err != nil { + return Listing{}, err + } + var repo *struct { + Issues *struct { + TotalCount int `json:"totalCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []json.RawMessage `json:"nodes"` + } `json:"issues"` + } + if json.Unmarshal(resp.Data.Repository, &repo) != nil || repo == nil || repo.Issues == nil || len(repo.Issues.Nodes) > listPageSize || repo.Issues.TotalCount < 0 { + return Listing{}, fail(ReasonMalformed) + } + out.Total = repo.Issues.TotalCount + for _, raw := range repo.Issues.Nodes { + var ri remoteIssue + if json.Unmarshal(raw, &ri) != nil { + return Listing{}, fail(ReasonMalformed) + } + issue, ok := issueFrom(base, ri) + if !ok || issue.State != IssueOpen { + return Listing{}, fail(ReasonMalformed) + } + // An Issue that moves between pages while they are read appears once. + if seen[issue.Number] { + continue + } + seen[issue.Number] = true + out.Issues = append(out.Issues, issue) + } + info := repo.Issues.PageInfo + if !info.HasNextPage { + out.Truncated = out.Total > len(out.Issues) + return out, nil + } + if info.EndCursor == "" || len(info.EndCursor) > maxCursorBytes || !cursorPattern.MatchString(info.EndCursor) || info.EndCursor == after { + return Listing{}, fail(ReasonMalformed) + } + after = info.EndCursor + } + out.Truncated = true + return out, nil +} + +var cursorPattern = regexp.MustCompile(`^[A-Za-z0-9+/=_:-]+$`) + +type ref struct { + pr bool + number int64 +} + +func (r ref) alias() string { + if r.pr { + return fmt.Sprintf("p%d", r.number) + } + return fmt.Sprintf("i%d", r.number) +} + +// States reads the given Issues and pull requests by number in queries of at +// most MaxBatch numbers. Invalid numbers are dropped; past MaxStateNumbers the +// newest numbers are read and Truncated is set. +func (g *GH) States(ctx context.Context, repository string, issues, prs []int64) (States, error) { + owner, name, err := bind(repository) + if err != nil { + return States{}, err + } + base := "https://github.com/" + owner + "/" + name + refs, truncated := wanted(issues, prs) + out := States{Issues: map[int64]Issue{}, PRs: map[int64]PR{}, Truncated: truncated} + for start := 0; start < len(refs); start += MaxBatch { + batch := refs[start:min(start+MaxBatch, len(refs))] + var q strings.Builder + q.WriteString(`query($owner:String!,$name:String!){repository(owner:$owner,name:$name){`) + for _, r := range batch { + // Numbers are validated integers; nothing else is written into the query. + if r.pr { + fmt.Fprintf(&q, "%s:pullRequest(number:%d){%s} ", r.alias(), r.number, prFields) + } else { + fmt.Fprintf(&q, "%s:issue(number:%d){%s} ", r.alias(), r.number, issueStateFields) + } + } + q.WriteString(`}}`) + resp, err := g.query(ctx, q.String(), map[string]string{"owner": owner, "name": name}) + if err != nil { + return States{}, err + } + var nodes map[string]json.RawMessage + if json.Unmarshal(resp.Data.Repository, &nodes) != nil || nodes == nil { + return States{}, fail(ReasonMalformed) + } + missing := missingReasons(resp.Errors) + for _, r := range batch { + raw, ok := nodes[r.alias()] + if !ok { + return States{}, fail(ReasonMalformed) + } + null := string(raw) == "null" + switch { + case r.pr && null: + out.PRs[r.number] = PR{Number: r.number} + case r.pr: + var rp remotePR + if json.Unmarshal(raw, &rp) != nil { + return States{}, fail(ReasonMalformed) + } + p, ok := prFrom(base, rp) + if !ok || p.Number != r.number { + return States{}, fail(ReasonMalformed) + } + out.PRs[r.number] = p + case null: + reason := missing[r.alias()] + if reason == "" { + reason = UnavailableNotFound + } + out.Issues[r.number] = Issue{Number: r.number, Labels: []string{}, Unavailable: reason} + default: + var ri remoteIssue + if json.Unmarshal(raw, &ri) != nil { + return States{}, fail(ReasonMalformed) + } + issue, ok := issueFrom(base, ri) + if !ok || issue.Number != r.number { + return States{}, fail(ReasonMalformed) + } + out.Issues[r.number] = issue + } + } + } + return out, nil +} + +// wanted is the deduplicated, valid numbers to read, newest first, capped at +// MaxStateNumbers. +func wanted(issues, prs []int64) ([]ref, bool) { + var refs []ref + seen := map[ref]bool{} + add := func(pr bool, ns []int64) { + for _, n := range ns { + r := ref{pr, n} + if n < 1 || n > maxSafeInteger || seen[r] { + continue + } + seen[r] = true + refs = append(refs, r) + } + } + add(false, issues) + add(true, prs) + sort.Slice(refs, func(i, j int) bool { + if refs[i].number != refs[j].number { + return refs[i].number > refs[j].number + } + return !refs[i].pr && refs[j].pr + }) + if len(refs) > MaxStateNumbers { + return refs[:MaxStateNumbers], true + } + return refs, false +} + +// missingReasons maps the aliases GitHub reports as not found or forbidden to a +// plain reason. +func missingReasons(errs []gqlError) map[string]string { + out := map[string]string{} + for _, e := range errs { + if len(e.Path) < 2 { + continue + } + alias, ok := e.Path[1].(string) + if !ok { + continue + } + switch e.Type { + case "NOT_FOUND": + out[alias] = UnavailableNotFound + case "FORBIDDEN": + out[alias] = UnavailableNoAccess + } + } + return out +} + +// query runs one bounded read-only GraphQL query. GitHub reports a missing Issue +// or pull request as a null node with a NOT_FOUND or FORBIDDEN error, and gh +// then exits non-zero; such an answer is kept when every error is of those +// kinds. Any other failure maps to one fixed reason. +func (g *GH) query(ctx context.Context, q string, vars map[string]string) (gqlResponse, error) { + argv := []string{"gh", "api", "graphql", "--hostname", "github.com", "-f", "query=" + q} + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + argv = append(argv, "-f", k+"="+vars[k]) + } + timeout, limit := g.Timeout, g.MaxBytes + if timeout <= 0 { + timeout = callTimeout + } + if limit <= 0 { + limit = maxCallBytes + } + res := g.Run(ctx, process.Options{Argv: argv, Stdin: []byte{}, Timeout: timeout, MaxOutputBytes: limit}) + switch { + case res.Overflow: + return gqlResponse{}, fail(ReasonOversized) + case res.TimedOut || res.ParentDeadline || res.Interrupted: + return gqlResponse{}, fail(ReasonTimeout) + } + var resp gqlResponse + parsed := json.Unmarshal([]byte(res.Stdout), &resp) == nil && resp.Data != nil + if parsed && len(resp.Data.Repository) > 0 && string(resp.Data.Repository) != "null" && onlyMissing(resp.Errors) { + return resp, nil + } + if parsed || res.OK { + for _, e := range resp.Errors { + if e.Type == "RATE_LIMITED" { + return gqlResponse{}, fail(ReasonRateLimited) + } + } + if parsed && (len(resp.Data.Repository) == 0 || string(resp.Data.Repository) == "null") { + return gqlResponse{}, fail(ReasonWrongHost) + } + if res.OK { + return gqlResponse{}, fail(ReasonMalformed) + } + } + return gqlResponse{}, fail(classify(res)) +} + +func onlyMissing(errs []gqlError) bool { + for _, e := range errs { + if e.Type != "NOT_FOUND" && e.Type != "FORBIDDEN" || len(e.Path) < 2 { + return false + } + } + return true +} + +// classify maps a failed gh run to one fixed reason from its stderr and error +// text, which are read here and never returned. +func classify(res process.Result) string { + text := strings.ToLower(res.Stderr + "\n" + res.Error) + has := func(words ...string) bool { + for _, w := range words { + if strings.Contains(text, w) { + return true + } + } + return false + } + switch { + case res.Code == -1 && has("executable file not found", "no such file or directory"): + return ReasonGHMissing + case has("rate limit", "rate_limited", "abuse detection"): + return ReasonRateLimited + case has("gh auth login", "not logged in", "authentication required", "requires authentication", "bad credentials", "http 401", "auth status"): + return ReasonNotAuthenticated + case has("could not resolve host", "no such host", "dial tcp", "connection refused", "network is unreachable", "i/o timeout", "tls handshake", "connection reset", "timeout awaiting"): + return ReasonOffline + case has("could not resolve to a repository", "http 404", "not a known github host", "none of the git remotes", "unknown host", "enterprise"): + return ReasonWrongHost + } + return ReasonFailed +} + +var commitPattern = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + +func validNumber(n int64) bool { return n >= 1 && n <= maxSafeInteger } + +func commit(s string) string { + if commitPattern.MatchString(s) { + return s + } + return "" +} + +// stamp is a valid RFC 3339 time in UTC, or "". +func stamp(s *string) string { + if s == nil { + return "" + } + t, err := time.Parse(time.RFC3339, *s) + if err != nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +func str(s *string) string { + if s == nil { + return "" + } + return *s +} + +// text bounds untrusted display text: valid UTF-8, no control characters, no +// format characters (unicode.Cf, such as U+202E bidi overrides or zero-width +// joiners, which can disguise text), at most limit runes. +func text(s string, limit int) string { + s = strings.ToValidUTF8(s, "�") + var b strings.Builder + n := 0 + for _, r := range s { + if unicode.Is(unicode.Cf, r) { + continue + } + if unicode.IsControl(r) { + r = ' ' + } + if n == limit { + b.WriteString("…") + break + } + b.WriteRune(r) + n++ + } + return strings.TrimSpace(b.String()) +} + +func oneOf(s string, values ...string) string { + for _, v := range values { + if s == v { + return s + } + } + return "" +} + +// issueFrom validates one remote Issue. Its URLs are built from the validated +// number and the repository's fixed base, never taken from GitHub. +func issueFrom(base string, ri remoteIssue) (Issue, bool) { + state := oneOf(ri.State, IssueOpen, IssueClosed) + if !validNumber(ri.Number) || state == "" { + return Issue{}, false + } + issue := Issue{Number: ri.Number, Found: true, State: state, StateReason: oneOf(str(ri.StateReason), ReasonCompleted, ReasonNotPlanned, ReasonReopened), Title: text(ri.Title, maxTitleRunes), Labels: []string{}, CreatedAt: stamp(&ri.CreatedAt), UpdatedAt: stamp(&ri.UpdatedAt), Locked: ri.Locked, URL: fmt.Sprintf("%s/issues/%d", base, ri.Number)} + if ri.Labels != nil { + for _, l := range ri.Labels.Nodes { + if len(issue.Labels) == maxLabels { + break + } + if name := text(l.Name, maxLabelRunes); name != "" { + issue.Labels = append(issue.Labels, name) + } + } + } + if ri.Milestone != nil { + issue.Milestone = text(ri.Milestone.Title, maxMilestone) + } + if ri.Reopened != nil && len(ri.Reopened.Nodes) > 0 { + issue.ReopenedAt = stamp(ri.Reopened.Nodes[len(ri.Reopened.Nodes)-1].CreatedAt) + } + if state == IssueClosed { + issue.ClosedAt = stamp(ri.ClosedAt) + if ri.Timeline != nil && len(ri.Timeline.Nodes) > 0 { + if c := ri.Timeline.Nodes[len(ri.Timeline.Nodes)-1].Closer; c != nil { + switch { + case c.Type == "PullRequest" && validNumber(c.Number): + issue.ClosedBy = &Link{Kind: "pull", Number: c.Number, URL: fmt.Sprintf("%s/pull/%d", base, c.Number)} + case c.Type == "Commit" && commit(c.OID) != "": + issue.ClosedBy = &Link{Kind: "commit", Commit: c.OID, URL: base + "/commit/" + c.OID} + } + } + } + } + return issue, true +} + +func prFrom(base string, rp remotePR) (PR, bool) { + state := oneOf(rp.State, PROpen, PRClosed, PRMerged) + if !validNumber(rp.Number) || state == "" { + return PR{}, false + } + p := PR{Number: rp.Number, Found: true, State: state, IsDraft: rp.IsDraft, HeadRefOid: commit(rp.HeadRefOid), HeadDeleted: rp.HeadRef == nil, ReviewDecision: oneOf(str(rp.ReviewDecision), "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"), URL: fmt.Sprintf("%s/pull/%d", base, rp.Number)} + if state == PRMerged { + p.MergedAt = stamp(rp.MergedAt) + if rp.MergeCommit != nil { + p.MergeCommit = commit(rp.MergeCommit.OID) + } + } + if rp.Commits != nil && len(rp.Commits.Nodes) > 0 { + if r := rp.Commits.Nodes[len(rp.Commits.Nodes)-1].Commit.Rollup; r != nil { + p.Checks = oneOf(r.State, "SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED") + } + } + return p, true +} diff --git a/lab/internal/forgesync/fetch_test.go b/lab/internal/forgesync/fetch_test.go new file mode 100644 index 0000000..eeed0cd --- /dev/null +++ b/lab/internal/forgesync/fetch_test.go @@ -0,0 +1,362 @@ +package forgesync + +import ( + "context" + "encoding/json" + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/process" + "regexp" + "strings" + "sync" + "testing" + "unicode" +) + +// fakeGH answers gh api graphql calls without running any process. +type fakeGH struct { + mu sync.Mutex + calls []process.Options + reply func(query string, vars map[string]string) process.Result +} + +func (f *fakeGH) run(_ context.Context, o process.Options) process.Result { + f.mu.Lock() + f.calls = append(f.calls, o) + f.mu.Unlock() + vars := map[string]string{} + query := "" + for i := 0; i+1 < len(o.Argv); i++ { + if o.Argv[i] != "-f" { + continue + } + k, v, _ := strings.Cut(o.Argv[i+1], "=") + if k == "query" { + query = v + } else { + vars[k] = v + } + } + return f.reply(query, vars) +} + +func (f *fakeGH) gh() *GH { return &GH{Run: f.run} } + +func ok(v any) process.Result { + raw, _ := json.Marshal(v) + return process.Result{OK: true, Code: 0, Reconciled: true, Stdout: string(raw)} +} + +func issueNode(n int64, state string) map[string]any { + return map[string]any{"number": n, "state": state, "stateReason": nil, "createdAt": "2026-10-01T00:00:00Z", "updatedAt": "2026-10-02T00:00:00Z", "title": fmt.Sprintf("Issue %d", n), "locked": false, "labels": map[string]any{"nodes": []any{map[string]any{"name": "bug"}}}, "milestone": nil} +} + +var aliasPattern = regexp.MustCompile(`([ip])([0-9]+):(issue|pullRequest)\(number:([0-9]+)\)`) + +// statesReply answers every alias in the query; missing names are returned as +// null nodes with a NOT_FOUND error and a non-zero exit, as gh does. +func statesReply(missing map[string]bool) func(string, map[string]string) process.Result { + return func(query string, _ map[string]string) process.Result { + repo := map[string]any{} + var errs []any + for _, m := range aliasPattern.FindAllStringSubmatch(query, -1) { + alias := m[1] + m[2] + var n int64 + fmt.Sscan(m[4], &n) + switch { + case missing[alias]: + repo[alias] = nil + errs = append(errs, map[string]any{"type": "NOT_FOUND", "message": "Could not resolve", "path": []any{"repository", alias}}) + case m[1] == "p": + repo[alias] = map[string]any{"number": n, "state": "MERGED", "isDraft": false, "mergedAt": "2026-10-18T10:00:00Z", "mergeCommit": map[string]any{"oid": strings.Repeat("a", 40)}, "headRefOid": strings.Repeat("b", 40), "headRef": nil, "reviewDecision": "APPROVED", "commits": map[string]any{"nodes": []any{map[string]any{"commit": map[string]any{"statusCheckRollup": map[string]any{"state": "SUCCESS"}}}}}} + default: + node := issueNode(n, "CLOSED") + node["stateReason"] = "COMPLETED" + node["closedAt"] = "2026-10-18T11:00:00Z" + node["timelineItems"] = map[string]any{"nodes": []any{map[string]any{"closer": map[string]any{"__typename": "PullRequest", "number": 5}}}} + repo[alias] = node + } + } + res := ok(map[string]any{"data": map[string]any{"repository": repo}, "errors": errs}) + if len(errs) > 0 { + res.OK, res.Code, res.Stderr = false, 1, "gh: Could not resolve to an Issue" + } + return res + } +} + +func TestStatesBatchesAtMostFiftyNumbersPerQuery(t *testing.T) { + f := &fakeGH{reply: statesReply(map[string]bool{"i3": true})} + var issues, prs []int64 + for n := int64(1); n <= 120; n++ { + issues = append(issues, n) + } + for n := int64(200); n < 230; n++ { + prs = append(prs, n) + } + // Duplicates and invalid numbers are dropped. + issues = append(issues, 1, 0, -4) + got, err := f.gh().States(context.Background(), "o/r", issues, prs) + if err != nil { + t.Fatal(err) + } + if len(f.calls) != 3 { + t.Fatalf("%d calls for 150 numbers, want 3", len(f.calls)) + } + for _, c := range f.calls { + q := c.Argv[slicesIndex(c.Argv, "-f")+1] + if n := len(aliasPattern.FindAllString(q, -1)); n > MaxBatch { + t.Fatalf("a query carries %d numbers", n) + } + if c.Argv[0] != "gh" || c.Argv[1] != "api" || c.Argv[2] != "graphql" || c.Argv[4] != "github.com" || c.Timeout <= 0 || c.MaxOutputBytes <= 0 { + t.Fatalf("call %v timeout %v cap %d", c.Argv[:5], c.Timeout, c.MaxOutputBytes) + } + } + if len(got.Issues) != 120 || len(got.PRs) != 30 || got.Truncated { + t.Fatalf("issues %d prs %d truncated %v", len(got.Issues), len(got.PRs), got.Truncated) + } + if i := got.Issues[3]; i.Found || i.Unavailable != UnavailableNotFound { + t.Fatalf("missing Issue %+v", i) + } + i := got.Issues[7] + if !i.Found || i.State != IssueClosed || i.StateReason != ReasonCompleted || i.ClosedBy == nil || i.ClosedBy.URL != "https://github.com/o/r/pull/5" || i.URL != "https://github.com/o/r/issues/7" || i.ClosedAt != "2026-10-18T11:00:00Z" { + t.Fatalf("closed Issue %+v", i) + } + p := got.PRs[201] + if !p.Found || p.State != PRMerged || p.MergeCommit != strings.Repeat("a", 40) || !p.HeadDeleted || p.Checks != "SUCCESS" || p.ReviewDecision != "APPROVED" || p.URL != "https://github.com/o/r/pull/201" { + t.Fatalf("PR %+v", p) + } +} + +func slicesIndex(s []string, v string) int { + for i, x := range s { + if x == v { + return i + } + } + return -1 +} + +func TestStatesCapsTheNumbersRead(t *testing.T) { + f := &fakeGH{reply: statesReply(nil)} + var issues []int64 + for n := int64(1); n <= MaxStateNumbers+20; n++ { + issues = append(issues, n) + } + got, err := f.gh().States(context.Background(), "o/r", issues, nil) + if err != nil { + t.Fatal(err) + } + if !got.Truncated || len(got.Issues) != MaxStateNumbers || len(f.calls) != MaxStateNumbers/MaxBatch { + t.Fatalf("truncated %v issues %d calls %d", got.Truncated, len(got.Issues), len(f.calls)) + } + // The newest numbers are the ones read. + if _, ok := got.Issues[MaxStateNumbers+20]; !ok { + t.Fatal("the newest Issue was not read") + } +} + +func listReply(total int, pageSize func(page int) int) func(string, map[string]string) process.Result { + return func(_ string, vars map[string]string) process.Result { + page := 0 + if after := vars["after"]; after != "" { + fmt.Sscanf(after, "cursor%d", &page) + } + size := pageSize(page) + nodes := []any{} + for i := 0; i < size; i++ { + nodes = append(nodes, issueNode(int64(page*100+i+1), "OPEN")) + } + more := page*100+size < total + return ok(map[string]any{"data": map[string]any{"repository": map[string]any{"issues": map[string]any{"totalCount": total, "pageInfo": map[string]any{"hasNextPage": more, "endCursor": fmt.Sprintf("cursor%d", page+1)}, "nodes": nodes}}}}) + } +} + +func TestOpenIssuesPaginatesAndTruncatesAtFiveHundred(t *testing.T) { + f := &fakeGH{reply: listReply(742, func(int) int { return 100 })} + got, err := f.gh().OpenIssues(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + if len(f.calls) != MaxListed/listPageSize || len(got.Issues) != MaxListed || got.Total != 742 || !got.Truncated { + t.Fatalf("calls %d issues %d total %d truncated %v", len(f.calls), len(got.Issues), got.Total, got.Truncated) + } + if got.Issues[0].Number != 1 || got.Issues[499].Number != 500 || got.Issues[0].Labels[0] != "bug" { + t.Fatalf("order %+v", got.Issues[0]) + } + // The first page carries no cursor; later ones carry the previous end cursor. + if v := f.calls[0].Argv; strings.Contains(strings.Join(v, " "), "after=") { + t.Fatalf("first page with a cursor: %v", v[5:]) + } + f = &fakeGH{reply: listReply(130, func(page int) int { return []int{100, 30}[page] })} + got, err = f.gh().OpenIssues(context.Background(), "o/r") + if err != nil || len(f.calls) != 2 || len(got.Issues) != 130 || got.Truncated { + t.Fatalf("short listing: %v calls %d issues %d truncated %v", err, len(f.calls), len(got.Issues), got.Truncated) + } +} + +func TestOpenIssuesRefusesMalformedPages(t *testing.T) { + for name, reply := range map[string]func(string, map[string]string) process.Result{ + "not json": func(string, map[string]string) process.Result { return process.Result{OK: true, Stdout: ""} }, + "closed in open": func(string, map[string]string) process.Result { + return ok(listPage([]any{issueNode(1, "CLOSED")}, false, "")) + }, + "unknown state": func(string, map[string]string) process.Result { + return ok(listPage([]any{issueNode(1, "WEIRD")}, false, "")) + }, + "bad number": func(string, map[string]string) process.Result { + return ok(listPage([]any{issueNode(0, "OPEN")}, false, "")) + }, + "bad cursor": func(string, map[string]string) process.Result { return ok(listPage([]any{}, true, "$(rm -rf /)")) }, + "repeated cursor": func(_ string, v map[string]string) process.Result { return ok(listPage([]any{}, true, "same")) }, + } { + f := &fakeGH{reply: reply} + if _, err := f.gh().OpenIssues(context.Background(), "o/r"); ReasonOf(err) != ReasonMalformed { + t.Errorf("%s: %v", name, err) + } + } +} + +func listPage(nodes []any, more bool, cursor string) map[string]any { + return map[string]any{"data": map[string]any{"repository": map[string]any{"issues": map[string]any{"totalCount": len(nodes), "pageInfo": map[string]any{"hasNextPage": more, "endCursor": cursor}, "nodes": nodes}}}} +} + +// Untrusted Issue text is bounded and stripped of control characters; links are +// built from the validated number, never taken from GitHub. +func TestOpenIssuesBoundsUntrustedText(t *testing.T) { + node := issueNode(9, "OPEN") + node["title"] = "evil\x1b[31m\n" + strings.Repeat("x", 1000) + node["url"] = "javascript:alert(1)" + labels := []any{} + for i := 0; i < 40; i++ { + labels = append(labels, map[string]any{"name": strings.Repeat("l", 500)}) + } + node["labels"] = map[string]any{"nodes": labels} + node["milestone"] = map[string]any{"title": strings.Repeat("m", 1000)} + node["updatedAt"] = "yesterday" + f := &fakeGH{reply: func(string, map[string]string) process.Result { return ok(listPage([]any{node}, false, "")) }} + got, err := f.gh().OpenIssues(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + i := got.Issues[0] + if strings.ContainsAny(i.Title, "\x1b\n") || len([]rune(i.Title)) > maxTitleRunes+1 || len(i.Labels) != maxLabels || len([]rune(i.Labels[0])) > maxLabelRunes+1 || len([]rune(i.Milestone)) > maxMilestone+1 { + t.Fatalf("unbounded: %q %d labels", i.Title, len(i.Labels)) + } + if i.URL != "https://github.com/o/r/issues/9" || i.UpdatedAt != "" { + t.Fatalf("url %q updated %q", i.URL, i.UpdatedAt) + } +} + +// Unicode format characters (unicode.Cf), such as bidi overrides and zero-width +// joiners, are stripped from titles, labels and milestones. +func TestOpenIssuesStripsFormatCharacters(t *testing.T) { + rlo, pdf, zwsp, lri, pdi, bom, zwj, rli := string(rune(0x202e)), string(rune(0x202c)), string(rune(0x200b)), string(rune(0x2066)), string(rune(0x2069)), string(rune(0xfeff)), string(rune(0x200d)), string(rune(0x2067)) + node := issueNode(9, "OPEN") + node["title"] = "fix " + rlo + "gnp.exe" + pdf + " login" + zwsp + lri + "x" + pdi + bom + node["labels"] = map[string]any{"nodes": []any{map[string]any{"name": rlo + "bug"}, map[string]any{"name": zwj}}} + node["milestone"] = map[string]any{"title": "v1" + rli} + f := &fakeGH{reply: func(string, map[string]string) process.Result { return ok(listPage([]any{node}, false, "")) }} + got, err := f.gh().OpenIssues(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + i := got.Issues[0] + if i.Title != "fix gnp.exe loginx" || len(i.Labels) != 1 || i.Labels[0] != "bug" || i.Milestone != "v1" { + t.Fatalf("format characters kept: %q %q %q", i.Title, i.Labels, i.Milestone) + } + for _, s := range append([]string{i.Title, i.Milestone}, i.Labels...) { + for _, r := range s { + if unicode.Is(unicode.Cf, r) { + t.Fatalf("%q keeps %U", s, r) + } + } + } +} + +// The latest reopen is read with every Issue, for row 15. +func TestIssuesReadTheLatestReopen(t *testing.T) { + node := issueNode(9, "OPEN") + node["stateReason"] = "REOPENED" + node["reopened"] = map[string]any{"nodes": []any{map[string]any{"createdAt": "2026-10-20T10:00:00Z"}}} + bad := issueNode(10, "OPEN") + bad["reopened"] = map[string]any{"nodes": []any{map[string]any{"createdAt": "soon"}}} + f := &fakeGH{reply: func(string, map[string]string) process.Result { return ok(listPage([]any{node, bad}, false, "")) }} + got, err := f.gh().OpenIssues(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + if got.Issues[0].ReopenedAt != "2026-10-20T10:00:00Z" || got.Issues[1].ReopenedAt != "" { + t.Fatalf("reopened %q %q", got.Issues[0].ReopenedAt, got.Issues[1].ReopenedAt) + } + if q := f.calls[0].Argv[slicesIndex(f.calls[0].Argv, "-f")+1]; !strings.Contains(q, "reopened:timelineItems(last:1,itemTypes:[REOPENED_EVENT])") { + t.Fatalf("query lacks the reopen: %s", q) + } +} + +func TestFetcherRefusesAnInvalidRepositoryWithoutCallingGH(t *testing.T) { + f := &fakeGH{reply: func(string, map[string]string) process.Result { t.Fatal("gh was called"); return process.Result{} }} + for _, repo := range []string{"", "o", "../x", "o/r; rm -rf /", "-f/x y"} { + if _, err := f.gh().OpenIssues(context.Background(), repo); ReasonOf(err) != ReasonRepository { + t.Errorf("%q: %v", repo, err) + } + if _, err := f.gh().States(context.Background(), repo, []int64{1}, nil); ReasonOf(err) != ReasonRepository { + t.Errorf("%q: %v", repo, err) + } + } +} + +// Every failure kind maps to its one fixed reason, and no reason carries gh's +// own output. +func TestFetcherFailuresMapToFixedReasons(t *testing.T) { + const secret = "/Users/someone/.config/gh/hosts.yml ghp_SECRETTOKEN" + cases := []struct { + name string + result process.Result + reason string + }{ + {"gh missing", process.Result{Code: -1, Error: `exec: "gh": executable file not found in $PATH ` + secret}, ReasonGHMissing}, + {"not authenticated", process.Result{Code: 4, Stderr: "To get started with GitHub CLI, please run: gh auth login " + secret}, ReasonNotAuthenticated}, + {"bad credentials", process.Result{Code: 1, Stderr: "HTTP 401: Bad credentials " + secret}, ReasonNotAuthenticated}, + {"rate limited", process.Result{Code: 1, Stderr: "HTTP 403: API rate limit exceeded for user " + secret}, ReasonRateLimited}, + {"rate limited graphql", ok(map[string]any{"data": nil, "errors": []any{map[string]any{"type": "RATE_LIMITED", "message": secret}}}), ReasonRateLimited}, + {"offline", process.Result{Code: 1, Stderr: "error connecting to api.github.com: dial tcp: lookup api.github.com: no such host " + secret}, ReasonOffline}, + {"wrong host", process.Result{Code: 1, Stderr: "none of the git remotes configured for this repository point to a known GitHub host " + secret}, ReasonWrongHost}, + {"repository not found", func() process.Result { + r := ok(map[string]any{"data": map[string]any{"repository": nil}, "errors": []any{map[string]any{"type": "NOT_FOUND", "path": []any{"repository"}, "message": secret}}}) + r.OK, r.Code = false, 1 + return r + }(), ReasonWrongHost}, + {"timeout", process.Result{TimedOut: true, Error: "context deadline exceeded " + secret}, ReasonTimeout}, + {"parent deadline", process.Result{ParentDeadline: true}, ReasonTimeout}, + {"byte cap", process.Result{Overflow: true, Stdout: secret}, ReasonOversized}, + {"malformed", process.Result{OK: true, Stdout: "not json " + secret}, ReasonMalformed}, + {"other", process.Result{Code: 1, Stderr: "something odd " + secret}, ReasonFailed}, + } + for _, c := range cases { + f := &fakeGH{reply: func(string, map[string]string) process.Result { return c.result }} + _, err := f.gh().OpenIssues(context.Background(), "o/r") + if ReasonOf(err) != c.reason || err.Error() != c.reason { + t.Errorf("%s: %v, want %q", c.name, err, c.reason) + } + _, err = f.gh().States(context.Background(), "o/r", []int64{1}, []int64{2}) + if ReasonOf(err) != c.reason || strings.Contains(err.Error(), "SECRET") || strings.Contains(err.Error(), "/Users/") { + t.Errorf("%s states: %v", c.name, err) + } + } + if ReasonOf(fmt.Errorf("raw %s", secret)) != ReasonFailed { + t.Fatal("a foreign error leaks its text") + } +} + +// The real process runner enforces the byte cap: a gh stand-in that prints +// more than the cap fails closed as oversized. +func TestFetcherByteCapWithTheRealRunner(t *testing.T) { + g := &GH{Run: func(ctx context.Context, o process.Options) process.Result { + o.Argv = []string{"sh", "-c", "head -c 4096 /dev/zero"} + o.MaxOutputBytes = 64 + return process.Run(ctx, o) + }} + if _, err := g.OpenIssues(context.Background(), "o/r"); ReasonOf(err) != ReasonOversized { + t.Fatalf("byte cap: %v", err) + } +} diff --git a/lab/internal/forgesync/state.go b/lab/internal/forgesync/state.go new file mode 100644 index 0000000..1e9fca9 --- /dev/null +++ b/lab/internal/forgesync/state.go @@ -0,0 +1,462 @@ +// Package forgesync reads GitHub Issue and pull request state back, read-only, +// and maps it with a Molecule's ledger lane to the one state every UI surface +// shows (#242). It never writes to GitHub or to the ledger: the sync is a +// projection, and SyncState is a pure function of what was read. +package forgesync + +import ( + "sort" + "strconv" + "time" +) + +// Issue states, state reasons and pull request states as GitHub's GraphQL API +// reports them. Any other value read is dropped by the fetcher. +const ( + IssueOpen = "OPEN" + IssueClosed = "CLOSED" + + ReasonCompleted = "COMPLETED" + ReasonNotPlanned = "NOT_PLANNED" + ReasonReopened = "REOPENED" + + PROpen = "OPEN" + PRClosed = "CLOSED" + PRMerged = "MERGED" +) + +// Molecule lanes, the same values as the UI's ledger lanes. +const ( + LaneRunning = "running" + LaneDecision = "decision" + LaneBlocked = "blocked" + LaneFinished = "finished" + LaneOther = "other" + LaneRetired = "retired" +) + +// Buckets are the lanes and counts a DisplayState is shown under. A ledger lane +// (running, decision, blocked, finished, other) is kept as is when GitHub state +// is unavailable or not checked. +const ( + BucketIssues = "issues" + BucketRunning = LaneRunning + BucketDecision = LaneDecision + BucketBlocked = LaneBlocked + BucketDraftPR = "draft-pr" + BucketMerged = "merged" + BucketHistory = "history" + BucketRetired = LaneRetired +) + +// Kinds are the stable machine names of the matrix rows. +const ( + KindNotStarted = "not-started" + KindInProgress = "in-progress" + KindNeedsYou = "needs-you" + KindBlocked = "blocked" + KindNotStartedRetired = "not-started-retired" + KindDraftPR = "draft-pr" + KindInReview = "pr-in-review" + KindMergedIssueOpen = "merged-issue-open" + KindPRClosed = "pr-closed" + KindMerged = "merged" + KindClosedElsewhere = "issue-closed-elsewhere" + KindClosedNotPlanned = "issue-closed-not-planned" + KindClosedInProgress = "issue-closed-in-progress" + KindReopenedAfterMerge = "reopened-after-merge" + KindUnavailable = "issue-unavailable" + KindRecorded = "recorded" + KindNotChecked = "not-checked" + KindRetired = "retired" +) + +// Notes are fixed texts; none carries GitHub or ledger content. +const ( + NoteHeadMoved = "changed on GitHub after Forgecell published" + NoteLocked = "locked" + NoteNotChecked = "not checked" + NoteRetireOrKeep = "Issue was closed. Retire or keep?" + NoteCloseOnGitHub = "the pull request merged; close the Issue on GitHub if the work is done" + NoteHeadDeleted = "head branch deleted" + NotePRNotFound = "the published pull request was not found on GitHub" + NotePRNotChecked = "the published pull request was not checked" + NoteOutsideMerge = "closed by a pull request Forgecell did not publish" + NoteMergedInHistory = "a retired attempt's pull request merged" + NoteIssueClosed = "Issue is closed" + NoteStale = "couldn't check GitHub just now; nothing is startable until GitHub state is current" +) + +// Link is a closing pull request or commit. URL is built only from a validated +// number or commit id joined to the repository's fixed GitHub base. +type Link struct { + Kind string `json:"kind"` // "pull" or "commit" + Number int64 `json:"number,omitempty"` + Commit string `json:"commit,omitempty"` + URL string `json:"url"` +} + +// Issue is one Issue as read from GitHub. Found is false when GitHub reports it +// not found, transferred or not readable; Unavailable then names why, in a +// fixed plain text. +type Issue struct { + Number int64 `json:"number"` + Found bool `json:"found"` + Unavailable string `json:"unavailable,omitempty"` + State string `json:"state,omitempty"` + StateReason string `json:"stateReason,omitempty"` + ClosedAt string `json:"closedAt,omitempty"` + ClosedBy *Link `json:"closedBy,omitempty"` + Title string `json:"title,omitempty"` + Labels []string `json:"labels"` + Milestone string `json:"milestone,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Locked bool `json:"locked,omitempty"` + // ReopenedAt is the time of the Issue's latest reopen, or "" when it was + // never reopened or the time was not read. + ReopenedAt string `json:"reopenedAt,omitempty"` + URL string `json:"url,omitempty"` +} + +// PR is one pull request as read from GitHub. +type PR struct { + Number int64 `json:"number"` + Found bool `json:"found"` + State string `json:"state,omitempty"` + IsDraft bool `json:"isDraft,omitempty"` + MergedAt string `json:"mergedAt,omitempty"` + MergeCommit string `json:"mergeCommit,omitempty"` + HeadRefOid string `json:"headRefOid,omitempty"` + HeadDeleted bool `json:"headDeleted,omitempty"` + ReviewDecision string `json:"reviewDecision,omitempty"` + // Checks is SUCCESS, FAILURE, PENDING or "" for none. + Checks string `json:"checks,omitempty"` + URL string `json:"url,omitempty"` +} + +// Molecule is one attempt for an Issue, from the ledger and its receipt. +type Molecule struct { + ID string + Lane string + StartedAt string + // Step is the running step or the waiting gate; Reason the blocked reason. + // Both are display text the caller already bounded and redacted. + Step string + Reason string + // PRNumber and PublishedCommit come from a published delivery receipt. + PRNumber int64 + PublishedCommit string +} + +// Work is every recorded attempt for one Issue and whether the loop's own +// summary (molecule.QueueSummary.Startable) allows a fresh Molecule. +type Work struct { + Molecules []Molecule + SummaryStartable bool +} + +// MergeView is an observed merge of a Forgecell pull request. +type MergeView struct { + PR int64 `json:"pr"` + Commit string `json:"commit,omitempty"` + At string `json:"at,omitempty"` + URL string `json:"url,omitempty"` +} + +// DisplayState is what every surface shows for one Issue or one Molecule. +type DisplayState struct { + Row int `json:"row"` + Kind string `json:"kind"` + Label string `json:"label"` + Bucket string `json:"bucket"` + // Checked is false when no GitHub state was read: the state is ledger only. + Checked bool `json:"checked"` + Startable bool `json:"startable"` + Locked bool `json:"locked,omitempty"` + Notes []string `json:"notes"` + // MoleculeID is the attempt that decides the state; EarlierAttempts are the + // others, newest first. + MoleculeID string `json:"moleculeId,omitempty"` + EarlierAttempts []string `json:"earlierAttempts"` + Retired int `json:"retired,omitempty"` + PR *PRView `json:"pr,omitempty"` + // ForgecellMerge is set only when a Forgecell-published pull request is + // observed merged; analytics counts merged PRs only from it. + ForgecellMerge *MergeView `json:"forgecellMerge,omitempty"` + ClosedBy *Link `json:"closedBy,omitempty"` + ClosedAt string `json:"closedAt,omitempty"` +} + +// PRView is the published pull request's live state. +type PRView struct { + Number int64 `json:"number"` + State string `json:"state"` + Draft bool `json:"draft,omitempty"` + Checks string `json:"checks"` + Review string `json:"review,omitempty"` + URL string `json:"url,omitempty"` +} + +func live(lane string) bool { + return lane == LaneRunning || lane == LaneDecision || lane == LaneBlocked +} + +func startedAt(m Molecule) (time.Time, bool) { + t, err := time.Parse(time.RFC3339Nano, m.StartedAt) + return t, err == nil +} + +// newestFirst orders attempts newest first; an unparseable start time sorts +// last, and ties break by id so the order never depends on input order. +func newestFirst(ms []Molecule) []Molecule { + out := append([]Molecule{}, ms...) + sort.SliceStable(out, func(i, j int) bool { + a, aok := startedAt(out[i]) + b, bok := startedAt(out[j]) + switch { + case aok != bok: + return aok + case aok && !a.Equal(b): + return a.After(b) + } + return out[i].ID > out[j].ID + }) + return out +} + +// SyncState is the one state of an Issue and its recorded work. issue is nil +// when no GitHub state was read; prs holds the pull requests read, by number. +// +// Precedence: a live Molecule (running, gate or blocked) beats everything except +// a closed Issue (row 14); then pull request states; then Issue states. The +// newest non-retired Molecule decides, and retired ones never do, but a retired +// Molecule whose pull request merged still counts as a Forgecell merge. +func SyncState(issue *Issue, prs map[int64]PR, work Work) DisplayState { + d := DisplayState{Notes: []string{}, EarlierAttempts: []string{}} + var decider *Molecule + for _, m := range newestFirst(work.Molecules) { + if m.Lane == LaneRetired { + d.Retired++ + if mv := forgecellMerge(m, prs); mv != nil && d.ForgecellMerge == nil { + d.ForgecellMerge = mv + } + } + if decider == nil && m.Lane != LaneRetired { + m := m + decider = &m + continue + } + d.EarlierAttempts = append(d.EarlierAttempts, m.ID) + } + if decider != nil { + d.MoleculeID = decider.ID + } + if issue == nil { + d.Kind, d.Row = KindNotChecked, 0 + d.Notes = append(d.Notes, NoteNotChecked) + if decider == nil { + d.Label, d.Bucket = "Not checked", BucketIssues + return d + } + d.Label, d.Bucket = ledgerLabel(*decider), decider.Lane + return d + } + d.Checked = true + if !issue.Found { + d.Row, d.Kind = 16, KindUnavailable + d.Label = "Issue unavailable · " + issue.Unavailable + d.Bucket = BucketIssues + if decider != nil { + d.Bucket = decider.Lane + d.Notes = append(d.Notes, ledgerLabel(*decider)) + } + if issue.Locked { + d.Locked = true + d.Notes = append(d.Notes, NoteLocked) + } + return d + } + open := issue.State == IssueOpen + if !open { + d.ClosedAt, d.ClosedBy = issue.ClosedAt, issue.ClosedBy + } + var pr *PR + if decider != nil && decider.PRNumber > 0 { + if p, ok := prs[decider.PRNumber]; ok { + pr = &p + } + } + if pr != nil && pr.Found { + d.PR = &PRView{Number: pr.Number, State: pr.State, Draft: pr.IsDraft, Checks: checksText(pr.Checks), Review: reviewText(pr.ReviewDecision), URL: pr.URL} + if mv := forgecellMerge(*decider, prs); mv != nil { + d.ForgecellMerge = mv + } + } + switch { + case decider != nil && live(decider.Lane) && !open: + d.Row, d.Kind, d.Label, d.Bucket = 14, KindClosedInProgress, "Issue closed while in progress", BucketDecision + d.Notes = append(d.Notes, NoteRetireOrKeep) + case decider != nil && live(decider.Lane): + d.Label, d.Bucket = ledgerLabel(*decider), decider.Lane + switch decider.Lane { + case LaneRunning: + d.Row, d.Kind = 2, KindInProgress + case LaneDecision: + d.Row, d.Kind = 3, KindNeedsYou + default: + d.Row, d.Kind = 4, KindBlocked + } + case pr != nil && pr.Found && pr.State == PROpen: + d.Bucket = BucketDraftPR + if pr.IsDraft { + d.Row, d.Kind, d.Label = 6, KindDraftPR, "Draft PR open" + } else { + d.Row, d.Kind, d.Label = 7, KindInReview, "PR in review" + } + if pr.HeadRefOid != "" && decider.PublishedCommit != "" && pr.HeadRefOid != decider.PublishedCommit { + d.Notes = append(d.Notes, NoteHeadMoved) + } + if !open { + d.Notes = append(d.Notes, NoteIssueClosed) + } + case !open && issue.StateReason == ReasonNotPlanned: + d.Row, d.Kind, d.Label, d.Bucket = 13, KindClosedNotPlanned, "Issue closed · not planned", BucketHistory + case !open && d.ForgecellMerge != nil: + d.Row, d.Kind, d.Label, d.Bucket = 11, KindMerged, "Merged", BucketMerged + if pr == nil || pr.State != PRMerged { + d.Notes = append(d.Notes, NoteMergedInHistory) + } + case !open: + d.Row, d.Kind, d.Label, d.Bucket = 12, KindClosedElsewhere, "Issue closed elsewhere", BucketHistory + if c := issue.ClosedBy; c != nil && c.Kind == "pull" && (pr == nil || c.Number != pr.Number) { + d.Notes = append(d.Notes, NoteOutsideMerge) + } + case pr != nil && pr.Found && pr.State == PRMerged && reopenedAfter(issue.ReopenedAt, pr.MergedAt): + d.Row, d.Kind, d.Label, d.Bucket = 15, KindReopenedAfterMerge, "Reopened after merge", BucketIssues + case pr != nil && pr.Found && pr.State == PRMerged: + d.Row, d.Kind, d.Label, d.Bucket = 9, KindMergedIssueOpen, "Merged · Issue still open", BucketMerged + d.Notes = append(d.Notes, NoteCloseOnGitHub) + case pr != nil && pr.Found && pr.State == PRClosed: + d.Row, d.Kind, d.Label, d.Bucket = 10, KindPRClosed, "PR closed without merge", BucketIssues + case decider != nil: + // Recorded work no row names, such as a Molecule that finished without a + // pull request, or whose pull request was not found: its ledger lane stays. + d.Kind, d.Label, d.Bucket = KindRecorded, ledgerLabel(*decider), decider.Lane + switch { + case decider.PRNumber > 0 && pr == nil: + d.Notes = append(d.Notes, NotePRNotChecked) + case decider.PRNumber > 0: + d.Notes = append(d.Notes, NotePRNotFound) + } + case d.Retired > 0: + d.Row, d.Kind, d.Label, d.Bucket = 5, KindNotStartedRetired, "Not started ("+strconv.Itoa(d.Retired)+" retired)", BucketIssues + default: + d.Row, d.Kind, d.Label, d.Bucket = 1, KindNotStarted, "Not started", BucketIssues + } + if pr != nil && pr.Found && pr.HeadDeleted && pr.State == PROpen { + d.Notes = append(d.Notes, NoteHeadDeleted) + } + // Startable: the Issue is open and readable, and either the loop's summary + // allows a fresh Molecule or the latest one ended in a closed or reopened + // pull request (rows 10 and 15). The loop itself keeps the summary only. + d.Startable = open && (work.SummaryStartable && (d.Row == 1 || d.Row == 5) || d.Row == 10 || d.Row == 15) + if issue.Locked { + d.Locked = true + d.Notes = append(d.Notes, NoteLocked) + } + return d +} + +// ForMolecule is the state of one Molecule: the Issue state with that Molecule +// as the only attempt, or Retired for a retired one, noting a merged pull +// request. Startable is an Issue's property and is never set here. +func ForMolecule(issue *Issue, prs map[int64]PR, m Molecule) DisplayState { + if m.Lane == LaneRetired { + d := DisplayState{Kind: KindRetired, Label: "Retired", Bucket: BucketRetired, Checked: issue != nil, Notes: []string{}, EarlierAttempts: []string{}, MoleculeID: m.ID} + if issue == nil { + d.Notes = append(d.Notes, NoteNotChecked) + } + if mv := forgecellMerge(m, prs); mv != nil { + d.ForgecellMerge, d.Label = mv, "Retired · PR merged" + } + return d + } + d := SyncState(issue, prs, Work{Molecules: []Molecule{m}}) + d.Startable = false + return d +} + +// reopenedAfter reports a reopen strictly after the merge. A reopen before the +// merge, or either time missing or unreadable, is not one: the row stays +// merged (row 9) and not startable. +func reopenedAfter(reopenedAt, mergedAt string) bool { + r, err := time.Parse(time.RFC3339, reopenedAt) + if err != nil { + return false + } + m, err := time.Parse(time.RFC3339, mergedAt) + return err == nil && r.After(m) +} + +// forgecellMerge is the merge of m's own published pull request, if observed. +func forgecellMerge(m Molecule, prs map[int64]PR) *MergeView { + if m.PRNumber <= 0 { + return nil + } + p, ok := prs[m.PRNumber] + if !ok || !p.Found || p.State != PRMerged { + return nil + } + return &MergeView{PR: p.Number, Commit: p.MergeCommit, At: p.MergedAt, URL: p.URL} +} + +// ledgerLabel is the ledger-only state of an attempt. +func ledgerLabel(m Molecule) string { + with := func(head, detail string) string { + if detail == "" { + return head + } + return head + " · " + detail + } + switch m.Lane { + case LaneRunning: + return with("In progress", m.Step) + case LaneDecision: + return with("Needs you", m.Step) + case LaneBlocked: + return with("Blocked", m.Reason) + case LaneFinished: + if m.PRNumber > 0 { + return "Draft published" + } + return "Finished" + case LaneRetired: + return "Retired" + } + return "Recorded" +} + +func checksText(s string) string { + switch s { + case "SUCCESS": + return "passing" + case "FAILURE", "ERROR": + return "failing" + case "PENDING", "EXPECTED": + return "pending" + } + return "none" +} + +func reviewText(s string) string { + switch s { + case "APPROVED": + return "approved" + case "CHANGES_REQUESTED": + return "changes requested" + case "REVIEW_REQUIRED": + return "review required" + } + return "" +} diff --git a/lab/internal/forgesync/state_test.go b/lab/internal/forgesync/state_test.go new file mode 100644 index 0000000..5badd8e --- /dev/null +++ b/lab/internal/forgesync/state_test.go @@ -0,0 +1,229 @@ +package forgesync + +import ( + "slices" + "testing" +) + +const published = "0123456789abcdef0123456789abcdef01234567" + +func openIssue() *Issue { + return &Issue{Number: 7, Found: true, State: IssueOpen, Labels: []string{}} +} + +func closedIssue(reason string) *Issue { + return &Issue{Number: 7, Found: true, State: IssueClosed, StateReason: reason, ClosedAt: "2026-10-18T10:00:00Z", Labels: []string{}} +} + +func mol(id, lane, started string) Molecule { + return Molecule{ID: id, Lane: lane, StartedAt: started} +} + +func pubMol(id, lane, started string, pr int64) Molecule { + m := mol(id, lane, started) + m.PRNumber, m.PublishedCommit = pr, published + return m +} + +func pr(n int64, state string, draft bool) PR { + return PR{Number: n, Found: true, State: state, IsDraft: draft, HeadRefOid: published, Checks: "SUCCESS", ReviewDecision: "REVIEW_REQUIRED", MergedAt: "2026-10-18T09:00:00Z", MergeCommit: "1111111111111111111111111111111111111111"} +} + +func prs(ps ...PR) map[int64]PR { + out := map[int64]PR{} + for _, p := range ps { + out[p.Number] = p + } + return out +} + +// TestSyncStateMatrix has one case per row of the #242 matrix, asserting the +// label, bucket and startable; startable is yes exactly for rows 1, 5, 10 and 15. +func TestSyncStateMatrix(t *testing.T) { + const t1, t2 = "2026-10-20T10:00:00Z", "2026-10-21T10:00:00Z" + moved := pr(5, PROpen, true) + moved.HeadRefOid = "2222222222222222222222222222222222222222" + movedReady := pr(5, PROpen, false) + movedReady.HeadRefOid = moved.HeadRefOid + // pr() merges at 2026-10-18T09:00:00Z. + reopened := openIssue() + reopened.StateReason, reopened.ReopenedAt = ReasonReopened, "2026-10-19T09:00:00Z" + reopenedBefore := openIssue() + reopenedBefore.StateReason, reopenedBefore.ReopenedAt = ReasonReopened, "2026-10-17T09:00:00Z" + reopenedUnknown := openIssue() + reopenedUnknown.StateReason = ReasonReopened + locked := openIssue() + locked.Locked = true + lockedUnavailable := &Issue{Number: 7, Unavailable: UnavailableNoAccess, Locked: true} + cases := []struct { + name string + row int + issue *Issue + prs map[int64]PR + work Work + label string + bucket string + startable bool + note string + }{ + {"1 open, no work", 1, openIssue(), nil, Work{SummaryStartable: true}, "Not started", BucketIssues, true, ""}, + {"2 open, running", 2, openIssue(), nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneRunning, Step: "queued for checks", StartedAt: t1}}}, "In progress · queued for checks", BucketRunning, false, ""}, + {"3 open, at a gate", 3, openIssue(), nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneDecision, Step: "scope", StartedAt: t1}}}, "Needs you · scope", BucketDecision, false, ""}, + {"4 open, blocked", 4, openIssue(), nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneBlocked, Reason: "checks failed", StartedAt: t1}}}, "Blocked · checks failed", BucketBlocked, false, ""}, + {"5 open, only retired", 5, openIssue(), nil, Work{Molecules: []Molecule{mol("a", LaneRetired, t1), mol("b", LaneRetired, t2)}, SummaryStartable: true}, "Not started (2 retired)", BucketIssues, true, ""}, + {"6 open, draft PR", 6, openIssue(), prs(pr(5, PROpen, true)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Draft PR open", BucketDraftPR, false, ""}, + {"7 open, PR ready", 7, openIssue(), prs(pr(5, PROpen, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "PR in review", BucketDraftPR, false, ""}, + {"8 open, head moved", 6, openIssue(), prs(moved), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Draft PR open", BucketDraftPR, false, NoteHeadMoved}, + {"9 open, PR merged", 9, openIssue(), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Merged · Issue still open", BucketMerged, false, NoteCloseOnGitHub}, + {"10 open, PR closed", 10, openIssue(), prs(pr(5, PRClosed, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "PR closed without merge", BucketIssues, true, ""}, + {"11 closed completed, merged", 11, closedIssue(ReasonCompleted), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Merged", BucketMerged, false, ""}, + {"12 closed completed, no PR", 12, closedIssue(ReasonCompleted), nil, Work{SummaryStartable: true}, "Issue closed elsewhere", BucketHistory, false, ""}, + {"12 closed completed, PR closed", 12, closedIssue(ReasonCompleted), prs(pr(5, PRClosed, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Issue closed elsewhere", BucketHistory, false, ""}, + {"13 closed not planned", 13, closedIssue(ReasonNotPlanned), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Issue closed · not planned", BucketHistory, false, ""}, + {"14 closed while running", 14, closedIssue(ReasonCompleted), nil, Work{Molecules: []Molecule{mol("m", LaneRunning, t1)}}, "Issue closed while in progress", BucketDecision, false, NoteRetireOrKeep}, + {"14 closed at a gate", 14, closedIssue(ReasonNotPlanned), nil, Work{Molecules: []Molecule{mol("m", LaneDecision, t1)}}, "Issue closed while in progress", BucketDecision, false, NoteRetireOrKeep}, + {"15 reopened after merge", 15, reopened, prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Reopened after merge", BucketIssues, true, ""}, + {"16 not found, running", 16, &Issue{Number: 7, Unavailable: UnavailableNotFound}, nil, Work{Molecules: []Molecule{mol("m", LaneRunning, t1)}}, "Issue unavailable · " + UnavailableNotFound, BucketRunning, false, "In progress"}, + {"16 no access, no work", 16, &Issue{Number: 7, Unavailable: UnavailableNoAccess}, nil, Work{SummaryStartable: true}, "Issue unavailable · " + UnavailableNoAccess, BucketIssues, false, ""}, + {"17 locked, not started", 1, locked, nil, Work{SummaryStartable: true}, "Not started", BucketIssues, true, NoteLocked}, + {"8 PR ready, head moved", 7, openIssue(), prs(movedReady), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "PR in review", BucketDraftPR, false, NoteHeadMoved}, + {"9 reopened before the merge", 9, reopenedBefore, prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Merged · Issue still open", BucketMerged, false, NoteCloseOnGitHub}, + {"9 reopened, time unknown", 9, reopenedUnknown, prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Merged · Issue still open", BucketMerged, false, NoteCloseOnGitHub}, + {"6 closed Issue, open draft PR", 6, closedIssue(ReasonCompleted), prs(pr(5, PROpen, true)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}, "Draft PR open", BucketDraftPR, false, NoteIssueClosed}, + {"17 locked + 2 running", 2, locked, nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneRunning, Step: "code", StartedAt: t1}}}, "In progress · code", BucketRunning, false, NoteLocked}, + {"17 locked + 3 at a gate", 3, locked, nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneDecision, Step: "review", StartedAt: t1}}}, "Needs you · review", BucketDecision, false, NoteLocked}, + {"17 locked + 4 blocked", 4, locked, nil, Work{Molecules: []Molecule{{ID: "m", Lane: LaneBlocked, Reason: "checks failed", StartedAt: t1}}}, "Blocked · checks failed", BucketBlocked, false, NoteLocked}, + {"17 locked + 16 unavailable", 16, lockedUnavailable, nil, Work{Molecules: []Molecule{mol("m", LaneBlocked, t1)}}, "Issue unavailable · " + UnavailableNoAccess, BucketBlocked, false, NoteLocked}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := SyncState(c.issue, c.prs, c.work) + if d.Row != c.row || d.Label != c.label || d.Bucket != c.bucket || d.Startable != c.startable || !d.Checked { + t.Fatalf("got row %d %q %q startable %v checked %v; want row %d %q %q startable %v", d.Row, d.Label, d.Bucket, d.Startable, d.Checked, c.row, c.label, c.bucket, c.startable) + } + if c.note != "" && !slices.Contains(d.Notes, c.note) { + t.Fatalf("notes %v lack %q", d.Notes, c.note) + } + if c.issue.Locked != d.Locked { + t.Fatalf("locked %v", d.Locked) + } + }) + } +} + +// Only rows 1, 5, 10 and 15 are ever startable, and never for a closed or +// unavailable Issue, whatever the loop's summary says. +func TestSyncStateStartableNeedsAnOpenReadableIssue(t *testing.T) { + for _, issue := range []*Issue{nil, closedIssue(ReasonCompleted), closedIssue(ReasonNotPlanned), {Number: 7, Unavailable: UnavailableNotFound}} { + if d := SyncState(issue, nil, Work{SummaryStartable: true}); d.Startable { + t.Fatalf("%+v startable: %+v", issue, d) + } + } + // Row 1 also needs the loop's summary: an incomplete inventory is not startable. + if d := SyncState(openIssue(), nil, Work{}); d.Row != 1 || d.Startable { + t.Fatalf("row 1 without the summary: %+v", d) + } +} + +func TestSyncStatePrecedence(t *testing.T) { + const t1, t2 = "2026-10-20T10:00:00Z", "2026-10-21T10:00:00Z" + // A live Molecule beats a merged pull request of an earlier attempt. + d := SyncState(openIssue(), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{pubMol("old", LaneFinished, t1, 5), mol("new", LaneRunning, t2)}}) + if d.Row != 2 || d.MoleculeID != "new" || !slices.Equal(d.EarlierAttempts, []string{"old"}) { + t.Fatalf("live first: %+v", d) + } + // PR state beats Issue state: an open PR on a closed Issue stays a draft PR. + d = SyncState(closedIssue(ReasonCompleted), prs(pr(5, PROpen, true)), Work{Molecules: []Molecule{pubMol("m", LaneFinished, t1, 5)}}) + if d.Row != 6 || d.Bucket != BucketDraftPR { + t.Fatalf("PR before Issue: %+v", d) + } + // A blocked Molecule on a closed Issue is row 14, out of Blocked. + d = SyncState(closedIssue(ReasonCompleted), nil, Work{Molecules: []Molecule{mol("m", LaneBlocked, t1)}}) + if d.Row != 14 || d.Bucket == BucketBlocked { + t.Fatalf("blocked on a closed Issue: %+v", d) + } +} + +func TestSyncStateSeveralMoleculesNewestNonRetiredDecides(t *testing.T) { + ms := []Molecule{ + pubMol("first", LaneFinished, "2026-10-20T10:00:00Z", 5), + mol("retired-newest", LaneRetired, "2026-10-23T10:00:00Z"), + pubMol("second", LaneFinished, "2026-10-22T10:00:00Z", 6), + mol("undated", LaneOther, ""), + } + d := SyncState(openIssue(), prs(pr(5, PRClosed, false), pr(6, PROpen, false)), Work{Molecules: ms}) + if d.MoleculeID != "second" || d.Row != 7 || d.Retired != 1 { + t.Fatalf("decider: %+v", d) + } + if want := []string{"retired-newest", "first", "undated"}; !slices.Equal(d.EarlierAttempts, want) { + t.Fatalf("earlier %v, want %v", d.EarlierAttempts, want) + } + // Input order never changes the decider. + slices.Reverse(ms) + if again := SyncState(openIssue(), prs(pr(5, PRClosed, false), pr(6, PROpen, false)), Work{Molecules: ms}); again.MoleculeID != "second" { + t.Fatalf("order-dependent: %+v", again) + } +} + +// A retired Molecule never decides, but its merged pull request counts as a +// Forgecell merge in history and analytics. +func TestSyncStateRetiredAndMerged(t *testing.T) { + retired := pubMol("r", LaneRetired, "2026-10-20T10:00:00Z", 5) + d := SyncState(closedIssue(ReasonCompleted), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{retired}, SummaryStartable: true}) + if d.Row != 11 || d.Bucket != BucketMerged || d.ForgecellMerge == nil || d.ForgecellMerge.PR != 5 || d.MoleculeID != "" { + t.Fatalf("retired merged, Issue closed: %+v", d) + } + d = SyncState(openIssue(), prs(pr(5, PRMerged, false)), Work{Molecules: []Molecule{retired}, SummaryStartable: true}) + if d.Row != 5 || !d.Startable || d.ForgecellMerge == nil { + t.Fatalf("retired merged, Issue open: %+v", d) + } + one := ForMolecule(openIssue(), prs(pr(5, PRMerged, false)), retired) + if one.Bucket != BucketRetired || one.ForgecellMerge == nil || one.Label != "Retired · PR merged" { + t.Fatalf("ForMolecule retired: %+v", one) + } +} + +// A merge or close by a pull request Forgecell did not publish is row 12 with +// that link, never a Forgecell merge. +func TestSyncStateMergeOutsideForgecell(t *testing.T) { + issue := closedIssue(ReasonCompleted) + issue.ClosedBy = &Link{Kind: "pull", Number: 99, URL: "https://github.com/o/r/pull/99"} + for _, work := range []Work{{}, {Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}}} { + d := SyncState(issue, prs(pr(5, PRClosed, false), pr(99, PRMerged, false)), work) + if d.Row != 12 || d.ForgecellMerge != nil || d.ClosedBy == nil || d.ClosedBy.Number != 99 || !slices.Contains(d.Notes, NoteOutsideMerge) { + t.Fatalf("outside merge: %+v", d) + } + } +} + +// Without GitHub state every row keeps its ledger lane, labelled not checked, +// and nothing is startable. +func TestSyncStateNotChecked(t *testing.T) { + d := SyncState(nil, nil, Work{Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}, SummaryStartable: true}) + if d.Checked || d.Kind != KindNotChecked || d.Label != "Draft published" || d.Bucket != LaneFinished || d.Startable || !slices.Contains(d.Notes, NoteNotChecked) { + t.Fatalf("not checked: %+v", d) + } + // A published Molecule whose pull request was not read is not guessed. + d = SyncState(openIssue(), nil, Work{Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}}) + if d.Kind != KindRecorded || d.Label != "Draft published" || !slices.Contains(d.Notes, NotePRNotChecked) { + t.Fatalf("PR not read: %+v", d) + } + d = SyncState(openIssue(), prs(PR{Number: 5}), Work{Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}}) + if d.Kind != KindRecorded || !slices.Contains(d.Notes, NotePRNotFound) { + t.Fatalf("PR not found: %+v", d) + } +} + +func TestSyncStatePRView(t *testing.T) { + p := pr(5, PROpen, true) + p.Checks, p.ReviewDecision = "FAILURE", "CHANGES_REQUESTED" + d := SyncState(openIssue(), prs(p), Work{Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}}) + if d.PR == nil || d.PR.Checks != "failing" || d.PR.Review != "changes requested" || !d.PR.Draft { + t.Fatalf("PR view: %+v", d.PR) + } + p.Checks, p.ReviewDecision = "", "" + d = SyncState(openIssue(), prs(p), Work{Molecules: []Molecule{pubMol("m", LaneFinished, "2026-10-20T10:00:00Z", 5)}}) + if d.PR.Checks != "none" || d.PR.Review != "" { + t.Fatalf("PR view without checks: %+v", d.PR) + } +} diff --git a/lab/internal/ui/issues.go b/lab/internal/ui/issues.go new file mode 100644 index 0000000..ee79905 --- /dev/null +++ b/lab/internal/ui/issues.go @@ -0,0 +1,275 @@ +package ui + +import ( + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "github.com/DanielNetzer/forgecell/lab/internal/formula" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +// issueSync is one server's read-only GitHub sync (#242): the forgesync cache +// and its fetcher, and the active Formula's repository. Tests replace the +// fetcher, the clock and the repository the same way as the other seams. +type issueSync struct { + cache *forgesync.Cache + // repository is the active Formula's repository; it never comes from a + // request. + repository func(lab string) string + receiptsMu sync.Mutex + receipts map[string]publishedEntry +} + +func newIssueSync(fetcher forgesync.Fetcher, now func() time.Time) *issueSync { + if fetcher == nil { + fetcher = forgesync.NewGH() + } + return &issueSync{cache: forgesync.NewCache(fetcher, now), repository: func(lab string) string { return formula.ActiveIdentity(lab).Repository }, receipts: map[string]publishedEntry{}} +} + +// published is the pull request number and commit of one published receipt. +type published struct { + number int64 + commit string +} + +type publishedEntry struct { + stamp fileStamp + value published +} + +var pullNumber = regexp.MustCompile(`/pull/([1-9][0-9]{0,15})$`) + +var publishedCommit = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + +// published reads the pull request of a Molecule's published receipt, reusing +// the last read while the receipt file is unchanged. Anything but a published +// receipt naming the Issue repository's pull request yields the zero value. +func (h *handler) published(id, repo string) published { + s := h.forge + path := filepath.Join(h.opts.LabDir, "deliveries", id+".json") + st, cacheable := stamp(path) + if cacheable { + s.receiptsMu.Lock() + e, ok := s.receipts[id] + s.receiptsMu.Unlock() + if ok && e.stamp == st { + return e.value + } + } + var out published + receipt, found, err := h.receipts(h.opts.LabDir, id) + if found && err == nil && receipt.State == "published" && pullURL(receipt.URL, repo) { + if m := pullNumber.FindStringSubmatch(receipt.URL); m != nil { + out.number, _ = strconv.ParseInt(m[1], 10, 64) + } + if publishedCommit.MatchString(receipt.Commit) { + out.commit = receipt.Commit + } + } + if cacheable { + s.receiptsMu.Lock() + if len(s.receipts) >= maxSyncMolecules { + s.receipts = map[string]publishedEntry{} + } + s.receipts[id] = publishedEntry{st, out} + s.receiptsMu.Unlock() + } + return out +} + +// maxSyncMolecules bounds the receipt cache; the inventory itself is bounded. +const maxSyncMolecules = 8192 + +// maxStepRunes bounds the ledger step and reason shown with a state. +const maxStepRunes = 200 + +func bounded(s string) string { + if utf8.RuneCountInString(s) <= maxStepRunes { + return s + } + return string([]rune(s)[:maxStepRunes]) + "…" +} + +// syncMolecule maps one listed Summary to the sync's input. +func (h *handler) syncMolecule(s Summary, repo string) forgesync.Molecule { + m := forgesync.Molecule{ID: s.ID, Lane: s.Lane, StartedAt: s.StartedAt} + switch s.Lane { + case LaneRunning: + m.Step = s.Activity + if m.Step == "" { + m.Step = s.CurrentAtom + } + case LaneDecision: + m.Step = "review" + if s.Phase == "scope-waiting" { + m.Step = "scope" + } + case LaneBlocked: + m.Reason = s.Status + if s.Inbox.Kind == InboxBlocked && s.Inbox.Summary != "" { + m.Reason = s.Inbox.Summary + } + } + m.Step, m.Reason = bounded(m.Step), bounded(m.Reason) + if s.Delivery != "" { + p := h.published(s.ID, repo) + m.PRNumber, m.PublishedCommit = p.number, p.commit + } + return m +} + +// syncWork is one bounded pass over the Lab for the sync: every listed +// Molecule, those of the repository grouped by Issue, and the numbers to read. +type syncWork struct { + list MoleculeList + inputs map[string]forgesync.Molecule + byIssue map[int64][]forgesync.Molecule + want forgesync.Want +} + +func (h *handler) syncWork(repo string) syncWork { + w := syncWork{list: h.list(ledgerETag(h.opts.LabDir)), inputs: map[string]forgesync.Molecule{}, byIssue: map[int64][]forgesync.Molecule{}, want: forgesync.Want{Repository: repo}} + seenIssue, seenPR := map[int64]bool{}, map[int64]bool{} + for _, s := range w.list.Molecules { + if repo == "" || !strings.EqualFold(s.Repository, repo) { + continue + } + m := h.syncMolecule(s, repo) + w.inputs[s.ID] = m + w.byIssue[s.Number] = append(w.byIssue[s.Number], m) + if !seenIssue[s.Number] { + seenIssue[s.Number] = true + w.want.Issues = append(w.want.Issues, s.Number) + } + if m.PRNumber > 0 && !seenPR[m.PRNumber] { + seenPR[m.PRNumber] = true + w.want.PRs = append(w.want.PRs, m.PRNumber) + } + } + return w +} + +// syncRepository is the active Formula's repository when it is a valid +// owner/name, and "" otherwise. +func (h *handler) syncRepository() string { + return repository(h.forge.repository(h.opts.LabDir)) +} + +// Freshness is the GitHub state's age and the latest failure, shared by both +// sync routes. +type Freshness struct { + // Checked is false when no GitHub state was ever read: states are ledger only. + Checked bool `json:"checked"` + // Reason is the fixed reason the latest sync failed; with Checked it means the + // state shown is the last good one, AgeSeconds old. + Reason string `json:"reason,omitempty"` + // Stale means the state shown is not current (the latest sync failed, or it is + // older than twice the TTL): then nothing is startable. + Stale bool `json:"stale"` + FetchedAt string `json:"fetchedAt,omitempty"` + AgeSeconds int64 `json:"ageSeconds"` +} + +func freshness(r forgesync.Result) Freshness { + f := Freshness{Checked: r.Snapshot != nil, Reason: r.Reason, Stale: r.Snapshot != nil && !r.Current()} + if r.Snapshot != nil { + f.FetchedAt = r.Snapshot.FetchedAt.UTC().Format(time.RFC3339) + f.AgeSeconds = int64(r.Age / time.Second) + } + return f +} + +// refresh reports ?refresh=1, the only query parameter the sync routes read. +func refresh(r *http.Request) bool { return r.URL.Query().Get("refresh") == "1" } + +// IssuesView is GET /api/issues: the open Issues of the active Formula's +// repository, oldest first, each with its one sync state. +type IssuesView struct { + Sample bool `json:"sample"` + Repository string `json:"repository"` + Freshness + // Total is GitHub's count of open Issues; Truncated means Issues lists fewer. + Total int `json:"total"` + Shown int `json:"shown"` + Truncated bool `json:"truncated"` + Note string `json:"note,omitempty"` + // Complete is false when the Lab's ledgers could not all be read; then no + // Issue is startable. + Complete bool `json:"complete"` + Issues []IssueRow `json:"issues"` +} + +// IssueRow is one open Issue. Startable is the sync's rule; SummaryStartable is +// the loop's own (molecule.QueueSummary.Startable), which the loop keeps. +type IssueRow struct { + Number int64 `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Labels []string `json:"labels"` + Milestone string `json:"milestone,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Locked bool `json:"locked,omitempty"` + State forgesync.DisplayState `json:"state"` + Startable bool `json:"startable"` + SummaryState string `json:"summaryState"` + SummaryStartable bool `json:"summaryStartable"` + LatestMolecule string `json:"latestMolecule,omitempty"` + MoleculeLink string `json:"moleculeLink,omitempty"` +} + +func (h *handler) issues(w http.ResponseWriter, r *http.Request) { + if h.opts.Demo { + h.json(w, http.StatusOK, demoIssues()) + return + } + repo := h.syncRepository() + out := IssuesView{Repository: repo, Issues: []IssueRow{}} + if repo == "" { + out.Reason = forgesync.ReasonRepository + h.json(w, http.StatusOK, out) + return + } + work := h.syncWork(repo) + res := h.forge.cache.Get(r.Context(), work.want, refresh(r)) + out.Freshness = freshness(res) + queue := molecule.ReadQueueInventory(h.opts.LabDir) + out.Complete = queue.Complete && work.list.Complete + snap := res.Snapshot + if snap == nil { + h.json(w, http.StatusOK, out) + return + } + out.Total, out.Shown, out.Truncated = snap.Listing.Total, len(snap.Listing.Issues), snap.Listing.Truncated + if out.Truncated { + out.Note = "showing " + strconv.Itoa(out.Shown) + " of " + strconv.Itoa(out.Total) + } + for _, issue := range snap.Listing.Issues { + summary := queue.Summarize(repo, issue.Number) + issue := issue + state := forgesync.SyncState(&issue, snap.PRs, forgesync.Work{Molecules: work.byIssue[issue.Number], SummaryStartable: summary.Startable()}) + if !out.Complete { + state.Startable = false + } + // Stale GitHub state never allows a start. + if out.Stale { + state.Startable = false + state.Notes = append(state.Notes, forgesync.NoteStale) + } + row := IssueRow{Number: issue.Number, Title: issue.Title, URL: issue.URL, Labels: issue.Labels, Milestone: issue.Milestone, UpdatedAt: issue.UpdatedAt, Locked: issue.Locked, State: state, Startable: state.Startable, SummaryState: summary.State, SummaryStartable: summary.Startable()} + if row.Labels == nil { + row.Labels = []string{} + } + if id := state.MoleculeID; moleculeID.MatchString(id) { + row.LatestMolecule, row.MoleculeLink = id, "/?m="+id + } + out.Issues = append(out.Issues, row) + } + h.json(w, http.StatusOK, out) +} diff --git a/lab/internal/ui/issues_demo.go b/lab/internal/ui/issues_demo.go new file mode 100644 index 0000000..5f5e315 --- /dev/null +++ b/lab/internal/ui/issues_demo.go @@ -0,0 +1,110 @@ +package ui + +import ( + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" +) + +// Demo mode serves sample sync data, labelled sample=true, built through the +// same forgesync.SyncState as real mode. It never calls the fetcher or gh. + +const demoRepo = "sample/lab" + +const demoBase = "https://github.com/" + demoRepo + +func demoIssue(n int64, title, state, reason string, labels ...string) forgesync.Issue { + if labels == nil { + labels = []string{} + } + return forgesync.Issue{Number: n, Found: true, State: state, StateReason: reason, Title: title, Labels: labels, UpdatedAt: "2026-10-26T12:00:00Z", URL: fmt.Sprintf("%s/issues/%d", demoBase, n)} +} + +func demoPR(n int64, state string, draft bool) forgesync.PR { + p := forgesync.PR{Number: n, Found: true, State: state, IsDraft: draft, Checks: "SUCCESS", URL: fmt.Sprintf("%s/pull/%d", demoBase, n)} + if state == forgesync.PRMerged { + p.MergedAt, p.MergeCommit = "2026-10-26T12:00:00Z", "0123456789abcdef0123456789abcdef01234567" + } + return p +} + +// demoIssueCase is one sample open Issue with its sample work. +type demoIssueCase struct { + issue forgesync.Issue + molecules []forgesync.Molecule + startable bool +} + +func demoMol(id, lane string, pr int64) forgesync.Molecule { + return forgesync.Molecule{ID: id, Lane: lane, StartedAt: "2026-10-25T10:00:00Z", PRNumber: pr, PublishedCommit: "0123456789abcdef0123456789abcdef01234567"} +} + +// demoIssueCases cover the open-Issue rows of the sync matrix. +func demoIssueCases() ([]demoIssueCase, map[int64]forgesync.PR) { + locked := demoIssue(912, "Sample locked Issue", forgesync.IssueOpen, "", "docs") + locked.Locked = true + reopened := demoIssue(911, "Sample reopened after merge", forgesync.IssueOpen, forgesync.ReasonReopened) + reopened.ReopenedAt = "2026-10-26T13:00:00Z" + moved := demoPR(808, forgesync.PROpen, false) + moved.HeadRefOid = "fedcba9876543210fedcba9876543210fedcba98" + prs := map[int64]forgesync.PR{806: demoPR(806, forgesync.PROpen, true), 807: demoPR(807, forgesync.PROpen, false), 808: moved, 809: demoPR(809, forgesync.PRMerged, false), 810: demoPR(810, forgesync.PRClosed, false), 811: demoPR(811, forgesync.PRMerged, false)} + return []demoIssueCase{ + {issue: demoIssue(901, "Sample Issue nobody started", forgesync.IssueOpen, "", "good first issue"), startable: true}, + {issue: demoIssue(902, "Sample Issue in progress", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{{ID: "demo-sync-902", Lane: forgesync.LaneRunning, Step: "harness", StartedAt: "2026-10-26T10:00:00Z"}}}, + {issue: demoIssue(903, "Sample Issue at a gate", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{{ID: "demo-sync-903", Lane: forgesync.LaneDecision, Step: "scope", StartedAt: "2026-10-26T10:00:00Z"}}}, + {issue: demoIssue(904, "Sample blocked Issue", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{{ID: "demo-sync-904", Lane: forgesync.LaneBlocked, Reason: "Required checks did not pass.", StartedAt: "2026-10-26T10:00:00Z"}}}, + {issue: demoIssue(905, "Sample Issue with retired attempts", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-905", forgesync.LaneRetired, 0)}, startable: true}, + {issue: demoIssue(906, "Sample draft PR", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-906", forgesync.LaneFinished, 806)}}, + {issue: demoIssue(907, "Sample PR in review", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-907", forgesync.LaneFinished, 807)}}, + {issue: demoIssue(908, "Sample PR changed on GitHub", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-908", forgesync.LaneFinished, 808)}}, + {issue: demoIssue(909, "Sample merged, Issue still open", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-909", forgesync.LaneFinished, 809)}}, + {issue: demoIssue(910, "Sample PR closed without merge", forgesync.IssueOpen, ""), molecules: []forgesync.Molecule{demoMol("demo-sync-910", forgesync.LaneFinished, 810)}}, + {issue: reopened, molecules: []forgesync.Molecule{demoMol("demo-sync-911", forgesync.LaneFinished, 811)}}, + {issue: locked, startable: true}, + }, prs +} + +// demoIssues is GET /api/issues in demo mode. +func demoIssues() IssuesView { + cases, prs := demoIssueCases() + out := IssuesView{Sample: true, Repository: demoRepo, Freshness: Freshness{Checked: true, FetchedAt: "2026-10-26T12:00:00Z"}, Complete: true, Total: len(cases), Shown: len(cases), Issues: []IssueRow{}} + for _, c := range cases { + issue := c.issue + state := forgesync.SyncState(&issue, prs, forgesync.Work{Molecules: c.molecules, SummaryStartable: c.startable}) + row := IssueRow{Number: issue.Number, Title: issue.Title, URL: issue.URL, Labels: issue.Labels, UpdatedAt: issue.UpdatedAt, Locked: issue.Locked, State: state, Startable: state.Startable, SummaryState: "sample", SummaryStartable: c.startable} + if state.MoleculeID != "" { + row.LatestMolecule = state.MoleculeID + } + out.Issues = append(out.Issues, row) + } + return out +} + +// demoSync is GET /api/sync in demo mode: every sample Molecule of demoList, +// with sample GitHub state. Finished sample Molecules cycle through a merged +// pull request with a closed Issue, a draft pull request and a closed one. +func demoSync() SyncView { + out := SyncView{Sample: true, Repository: demoRepo, Freshness: Freshness{Checked: true, FetchedAt: "2026-10-26T12:00:00Z"}, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}} + for _, s := range demoList().Molecules { + m := forgesync.Molecule{ID: s.ID, Lane: s.Lane, Step: s.CurrentAtom} + issue := demoIssue(s.Number, s.Title, forgesync.IssueOpen, "") + prs := map[int64]forgesync.PR{} + if s.Lane == LaneFinished { + m.PRNumber = 5000 + s.Number + switch s.Number % 3 { + case 0: + prs[m.PRNumber] = demoPR(m.PRNumber, forgesync.PRMerged, false) + issue.State, issue.StateReason = forgesync.IssueClosed, forgesync.ReasonCompleted + case 1: + prs[m.PRNumber] = demoPR(m.PRNumber, forgesync.PROpen, true) + default: + prs[m.PRNumber] = demoPR(m.PRNumber, forgesync.PRClosed, false) + } + } + d := forgesync.ForMolecule(&issue, prs, m) + out.Molecules[s.ID] = d + if d.Bucket != forgesync.BucketRetired { + out.Counts[d.Bucket]++ + } + } + return out +} diff --git a/lab/internal/ui/issues_test.go b/lab/internal/ui/issues_test.go new file mode 100644 index 0000000..1995d5a --- /dev/null +++ b/lab/internal/ui/issues_test.go @@ -0,0 +1,445 @@ +package ui + +import ( + "context" + "fmt" + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "github.com/DanielNetzer/forgecell/lab/internal/molecule" + "github.com/DanielNetzer/forgecell/lab/internal/process" + "github.com/DanielNetzer/forgecell/lab/internal/readiness" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" +) + +const syncCommit = "0123456789abcdef0123456789abcdef01234567" + +// syncFetcher is a fake forgesync.Fetcher: it serves fixed GitHub state and +// records every call, so no test runs gh. +type syncFetcher struct { + mu sync.Mutex + lists int + states int + repos []string + listing forgesync.Listing + issues map[int64]forgesync.Issue + prs map[int64]forgesync.PR + err error + gotIssues []int64 +} + +func (f *syncFetcher) OpenIssues(_ context.Context, repo string) (forgesync.Listing, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.lists++ + f.repos = append(f.repos, repo) + if f.err != nil { + return forgesync.Listing{}, f.err + } + return f.listing, nil +} + +func (f *syncFetcher) States(_ context.Context, repo string, issues, prs []int64) (forgesync.States, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.states++ + f.repos = append(f.repos, repo) + f.gotIssues = issues + out := forgesync.States{Issues: map[int64]forgesync.Issue{}, PRs: map[int64]forgesync.PR{}} + for _, n := range issues { + if i, ok := f.issues[n]; ok { + out.Issues[n] = i + } + } + for _, n := range prs { + if p, ok := f.prs[n]; ok { + out.PRs[n] = p + } + } + return out, nil +} + +func (f *syncFetcher) calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.lists + f.states +} + +type syncClock struct { + mu sync.Mutex + t time.Time +} + +func (c *syncClock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t } +func (c *syncClock) add(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +func openSyncIssue(n int64) forgesync.Issue { + return forgesync.Issue{Number: n, Found: true, State: forgesync.IssueOpen, Title: fmt.Sprintf("Issue %d", n), Labels: []string{"bug"}, URL: fmt.Sprintf("https://github.com/o/r/issues/%d", n)} +} + +func syncPR(n int64, state string, draft bool) forgesync.PR { + return forgesync.PR{Number: n, Found: true, State: state, IsDraft: draft, HeadRefOid: syncCommit, MergedAt: "2026-10-18T10:00:00Z", MergeCommit: strings.Repeat("a", 40), URL: fmt.Sprintf("https://github.com/o/r/pull/%d", n)} +} + +// newSyncFetcher is GitHub's side of syncLab: Issues 1-6, 8 and 9 open, Issue 7 +// closed as completed; pull requests 60 (draft, open), 70 (merged) and 80 +// (closed without merge). +func newSyncFetcher() *syncFetcher { + f := &syncFetcher{issues: map[int64]forgesync.Issue{}, prs: map[int64]forgesync.PR{60: syncPR(60, forgesync.PROpen, true), 70: syncPR(70, forgesync.PRMerged, false), 80: syncPR(80, forgesync.PRClosed, false)}} + for _, n := range []int64{1, 2, 3, 4, 5, 6, 8, 9} { + f.listing.Issues = append(f.listing.Issues, openSyncIssue(n)) + } + f.listing.Total = len(f.listing.Issues) + closed := openSyncIssue(7) + closed.State, closed.StateReason = forgesync.IssueClosed, forgesync.ReasonCompleted + f.issues[7] = closed + return f +} + +func writePublishedReceipt(t *testing.T, lab, id string, pr int64) { + t.Helper() + if err := os.MkdirAll(filepath.Join(lab, "deliveries"), 0700); err != nil { + t.Fatal(err) + } + raw := fmt.Sprintf(`{"state":"published","title":"t","acceptanceReviewAcknowledged":false,"preview":{"moleculeId":%q,"diff":"secret diff"},"commit":%q,"url":"https://github.com/o/r/pull/%d","lifecycle":{"state":"OPEN"}}`, id, syncCommit, pr) + if err := os.WriteFile(filepath.Join(lab, "deliveries", id+".json"), []byte(raw), 0600); err != nil { + t.Fatal(err) + } +} + +// syncLab is a fixture Lab with one Molecule per ledger lane the sync maps: +// Issue 2 running, 3 at the scope gate, 4 blocked, 5 retired only, and 6, 7 and +// 8 published as pull requests 60, 70 and 80. Issues 1 and 9 have no work. +func syncLab(t *testing.T) string { + t.Helper() + lab, base := labFixture(t) + os.Remove(filepath.Join(lab, "ledgers", base.ID+".json")) + add := func(id string, n int64, status, phase string) molecule.Record { + r := base + r.ID, r.Status, r.Issue.Number, r.FinishedAt = id, status, n, "" + r.Issue.URL = fmt.Sprintf("https://github.com/o/r/issues/%d", n) + r.StartedAt = fmt.Sprintf("2026-10-24T10:%02d:00Z", n) + r.Readiness = &readiness.State{SchemaVersion: "v1", Phase: phase} + r.Atoms, r.HarnessAttempts = []molecule.Atom{{ID: "code", Type: "harness", Status: "active"}}, nil + return r + } + for _, r := range []molecule.Record{add("mol-2-run", 2, "running", "coding"), add("mol-3-scope", 3, "waiting", "scope-waiting"), add("mol-4-blocked", 4, "blocked", "blocked")} { + writeRecord(t, lab, r) + } + retired := add("mol-5-retired", 5, molecule.StatusRetired, "scope-waiting") + retired.Retired = &molecule.Retirement{At: "2026-10-26T10:00:00Z", Reason: "stale", PriorStatus: "waiting"} + writeRecord(t, lab, retired) + for _, p := range []struct { + id string + n int64 + }{{"mol-6-draft", 6}, {"mol-7-merged", 7}, {"mol-8-closed", 8}} { + r := add(p.id, p.n, "completed", "review-waiting") + r.Atoms = []molecule.Atom{{ID: "code", Type: "harness", Status: "completed"}} + writeRecord(t, lab, r) + writePublishedReceipt(t, lab, p.id, p.n*10) + } + return lab +} + +func syncHandler(t *testing.T, o Options, f forgesync.Fetcher) (http.Handler, *syncClock) { + t.Helper() + h := testHandler(t, o) + clk := &syncClock{t: time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)} + h.(*handler).forge = newIssueSync(f, clk.now) + h.(*handler).forge.repository = func(string) string { return "o/r" } + return h, clk +} + +func issueRows(t *testing.T, h http.Handler, path string) (IssuesView, map[int64]IssueRow) { + t.Helper() + var v IssuesView + decode(t, do(t, h, authed(path)), &v) + rows := map[int64]IssueRow{} + for _, r := range v.Issues { + rows[r.Number] = r + } + return v, rows +} + +func TestIssuesRowsAndStatesMatchTheFixture(t *testing.T) { + lab := syncLab(t) + h, _ := syncHandler(t, Options{LabDir: lab}, newSyncFetcher()) + v, rows := issueRows(t, h, "/api/issues") + if v.Sample || v.Repository != "o/r" || !v.Checked || v.Reason != "" || !v.Complete || v.Truncated || len(v.Issues) != 8 { + t.Fatalf("view %+v", v) + } + want := map[int64]struct { + kind, bucket, molecule string + startable bool + }{ + 1: {forgesync.KindNotStarted, forgesync.BucketIssues, "", true}, + 2: {forgesync.KindInProgress, forgesync.BucketRunning, "mol-2-run", false}, + 3: {forgesync.KindNeedsYou, forgesync.BucketDecision, "mol-3-scope", false}, + 4: {forgesync.KindBlocked, forgesync.BucketBlocked, "mol-4-blocked", false}, + 5: {forgesync.KindNotStartedRetired, forgesync.BucketIssues, "", true}, + 6: {forgesync.KindDraftPR, forgesync.BucketDraftPR, "mol-6-draft", false}, + 8: {forgesync.KindPRClosed, forgesync.BucketIssues, "mol-8-closed", true}, + 9: {forgesync.KindNotStarted, forgesync.BucketIssues, "", true}, + } + queue := molecule.ReadQueueInventory(lab) + for n, w := range want { + r, ok := rows[n] + if !ok { + t.Fatalf("Issue %d missing", n) + } + if r.State.Kind != w.kind || r.State.Bucket != w.bucket || r.LatestMolecule != w.molecule || r.Startable != w.startable || r.Title != fmt.Sprintf("Issue %d", n) || r.URL != fmt.Sprintf("https://github.com/o/r/issues/%d", n) { + t.Errorf("Issue %d: %+v", n, r) + } + if w.molecule != "" && r.MoleculeLink != "/?m="+w.molecule { + t.Errorf("Issue %d link %q", n, r.MoleculeLink) + } + // startable agrees with the loop's QueueInventory.Summarize, widened only + // by row 10 (a pull request closed without merge). + s := queue.Summarize("o/r", n) + if r.SummaryStartable != s.Startable() || r.SummaryState != s.State { + t.Errorf("Issue %d summary %v %q, loop %v %q", n, r.SummaryStartable, r.SummaryState, s.Startable(), s.State) + } + if r.State.Row != 10 && r.Startable != s.Startable() { + t.Errorf("Issue %d startable %v, loop %v", n, r.Startable, s.Startable()) + } + } + if rows[2].State.Label != "In progress · code" || rows[3].State.Label != "Needs you · scope" || rows[5].State.Label != "Not started (1 retired)" { + t.Fatalf("labels %q %q %q", rows[2].State.Label, rows[3].State.Label, rows[5].State.Label) + } +} + +func TestSyncMoleculeStatesMatchTheFixture(t *testing.T) { + h, _ := syncHandler(t, Options{LabDir: syncLab(t)}, newSyncFetcher()) + var v SyncView + decode(t, do(t, h, authed("/api/sync")), &v) + if v.Sample || !v.Checked || v.Reason != "" || len(v.Molecules) != 7 { + t.Fatalf("view %+v", v) + } + want := map[string]struct{ kind, bucket string }{ + "mol-2-run": {forgesync.KindInProgress, forgesync.BucketRunning}, + "mol-3-scope": {forgesync.KindNeedsYou, forgesync.BucketDecision}, + "mol-4-blocked": {forgesync.KindBlocked, forgesync.BucketBlocked}, + "mol-5-retired": {forgesync.KindRetired, forgesync.BucketRetired}, + "mol-6-draft": {forgesync.KindDraftPR, forgesync.BucketDraftPR}, + "mol-7-merged": {forgesync.KindMerged, forgesync.BucketMerged}, + "mol-8-closed": {forgesync.KindPRClosed, forgesync.BucketIssues}, + } + for id, w := range want { + d := v.Molecules[id] + if d.Kind != w.kind || d.Bucket != w.bucket || d.MoleculeID != id || d.Startable { + t.Errorf("%s: %+v", id, d) + } + } + m := v.Molecules["mol-7-merged"].ForgecellMerge + if m == nil || m.PR != 70 || m.Commit != strings.Repeat("a", 40) || m.URL != "https://github.com/o/r/pull/70" { + t.Fatalf("merge %+v", m) + } + if v.Counts[forgesync.BucketMerged] != 1 || v.Counts[forgesync.BucketRetired] != 0 || v.Counts[forgesync.BucketBlocked] != 1 { + t.Fatalf("counts %v", v.Counts) + } +} + +// The repository is always the active Formula's; no query parameter selects it. +func TestIssuesRepositoryCannotBeOverridden(t *testing.T) { + f := newSyncFetcher() + h, _ := syncHandler(t, Options{LabDir: syncLab(t)}, f) + for _, path := range []string{"/api/issues?repo=evil/x&repository=evil/x&owner=evil&name=x", "/api/sync?repo=evil/x&repository=evil/x", "/api/issues?refresh=1&repo=evil/x"} { + w := do(t, h, authed(path)) + if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "evil") { + t.Fatalf("%s: %d %s", path, w.Code, w.Body) + } + } + for _, repo := range f.repos { + if repo != "o/r" { + t.Fatalf("fetched %q", repo) + } + } + // No valid repository: a fixed reason, and GitHub is never read. + f = newSyncFetcher() + h, _ = syncHandler(t, Options{LabDir: syncLab(t)}, f) + h.(*handler).forge.repository = func(string) string { return "../x" } + v, _ := issueRows(t, h, "/api/issues") + if v.Reason != forgesync.ReasonRepository || v.Checked || len(v.Issues) != 0 || f.calls() != 0 { + t.Fatalf("invalid repository: %+v calls %d", v, f.calls()) + } + var s SyncView + decode(t, do(t, h, authed("/api/sync")), &s) + if s.Reason != forgesync.ReasonRepository || s.Checked || f.calls() != 0 { + t.Fatalf("invalid repository sync: %+v", s) + } +} + +// Both routes share one cache: GitHub is read at most once per TTL, and +// ?refresh=1 bypasses it. +func TestIssuesCacheIsHonouredAndRefreshBypassesIt(t *testing.T) { + f := newSyncFetcher() + h, clk := syncHandler(t, Options{LabDir: syncLab(t)}, f) + issueRows(t, h, "/api/issues") + if f.lists != 1 || f.states != 1 { + t.Fatalf("first: lists %d states %d", f.lists, f.states) + } + // Open Issues come from the listing; only the closed one is read by number. + if len(f.gotIssues) != 1 || f.gotIssues[0] != 7 { + t.Fatalf("states asked for %v", f.gotIssues) + } + clk.add(30 * time.Second) + v, _ := issueRows(t, h, "/api/issues") + decode(t, do(t, h, authed("/api/sync")), &SyncView{}) + if f.lists != 1 || v.AgeSeconds != 30 || v.FetchedAt != "2026-10-27T09:00:00Z" { + t.Fatalf("within the TTL: lists %d age %d at %s", f.lists, v.AgeSeconds, v.FetchedAt) + } + if v, _ = issueRows(t, h, "/api/issues?refresh=1"); f.lists != 2 || v.AgeSeconds != 0 { + t.Fatalf("refresh: lists %d age %d", f.lists, v.AgeSeconds) + } + clk.add(forgesync.TTL) + if issueRows(t, h, "/api/sync"); f.lists != 3 { + t.Fatalf("after the TTL: lists %d", f.lists) + } +} + +// A gh failure gives one fixed reason and never gh's output, local paths or +// tokens; without a snapshot every Molecule keeps its ledger lane, not checked. +func TestIssuesGHFailureIsRedactedAndFallsBackToTheLedger(t *testing.T) { + lab := syncLab(t) + leak := "gh auth login required; config at " + lab + "/gh/hosts.yml token ghp_SECRETSECRET HOME=" + os.Getenv("HOME") + gh := &forgesync.GH{Run: func(context.Context, process.Options) process.Result { + return process.Result{Code: 4, Reconciled: true, Stderr: leak, Stdout: leak, Error: "exit status 4"} + }} + h, _ := syncHandler(t, Options{LabDir: lab}, gh) + for _, path := range []string{"/api/issues", "/api/sync"} { + w := do(t, h, authed(path)) + body := w.Body.String() + if w.Code != http.StatusOK || strings.Contains(body, lab) || strings.Contains(body, "ghp_") || strings.Contains(body, "hosts.yml") || strings.Contains(body, "exit status") || !strings.Contains(body, forgesync.ReasonNotAuthenticated) { + t.Fatalf("%s leaks or lacks the reason: %s", path, body) + } + } + v, _ := issueRows(t, h, "/api/issues") + if v.Checked || v.Stale || len(v.Issues) != 0 { + t.Fatalf("issues without a snapshot: %+v", v) + } + var s SyncView + decode(t, do(t, h, authed("/api/sync")), &s) + d := s.Molecules["mol-6-draft"] + if s.Checked || d.Checked || d.Kind != forgesync.KindNotChecked || d.Label != "Draft published" || d.Bucket != LaneFinished { + t.Fatalf("ledger fallback %+v", d) + } + if b := s.Molecules["mol-4-blocked"]; b.Bucket != LaneBlocked || b.Checked { + t.Fatalf("blocked fallback %+v", b) + } +} + +// After a good sync, a failure keeps the last snapshot with its age. +func TestIssuesFailureKeepsTheStaleSnapshot(t *testing.T) { + f := newSyncFetcher() + h, clk := syncHandler(t, Options{LabDir: syncLab(t)}, f) + issueRows(t, h, "/api/issues") + clk.add(4 * time.Minute) + f.mu.Lock() + f.err = &forgesync.Failure{Reason: forgesync.ReasonOffline} + f.mu.Unlock() + v, rows := issueRows(t, h, "/api/issues") + if !v.Checked || !v.Stale || v.Reason != forgesync.ReasonOffline || v.AgeSeconds != 240 || rows[6].State.Kind != forgesync.KindDraftPR { + t.Fatalf("stale %+v", v) + } + // Stale state fails closed: no row is startable, each says why. + for n, r := range rows { + if r.Startable || r.State.Startable || !slices.Contains(r.State.Notes, forgesync.NoteStale) { + t.Fatalf("Issue %d on stale state: startable %v notes %v", n, r.Startable, r.State.Notes) + } + } + var s SyncView + decode(t, do(t, h, authed("/api/sync")), &s) + if !s.Stale { + t.Fatalf("sync not stale: %+v", s.Freshness) + } + for id, d := range s.Molecules { + if d.Startable || !slices.Contains(d.Notes, forgesync.NoteStale) { + t.Fatalf("%s on stale state: %+v", id, d) + } + } +} + +// A snapshot older than twice the TTL is stale even when no sync failed. +func TestSyncFreshnessIsStaleAfterTwiceTheTTL(t *testing.T) { + snap := &forgesync.Snapshot{FetchedAt: time.Date(2026, 10, 27, 9, 0, 0, 0, time.UTC)} + if f := freshness(forgesync.Result{Snapshot: snap, Age: 2 * forgesync.TTL}); f.Stale { + t.Fatalf("at 2x TTL: %+v", f) + } + if f := freshness(forgesync.Result{Snapshot: snap, Age: 2*forgesync.TTL + time.Second}); !f.Stale || !f.Checked { + t.Fatalf("past 2x TTL: %+v", f) + } + if f := freshness(forgesync.Result{Reason: forgesync.ReasonOffline}); f.Stale || f.Checked { + t.Fatalf("no snapshot: %+v", f) + } +} + +func TestIssuesTruncationIsShown(t *testing.T) { + f := newSyncFetcher() + f.listing.Total, f.listing.Truncated = 742, true + h, _ := syncHandler(t, Options{LabDir: syncLab(t)}, f) + v, _ := issueRows(t, h, "/api/issues") + if !v.Truncated || v.Total != 742 || v.Shown != 8 || v.Note != "showing 8 of 742" { + t.Fatalf("truncation %+v", v) + } +} + +// The sync routes keep every read guard: Host, Origin, Fetch Metadata, cookie, +// tab header and GET only. A refused request never reads GitHub. +func TestIssuesAndSyncRoutesKeepTheReadGuards(t *testing.T) { + f := newSyncFetcher() + h, _ := syncHandler(t, Options{LabDir: syncLab(t)}, f) + for _, path := range []string{"/api/issues", "/api/sync", "/api/issues?refresh=1"} { + forged := authed(path) + forged.origin = "http://evil.example" + crossSite := authed(path) + crossSite.site = "cross-site" + noTab := authed(path) + noTab.header = map[string]string{sessionHeader: ""} + wrongHost := authed(path) + wrongHost.host = "localhost:4321" + for name, r := range map[string]request{"forged Origin": forged, "cross-site": crossSite, "no cookie": {path: path}, "no tab header": noTab, "wrong Host": wrongHost} { + if w := do(t, h, r); w.Code != http.StatusForbidden { + t.Errorf("%s %s: %d", name, path, w.Code) + } + } + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + r := authed(path) + r.method, r.origin = method, "http://"+testHost + if w := do(t, h, r); w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodGet { + t.Errorf("%s %s: %d Allow %q", method, path, w.Code, w.Header().Get("Allow")) + } + } + } + if f.calls() != 0 { + t.Fatalf("a refused request read GitHub %d times", f.calls()) + } +} + +// Demo mode serves labelled sample sync data and never calls the fetcher. +func TestIssuesDemoNeverCallsTheFetcher(t *testing.T) { + f := newSyncFetcher() + h, _ := syncHandler(t, Options{Demo: true}, f) + _, rows := issueRows(t, h, "/api/issues?refresh=1") + var v IssuesView + decode(t, do(t, h, authed("/api/issues")), &v) + if !v.Sample || !v.Checked || len(v.Issues) == 0 || !rows[901].Startable || rows[902].Startable || !rows[910].Startable || !rows[911].Startable || !rows[912].Locked { + t.Fatalf("demo issues %+v", v) + } + var s SyncView + decode(t, do(t, h, authed("/api/sync?refresh=1")), &s) + if !s.Sample || len(s.Molecules) != demoCount || s.Molecules["demo-101"].Label == "" { + t.Fatalf("demo sync: sample %v %d molecules", s.Sample, len(s.Molecules)) + } + if f.calls() != 0 { + t.Fatalf("demo called the fetcher %d times", f.calls()) + } +} diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 3dbd4ce..66f0aeb 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -194,6 +194,9 @@ type handler struct { // and tree. Tests may replace numstat. numstat func(ctx context.Context, lab, workspace, base, tree string) (molecule.DiffStat, error) diffs *diffCache + // forge is the read-only GitHub sync behind /api/issues and /api/sync; tests + // replace its fetcher and clock. + forge *issueSync } // staticFiles are the only embedded files served, with their content types. @@ -274,7 +277,7 @@ func NewHandler(o Options, token, host string) (http.Handler, error) { if err != nil { return nil, err } - return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache()}, nil + return &handler{opts: o, token: token, exchanged: make(chan struct{}), session: session, tab: tab, host: host, origin: "http://" + host, cookie: "forgecell_ui_" + port, static: static, records: molecule.ReadRecord, receipts: delivery.ReadReceiptStatus, cache: newListCache(), runScope: molecule.Run, preview: delivery.Preview, publish: delivery.Publish, jobs: newGateJobs(), runIssue: molecule.RunIntake, runLoop: molecule.RunLoop, loop: &driveLoop{}, retire: molecule.Retire, listIssues: intake.ReadSelectedOpenIssues, amend: molecule.AmendFromLatest, numstat: molecule.DiffNumstat, diffs: newDiffCache(), forge: newIssueSync(nil, nil)}, nil } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -358,6 +361,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.molecules(w, r) case path == "/api/analytics": h.analytics(w, r) + case path == "/api/issues": + h.issues(w, r) + case path == "/api/sync": + h.syncStates(w, r) case strings.HasPrefix(path, "/api/molecules/"): h.molecule(w, r, strings.TrimPrefix(path, "/api/molecules/")) case staticFiles[path] != "": diff --git a/lab/internal/ui/sync.go b/lab/internal/ui/sync.go new file mode 100644 index 0000000..de20914 --- /dev/null +++ b/lab/internal/ui/sync.go @@ -0,0 +1,64 @@ +package ui + +import ( + "github.com/DanielNetzer/forgecell/lab/internal/forgesync" + "net/http" + "strings" +) + +// SyncView is GET /api/sync: the one sync state of every listed Molecule, keyed +// by Molecule id, with the GitHub state's age. Without GitHub state every +// Molecule keeps its ledger lane, labelled not checked; nothing is guessed. +type SyncView struct { + Sample bool `json:"sample"` + Repository string `json:"repository"` + Freshness + // StatesTruncated means more Issues and pull requests were wanted than one + // sync reads; the rest are not checked. + StatesTruncated bool `json:"statesTruncated"` + Molecules map[string]forgesync.DisplayState `json:"molecules"` + // Counts tallies the buckets of the non-retired Molecules. + Counts map[string]int `json:"counts"` +} + +func (h *handler) syncStates(w http.ResponseWriter, r *http.Request) { + if h.opts.Demo { + h.json(w, http.StatusOK, demoSync()) + return + } + repo := h.syncRepository() + out := SyncView{Repository: repo, Molecules: map[string]forgesync.DisplayState{}, Counts: map[string]int{}} + work := h.syncWork(repo) + var res forgesync.Result + if repo == "" { + res.Reason = forgesync.ReasonRepository + } else { + res = h.forge.cache.Get(r.Context(), work.want, refresh(r)) + } + out.Freshness = freshness(res) + snap := res.Snapshot + var prs map[int64]forgesync.PR + if snap != nil { + prs, out.StatesTruncated = snap.PRs, snap.StatesTruncated + } + for _, s := range work.list.Molecules { + m, ok := work.inputs[s.ID] + var issue *forgesync.Issue + if ok && strings.EqualFold(s.Repository, repo) { + issue = snap.Issue(s.Number) + } else { + // Another repository's Molecule is never checked against this one. + m = forgesync.Molecule{ID: s.ID, Lane: s.Lane, StartedAt: s.StartedAt} + } + d := forgesync.ForMolecule(issue, prs, m) + if out.Stale { + d.Startable = false + d.Notes = append(d.Notes, forgesync.NoteStale) + } + out.Molecules[s.ID] = d + if d.Bucket != forgesync.BucketRetired { + out.Counts[d.Bucket]++ + } + } + h.json(w, http.StatusOK, out) +}