diff --git a/docs/src/content/docs/features/ralph.md b/docs/src/content/docs/features/ralph.md index e6f42e399..9bc80e975 100644 --- a/docs/src/content/docs/features/ralph.md +++ b/docs/src/content/docs/features/ralph.md @@ -268,6 +268,7 @@ All new features are **opt-in** and disabled by default. Existing `squad watch` |------|-------------|---------| | `--board` | Enable project board lifecycle (In Progress / Done / Blocked + reconciliation) | `squad watch --board` | | `--board-project N` | Project board number (default: 1) | `squad watch --board --board-project 2` | +| `--board-owner X` | Project owner — `@me` for personal, org name for org-owned ProjectV2 boards (default: `@me`) | `squad watch --board --board-project 22 --board-owner my-org` | #### Housekeeping & Governance diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index fcf9ceeb3..bee8ce282 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -45,6 +45,8 @@ squad init | `squad watch --monitor-teams` | Scan Teams for actionable messages each round | Yes | | `squad watch --monitor-email` | Scan email for alerts and action items each round | Yes | | `squad watch --board` | Enable project board lifecycle management | Yes | +| `squad watch --board-project N` | Project board number to manage (default: 1) | Yes | +| `squad watch --board-owner X` | Project owner — `@me` for personal, org name for org-owned ProjectV2 (default: `@me`) | Yes | | `squad watch --two-pass` | Use two-pass scanning (lightweight → hydrate) | Yes | | `squad watch --wave-dispatch` | Parallel sub-task execution within issues | Yes | | `squad watch --retro` | Enforce retrospective checks | Yes | diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index ac1638e1a..23870e40e 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -183,6 +183,7 @@ async function main(): Promise { console.log(` --self-pull git fetch/pull at round start`); console.log(` --board project board lifecycle + reconciliation`); console.log(` --board-project N project number (default 1)`); + console.log(` --board-owner X project owner (default @me; use org name for org-owned ProjectV2)`); console.log(` --monitor-teams scan Teams for actionable messages`); console.log(` --monitor-email scan email for actionable items`); console.log(` --two-pass lightweight list then hydrate actionable`); @@ -483,6 +484,15 @@ async function main(): Promise { : { projectNumber: parseInt(args[boardProjectIdx + 1]!, 10) }; } + // --board-owner sets the project owner (default @me, override for org-owned ProjectV2 boards) + const boardOwnerIdx = args.indexOf('--board-owner'); + if (boardOwnerIdx !== -1 && args[boardOwnerIdx + 1]) { + const existing = capabilities['board']; + capabilities['board'] = typeof existing === 'object' && existing !== null + ? { ...existing, owner: args[boardOwnerIdx + 1]! } + : { owner: args[boardOwnerIdx + 1]! }; + } + // Load config: .squad/config.json merged with CLI overrides const config = loadWatchConfig(getSquadStartDir(), { interval, @@ -506,7 +516,7 @@ async function main(): Promise { // After parsing all flags, check for positional args that look like prompts. // Skip values that follow known value-flags (e.g. "--interval 5" → "5" is not positional). const knownValueFlags = new Set([ - '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--auth-user', + '--interval', '--copilot-flags', '--agent-cmd', '--max-concurrent', '--timeout', '--board-project', '--board-owner', '--auth-user', '--dispatch-mode', '--log-file', '--notify-level', '--overnight-start', '--overnight-end', '--sentinel-file', '--state-backend', ]); const watchArgStart = args.indexOf(cmd) + 1; diff --git a/packages/squad-cli/src/cli/commands/watch/capabilities/board.ts b/packages/squad-cli/src/cli/commands/watch/capabilities/board.ts index 0f3f191d8..abbd1094b 100644 --- a/packages/squad-cli/src/cli/commands/watch/capabilities/board.ts +++ b/packages/squad-cli/src/cli/commands/watch/capabilities/board.ts @@ -26,13 +26,15 @@ export class BoardCapability implements WatchCapability { async execute(context: WatchContext): Promise { const projectNumber = (context.config['projectNumber'] as number) ?? 1; + const rawOwner = context.config['owner']; + const owner = typeof rawOwner === 'string' ? rawOwner : '@me'; let mismatches = 0; try { // Reconcile: move closed issues to Done, open issues out of Done const { stdout: itemsJson } = await execFileAsync('gh', [ 'project', 'item-list', String(projectNumber), - '--owner', '@me', + '--owner', owner, '--format', 'json', '--limit', '300', ], { maxBuffer: 10 * 1024 * 1024 }); diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index 9afea3def..16a466c94 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -567,6 +567,7 @@ export interface WatchOptions { monitorEmail?: boolean; board?: boolean; boardProject?: number; + boardOwner?: string; twoPass?: boolean; waveDispatch?: boolean; retro?: boolean; @@ -580,7 +581,11 @@ function legacyToConfig(options: WatchOptions): WatchConfig { if (options.execute) capabilities['execute'] = true; if (options.monitorTeams) capabilities['monitor-teams'] = true; if (options.monitorEmail) capabilities['monitor-email'] = true; - if (options.board) capabilities['board'] = { projectNumber: options.boardProject ?? 1 }; + if (options.board) { + const boardConfig: Record = { projectNumber: options.boardProject ?? 1 }; + if (options.boardOwner) boardConfig['owner'] = options.boardOwner; + capabilities['board'] = boardConfig; + } if (options.twoPass) capabilities['two-pass'] = true; if (options.waveDispatch) capabilities['wave-dispatch'] = true; if (options.retro) capabilities['retro'] = true; diff --git a/test/watch-board-capability.test.ts b/test/watch-board-capability.test.ts new file mode 100644 index 000000000..e7d736c41 --- /dev/null +++ b/test/watch-board-capability.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for BoardCapability — covers `--owner` propagation so org-owned + * ProjectV2 boards work (not just `@me` personal projects). + * + * Closes #1079 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +interface ExecFileCall { + cmd: string; + args: readonly string[]; +} + +const cpMocks = vi.hoisted(() => { + const calls: ExecFileCall[] = []; + // promisify(execFile) uses util.promisify.custom — without that symbol, + // promisify falls back to (err, value) → resolve(value). Shape the value + // as { stdout, stderr } so destructuring in the capability still works. + type ExecCb = (err: Error | null, value: { stdout: string; stderr: string }) => void; + return { + calls, + execFile: vi.fn((cmd: string, args: readonly string[], ...rest: unknown[]) => { + calls.push({ cmd, args }); + const cb = rest[rest.length - 1] as ExecCb | undefined; + if (typeof cb === 'function') { + cb(null, { stdout: JSON.stringify({ items: [] }), stderr: '' }); + } + }), + execFileSync: vi.fn(() => 'owner/repo'), + }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { ...actual, execFile: cpMocks.execFile, execFileSync: cpMocks.execFileSync }; +}); + +import { BoardCapability } from '../packages/squad-cli/src/cli/commands/watch/capabilities/board.js'; +import type { WatchContext } from '../packages/squad-cli/src/cli/commands/watch/types.js'; + +function makeContext(config: Record): WatchContext { + return { + teamRoot: '/tmp/team', + // The capability never touches the adapter — only `config`. + adapter: {} as WatchContext['adapter'], + round: 1, + roster: [], + config, + }; +} + +beforeEach(() => { + cpMocks.calls.length = 0; + cpMocks.execFile.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('BoardCapability owner config', () => { + it('passes a configured owner through to gh project item-list', async () => { + const cap = new BoardCapability(); + const result = await cap.execute(makeContext({ projectNumber: 22, owner: 'my-org' })); + + expect(result.success).toBe(true); + + const itemList = cpMocks.calls.find( + (c) => c.cmd === 'gh' && c.args[0] === 'project' && c.args[1] === 'item-list', + ); + expect(itemList).toBeDefined(); + const ownerIdx = itemList!.args.indexOf('--owner'); + expect(ownerIdx).toBeGreaterThan(-1); + expect(itemList!.args[ownerIdx + 1]).toBe('my-org'); + // Project number should also be respected + expect(itemList!.args[2]).toBe('22'); + }); + + it('defaults owner to @me when none is configured', async () => { + const cap = new BoardCapability(); + await cap.execute(makeContext({ projectNumber: 1 })); + + const itemList = cpMocks.calls.find( + (c) => c.cmd === 'gh' && c.args[0] === 'project' && c.args[1] === 'item-list', + ); + expect(itemList).toBeDefined(); + const ownerIdx = itemList!.args.indexOf('--owner'); + expect(itemList!.args[ownerIdx + 1]).toBe('@me'); + }); +});