From 35a38b99b73f137adc3f9725a674c312929262ec Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Mon, 1 Jun 2026 15:28:52 +0530 Subject: [PATCH] feat(agents): add diff and open commands - agents:diff renders the change set for an agent run (with --no-strip-binary to keep binary patches) - agents:open opens the run's preview, dashboard, or PR in the browser, honoring NETLIFY_WEB_UI for staging Part 3/8 of the agents CLI revamp split. --- docs/commands/agents.md | 72 ++++++ docs/index.md | 2 + src/commands/agents/agents-diff.ts | 124 ++++++++++ src/commands/agents/agents-open.ts | 81 +++++++ src/commands/agents/agents.ts | 42 ++++ .../commands/agents/agents-diff.test.ts | 169 ++++++++++++++ .../commands/agents/agents-open.test.ts | 215 ++++++++++++++++++ 7 files changed, 705 insertions(+) create mode 100644 src/commands/agents/agents-diff.ts create mode 100644 src/commands/agents/agents-open.ts create mode 100644 tests/integration/commands/agents/agents-diff.test.ts create mode 100644 tests/integration/commands/agents/agents-open.test.ts diff --git a/docs/commands/agents.md b/docs/commands/agents.md index 78e3816b28c..f0e121f45d3 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -28,7 +28,9 @@ netlify agents | Subcommand | description | |:--------------------------- |:-----| | [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site | +| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run | | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | +| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | @@ -39,6 +41,8 @@ netlify agents netlify agents:create --prompt "Add a contact form" netlify agents:list --status running netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a ``` --- @@ -81,6 +85,43 @@ netlify agents:create -p "Update README" -a codex -b feature-branch netlify agents:create "Triage this error" --attach error.log --attach screenshot.png ``` +--- +## `agents:diff` + +Print the code changes produced by an agent run + +**Usage** + +```bash +netlify agents:diff +``` + +**Arguments** + +- id - agent run ID + +**Flags** + +- `cumulative` (*boolean*) - with --session, show the cumulative diff up through that session +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `no-color` (*boolean*) - disable color in the output +- `no-strip-binary` (*boolean*) - include raw binary content in the diff (binary is stripped by default) +- `page` (*string*) - page number (1-based) +- `per-page` (*string*) - files per page (max 100) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in +- `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - show a single session diff instead of the run aggregate + +**Examples** + +```bash +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2 +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative +netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less +``` + --- ## `agents:list` @@ -121,6 +162,37 @@ netlify agents:list --account my-team netlify agents:list --ndjson ``` +--- +## `agents:open` + +Open the agent run preview, dashboard, or pull request in a browser + +**Usage** + +```bash +netlify agents:open +``` + +**Arguments** + +- id - agent run ID to open +- target - what to open: preview (default), dashboard, or pr + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `project` (*string*) - project ID or name (if not in a linked directory) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard +netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr +``` + --- ## `agents:show` diff --git a/docs/index.md b/docs/index.md index 6e617e15048..89896b1f2c6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,9 @@ Manage Netlify AI agent runs | Subcommand | description | |:--------------------------- |:-----| | [`agents:create`](/commands/agents#agentscreate) | Create and start a new agent run on your site | +| [`agents:diff`](/commands/agents#agentsdiff) | Print the code changes produced by an agent run | | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | +| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | diff --git a/src/commands/agents/agents-diff.ts b/src/commands/agents/agents-diff.ts new file mode 100644 index 00000000000..96395c74b86 --- /dev/null +++ b/src/commands/agents/agents-diff.ts @@ -0,0 +1,124 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi, type AgentsApi } from './api.js' +import { formatDiff } from './utils.js' + +interface AgentDiffOptions extends OptionValues { + page?: string + perPage?: string + session?: string + cumulative?: boolean + stripBinary?: boolean + color?: boolean +} + +const parsePositiveInt = (input: string | undefined, name: string): number | undefined => { + if (input === undefined) return undefined + if (!/^[1-9]\d*$/.test(input)) { + throw new Error(`--${name} must be a positive integer`) + } + return Number.parseInt(input, 10) +} + +const verifyRunnerExists = async (api: AgentsApi, id: string): Promise => { + try { + await api.getAgentRunner(id) + } catch (error_) { + const error = error_ as Error & { status?: number } + if (error.status === 404) { + throw new Error(`Agent run not found: ${id}`) + } + throw error + } +} + +export const agentsDiff = async (id: string, options: AgentDiffOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent run ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + const useColor = options.color !== false && process.stdout.isTTY + + if (options.session) { + const kind = options.cumulative ? 'cumulative' : 'result' + const spinner = startSpinner({ text: `Fetching session ${kind} diff...` }) + try { + const diff = options.cumulative + ? await api.getSessionCumulativeDiff(id, options.session) + : await api.getSessionResultDiff(id, options.session) + stopSpinner({ spinner }) + if (!diff) { + await verifyRunnerExists(api, id) + log(chalk.yellow('No diff available for this session.')) + return + } + process.stdout.write(useColor ? formatDiff(diff) : diff) + if (!diff.endsWith('\n')) process.stdout.write('\n') + return + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + if (error.message.startsWith('Agent run not found:')) { + return logAndThrowError(error.message) + } + return logAndThrowError(`Failed to fetch diff: ${error.message}`) + } + } + + let page: number | undefined + let perPage: number | undefined + try { + page = parsePositiveInt(options.page, 'page') ?? 1 + perPage = parsePositiveInt(options.perPage, 'per-page') + } catch (error_) { + return logAndThrowError((error_ as Error).message) + } + + const spinner = startSpinner({ text: 'Fetching agent run diff...' }) + try { + const result = await api.getAgentRunnerDiff(id, { + page, + per_page: perPage, + strip_binary: options.stripBinary !== false, + }) + stopSpinner({ spinner }) + + if (!result.data) { + await verifyRunnerExists(api, id) + log(chalk.yellow('No diff available for this agent run.')) + return + } + + process.stdout.write(useColor ? formatDiff(result.data) : result.data) + if (!result.data.endsWith('\n')) process.stdout.write('\n') + + log() + log(chalk.dim(formatFooter(result.page, result.perPage, result.total, result.hasNext))) + return result + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + if (error.message.startsWith('Agent run not found:')) { + return logAndThrowError(error.message) + } + return logAndThrowError(`Failed to fetch diff: ${error.message}`) + } +} + +const formatFooter = (page: number, perPage: number, total: number | undefined, hasNext: boolean): string => { + const parts: string[] = [] + if (total != null) { + const start = (page - 1) * perPage + 1 + const end = Math.min(page * perPage, total) + parts.push(`Showing files ${start.toString()}-${end.toString()} of ${total.toString()}`) + } else { + parts.push(`Showing page ${page.toString()}`) + } + if (hasNext) { + parts.push(`Use --page ${(page + 1).toString()} for the next page`) + } + return parts.join(' • ') +} diff --git a/src/commands/agents/agents-open.ts b/src/commands/agents/agents-open.ts new file mode 100644 index 00000000000..fba7c8c85fa --- /dev/null +++ b/src/commands/agents/agents-open.ts @@ -0,0 +1,81 @@ +import type { OptionValues } from 'commander' + +import { chalk, log, logAndThrowError } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import openBrowser from '../../utils/open-browser.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import { buildAgentDashboardUrl } from './utils.js' + +const VALID_TARGETS = ['preview', 'dashboard', 'pr'] as const +type OpenTarget = (typeof VALID_TARGETS)[number] + +const isOpenTarget = (input: string): input is OpenTarget => (VALID_TARGETS as readonly string[]).includes(input) + +interface AgentOpenOptions extends OptionValues { + json?: boolean +} + +export const agentsOpen = async ( + id: string, + targetArg: string | undefined, + _options: AgentOpenOptions, + command: BaseCommand, +) => { + if (!id) return logAndThrowError('Agent run ID is required') + + const candidate = targetArg ?? 'preview' + if (!isOpenTarget(candidate)) { + return logAndThrowError(`Invalid target "${candidate}". Choose one of: ${VALID_TARGETS.join(', ')}`) + } + const target: OpenTarget = candidate + + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + const dashboardUrl = buildAgentDashboardUrl(siteInfo.name, id) + + if (target === 'dashboard') { + return openUrl(dashboardUrl) + } + + const spinner = startSpinner({ text: 'Looking up agent run...' }) + let runner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner }) + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent run not found: ${id}`) + return logAndThrowError(`Failed to fetch agent run: ${error.message}`) + } + + if (target === 'pr') { + if (runner.pr_url) return openUrl(runner.pr_url) + if (runner.pr_is_being_created) { + log(chalk.yellow('A pull request is being created. Try again in a moment.')) + return + } + if (runner.pr_error) { + log(chalk.red(`Pull request creation failed: ${runner.pr_error}`)) + log(`Retry with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + log(chalk.yellow('No pull request exists for this agent run.')) + log(`Create one with: ${chalk.cyan(`netlify agents:pr ${id}`)}`) + return + } + + const previewUrl = runner.latest_session_deploy_url + if (!previewUrl) { + log(chalk.yellow('No deploy preview available yet — opening dashboard instead.')) + return openUrl(dashboardUrl) + } + return openUrl(previewUrl) +} + +const openUrl = async (url: string): Promise => { + log(`Opening ${chalk.blue(url)}`) + await openBrowser({ url }) +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 54f587dc9a4..5c4d0482e7c 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -100,6 +100,46 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsStop(id, options, command) }) + program + .command('agents:open') + .argument('', 'agent run ID to open') + .argument('[target]', 'what to open: preview (default), dashboard, or pr', 'preview') + .description('Open the agent run preview, dashboard, or pull request in a browser') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a dashboard', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a pr', + ]) + .action(async (id: string, target: string | undefined, options: OptionValues, command: BaseCommand) => { + const { agentsOpen } = await import('./agents-open.js') + await agentsOpen(id, target, options, command) + }) + + program + .command('agents:diff') + .argument('', 'agent run ID') + .description('Print the code changes produced by an agent run') + .option('--page ', 'page number (1-based)') + .option('--per-page ', 'files per page (max 100)') + .option('--session ', 'show a single session diff instead of the run aggregate') + .option('--cumulative', 'with --session, show the cumulative diff up through that session') + .option('--no-strip-binary', 'include raw binary content in the diff (binary is stripped by default)') + .option('--no-color', 'disable color in the output') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --page 2', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --session 70d8... --cumulative', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a --no-color | less', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsDiff } = await import('./agents-diff.js') + await agentsDiff(id, options, command) + }) + const name = chalk.greenBright('`agents`') return program @@ -114,6 +154,8 @@ Note: Agent runs execute remotely on Netlify infrastructure, not locally.`, 'netlify agents:create --prompt "Add a contact form"', 'netlify agents:list --status running', 'netlify agents:show 60c7c3b3e7b4a0001f5e4b3a --watch', + 'netlify agents:diff 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:open 60c7c3b3e7b4a0001f5e4b3a', ]) .action(agents) } diff --git a/tests/integration/commands/agents/agents-diff.test.ts b/tests/integration/commands/agents/agents-diff.test.ts new file mode 100644 index 00000000000..693d0cf97d1 --- /dev/null +++ b/tests/integration/commands/agents/agents-diff.test.ts @@ -0,0 +1,169 @@ +import type express from 'express' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +const SAMPLE_DIFF = `diff --git a/foo.txt b/foo.txt +index 0000000..1111111 100644 +--- a/foo.txt ++++ b/foo.txt +@@ -1 +1 @@ +-old ++new +` + +describe('agents:diff command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should print the agent run diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/diff', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('diff --git a/foo.txt b/foo.txt') + expect(cliResponse).toContain('+new') + expect(cliResponse).toContain('-old') + }) + }) + }) + + test('should print a session result diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/session_id/diff/result', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--session', 'session_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('+new') + }) + }) + }) + + test('should print a cumulative session diff', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/session_id/diff/cumulative', + method: 'GET' as const, + response: (_req: express.Request, res: express.Response) => { + res.type('text/plain').send(SAMPLE_DIFF) + }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + await callCli( + ['agents:diff', 'test_id', '--session', 'session_id', '--cumulative', '--no-color'], + getCLIOptions({ apiUrl, builder }), + ) + + const diffRequest = requests.find((r) => r.path.endsWith('/diff/cumulative')) + expect(diffRequest).toBeDefined() + }) + }) + }) + + test('should report when no diff is available for the task', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/diff', + method: 'GET' as const, + status: 404, + response: { error: 'not found' }, + }, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { id: 'test_id', state: 'done' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:diff', 'test_id', '--no-color'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('No diff available for this agent run.') + }) + }) + }) + + test('should reject non-positive --page', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:diff', 'test_id', '--page', '0'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('--page must be a positive integer') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:diff'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-open.test.ts b/tests/integration/commands/agents/agents-open.test.ts new file mode 100644 index 00000000000..d9f39a84b8c --- /dev/null +++ b/tests/integration/commands/agents/agents-open.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:open command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + const noBrowserEnv = { BROWSER: 'none' } + + test('should open the deploy preview URL for a task', async (t) => { + const runnerWithPreview = { ...mockAgentRunner, latest_session_deploy_url: 'https://preview.netlify.app' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPreview, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('Opening') + expect(cliResponse).toContain('https://preview.netlify.app') + }) + }) + }) + + test('should fall back to dashboard when no preview is available', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('No deploy preview available') + expect(cliResponse).toContain('app.netlify.com/projects/site-name/agent-runs/test_id') + }) + }) + }) + + test('should open the dashboard when target is "dashboard"', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'dashboard'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('app.netlify.com/projects/site-name/agent-runs/test_id') + }) + }) + }) + + test('should open the PR url when target is "pr"', async (t) => { + const runnerWithPr = { ...mockAgentRunner, pr_url: 'https://github.com/owner/repo/pull/42' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerWithPr, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('https://github.com/owner/repo/pull/42') + }) + }) + }) + + test('should explain when no PR exists yet', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('No pull request exists for this agent run') + expect(cliResponse).toContain('netlify agents:pr test_id') + }) + }) + }) + + test('should explain when a PR is being created', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_is_being_created: true }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('A pull request is being created') + }) + }) + }) + + test('should surface PR creation errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: { ...mockAgentRunner, pr_error: 'Repository not connected' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:open', 'test_id', 'pr'], + getCLIOptions({ apiUrl, builder, env: noBrowserEnv }), + )) as string + + expect(cliResponse).toContain('Pull request creation failed: Repository not connected') + expect(cliResponse).toContain('netlify agents:pr test_id') + }) + }) + }) + + test('should reject invalid targets', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:open', 'test_id', 'whatever'], getCLIOptions({ apiUrl, builder, env: noBrowserEnv })), + ).rejects.toThrow('Invalid target "whatever"') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:open'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +})