diff --git a/.changeset/forge-set-assignee.md b/.changeset/forge-set-assignee.md new file mode 100644 index 000000000..f85757b0e --- /dev/null +++ b/.changeset/forge-set-assignee.md @@ -0,0 +1,8 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": patch +--- + +feat(platform): add `setAssignee()` to PlatformAdapter so assignee changes route through the platform abstraction (GitHub + ADO) instead of inline `gh`/`az` calls + +`GitHubAdapter.setAssignee` uses `gh issue edit --add/--remove-assignee`; `AzureDevOpsAdapter.setAssignee` sets `System.AssignedTo`. The watch command's `editWorkItem` now delegates assignee operations to the adapter, removing the hardcoded GitHub-vs-ADO branch. diff --git a/packages/squad-cli/src/cli/commands/loop.ts b/packages/squad-cli/src/cli/commands/loop.ts index c544d1196..21b8ac513 100644 --- a/packages/squad-cli/src/cli/commands/loop.ts +++ b/packages/squad-cli/src/cli/commands/loop.ts @@ -236,6 +236,7 @@ function createNoopAdapter(): ReturnType { addTag: async () => {}, removeTag: async () => {}, addComment: async () => {}, + setAssignee: async () => {}, listPullRequests: async () => [], createPullRequest: async () => { throw new Error(msg); }, mergePullRequest: async () => {}, diff --git a/packages/squad-cli/src/cli/commands/watch/index.ts b/packages/squad-cli/src/cli/commands/watch/index.ts index d07026935..a9587ae76 100644 --- a/packages/squad-cli/src/cli/commands/watch/index.ts +++ b/packages/squad-cli/src/cli/commands/watch/index.ts @@ -136,28 +136,20 @@ async function listWatchPullRequests( async function editWorkItem( adapter: PlatformAdapter, id: number, - options: { addLabel?: string; removeLabel?: string; addAssignee?: string; removeAssignee?: string }, + options: { addLabel?: string; removeLabel?: string; addAssignee?: string; removeAssignee?: boolean }, ): Promise { if (options.addLabel) await adapter.addTag(id, options.addLabel); if (options.removeLabel) await adapter.removeTag(id, options.removeLabel); if (options.addAssignee) { - if (adapter.type === 'github') { - try { - await execFileAsync('gh', ['issue', 'edit', String(id), '--add-assignee', options.addAssignee]); - } catch { /* best-effort */ } - } else if (adapter.type === 'azure-devops') { - const assignee = options.addAssignee === '@me' ? '' : options.addAssignee; - if (assignee) { - try { - execFileSync(azCmd, [ - 'boards', 'work-item', 'update', - '--id', String(id), - '--fields', `System.AssignedTo=${assignee}`, - '--output', 'json', - ], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...azExecOpts }); - } catch { /* best-effort */ } - } - } + try { + await adapter.setAssignee(id, options.addAssignee); + } catch { /* best-effort */ } + } + if (options.removeAssignee) { + try { + // setAssignee(undefined) unassigns the current assignee on both platforms. + await adapter.setAssignee(id, undefined); + } catch { /* best-effort */ } } } diff --git a/packages/squad-sdk/src/platform/azure-devops.ts b/packages/squad-sdk/src/platform/azure-devops.ts index e9036be24..54042547d 100644 --- a/packages/squad-sdk/src/platform/azure-devops.ts +++ b/packages/squad-sdk/src/platform/azure-devops.ts @@ -341,6 +341,17 @@ export class AzureDevOpsAdapter implements PlatformAdapter { ]); } + async setAssignee(workItemId: number, assignee: string | undefined): Promise { + // ADO has no '@me' token; the az CLI can't resolve current user, so skip it + // (matches prior behavior). undefined/empty unassigns; a name assigns. + if (assignee === '@me') return; + const value = assignee ?? ''; + this.az([ + 'boards', 'work-item', 'update', '--id', String(workItemId), + '--fields', `System.AssignedTo=${value}`, ...this.workItemArgs, '--output', 'json', + ]); + } + async listPullRequests(options: { status?: string; limit?: number }): Promise { const args = [ 'repos', 'pr', 'list', diff --git a/packages/squad-sdk/src/platform/github.ts b/packages/squad-sdk/src/platform/github.ts index 91d16c2f9..c784cc75c 100644 --- a/packages/squad-sdk/src/platform/github.ts +++ b/packages/squad-sdk/src/platform/github.ts @@ -147,6 +147,19 @@ export class GitHubAdapter implements PlatformAdapter { this.gh(['issue', 'comment', String(workItemId), '--repo', this.repoFlag, '--body', comment]); } + async setAssignee(workItemId: number, assignee: string | undefined): Promise { + const args = ['issue', 'edit', String(workItemId), '--repo', this.repoFlag]; + if (assignee) { + args.push('--add-assignee', assignee); + } else { + // Unassign: remove the current assignee (if any). + const wi = await this.getWorkItem(workItemId); + if (!wi.assignedTo) return; + args.push('--remove-assignee', wi.assignedTo); + } + this.gh(args); + } + async listPullRequests(options: { status?: string; limit?: number }): Promise { const args = ['pr', 'list', '--repo', this.repoFlag, '--json', 'number,title,headRefName,baseRefName,state,isDraft,reviewDecision,author,url']; if (options.status) args.push('--state', mapStatusToGhState(options.status)); diff --git a/packages/squad-sdk/src/platform/types.ts b/packages/squad-sdk/src/platform/types.ts index ae33ea0d2..18d3b69e5 100644 --- a/packages/squad-sdk/src/platform/types.ts +++ b/packages/squad-sdk/src/platform/types.ts @@ -49,6 +49,11 @@ export interface PlatformAdapter { addTag(workItemId: number, tag: string): Promise; removeTag(workItemId: number, tag: string): Promise; addComment(workItemId: number, comment: string): Promise; + /** + * Assign a work item to a user. Pass undefined/empty to unassign the current assignee. + * '@me' assigns the current user on GitHub; the ADO adapter cannot resolve '@me' and skips it (no-op). + */ + setAssignee(workItemId: number, assignee: string | undefined): Promise; /** Ensure a tag/label exists (creates it if missing). No-op on platforms with auto-created tags. */ ensureTag?(tag: string, options?: { color?: string; description?: string }): Promise; diff --git a/test/platform-adapter.test.ts b/test/platform-adapter.test.ts index 0963d24f1..949edd05c 100644 --- a/test/platform-adapter.test.ts +++ b/test/platform-adapter.test.ts @@ -251,6 +251,7 @@ describe('PlatformAdapter createWorkItem interface', () => { addTag: async () => {}, removeTag: async () => {}, addComment: async () => {}, + setAssignee: async () => {}, listPullRequests: async () => [], createPullRequest: async () => ({ id: 1, title: '', sourceBranch: '', targetBranch: '', status: 'active' as const, author: '', url: '' }), mergePullRequest: async () => {}, @@ -275,6 +276,7 @@ describe('PlatformAdapter createWorkItem interface', () => { addTag: async () => {}, removeTag: async () => {}, addComment: async () => {}, + setAssignee: async () => {}, listPullRequests: async () => [], createPullRequest: async () => ({ id: 1, title: '', sourceBranch: '', targetBranch: '', status: 'active' as const, author: '', url: '' }), mergePullRequest: async () => {}, @@ -307,6 +309,7 @@ describe('PlatformAdapter createWorkItem interface', () => { addTag: async () => {}, removeTag: async () => {}, addComment: async () => {}, + setAssignee: async () => {}, listPullRequests: async () => [], createPullRequest: async () => ({ id: 1, title: '', sourceBranch: '', targetBranch: '', status: 'active' as const, author: '', url: '' }), mergePullRequest: async () => {}, @@ -320,6 +323,31 @@ describe('PlatformAdapter createWorkItem interface', () => { }); }); +// ─── setAssignee ────────────────────────────────────────────────────── + +describe('setAssignee', () => { + it('is part of the PlatformAdapter interface and accepts a user or undefined', async () => { + const calls: Array = []; + const mockAdapter: PlatformAdapter = { + type: 'github' as PlatformType, + listWorkItems: async () => [], + getWorkItem: async (id: number) => ({ id, title: '', state: '', tags: [], url: '' }), + createWorkItem: async (o) => ({ id: 1, title: o.title, state: 'new', tags: [], url: '' }), + addTag: async () => {}, + removeTag: async () => {}, + addComment: async () => {}, + setAssignee: async (_id, user) => { calls.push(user); }, + listPullRequests: async () => [], + createPullRequest: async () => ({ id: 1, title: '', sourceBranch: '', targetBranch: '', status: 'active' as const, author: '', url: '' }), + mergePullRequest: async () => {}, + createBranch: async () => {}, + }; + await mockAdapter.setAssignee(1, 'copilot-swe-agent'); + await mockAdapter.setAssignee(1, undefined); + expect(calls).toEqual(['copilot-swe-agent', undefined]); + }); +}); + // ─── PullRequest Type Shape ──────────────────────────────────────────── describe('PullRequest type', () => { @@ -1071,142 +1099,142 @@ describe('ADO exports from platform index', () => { expect(types.length).toBeGreaterThan(0); }); }); - -// ─── createPlatformAdapter with .squad/config.json GitHub override ───── - -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { execSync } from 'node:child_process'; -import { tmpdir } from 'node:os'; - -describe('createPlatformAdapter with .squad/config.json github override', () => { - let tempDir: string; - - function setupTempRepo(): string { - tempDir = mkdtempSync(join(tmpdir(), 'squad-test-')); - // Initialize a git repo with a GitHub remote (fallback detection target) - execSync('git init', { cwd: tempDir, stdio: 'ignore' }); - execSync('git remote add origin https://github.com/fallbackowner/fallbackrepo.git', { cwd: tempDir, stdio: 'ignore' }); - return tempDir; - } - - function cleanup(): void { - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - } - } - - it('uses owner/repo from .squad/config.json when present', async () => { - const repoRoot = setupTempRepo(); - try { - // Write .squad/config.json with github override - const squadDir = join(repoRoot, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ - github: { owner: 'testowner', repo: 'testrepo' } - })); - - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - // Should be a GitHubAdapter with the config values, not the remote values - expect(adapter).toBeDefined(); - // Access internal state via the adapter's known interface - expect((adapter as any).owner).toBe('testowner'); - expect((adapter as any).repo).toBe('testrepo'); - } finally { - cleanup(); - } - }); - - it('falls back to git remote when github key is absent', async () => { - const repoRoot = setupTempRepo(); - try { - // Write .squad/config.json WITHOUT github key - const squadDir = join(repoRoot, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ version: 1 })); - - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - expect(adapter).toBeDefined(); - expect((adapter as any).owner).toBe('fallbackowner'); - expect((adapter as any).repo).toBe('fallbackrepo'); - } finally { - cleanup(); - } - }); - - it('falls back to git remote when github key is malformed (missing repo)', async () => { - const repoRoot = setupTempRepo(); - try { - const squadDir = join(repoRoot, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ - github: { owner: 'testowner' } // missing repo - })); - - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - expect(adapter).toBeDefined(); - expect((adapter as any).owner).toBe('fallbackowner'); - expect((adapter as any).repo).toBe('fallbackrepo'); - } finally { - cleanup(); - } - }); - - it('falls back to git remote when github key is malformed (non-string values)', async () => { - const repoRoot = setupTempRepo(); - try { - const squadDir = join(repoRoot, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ - github: { owner: 123, repo: true } - })); - - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - expect(adapter).toBeDefined(); - expect((adapter as any).owner).toBe('fallbackowner'); - expect((adapter as any).repo).toBe('fallbackrepo'); - } finally { - cleanup(); - } - }); - - it('falls back to git remote when config.json is invalid JSON', async () => { - const repoRoot = setupTempRepo(); - try { - const squadDir = join(repoRoot, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'config.json'), '{ not valid json !!!'); - - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - expect(adapter).toBeDefined(); - expect((adapter as any).owner).toBe('fallbackowner'); - expect((adapter as any).repo).toBe('fallbackrepo'); - } finally { - cleanup(); - } - }); - - it('falls back to git remote when no .squad/config.json exists', async () => { - const repoRoot = setupTempRepo(); - try { - const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); - const adapter = createPlatformAdapter(repoRoot); - - expect(adapter).toBeDefined(); - expect((adapter as any).owner).toBe('fallbackowner'); - expect((adapter as any).repo).toBe('fallbackrepo'); - } finally { - cleanup(); - } - }); + +// ─── createPlatformAdapter with .squad/config.json GitHub override ───── + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +describe('createPlatformAdapter with .squad/config.json github override', () => { + let tempDir: string; + + function setupTempRepo(): string { + tempDir = mkdtempSync(join(tmpdir(), 'squad-test-')); + // Initialize a git repo with a GitHub remote (fallback detection target) + execSync('git init', { cwd: tempDir, stdio: 'ignore' }); + execSync('git remote add origin https://github.com/fallbackowner/fallbackrepo.git', { cwd: tempDir, stdio: 'ignore' }); + return tempDir; + } + + function cleanup(): void { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + } + + it('uses owner/repo from .squad/config.json when present', async () => { + const repoRoot = setupTempRepo(); + try { + // Write .squad/config.json with github override + const squadDir = join(repoRoot, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ + github: { owner: 'testowner', repo: 'testrepo' } + })); + + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + // Should be a GitHubAdapter with the config values, not the remote values + expect(adapter).toBeDefined(); + // Access internal state via the adapter's known interface + expect((adapter as any).owner).toBe('testowner'); + expect((adapter as any).repo).toBe('testrepo'); + } finally { + cleanup(); + } + }); + + it('falls back to git remote when github key is absent', async () => { + const repoRoot = setupTempRepo(); + try { + // Write .squad/config.json WITHOUT github key + const squadDir = join(repoRoot, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ version: 1 })); + + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + expect(adapter).toBeDefined(); + expect((adapter as any).owner).toBe('fallbackowner'); + expect((adapter as any).repo).toBe('fallbackrepo'); + } finally { + cleanup(); + } + }); + + it('falls back to git remote when github key is malformed (missing repo)', async () => { + const repoRoot = setupTempRepo(); + try { + const squadDir = join(repoRoot, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ + github: { owner: 'testowner' } // missing repo + })); + + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + expect(adapter).toBeDefined(); + expect((adapter as any).owner).toBe('fallbackowner'); + expect((adapter as any).repo).toBe('fallbackrepo'); + } finally { + cleanup(); + } + }); + + it('falls back to git remote when github key is malformed (non-string values)', async () => { + const repoRoot = setupTempRepo(); + try { + const squadDir = join(repoRoot, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ + github: { owner: 123, repo: true } + })); + + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + expect(adapter).toBeDefined(); + expect((adapter as any).owner).toBe('fallbackowner'); + expect((adapter as any).repo).toBe('fallbackrepo'); + } finally { + cleanup(); + } + }); + + it('falls back to git remote when config.json is invalid JSON', async () => { + const repoRoot = setupTempRepo(); + try { + const squadDir = join(repoRoot, '.squad'); + mkdirSync(squadDir, { recursive: true }); + writeFileSync(join(squadDir, 'config.json'), '{ not valid json !!!'); + + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + expect(adapter).toBeDefined(); + expect((adapter as any).owner).toBe('fallbackowner'); + expect((adapter as any).repo).toBe('fallbackrepo'); + } finally { + cleanup(); + } + }); + + it('falls back to git remote when no .squad/config.json exists', async () => { + const repoRoot = setupTempRepo(); + try { + const { createPlatformAdapter } = await import('../packages/squad-sdk/src/platform/index.js'); + const adapter = createPlatformAdapter(repoRoot); + + expect(adapter).toBeDefined(); + expect((adapter as any).owner).toBe('fallbackowner'); + expect((adapter as any).repo).toBe('fallbackrepo'); + } finally { + cleanup(); + } + }); });