Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/src/content/docs/features/ralph.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 11 additions & 1 deletion packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ async function main(): Promise<void> {
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`);
Comment on lines 183 to 187

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

console.log(` --monitor-email scan email for actionable items`);
console.log(` --two-pass lightweight list then hydrate actionable`);
Expand Down Expand Up @@ -483,6 +484,15 @@ async function main(): Promise<void> {
: { 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]! };
}
Comment on lines +487 to +494

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment on lines +487 to +494

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


// Load config: .squad/config.json merged with CLI overrides
const config = loadWatchConfig(getSquadStartDir(), {
interval,
Expand All @@ -506,7 +516,7 @@ async function main(): Promise<void> {
// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ export class BoardCapability implements WatchCapability {

async execute(context: WatchContext): Promise<CapabilityResult> {
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 });
Expand Down
7 changes: 6 additions & 1 deletion packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ export interface WatchOptions {
monitorEmail?: boolean;
board?: boolean;
boardProject?: number;
boardOwner?: string;
twoPass?: boolean;
waveDispatch?: boolean;
retro?: boolean;
Expand All @@ -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<string, unknown> = { 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;
Expand Down
91 changes: 91 additions & 0 deletions test/watch-board-capability.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:child_process')>('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<string, unknown>): 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');
});
});
Loading