Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prompt>` / `--file <path> [--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] [--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 <file> [--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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion docs/workshop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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:

Expand Down
37 changes: 34 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Commands:
run -p <prompt> | --file <path> [--agent name] [--url]
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]
export [file]
Expand Down Expand Up @@ -621,8 +622,10 @@ 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, column string
var labels []string
force := false
project := 0
for i := 0; i < len(args); i++ {
a := args[i]
switch {
Expand All @@ -634,6 +637,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])
Expand All @@ -654,6 +659,20 @@ 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]
case a == "--log-file" && i+1 < len(args):
i++
logFile = args[i]
Expand Down Expand Up @@ -686,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)
}
Expand All @@ -703,8 +726,16 @@ 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},
Project: project,
Column: column,
Force: force,
RetryLabel: retryLabel,
}
if project > 0 {
opts.ProjectSource = githubissues.GHProjectSource{Dir: root}
}
if exec {
ensured, code := ensureAPI(apiURL, root)
Expand Down
18 changes: 18 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ 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")
}
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) {
Expand Down
235 changes: 231 additions & 4 deletions internal/githubissues/gh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -71,3 +93,208 @@ 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
}

// 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
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
}
Loading