From 2e967976b11892ee2542c2ca41572b1c7bb5cf05 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Sun, 30 Aug 2026 13:48:02 +0530 Subject: [PATCH 1/2] feat(watch): skip issues that already have an open PR --- README.md | 2 +- docs/workshop/README.md | 2 +- internal/cli/cli.go | 16 +++- internal/cli/cli_test.go | 6 ++ internal/githubissues/gh.go | 146 ++++++++++++++++++++++++++++++- internal/githubissues/gh_test.go | 77 ++++++++++++++++ internal/watch/health.go | 19 ++-- internal/watch/watch.go | 93 ++++++++++++++++++-- internal/watch/watch_test.go | 134 ++++++++++++++++++++++++++++ 9 files changed, 472 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index df7388f..eafa87d 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in | `cast --theme office` / `none` | `init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) | | `recast` | Regenerate `.opencode/agents` from `.squad/team.md` | | `run -p ` / `--file [--agent name] [--url]` | Prompt the OpenCode HTTP API as `squad`; auto-starts `opencode serve` on :4096 only | -| `watch` / `triage` / `loop` `[--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] [--log-file path] [--verbose] [--notify-level all\|important\|none] [--state-backend memory\|git-notes\|orphan-branch]` | Issue triage (Ralph); `--execute` uses `run` | +| `watch` / `triage` / `loop` `[--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] [--force] [--retry-label name] [--log-file path] [--verbose] [--notify-level all\|important\|none] [--state-backend memory\|git-notes\|orphan-branch]` | Issue triage (Ralph); `--execute` uses `run`; skips issues with an open linked PR unless `--force` or `--retry-label` (default `ralph-retry`) | | `export [file]` / `import [--with-host]` | JSON snapshot of `.squad/` (optional host files) | | `externalize [--key name]` / `internalize` | Move *this* project's team out of the worktree | | `nap [--dry-run] [--deep]` / `scrub-emails [directory]` | Context and PII hygiene | diff --git a/docs/workshop/README.md b/docs/workshop/README.md index 2e244cd..5a5427f 100644 --- a/docs/workshop/README.md +++ b/docs/workshop/README.md @@ -433,7 +433,7 @@ Detach later with `squad-oc link --off` (this repo uses its local `.squad/` agai **Time:** about 10 minutes. -Ralph is `squad-oc watch` (aliases: `triage`, `loop`). It lists GitHub issues via `gh` and, with `--execute`, prompts the **squad** agent over the OpenCode **HTTP API** (`opencode serve`), not the TUI. +Ralph is `squad-oc watch` (aliases: `triage`, `loop`). It lists GitHub issues via `gh` and, with `--execute`, prompts the **squad** agent over the OpenCode **HTTP API** (`opencode serve`), not the TUI. Issues that already have an open linked PR are skipped; `--force` or `--retry-label` (default `ralph-retry`) re-enables them. Needs: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1932dcd..babaea8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -119,6 +119,7 @@ Commands: run -p | --file [--agent name] [--url] watch | triage | loop [--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] + [--force] [--retry-label name] [--log-file path] [--verbose] [--notify-level all|important|none] [--state-backend memory|git-notes|orphan-branch] export [file] @@ -621,8 +622,9 @@ func cmdWatch(args []string) int { interval := 10 verbose := false notifyLevel := watch.NotifyImportant - var overnightStart, overnightEnd, apiURL, logFile, stateBackend string + var overnightStart, overnightEnd, apiURL, logFile, stateBackend, retryLabel string var labels []string + force := false for i := 0; i < len(args); i++ { a := args[i] switch { @@ -634,6 +636,8 @@ func cmdWatch(args []string) int { health = true case a == "--verbose": verbose = true + case a == "--force": + force = true case a == "--interval" && i+1 < len(args): i++ n, err := strconv.Atoi(args[i]) @@ -654,6 +658,9 @@ func cmdWatch(args []string) int { case a == "--label" && i+1 < len(args): i++ labels = append(labels, args[i]) + case a == "--retry-label" && i+1 < len(args): + i++ + retryLabel = args[i] case a == "--log-file" && i+1 < len(args): i++ logFile = args[i] @@ -703,8 +710,11 @@ func cmdWatch(args []string) int { Logger: func(_ watch.NotifyLevel, msg string) { fmt.Println(msg) }, - Backend: backend, - Lister: githubissues.GHLister{Dir: root, Labels: labels}, + Backend: backend, + Lister: githubissues.GHLister{Dir: root, Labels: labels}, + PRChecker: githubissues.GHPRChecker{Dir: root}, + Force: force, + RetryLabel: retryLabel, } if exec { ensured, code := ensureAPI(apiURL, root) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 763d934..d0f663f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -88,6 +88,12 @@ func TestAliasDispatch(t *testing.T) { if code := Execute([]string{"watch", "--state-backend", "memory", "--health"}); code == 2 { t.Fatal("--state-backend memory should not be unknown") } + if code := Execute([]string{"watch", "--force", "--once"}); code == 2 { + t.Fatal("--force should not be unknown") + } + if code := Execute([]string{"watch", "--retry-label", "ralph-retry", "--once"}); code == 2 { + t.Fatal("--retry-label should not be unknown") + } } func TestInitExportImportViaCLI(t *testing.T) { diff --git a/internal/githubissues/gh.go b/internal/githubissues/gh.go index f953473..c6f33bf 100644 --- a/internal/githubissues/gh.go +++ b/internal/githubissues/gh.go @@ -13,12 +13,34 @@ import ( "github.com/xeaser/squad-opencode/internal/watch" ) -// ParseListJSON parses `gh issue list --json number,title,state`. +// ParseListJSON parses `gh issue list --json number,title,state,labels`. func ParseListJSON(data []byte) ([]watch.Issue, error) { - var issues []watch.Issue - if err := json.Unmarshal(data, &issues); err != nil { + var rows []struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + } + if err := json.Unmarshal(data, &rows); err != nil { return nil, err } + issues := make([]watch.Issue, 0, len(rows)) + for _, r := range rows { + var labels []string + for _, l := range r.Labels { + if l.Name != "" { + labels = append(labels, l.Name) + } + } + issues = append(issues, watch.Issue{ + Number: r.Number, + Title: r.Title, + State: r.State, + Labels: labels, + }) + } return issues, nil } @@ -34,7 +56,7 @@ func (g GHLister) args() []string { if limit <= 0 { limit = 20 } - args := []string{"issue", "list", "--json", "number,title,state", "--limit", strconv.Itoa(limit)} + args := []string{"issue", "list", "--json", "number,title,state,labels", "--limit", strconv.Itoa(limit)} for _, label := range g.Labels { if label == "" { continue @@ -71,3 +93,119 @@ func (g GHLister) List(ctx context.Context) ([]watch.Issue, error) { } return issues, nil } + +// GHPRChecker lists open PRs and maps linked issues (closing refs or body keywords). +type GHPRChecker struct { + Dir string + run func(ctx context.Context, args ...string) ([]byte, error) +} + +// OpenLinks implements watch.LinkedPRChecker. Issue → first matching open PR. +func (g GHPRChecker) OpenLinks(ctx context.Context) (map[int]int, error) { + raw, err := g.exec(ctx, "pr", "list", "--state", "open", "--limit", "1000", "--json", "number") + if err != nil { + return nil, err + } + var prs []struct { + Number int `json:"number"` + } + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &prs); err != nil { + return nil, fmt.Errorf("gh pr list: not JSON (%w)", err) + } + } + links := make(map[int]int) + for _, pr := range prs { + if pr.Number <= 0 { + continue + } + issues, err := g.linkedIssues(ctx, pr.Number) + if err != nil { + return nil, err + } + for _, n := range issues { + if n > 0 { + if _, exists := links[n]; !exists { + links[n] = pr.Number + } + } + } + } + return links, nil +} + +func (g GHPRChecker) linkedIssues(ctx context.Context, n int) ([]int, error) { + raw, err := g.exec(ctx, "pr", "view", strconv.Itoa(n), "--json", "closingIssuesReferences,body") + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + var wrap struct { + Body string `json:"body"` + ClosingIssuesReferences []struct { + Number int `json:"number"` + } `json:"closingIssuesReferences"` + } + if err := json.Unmarshal(raw, &wrap); err != nil { + return parseBodyIssueRefs(string(raw)), nil + } + var out []int + seen := map[int]bool{} + for _, ref := range wrap.ClosingIssuesReferences { + if ref.Number > 0 && !seen[ref.Number] { + seen[ref.Number] = true + out = append(out, ref.Number) + } + } + for _, num := range parseBodyIssueRefs(wrap.Body) { + if num > 0 && !seen[num] { + seen[num] = true + out = append(out, num) + } + } + return out, nil +} + +func (g GHPRChecker) exec(ctx context.Context, args ...string) ([]byte, error) { + if g.run != nil { + return g.run(ctx, args...) + } + cmd := exec.CommandContext(ctx, "gh", args...) + if g.Dir != "" { + cmd.Dir = g.Dir + } + cmd.Env = append(os.Environ(), "NO_COLOR=1", "CLICOLOR=0", "GH_FORCE_TTY=0", "GH_PROMPT_DISABLED=1") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gh %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return bytes.TrimSpace(stdout.Bytes()), nil +} + +func parseBodyIssueRefs(body string) []int { + low := strings.ToLower(body) + var out []int + for _, key := range []string{"closes #", "fixes #", "mentions #"} { + rest := low + for { + i := strings.Index(rest, key) + if i < 0 { + break + } + rest = rest[i+len(key):] + n := 0 + for len(rest) > 0 && rest[0] >= '0' && rest[0] <= '9' { + n = n*10 + int(rest[0]-'0') + rest = rest[1:] + } + if n > 0 { + out = append(out, n) + } + } + } + return out +} diff --git a/internal/githubissues/gh_test.go b/internal/githubissues/gh_test.go index e44ca79..223da1f 100644 --- a/internal/githubissues/gh_test.go +++ b/internal/githubissues/gh_test.go @@ -1,6 +1,8 @@ package githubissues import ( + "context" + "fmt" "strings" "testing" ) @@ -22,4 +24,79 @@ func TestGHListerArgsLabel(t *testing.T) { if !strings.Contains(got, "--limit 20") { t.Fatalf("default limit 20 in %q", got) } + if !strings.Contains(got, "labels") { + t.Fatalf("want labels in --json: %q", got) + } +} + +func TestParseListJSONLabels(t *testing.T) { + raw := []byte(`[{"number":1,"title":"Hi","state":"OPEN","labels":[{"name":"bug","id":"1"},{"name":"ralph-retry"}]}]`) + issues, err := ParseListJSON(raw) + if err != nil || len(issues) != 1 { + t.Fatalf("%v %+v", err, issues) + } + if len(issues[0].Labels) != 2 || issues[0].Labels[0] != "bug" || issues[0].Labels[1] != "ralph-retry" { + t.Fatalf("labels %+v", issues[0].Labels) + } +} + +func TestGHPRCheckerClosingRefsAndBody(t *testing.T) { + g := GHPRChecker{Dir: t.TempDir(), run: func(_ context.Context, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "pr list"): + if !strings.Contains(joined, "--state open") { + t.Fatalf("want open PRs only: %q", joined) + } + return []byte(`[{"number":3},{"number":4},{"number":5}]`), nil + case strings.Contains(joined, "pr view 3"): + return []byte(`{"closingIssuesReferences":[{"number":7}],"body":""}`), nil + case strings.Contains(joined, "pr view 4"): + return []byte(`{"closingIssuesReferences":[],"body":"Closes #8\nfixes #9\nmentions #10"}`), nil + case strings.Contains(joined, "pr view 5"): + return []byte(`{"closingIssuesReferences":[],"body":"see #11 and also #7"}`), nil + default: + return nil, fmt.Errorf("unexpected gh %v", args) + } + }} + links, err := g.OpenLinks(context.Background()) + if err != nil { + t.Fatal(err) + } + if links[7] != 3 || links[8] != 4 || links[9] != 4 || links[10] != 4 { + t.Fatalf("links %+v", links) + } + if _, ok := links[11]; ok { + t.Fatalf("bare #N must not link: %+v", links) + } +} + +func TestGHPRCheckerFirstPRWins(t *testing.T) { + g := GHPRChecker{run: func(_ context.Context, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "pr list"): + return []byte(`[{"number":3},{"number":9}]`), nil + case strings.Contains(joined, "pr view 3"): + return []byte(`{"closingIssuesReferences":[{"number":7}],"body":""}`), nil + case strings.Contains(joined, "pr view 9"): + return []byte(`{"closingIssuesReferences":[{"number":7}],"body":""}`), nil + default: + return nil, fmt.Errorf("unexpected gh %v", args) + } + }} + links, err := g.OpenLinks(context.Background()) + if err != nil || links[7] != 3 { + t.Fatalf("first PR wins: %+v %v", links, err) + } +} + +func TestGHPRCheckerExecError(t *testing.T) { + g := GHPRChecker{run: func(context.Context, ...string) ([]byte, error) { + return nil, fmt.Errorf("gh down") + }} + _, err := g.OpenLinks(context.Background()) + if err == nil { + t.Fatal("want exec error") + } } diff --git a/internal/watch/health.go b/internal/watch/health.go index f07cdb4..9b1ea49 100644 --- a/internal/watch/health.go +++ b/internal/watch/health.go @@ -13,15 +13,16 @@ import ( // Health is the live watch snapshot written to ralph-status.json (and optional state backends). type Health struct { - PID int `json:"pid"` - StartedAt time.Time `json:"startedAt"` - LastPoll time.Time `json:"lastPoll"` - LastSummary string `json:"lastSummary"` - LastError string `json:"lastError,omitempty"` - Consecutive int `json:"consecutiveErrors"` - NextPoll time.Time `json:"nextPoll"` - Round int `json:"round"` - Overnight bool `json:"overnight"` + PID int `json:"pid"` + StartedAt time.Time `json:"startedAt"` + LastPoll time.Time `json:"lastPoll"` + LastSummary string `json:"lastSummary"` + LastError string `json:"lastError,omitempty"` + Consecutive int `json:"consecutiveErrors"` + NextPoll time.Time `json:"nextPoll"` + Round int `json:"round"` + Overnight bool `json:"overnight"` + Skipped []SkippedIssue `json:"skipped,omitempty"` } // StatusPath is ralph-status.json under the live team directory. diff --git a/internal/watch/watch.go b/internal/watch/watch.go index e6cdc2f..e4a6bb3 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -19,11 +19,43 @@ import ( // pushOTLP is the OTel export hook. TestMain no-ops it; collector-down tests replace it. var pushOTLP = traces.Push +const ( + SkipReasonOpenPR = "open-pr" + DefaultRetryLabel = "ralph-retry" +) + // Issue is a work item (usually a GitHub issue). type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Labels []string `json:"labels,omitempty"` +} + +// SkippedIssue is one issue dropped this pass (open linked PR). +type SkippedIssue struct { Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` + Reason string `json:"reason"` // "open-pr" + PR int `json:"pr,omitempty"` +} + +// LinkedPRChecker maps issue numbers to an open PR that links them. +type LinkedPRChecker interface { + OpenLinks(ctx context.Context) (map[int]int, error) // issue -> pr +} + +// StaticPRChecker returns a fixed issue→PR map (tests). +type StaticPRChecker struct { + Links map[int]int + Err error +} + +// OpenLinks implements LinkedPRChecker. +func (s StaticPRChecker) OpenLinks(context.Context) (map[int]int, error) { + if s.Err != nil { + return nil, s.Err + } + return s.Links, nil } // IssueLister lists actionable issues. @@ -53,6 +85,9 @@ type Options struct { Now func() time.Time Runner opencodeclient.Runner Lister IssueLister + PRChecker LinkedPRChecker + Force bool + RetryLabel string Labels []string LogFile string Verbose bool @@ -244,7 +279,8 @@ func clockNow(opts Options) time.Time { // Pass runs one poll cycle. Returns whether execute ran. func Pass(ctx context.Context, opts Options) (executed bool, summary string, err error) { - defer func() { writePassHealth(opts, summary, err) }() + var skipped []SkippedIssue + defer func() { writePassHealth(opts, summary, err, skipped) }() prevOvernight := false if h, rerr := loadHealth(context.Background(), opts); rerr == nil { prevOvernight = h.Overnight @@ -277,18 +313,30 @@ func Pass(ctx context.Context, opts Options) (executed bool, summary string, err notify(opts, NotifyImportant, "list error: "+err.Error()) return false, "", err } + if opts.PRChecker != nil { + var links map[int]int + links, err = opts.PRChecker.OpenLinks(ctx) + if err != nil { + notify(opts, NotifyImportant, "open-pr check: "+err.Error()) + return false, "", err + } + issues, skipped = dropLinkedIssues(issues, links, opts) + } ctxText, err := BuildContext(opts.ProjectRoot, issues, opts.Labels...) if err != nil { notify(opts, NotifyImportant, "context error: "+err.Error()) return false, "", err } - summary = fmt.Sprintf("issues=%d execute=%v", len(issues), opts.Execute) + summary = fmt.Sprintf("issues=%d skipped=%d execute=%v", len(issues), len(skipped), opts.Execute) if !opts.Execute { if opts.Notify == NotifyAll { notify(opts, NotifyAll, ctxText) } return false, summary + "\n" + ctxText, nil } + if len(issues) == 0 { + return false, summary, nil + } if opts.Runner == nil { err = fmt.Errorf("execute requires a runner") notify(opts, NotifyImportant, err.Error()) @@ -338,7 +386,41 @@ func Pass(ctx context.Context, opts Options) (executed bool, summary string, err return true, summary + "\n" + res.Text, nil } -func writePassHealth(opts Options, summary string, err error) { +func dropLinkedIssues(issues []Issue, links map[int]int, opts Options) ([]Issue, []SkippedIssue) { + if opts.Force || len(links) == 0 { + return issues, nil + } + retry := opts.RetryLabel + if retry == "" { + retry = DefaultRetryLabel + } + var remaining []Issue + var skipped []SkippedIssue + for _, is := range issues { + pr, linked := links[is.Number] + if linked && !issueHasLabel(is, retry) { + skipped = append(skipped, SkippedIssue{ + Number: is.Number, + Reason: SkipReasonOpenPR, + PR: pr, + }) + continue + } + remaining = append(remaining, is) + } + return remaining, skipped +} + +func issueHasLabel(is Issue, name string) bool { + for _, l := range is.Labels { + if l == name { + return true + } + } + return false +} + +func writePassHealth(opts Options, summary string, err error, skipped []SkippedIssue) { if opts.ProjectRoot == "" && opts.Backend == nil { return } @@ -352,6 +434,7 @@ func writePassHealth(opts Options, summary string, err error) { } h.LastPoll = now h.LastSummary = firstLine(summary) + h.Skipped = skipped h.Round++ if err != nil { h.LastError = err.Error() diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 0ba3ac3..fdd3753 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -335,6 +335,140 @@ func TestFormatHealthZero(t *testing.T) { } } +func TestPassSkipsIssueWithOpenLinkedPR(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{{Number: 7, Title: "Bug", State: "OPEN"}}}, + PRChecker: StaticPRChecker{Links: map[int]int{7: 3}}, + Runner: fake, + }) + if err != nil || ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 0 { + t.Fatalf("must not execute skipped issue: %+v", fake.Calls) + } + if !strings.Contains(summary, "skipped=1") { + t.Fatal(summary) + } + h, herr := ReadHealth(root) + if herr != nil { + t.Fatal(herr) + } + if !strings.Contains(h.LastSummary, "skipped=1") { + t.Fatalf("health summary %q", h.LastSummary) + } + if len(h.Skipped) != 1 || h.Skipped[0].Number != 7 || h.Skipped[0].Reason != SkipReasonOpenPR || h.Skipped[0].PR != 3 { + t.Fatalf("skipped %+v", h.Skipped) + } +} + +func TestPassExecutesUnlinkedIssue(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{ + {Number: 7, Title: "has PR", State: "OPEN"}, + {Number: 8, Title: "free", State: "OPEN"}, + }}, + PRChecker: StaticPRChecker{Links: map[int]int{7: 3}}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + if !strings.Contains(fake.Calls[0].Prompt, "#8") || strings.Contains(fake.Calls[0].Prompt, "#7") { + t.Fatalf("prompt should list remaining only:\n%s", fake.Calls[0].Prompt) + } + if !strings.Contains(summary, "issues=1") || !strings.Contains(summary, "skipped=1") { + t.Fatal(summary) + } +} + +func TestPassForceExecutesLinkedIssue(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, _, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Force: true, + Lister: StaticLister{Issues: []Issue{{Number: 7, Title: "Bug", State: "OPEN"}}}, + PRChecker: StaticPRChecker{Links: map[int]int{7: 3}}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + h, _ := ReadHealth(root) + if len(h.Skipped) != 0 { + t.Fatalf("force must not record skip: %+v", h.Skipped) + } +} + +func TestPassRetryLabelExecutesLinkedIssue(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, _, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{{ + Number: 7, Title: "Bug", State: "OPEN", Labels: []string{"ralph-retry"}, + }}}, + PRChecker: StaticPRChecker{Links: map[int]int{7: 3}}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } +} + +func TestPassPRCheckerErrorFails(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, _, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{{Number: 7, Title: "Bug", State: "OPEN"}}}, + PRChecker: StaticPRChecker{Err: errors.New("gh down")}, + Runner: fake, + }) + if err == nil || ok { + t.Fatal("want checker error") + } + if len(fake.Calls) != 0 { + t.Fatal(fake.Calls) + } +} + func TestPassWritesHealth(t *testing.T) { root := t.TempDir() if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { From 7c3610e1eff4d702e013c44d4b156607dcc0aebc Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Sun, 30 Aug 2026 14:01:46 +0530 Subject: [PATCH 2/2] feat(watch): optional GitHub Project and column filter --- README.md | 2 +- docs/use-cases.md | 2 +- docs/workshop/README.md | 2 +- internal/cli/cli.go | 25 +++- internal/cli/cli_test.go | 12 ++ internal/githubissues/gh.go | 89 ++++++++++++++ internal/githubissues/gh_test.go | 70 +++++++++++ internal/watch/watch.go | 62 ++++++++++ internal/watch/watch_test.go | 192 +++++++++++++++++++++++++++++++ 9 files changed, 451 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eafa87d..f7acf7e 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in | `cast --theme office` / `none` | `init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) | | `recast` | Regenerate `.opencode/agents` from `.squad/team.md` | | `run -p ` / `--file [--agent name] [--url]` | Prompt the OpenCode HTTP API as `squad`; auto-starts `opencode serve` on :4096 only | -| `watch` / `triage` / `loop` `[--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] [--force] [--retry-label name] [--log-file path] [--verbose] [--notify-level all\|important\|none] [--state-backend memory\|git-notes\|orphan-branch]` | Issue triage (Ralph); `--execute` uses `run`; skips issues with an open linked PR unless `--force` or `--retry-label` (default `ralph-retry`) | +| `watch` / `triage` / `loop` `[--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] [--project N] [--column name] [--force] [--retry-label name] [--log-file path] [--verbose] [--notify-level all\|important\|none] [--state-backend memory\|git-notes\|orphan-branch]` | Issue triage (Ralph); `--execute` uses `run`; optional GitHub Project v2 + Status filter; skips issues with an open linked PR unless `--force` or `--retry-label` (default `ralph-retry`) | | `export [file]` / `import [--with-host]` | JSON snapshot of `.squad/` (optional host files) | | `externalize [--key name]` / `internalize` | Move *this* project's team out of the worktree | | `nap [--dry-run] [--deep]` / `scrub-emails [directory]` | Context and PII hygiene | diff --git a/docs/use-cases.md b/docs/use-cases.md index f3be761..09c0294 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -126,7 +126,7 @@ Each original-Squad ease row is a **squad-oc command** you already ran, a **late | 3. First conversation / hire the team | Workshop §2 — Tab → **squad** → **yes** | | 4. Playground (talk, parallel, decisions) | Workshop §3–4 | | 5. GitHub Issues routing | Workshop §6 — `watch --label` (no Copilot label bot) | -| 6. Project board tracking | **later** / not a Phase 0 command | +| 6. Project board tracking | **supported** — `watch --project N` (optional `--column` Status) | | 7. Ralph | Workshop §6 — `watch --execute --once` | | 8. Skills marketplace | Workshop §9 — `marketplace browse` / `install`; named `name@source` later P4 | | 9. MCP (Teams, Outlook, …) | Workshop §8 — `mcp apply` (disabled stub; Teams/Outlook are examples only) | diff --git a/docs/workshop/README.md b/docs/workshop/README.md index 5a5427f..27d7bd8 100644 --- a/docs/workshop/README.md +++ b/docs/workshop/README.md @@ -433,7 +433,7 @@ Detach later with `squad-oc link --off` (this repo uses its local `.squad/` agai **Time:** about 10 minutes. -Ralph is `squad-oc watch` (aliases: `triage`, `loop`). It lists GitHub issues via `gh` and, with `--execute`, prompts the **squad** agent over the OpenCode **HTTP API** (`opencode serve`), not the TUI. Issues that already have an open linked PR are skipped; `--force` or `--retry-label` (default `ralph-retry`) re-enables them. +Ralph is `squad-oc watch` (aliases: `triage`, `loop`). It lists GitHub issues via `gh` and, with `--execute`, prompts the **squad** agent over the OpenCode **HTTP API** (`opencode serve`), not the TUI. Optional `--project N` keeps only issues on that GitHub Project v2 (`--column` matches Status). Issues that already have an open linked PR are skipped; `--force` or `--retry-label` (default `ralph-retry`) re-enables them. Needs: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index babaea8..491e33e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -119,7 +119,7 @@ Commands: run -p | --file [--agent name] [--url] watch | triage | loop [--execute] [--interval minutes] [--once] [--health] [--url] [--overnight-start HH:MM] [--overnight-end HH:MM] [--label name] - [--force] [--retry-label name] + [--project N] [--column name] [--force] [--retry-label name] [--log-file path] [--verbose] [--notify-level all|important|none] [--state-backend memory|git-notes|orphan-branch] export [file] @@ -622,9 +622,10 @@ func cmdWatch(args []string) int { interval := 10 verbose := false notifyLevel := watch.NotifyImportant - var overnightStart, overnightEnd, apiURL, logFile, stateBackend, retryLabel string + var overnightStart, overnightEnd, apiURL, logFile, stateBackend, retryLabel, column string var labels []string force := false + project := 0 for i := 0; i < len(args); i++ { a := args[i] switch { @@ -658,6 +659,17 @@ func cmdWatch(args []string) int { case a == "--label" && i+1 < len(args): i++ labels = append(labels, args[i]) + case a == "--project" && i+1 < len(args): + i++ + n, err := strconv.Atoi(args[i]) + if err != nil || n < 1 { + fmt.Fprintln(os.Stderr, "invalid --project") + return 2 + } + project = n + case a == "--column" && i+1 < len(args): + i++ + column = args[i] case a == "--retry-label" && i+1 < len(args): i++ retryLabel = args[i] @@ -693,6 +705,10 @@ func cmdWatch(args []string) int { fmt.Fprintln(os.Stderr, err) return 2 } + if column != "" && project == 0 { + fmt.Fprintln(os.Stderr, "--column requires --project") + return 2 + } if health { return cmdWatchHealth(root, backend) } @@ -713,9 +729,14 @@ func cmdWatch(args []string) int { Backend: backend, Lister: githubissues.GHLister{Dir: root, Labels: labels}, PRChecker: githubissues.GHPRChecker{Dir: root}, + Project: project, + Column: column, Force: force, RetryLabel: retryLabel, } + if project > 0 { + opts.ProjectSource = githubissues.GHProjectSource{Dir: root} + } if exec { ensured, code := ensureAPI(apiURL, root) if code != 0 { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d0f663f..e562e4d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -94,6 +94,18 @@ func TestAliasDispatch(t *testing.T) { if code := Execute([]string{"watch", "--retry-label", "ralph-retry", "--once"}); code == 2 { t.Fatal("--retry-label should not be unknown") } + if code := Execute([]string{"watch", "--project", "3", "--once"}); code == 2 { + t.Fatal("--project should not be unknown") + } + if code := Execute([]string{"watch", "--project", "3", "--column", "Todo", "--once"}); code == 2 { + t.Fatal("--column with --project should not be unknown") + } + if Execute([]string{"watch", "--column", "Todo", "--once"}) != 2 { + t.Fatal("--column without --project should be 2") + } + if Execute([]string{"watch", "--project", "nope", "--once"}) != 2 { + t.Fatal("invalid --project should be 2") + } } func TestInitExportImportViaCLI(t *testing.T) { diff --git a/internal/githubissues/gh.go b/internal/githubissues/gh.go index c6f33bf..ca6a192 100644 --- a/internal/githubissues/gh.go +++ b/internal/githubissues/gh.go @@ -186,6 +186,95 @@ func (g GHPRChecker) exec(ctx context.Context, args ...string) ([]byte, error) { return bytes.TrimSpace(stdout.Bytes()), nil } +// GHProjectSource lists GitHub Project v2 items (issue number + Status). +type GHProjectSource struct { + Dir string + run func(ctx context.Context, args ...string) ([]byte, error) +} + +// Items implements watch.ProjectSource. +func (g GHProjectSource) Items(ctx context.Context, project int) ([]watch.ProjectItem, error) { + owner, err := g.owner(ctx) + if err != nil { + return nil, err + } + raw, err := g.exec(ctx, "project", "item-list", strconv.Itoa(project), "--owner", owner, "--format", "json", "--limit", "1000") + if err != nil { + return nil, err + } + return ParseProjectItemsJSON(raw) +} + +func (g GHProjectSource) owner(ctx context.Context) (string, error) { + raw, err := g.exec(ctx, "repo", "view", "--json", "owner") + if err != nil { + return "", err + } + var wrap struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + } + if err := json.Unmarshal(raw, &wrap); err != nil || wrap.Owner.Login == "" { + preview := string(raw) + if len(preview) > 160 { + preview = preview[:160] + } + if err != nil { + return "", fmt.Errorf("gh repo view: not JSON (%w): %q", err, preview) + } + return "", fmt.Errorf("gh repo view: empty owner: %q", preview) + } + return wrap.Owner.Login, nil +} + +func (g GHProjectSource) exec(ctx context.Context, args ...string) ([]byte, error) { + if g.run != nil { + return g.run(ctx, args...) + } + cmd := exec.CommandContext(ctx, "gh", args...) + if g.Dir != "" { + cmd.Dir = g.Dir + } + cmd.Env = append(os.Environ(), "NO_COLOR=1", "CLICOLOR=0", "GH_FORCE_TTY=0", "GH_PROMPT_DISABLED=1") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gh %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return bytes.TrimSpace(stdout.Bytes()), nil +} + +// ParseProjectItemsJSON parses `gh project item-list --format json`. +func ParseProjectItemsJSON(data []byte) ([]watch.ProjectItem, error) { + if len(bytes.TrimSpace(data)) == 0 { + return nil, nil + } + var wrap struct { + Items []struct { + Status string `json:"status"` + Content struct { + Number int `json:"number"` + } `json:"content"` + } `json:"items"` + } + if err := json.Unmarshal(data, &wrap); err != nil { + return nil, fmt.Errorf("gh project item-list: not JSON (%w)", err) + } + var out []watch.ProjectItem + for _, it := range wrap.Items { + if it.Content.Number <= 0 { + continue + } + out = append(out, watch.ProjectItem{ + Number: it.Content.Number, + Status: it.Status, + }) + } + return out, nil +} + func parseBodyIssueRefs(body string) []int { low := strings.ToLower(body) var out []int diff --git a/internal/githubissues/gh_test.go b/internal/githubissues/gh_test.go index 223da1f..213b4d2 100644 --- a/internal/githubissues/gh_test.go +++ b/internal/githubissues/gh_test.go @@ -91,6 +91,76 @@ func TestGHPRCheckerFirstPRWins(t *testing.T) { } } +func TestParseProjectItemsJSON(t *testing.T) { + raw := []byte(`{"items":[ + {"status":"Todo","content":{"type":"Issue","number":7,"title":"A"}}, + {"status":"In Progress","content":{"type":"Issue","number":8}}, + {"status":"Done","content":{"type":"DraftIssue","title":"no number"}}, + {"status":"Todo","content":{"type":"PullRequest","number":9}} + ]}`) + items, err := ParseProjectItemsJSON(raw) + if err != nil { + t.Fatal(err) + } + if len(items) != 3 { + t.Fatalf("items %+v", items) + } + if items[0].Number != 7 || items[0].Status != "Todo" { + t.Fatalf("item0 %+v", items[0]) + } + if items[1].Number != 8 || items[1].Status != "In Progress" { + t.Fatalf("item1 %+v", items[1]) + } + if items[2].Number != 9 || items[2].Status != "Todo" { + t.Fatalf("item2 %+v", items[2]) + } +} + +func TestGHProjectSourceItems(t *testing.T) { + var calls []string + g := GHProjectSource{Dir: t.TempDir(), run: func(_ context.Context, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + calls = append(calls, joined) + switch { + case strings.Contains(joined, "repo view"): + if !strings.Contains(joined, "--json owner") { + t.Fatalf("want owner json: %q", joined) + } + return []byte(`{"owner":{"login":"acme","id":"1"}}`), nil + case strings.Contains(joined, "project item-list"): + if !strings.Contains(joined, "item-list 4") { + t.Fatalf("want project 4: %q", joined) + } + if !strings.Contains(joined, "--owner acme") || !strings.Contains(joined, "--format json") { + t.Fatalf("want owner+json: %q", joined) + } + return []byte(`{"items":[{"status":"Ready","content":{"type":"Issue","number":12}}]}`), nil + default: + return nil, fmt.Errorf("unexpected gh %v", args) + } + }} + items, err := g.Items(context.Background(), 4) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Number != 12 || items[0].Status != "Ready" { + t.Fatalf("items %+v", items) + } + if len(calls) != 2 { + t.Fatalf("calls %v", calls) + } +} + +func TestGHProjectSourceExecError(t *testing.T) { + g := GHProjectSource{run: func(context.Context, ...string) ([]byte, error) { + return nil, fmt.Errorf("gh down") + }} + _, err := g.Items(context.Background(), 1) + if err == nil { + t.Fatal("want exec error") + } +} + func TestGHPRCheckerExecError(t *testing.T) { g := GHPRChecker{run: func(context.Context, ...string) ([]byte, error) { return nil, fmt.Errorf("gh down") diff --git a/internal/watch/watch.go b/internal/watch/watch.go index e4a6bb3..9f4c3f0 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -39,6 +39,31 @@ type SkippedIssue struct { PR int `json:"pr,omitempty"` } +// ProjectItem is one GitHub Project v2 item (issue number + Status). +type ProjectItem struct { + Number int + Status string +} + +// ProjectSource lists items on a GitHub Project v2. +type ProjectSource interface { + Items(ctx context.Context, project int) ([]ProjectItem, error) +} + +// StaticProjectSource returns a fixed list (tests). +type StaticProjectSource struct { + List []ProjectItem + Err error +} + +// Items implements ProjectSource. +func (s StaticProjectSource) Items(context.Context, int) ([]ProjectItem, error) { + if s.Err != nil { + return nil, s.Err + } + return s.List, nil +} + // LinkedPRChecker maps issue numbers to an open PR that links them. type LinkedPRChecker interface { OpenLinks(ctx context.Context) (map[int]int, error) // issue -> pr @@ -86,6 +111,9 @@ type Options struct { Runner opencodeclient.Runner Lister IssueLister PRChecker LinkedPRChecker + ProjectSource ProjectSource + Project int // GitHub Project v2 number; 0 = no filter + Column string // Status field; requires Project Force bool RetryLabel string Labels []string @@ -313,6 +341,20 @@ func Pass(ctx context.Context, opts Options) (executed bool, summary string, err notify(opts, NotifyImportant, "list error: "+err.Error()) return false, "", err } + if opts.Project > 0 { + if opts.ProjectSource == nil { + err = fmt.Errorf("no project source") + notify(opts, NotifyImportant, err.Error()) + return false, "", err + } + var items []ProjectItem + items, err = opts.ProjectSource.Items(ctx, opts.Project) + if err != nil { + notify(opts, NotifyImportant, "project filter: "+err.Error()) + return false, "", err + } + issues = intersectProject(issues, items, opts.Column) + } if opts.PRChecker != nil { var links map[int]int links, err = opts.PRChecker.OpenLinks(ctx) @@ -411,6 +453,26 @@ func dropLinkedIssues(issues []Issue, links map[int]int, opts Options) ([]Issue, return remaining, skipped } +func intersectProject(issues []Issue, items []ProjectItem, column string) []Issue { + want := make(map[int]bool, len(items)) + for _, it := range items { + if it.Number <= 0 { + continue + } + if column != "" && !strings.EqualFold(it.Status, column) { + continue + } + want[it.Number] = true + } + var remaining []Issue + for _, is := range issues { + if want[is.Number] { + remaining = append(remaining, is) + } + } + return remaining +} + func issueHasLabel(is Issue, name string) bool { for _, l := range is.Labels { if l == name { diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index fdd3753..9106dde 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -448,6 +448,198 @@ func TestPassRetryLabelExecutesLinkedIssue(t *testing.T) { } } +type recordingProjectSource struct { + calls int + items []ProjectItem + err error +} + +func (r *recordingProjectSource) Items(context.Context, int) ([]ProjectItem, error) { + r.calls++ + if r.err != nil { + return nil, r.err + } + return r.items, nil +} + +func TestPassNoProjectDoesNotCallProjectSource(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + src := &recordingProjectSource{items: []ProjectItem{{Number: 7, Status: "Todo"}}} + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{{Number: 8, Title: "off board", State: "OPEN"}}}, + ProjectSource: src, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if src.calls != 0 { + t.Fatalf("no --project must not call project source: calls=%d", src.calls) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + if !strings.Contains(fake.Calls[0].Prompt, "#8") { + t.Fatalf("listing unchanged:\n%s", fake.Calls[0].Prompt) + } +} + +func TestPassProjectIntersectsIssues(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Project: 3, + Lister: StaticLister{Issues: []Issue{ + {Number: 7, Title: "on board", State: "OPEN"}, + {Number: 8, Title: "off board", State: "OPEN"}, + {Number: 9, Title: "also on", State: "OPEN"}, + }}, + ProjectSource: StaticProjectSource{List: []ProjectItem{ + {Number: 7, Status: "Todo"}, + {Number: 9, Status: "In Progress"}, + }}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + prompt := fake.Calls[0].Prompt + if !strings.Contains(prompt, "#7") || !strings.Contains(prompt, "#9") || strings.Contains(prompt, "#8") { + t.Fatalf("want project intersection only:\n%s", prompt) + } + if !strings.Contains(summary, "issues=2") { + t.Fatal(summary) + } +} + +func TestPassColumnFiltersStatusCaseInsensitive(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Project: 3, + Column: "todo", + Lister: StaticLister{Issues: []Issue{ + {Number: 7, Title: "todo", State: "OPEN"}, + {Number: 8, Title: "doing", State: "OPEN"}, + }}, + ProjectSource: StaticProjectSource{List: []ProjectItem{ + {Number: 7, Status: "Todo"}, + {Number: 8, Status: "In Progress"}, + }}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + if !strings.Contains(fake.Calls[0].Prompt, "#7") || strings.Contains(fake.Calls[0].Prompt, "#8") { + t.Fatalf("want Todo column only:\n%s", fake.Calls[0].Prompt) + } +} + +func TestPassProjectThenOpenPRSkip(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Project: 3, + Lister: StaticLister{Issues: []Issue{ + {Number: 7, Title: "has PR", State: "OPEN"}, + {Number: 8, Title: "free", State: "OPEN"}, + }}, + ProjectSource: StaticProjectSource{List: []ProjectItem{ + {Number: 7, Status: "Todo"}, + {Number: 8, Status: "Todo"}, + }}, + PRChecker: StaticPRChecker{Links: map[int]int{7: 3}}, + Runner: fake, + }) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 1 { + t.Fatal(fake.Calls) + } + if !strings.Contains(fake.Calls[0].Prompt, "#8") || strings.Contains(fake.Calls[0].Prompt, "#7") { + t.Fatalf("PR skip after project filter:\n%s", fake.Calls[0].Prompt) + } + if !strings.Contains(summary, "issues=1") || !strings.Contains(summary, "skipped=1") { + t.Fatal(summary) + } +} + +func TestPassEmptyAfterProjectNoExecute(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, summary, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Project: 3, + Lister: StaticLister{Issues: []Issue{{Number: 7, Title: "off board", State: "OPEN"}}}, + ProjectSource: StaticProjectSource{List: []ProjectItem{{Number: 9, Status: "Todo"}}}, + Runner: fake, + }) + if err != nil || ok { + t.Fatalf("ok=%v err=%v %s", ok, err, summary) + } + if len(fake.Calls) != 0 { + t.Fatal(fake.Calls) + } + if !strings.Contains(summary, "issues=0") { + t.Fatal(summary) + } +} + +func TestPassProjectSourceErrorFails(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + fake := &opencodeclient.FakeRunner{Text: "done"} + ok, _, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Project: 3, + Lister: StaticLister{Issues: []Issue{{Number: 7, Title: "Bug", State: "OPEN"}}}, + ProjectSource: StaticProjectSource{Err: errors.New("gh down")}, + Runner: fake, + }) + if err == nil || ok { + t.Fatal("want project source error") + } + if len(fake.Calls) != 0 { + t.Fatal(fake.Calls) + } +} + func TestPassPRCheckerErrorFails(t *testing.T) { root := t.TempDir() if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil {