Skip to content

Commit 3637142

Browse files
authored
feat(watch): skip issues with open PRs; optional project filter (#33)
* feat(watch): skip issues that already have an open PR * feat(watch): optional GitHub Project and column filter
1 parent 79215b3 commit 3637142

10 files changed

Lines changed: 919 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in
8686
| `cast --theme office` / `none` | `init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) |
8787
| `recast` | Regenerate `.opencode/agents` from `.squad/team.md` |
8888
| `run -p <prompt>` / `--file <path> [--agent name] [--url]` | Prompt the OpenCode HTTP API as `squad`; auto-starts `opencode serve` on :4096 only |
89-
| `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` |
89+
| `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`) |
9090
| `export [file]` / `import <file> [--with-host]` | JSON snapshot of `.squad/` (optional host files) |
9191
| `externalize [--key name]` / `internalize` | Move *this* project's team out of the worktree |
9292
| `nap [--dry-run] [--deep]` / `scrub-emails [directory]` | Context and PII hygiene |

‎docs/use-cases.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Each original-Squad ease row is a **squad-oc command** you already ran, a **late
126126
| 3. First conversation / hire the team | Workshop §2 — Tab → **squad** → **yes** |
127127
| 4. Playground (talk, parallel, decisions) | Workshop §3–4 |
128128
| 5. GitHub Issues routing | Workshop §6 — `watch --label` (no Copilot label bot) |
129-
| 6. Project board tracking | **later** / not a Phase 0 command |
129+
| 6. Project board tracking | **supported** — `watch --project N` (optional `--column` Status) |
130130
| 7. Ralph | Workshop §6 — `watch --execute --once` |
131131
| 8. Skills marketplace | Workshop §9 — `marketplace browse` / `install`; named `name@source` later P4 |
132132
| 9. MCP (Teams, Outlook, …) | Workshop §8 — `mcp apply` (disabled stub; Teams/Outlook are examples only) |

‎docs/workshop/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ Detach later with `squad-oc link --off` (this repo uses its local `.squad/` agai
433433

434434
**Time:** about 10 minutes.
435435

436-
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.
436+
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.
437437

438438
Needs:
439439

‎internal/cli/cli.go‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Commands:
119119
run -p <prompt> | --file <path> [--agent name] [--url]
120120
watch | triage | loop [--execute] [--interval minutes] [--once] [--health] [--url]
121121
[--overnight-start HH:MM] [--overnight-end HH:MM] [--label name]
122+
[--project N] [--column name] [--force] [--retry-label name]
122123
[--log-file path] [--verbose] [--notify-level all|important|none]
123124
[--state-backend memory|git-notes|orphan-branch]
124125
export [file]
@@ -621,8 +622,10 @@ func cmdWatch(args []string) int {
621622
interval := 10
622623
verbose := false
623624
notifyLevel := watch.NotifyImportant
624-
var overnightStart, overnightEnd, apiURL, logFile, stateBackend string
625+
var overnightStart, overnightEnd, apiURL, logFile, stateBackend, retryLabel, column string
625626
var labels []string
627+
force := false
628+
project := 0
626629
for i := 0; i < len(args); i++ {
627630
a := args[i]
628631
switch {
@@ -634,6 +637,8 @@ func cmdWatch(args []string) int {
634637
health = true
635638
case a == "--verbose":
636639
verbose = true
640+
case a == "--force":
641+
force = true
637642
case a == "--interval" && i+1 < len(args):
638643
i++
639644
n, err := strconv.Atoi(args[i])
@@ -654,6 +659,20 @@ func cmdWatch(args []string) int {
654659
case a == "--label" && i+1 < len(args):
655660
i++
656661
labels = append(labels, args[i])
662+
case a == "--project" && i+1 < len(args):
663+
i++
664+
n, err := strconv.Atoi(args[i])
665+
if err != nil || n < 1 {
666+
fmt.Fprintln(os.Stderr, "invalid --project")
667+
return 2
668+
}
669+
project = n
670+
case a == "--column" && i+1 < len(args):
671+
i++
672+
column = args[i]
673+
case a == "--retry-label" && i+1 < len(args):
674+
i++
675+
retryLabel = args[i]
657676
case a == "--log-file" && i+1 < len(args):
658677
i++
659678
logFile = args[i]
@@ -686,6 +705,10 @@ func cmdWatch(args []string) int {
686705
fmt.Fprintln(os.Stderr, err)
687706
return 2
688707
}
708+
if column != "" && project == 0 {
709+
fmt.Fprintln(os.Stderr, "--column requires --project")
710+
return 2
711+
}
689712
if health {
690713
return cmdWatchHealth(root, backend)
691714
}
@@ -703,8 +726,16 @@ func cmdWatch(args []string) int {
703726
Logger: func(_ watch.NotifyLevel, msg string) {
704727
fmt.Println(msg)
705728
},
706-
Backend: backend,
707-
Lister: githubissues.GHLister{Dir: root, Labels: labels},
729+
Backend: backend,
730+
Lister: githubissues.GHLister{Dir: root, Labels: labels},
731+
PRChecker: githubissues.GHPRChecker{Dir: root},
732+
Project: project,
733+
Column: column,
734+
Force: force,
735+
RetryLabel: retryLabel,
736+
}
737+
if project > 0 {
738+
opts.ProjectSource = githubissues.GHProjectSource{Dir: root}
708739
}
709740
if exec {
710741
ensured, code := ensureAPI(apiURL, root)

‎internal/cli/cli_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ func TestAliasDispatch(t *testing.T) {
8888
if code := Execute([]string{"watch", "--state-backend", "memory", "--health"}); code == 2 {
8989
t.Fatal("--state-backend memory should not be unknown")
9090
}
91+
if code := Execute([]string{"watch", "--force", "--once"}); code == 2 {
92+
t.Fatal("--force should not be unknown")
93+
}
94+
if code := Execute([]string{"watch", "--retry-label", "ralph-retry", "--once"}); code == 2 {
95+
t.Fatal("--retry-label should not be unknown")
96+
}
97+
if code := Execute([]string{"watch", "--project", "3", "--once"}); code == 2 {
98+
t.Fatal("--project should not be unknown")
99+
}
100+
if code := Execute([]string{"watch", "--project", "3", "--column", "Todo", "--once"}); code == 2 {
101+
t.Fatal("--column with --project should not be unknown")
102+
}
103+
if Execute([]string{"watch", "--column", "Todo", "--once"}) != 2 {
104+
t.Fatal("--column without --project should be 2")
105+
}
106+
if Execute([]string{"watch", "--project", "nope", "--once"}) != 2 {
107+
t.Fatal("invalid --project should be 2")
108+
}
91109
}
92110

93111
func TestInitExportImportViaCLI(t *testing.T) {

‎internal/githubissues/gh.go‎

Lines changed: 231 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,34 @@ import (
1313
"github.com/xeaser/squad-opencode/internal/watch"
1414
)
1515

16-
// ParseListJSON parses `gh issue list --json number,title,state`.
16+
// ParseListJSON parses `gh issue list --json number,title,state,labels`.
1717
func ParseListJSON(data []byte) ([]watch.Issue, error) {
18-
var issues []watch.Issue
19-
if err := json.Unmarshal(data, &issues); err != nil {
18+
var rows []struct {
19+
Number int `json:"number"`
20+
Title string `json:"title"`
21+
State string `json:"state"`
22+
Labels []struct {
23+
Name string `json:"name"`
24+
} `json:"labels"`
25+
}
26+
if err := json.Unmarshal(data, &rows); err != nil {
2027
return nil, err
2128
}
29+
issues := make([]watch.Issue, 0, len(rows))
30+
for _, r := range rows {
31+
var labels []string
32+
for _, l := range r.Labels {
33+
if l.Name != "" {
34+
labels = append(labels, l.Name)
35+
}
36+
}
37+
issues = append(issues, watch.Issue{
38+
Number: r.Number,
39+
Title: r.Title,
40+
State: r.State,
41+
Labels: labels,
42+
})
43+
}
2244
return issues, nil
2345
}
2446

@@ -34,7 +56,7 @@ func (g GHLister) args() []string {
3456
if limit <= 0 {
3557
limit = 20
3658
}
37-
args := []string{"issue", "list", "--json", "number,title,state", "--limit", strconv.Itoa(limit)}
59+
args := []string{"issue", "list", "--json", "number,title,state,labels", "--limit", strconv.Itoa(limit)}
3860
for _, label := range g.Labels {
3961
if label == "" {
4062
continue
@@ -71,3 +93,208 @@ func (g GHLister) List(ctx context.Context) ([]watch.Issue, error) {
7193
}
7294
return issues, nil
7395
}
96+
97+
// GHPRChecker lists open PRs and maps linked issues (closing refs or body keywords).
98+
type GHPRChecker struct {
99+
Dir string
100+
run func(ctx context.Context, args ...string) ([]byte, error)
101+
}
102+
103+
// OpenLinks implements watch.LinkedPRChecker. Issue → first matching open PR.
104+
func (g GHPRChecker) OpenLinks(ctx context.Context) (map[int]int, error) {
105+
raw, err := g.exec(ctx, "pr", "list", "--state", "open", "--limit", "1000", "--json", "number")
106+
if err != nil {
107+
return nil, err
108+
}
109+
var prs []struct {
110+
Number int `json:"number"`
111+
}
112+
if len(bytes.TrimSpace(raw)) > 0 {
113+
if err := json.Unmarshal(raw, &prs); err != nil {
114+
return nil, fmt.Errorf("gh pr list: not JSON (%w)", err)
115+
}
116+
}
117+
links := make(map[int]int)
118+
for _, pr := range prs {
119+
if pr.Number <= 0 {
120+
continue
121+
}
122+
issues, err := g.linkedIssues(ctx, pr.Number)
123+
if err != nil {
124+
return nil, err
125+
}
126+
for _, n := range issues {
127+
if n > 0 {
128+
if _, exists := links[n]; !exists {
129+
links[n] = pr.Number
130+
}
131+
}
132+
}
133+
}
134+
return links, nil
135+
}
136+
137+
func (g GHPRChecker) linkedIssues(ctx context.Context, n int) ([]int, error) {
138+
raw, err := g.exec(ctx, "pr", "view", strconv.Itoa(n), "--json", "closingIssuesReferences,body")
139+
if err != nil {
140+
return nil, err
141+
}
142+
if len(raw) == 0 {
143+
return nil, nil
144+
}
145+
var wrap struct {
146+
Body string `json:"body"`
147+
ClosingIssuesReferences []struct {
148+
Number int `json:"number"`
149+
} `json:"closingIssuesReferences"`
150+
}
151+
if err := json.Unmarshal(raw, &wrap); err != nil {
152+
return parseBodyIssueRefs(string(raw)), nil
153+
}
154+
var out []int
155+
seen := map[int]bool{}
156+
for _, ref := range wrap.ClosingIssuesReferences {
157+
if ref.Number > 0 && !seen[ref.Number] {
158+
seen[ref.Number] = true
159+
out = append(out, ref.Number)
160+
}
161+
}
162+
for _, num := range parseBodyIssueRefs(wrap.Body) {
163+
if num > 0 && !seen[num] {
164+
seen[num] = true
165+
out = append(out, num)
166+
}
167+
}
168+
return out, nil
169+
}
170+
171+
func (g GHPRChecker) exec(ctx context.Context, args ...string) ([]byte, error) {
172+
if g.run != nil {
173+
return g.run(ctx, args...)
174+
}
175+
cmd := exec.CommandContext(ctx, "gh", args...)
176+
if g.Dir != "" {
177+
cmd.Dir = g.Dir
178+
}
179+
cmd.Env = append(os.Environ(), "NO_COLOR=1", "CLICOLOR=0", "GH_FORCE_TTY=0", "GH_PROMPT_DISABLED=1")
180+
var stdout, stderr bytes.Buffer
181+
cmd.Stdout = &stdout
182+
cmd.Stderr = &stderr
183+
if err := cmd.Run(); err != nil {
184+
return nil, fmt.Errorf("gh %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
185+
}
186+
return bytes.TrimSpace(stdout.Bytes()), nil
187+
}
188+
189+
// GHProjectSource lists GitHub Project v2 items (issue number + Status).
190+
type GHProjectSource struct {
191+
Dir string
192+
run func(ctx context.Context, args ...string) ([]byte, error)
193+
}
194+
195+
// Items implements watch.ProjectSource.
196+
func (g GHProjectSource) Items(ctx context.Context, project int) ([]watch.ProjectItem, error) {
197+
owner, err := g.owner(ctx)
198+
if err != nil {
199+
return nil, err
200+
}
201+
raw, err := g.exec(ctx, "project", "item-list", strconv.Itoa(project), "--owner", owner, "--format", "json", "--limit", "1000")
202+
if err != nil {
203+
return nil, err
204+
}
205+
return ParseProjectItemsJSON(raw)
206+
}
207+
208+
func (g GHProjectSource) owner(ctx context.Context) (string, error) {
209+
raw, err := g.exec(ctx, "repo", "view", "--json", "owner")
210+
if err != nil {
211+
return "", err
212+
}
213+
var wrap struct {
214+
Owner struct {
215+
Login string `json:"login"`
216+
} `json:"owner"`
217+
}
218+
if err := json.Unmarshal(raw, &wrap); err != nil || wrap.Owner.Login == "" {
219+
preview := string(raw)
220+
if len(preview) > 160 {
221+
preview = preview[:160]
222+
}
223+
if err != nil {
224+
return "", fmt.Errorf("gh repo view: not JSON (%w): %q", err, preview)
225+
}
226+
return "", fmt.Errorf("gh repo view: empty owner: %q", preview)
227+
}
228+
return wrap.Owner.Login, nil
229+
}
230+
231+
func (g GHProjectSource) exec(ctx context.Context, args ...string) ([]byte, error) {
232+
if g.run != nil {
233+
return g.run(ctx, args...)
234+
}
235+
cmd := exec.CommandContext(ctx, "gh", args...)
236+
if g.Dir != "" {
237+
cmd.Dir = g.Dir
238+
}
239+
cmd.Env = append(os.Environ(), "NO_COLOR=1", "CLICOLOR=0", "GH_FORCE_TTY=0", "GH_PROMPT_DISABLED=1")
240+
var stdout, stderr bytes.Buffer
241+
cmd.Stdout = &stdout
242+
cmd.Stderr = &stderr
243+
if err := cmd.Run(); err != nil {
244+
return nil, fmt.Errorf("gh %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
245+
}
246+
return bytes.TrimSpace(stdout.Bytes()), nil
247+
}
248+
249+
// ParseProjectItemsJSON parses `gh project item-list --format json`.
250+
func ParseProjectItemsJSON(data []byte) ([]watch.ProjectItem, error) {
251+
if len(bytes.TrimSpace(data)) == 0 {
252+
return nil, nil
253+
}
254+
var wrap struct {
255+
Items []struct {
256+
Status string `json:"status"`
257+
Content struct {
258+
Number int `json:"number"`
259+
} `json:"content"`
260+
} `json:"items"`
261+
}
262+
if err := json.Unmarshal(data, &wrap); err != nil {
263+
return nil, fmt.Errorf("gh project item-list: not JSON (%w)", err)
264+
}
265+
var out []watch.ProjectItem
266+
for _, it := range wrap.Items {
267+
if it.Content.Number <= 0 {
268+
continue
269+
}
270+
out = append(out, watch.ProjectItem{
271+
Number: it.Content.Number,
272+
Status: it.Status,
273+
})
274+
}
275+
return out, nil
276+
}
277+
278+
func parseBodyIssueRefs(body string) []int {
279+
low := strings.ToLower(body)
280+
var out []int
281+
for _, key := range []string{"closes #", "fixes #", "mentions #"} {
282+
rest := low
283+
for {
284+
i := strings.Index(rest, key)
285+
if i < 0 {
286+
break
287+
}
288+
rest = rest[i+len(key):]
289+
n := 0
290+
for len(rest) > 0 && rest[0] >= '0' && rest[0] <= '9' {
291+
n = n*10 + int(rest[0]-'0')
292+
rest = rest[1:]
293+
}
294+
if n > 0 {
295+
out = append(out, n)
296+
}
297+
}
298+
}
299+
return out
300+
}

0 commit comments

Comments
 (0)