diff --git a/README.md b/README.md index 4cac0bc..d80322d 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ Example stored config: ## Commands +Read commands (`auth status`, `repo list`, `issue list`, `issue show`, `pr list`, `pr show`, `release list`, `release view`, `label list`, `branch list`, `org repos`, `org team list`, `org team member-list`, `user list`, `user show`) accept a `--json` flag that prints the raw API response, pretty-printed, instead of the human-readable format — useful for scripting. + ### Global options ```text @@ -438,6 +440,43 @@ stoke issue create -o heavy-duty -r stoke -t "Ship v2" --body-file body.md Calls `POST /api/v1/repos/{owner}/{repo}/issues`. +### `stoke issue show` + +Show details of an issue. + +```text +Options: + -o, --owner repository owner (required) + -r, --repo repository name (required) + -n, --number issue number (required) + --json print raw JSON instead of human-readable output +``` + +```bash +stoke issue show -o heavy-duty -r stoke -n 10 +``` + +Calls `GET /api/v1/repos/{owner}/{repo}/issues/{number}`. + +### `stoke issue comment` + +Add a comment to an issue. Body is required (whitespace-only is rejected). When both `-b` and `--body-file` are set, **`--body-file` wins**. + +```text +Options: + -o, --owner repository owner (required) + -r, --repo repository name (required) + -n, --number issue number (required) + -b, --body comment body (markdown; required unless --body-file) + --body-file read the comment body from a file (wins over -b) +``` + +```bash +stoke issue comment -o heavy-duty -r stoke -n 10 -b "Confirmed." +``` + +Calls `POST /api/v1/repos/{owner}/{repo}/issues/{number}/comments`. + ### `stoke pr list` List pull requests in a repository. @@ -547,14 +586,16 @@ Options: --event approve|approved, request-changes|request_changes, comment (required) -b, --body review body (markdown; required for request-changes and comment) --body-file read the review body from a file (wins over -b) + --commit commit SHA the review applies to (sent as commit_id) ``` ```bash stoke pr review -o heavy-duty -r stoke -n 3 --event approve -b "Ship it." stoke pr review -o heavy-duty -r stoke -n 3 --event request-changes --body-file notes.md +stoke pr review -o heavy-duty -r stoke -n 3 --event approve --commit 9fceb02 ``` -Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. Prints the review URL when the forge returns one. +Calls `POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews`. `--commit` is sent as `commit_id`; when omitted, no `commit_id` is sent. Prints the review URL when the forge returns one. ### `stoke release list` diff --git a/src/api.js b/src/api.js index d0c5ba3..85a23e7 100644 --- a/src/api.js +++ b/src/api.js @@ -170,6 +170,14 @@ class ForgejoClient { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload); } + async getIssue(owner, repo, index) { + return this.get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}`); + } + + async createIssueComment(owner, repo, index, body) { + return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body }); + } + async listPullRequests(owner, repo, opts = {}) { return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, opts); } @@ -186,11 +194,13 @@ class ForgejoClient { return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body }); } - async createPullRequestReview(owner, repo, index, event, body) { - return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, { + async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) { + const payload = { event, body: body || '', - }); + }; + if (commitId) payload.commit_id = commitId; + return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, payload); } async mergePullRequest(owner, repo, index, payload) { diff --git a/src/cli.js b/src/cli.js index ba42423..08a4099 100755 --- a/src/cli.js +++ b/src/cli.js @@ -92,6 +92,12 @@ function parseDepth(value) { return n; } +// Read commands share a --json flag that prints the raw API response +// (pretty-printed) instead of the human-readable format. +function printJson(data) { + console.log(JSON.stringify(data, null, 2)); +} + function makeTokenName() { const host = require('node:os').hostname() || 'unknown'; return `stoke-${host}-${Date.now()}`; @@ -270,7 +276,8 @@ auth auth .command('status') .description('Show the current authentication status') - .action(async () => { + .option('--json', 'print raw JSON instead of human-readable output', false) + .action(async (options) => { try { const config = loadConfig(); if (!config || !config.token) { @@ -280,6 +287,10 @@ auth const client = ForgejoClient.fromConfig(config); const me = await client.get('/user'); + if (options.json) { + printJson(me); + return; + } console.log('Instance: ', config.url); console.log('Login: ', me.login); console.log('Username: ', me.username); @@ -320,11 +331,16 @@ repo .command('list') .description('List repositories for the authenticated user') .option('-l, --limit ', 'maximum repositories to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const repos = await client.listRepos(); + if (options.json) { + printJson(repos); + return; + } const display = options.limit > 0 ? repos.slice(0, options.limit) : repos; if (!display.length) { console.log('No repositories found.'); @@ -631,6 +647,7 @@ issue .option('-s, --state ', 'issue state: open, closed, all', 'open') .option('-t, --type ', 'issue type filter: issues, pulls', 'issues') .option('-l, --limit ', 'maximum issues to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); @@ -639,6 +656,10 @@ issue state: options.state, type: options.type, }); + if (options.json) { + printJson(issues); + return; + } const display = options.limit > 0 ? issues.slice(0, options.limit) : issues; if (!display.length) { console.log('No issues found.'); @@ -686,6 +707,64 @@ issue } }); +issue + .command('show') + .description('Show details of an issue') + .requiredOption('-o, --owner ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .requiredOption('-n, --number ', 'issue number', parseId) + .option('--json', 'print raw JSON instead of human-readable output', false) + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const issueData = await client.getIssue(options.owner, options.repo, options.number); + if (options.json) { + printJson(issueData); + return; + } + const author = issueData.user?.login || '(unknown)'; + console.log(`#${issueData.number} [${issueData.state}] ${issueData.title}`); + console.log(`URL: ${issueData.html_url}`); + console.log(`Author: ${author}`); + console.log(`Created: ${issueData.created_at}`); + if (issueData.body) { + console.log('\n' + issueData.body); + } + } catch (err) { + console.error(`Failed to show issue: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + +issue + .command('comment') + .description('Add a comment to an issue') + .requiredOption('-o, --owner ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .requiredOption('-n, --number ', 'issue number', parseId) + .option('-b, --body ', 'comment body (markdown; required unless --body-file)') + .option('--body-file ', 'read the comment body from a file (wins over -b)') + .action(async (options) => { + try { + const config = loadConfig(); + const client = ForgejoClient.fromConfig(config); + const rawBody = readBodyOption(options) || ''; + if (rawBody.trim().length === 0) { + console.error('Comment body is required. Use -b/--body or --body-file.'); + process.exit(1); + } + const result = await client.createIssueComment(options.owner, options.repo, options.number, rawBody); + console.log(`Comment added to #${options.number}.`); + console.log(`URL: ${result.html_url}`); + } catch (err) { + console.error(`Failed to comment on issue: ${err.message}`); + if (err.status) console.error(`HTTP status: ${err.status}`); + process.exit(1); + } + }); + const pr = program .command('pr') .description('Manage pull requests'); @@ -697,6 +776,7 @@ pr .requiredOption('-r, --repo ', 'repository name') .option('-s, --state ', 'PR state: open, closed, all', 'open') .option('-l, --limit ', 'maximum pull requests to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); @@ -704,6 +784,10 @@ pr const pulls = await client.listPullRequests(options.owner, options.repo, { state: options.state, }); + if (options.json) { + printJson(pulls); + return; + } const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls; if (!display.length) { console.log('No pull requests found.'); @@ -801,11 +885,16 @@ pr .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('-n, --number ', 'pull request number', parseId) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const prData = await client.getPullRequest(options.owner, options.repo, options.number); + if (options.json) { + printJson(prData); + return; + } const author = prData.user?.login || '(unknown)'; const headRef = prData.head?.ref || '?'; const baseRef = prData.base?.ref || '?'; @@ -862,6 +951,7 @@ pr .requiredOption('--event ', 'review event: approve|approved, request-changes|request_changes, comment') .option('-b, --body ', 'review body (markdown; required for request-changes and comment)') .option('--body-file ', 'read the review body from a file (wins over -b)') + .option('--commit ', 'commit SHA the review applies to (sent as commit_id)') .action(async (options) => { try { const config = loadConfig(); @@ -876,7 +966,7 @@ pr console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`); process.exit(1); } - const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody); + const result = await client.createPullRequestReview(options.owner, options.repo, options.number, event, rawBody, { commitId: options.commit }); console.log(`Review submitted on !${options.number}: ${event}.`); if (result && result.html_url) { console.log(`URL: ${result.html_url}`); @@ -898,11 +988,16 @@ release .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .option('-l, --limit ', 'maximum releases to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const releases = await client.listReleases(options.owner, options.repo); + if (options.json) { + printJson(releases); + return; + } const display = options.limit > 0 ? releases.slice(0, options.limit) : releases; if (!display.length) { console.log('No releases found.'); @@ -928,11 +1023,16 @@ release .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .requiredOption('--tag ', 'tag name of the release') + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag); + if (options.json) { + printJson(rel); + return; + } const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|'); console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd()); console.log(`URL: ${rel.html_url}`); @@ -1015,11 +1115,16 @@ label .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .option('-l, --limit ', 'maximum labels to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const labels = await client.listLabels(options.owner, options.repo); + if (options.json) { + printJson(labels); + return; + } const display = options.limit > 0 ? labels.slice(0, options.limit) : labels; if (!display.length) { console.log('No labels found.'); @@ -1147,11 +1252,16 @@ branchCmd .requiredOption('-o, --owner ', 'repository owner') .requiredOption('-r, --repo ', 'repository name') .option('-l, --limit ', 'maximum branches to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const branches = await client.listBranches(options.owner, options.repo); + if (options.json) { + printJson(branches); + return; + } const display = options.limit > 0 ? branches.slice(0, options.limit) : branches; if (!display.length) { console.log('No branches found.'); @@ -1234,11 +1344,16 @@ org .description('List repositories owned by an organization') .requiredOption('-o, --org ', 'organization name') .option('-l, --limit ', 'maximum repositories to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const repos = await client.listOrgRepos(options.org); + if (options.json) { + printJson(repos); + return; + } const display = options.limit > 0 ? repos.slice(0, options.limit) : repos; if (!display.length) { console.log('No repositories found.'); @@ -1289,11 +1404,16 @@ team .command('list') .description('List teams in an organization') .requiredOption('-o, --org ', 'organization name') + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const teams = await client.listOrgTeams(options.org); + if (options.json) { + printJson(teams); + return; + } if (!teams.length) { console.log('No teams found.'); return; @@ -1342,11 +1462,16 @@ team .command('member-list') .description('List members of a team') .requiredOption('--team-id ', 'team id (see `stoke org team list`)', parseId) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const members = await client.listTeamMembers(options.teamId); + if (options.json) { + printJson(members); + return; + } if (!members.length) { console.log('No members found.'); return; @@ -1406,11 +1531,16 @@ user .description('Search/list users on the Forgejo instance') .option('-q, --query ', 'search query (empty lists all visible users)', '') .option('-l, --limit ', 'maximum users to return (0 for all)', parseLimit, 50) + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const users = await client.searchUsers(options.query); + if (options.json) { + printJson(users); + return; + } const display = options.limit > 0 ? users.slice(0, options.limit) : users; if (!display.length) { console.log('No users found.'); @@ -1433,11 +1563,16 @@ user .command('show') .description('Show a single user profile') .requiredOption('-u, --user ', 'username to look up') + .option('--json', 'print raw JSON instead of human-readable output', false) .action(async (options) => { try { const config = loadConfig(); const client = ForgejoClient.fromConfig(config); const u = await client.getUser(options.user); + if (options.json) { + printJson(u); + return; + } console.log(`Login: ${u.login}`); console.log(`Full name: ${u.full_name || '-'}`); console.log(`Email: ${u.email || '-'}`); diff --git a/test/api.test.js b/test/api.test.js index cd60fc8..f0233af 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -151,6 +151,24 @@ test('getPullRequest fetches a single pull request', async () => { assert.equal(calls[0].opts.method, 'GET'); }); +test('getIssue fetches a single issue', async () => { + const calls = mockFetch(() => jsonResponse({ number: 7, title: 'Bug' })); + const client = new ForgejoClient('https://forge.test', 'tok'); + const issue = await client.getIssue('owner', 'repo', 7); + assert.equal(issue.number, 7); + assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7'); + assert.equal(calls[0].opts.method, 'GET'); +}); + +test('createIssueComment posts to the issue comments endpoint', async () => { + const calls = mockFetch(() => jsonResponse({ id: 5, html_url: 'https://forge.test/comment/5' })); + const client = new ForgejoClient('https://forge.test', 'tok'); + await client.createIssueComment('owner', 'repo', 7, 'Me too.'); + assert.equal(calls[0].url, 'https://forge.test/api/v1/repos/owner/repo/issues/7/comments'); + assert.equal(calls[0].opts.method, 'POST'); + assert.equal(JSON.parse(calls[0].opts.body).body, 'Me too.'); +}); + test('createPullRequestComment posts to the issue comments endpoint', async () => { const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' })); const client = new ForgejoClient('https://forge.test', 'tok'); @@ -180,6 +198,15 @@ test('createPullRequestReview preserves leading and trailing whitespace in the b assert.equal(body.body, rawBody); }); +test('createPullRequestReview omits commit_id unless a commitId is given', async () => { + const calls = mockFetch(() => jsonResponse({ id: 90 })); + const client = new ForgejoClient('https://forge.test', 'tok'); + await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', ''); + assert.ok(!('commit_id' in JSON.parse(calls[0].opts.body))); + await client.createPullRequestReview('owner', 'repo', 7, 'APPROVED', '', { commitId: 'abc123' }); + assert.equal(JSON.parse(calls[1].opts.body).commit_id, 'abc123'); +}); + test('getAll joins pagination with & when the endpoint already has a query', async () => { const calls = mockFetch(() => jsonResponse([])); const client = new ForgejoClient('https://forge.test', 'tok'); diff --git a/test/cli.test.js b/test/cli.test.js index 42bd9e2..e08255c 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -520,3 +520,203 @@ test('auth login rejects --full-scopes together with --scopes before any network assert.equal(res.status, 1); assert.match(res.stderr, /either --full-scopes or --scopes, not both/); }); + +test('issue show validates --number before any network call', () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`); + fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' })); + try { + const res = run(['issue', 'show', '-o', 'o', '-r', 'r', '-n', 'zero'], { STOKE_CONFIG_FILE: cfg }); + assert.equal(res.status, 1); + assert.match(res.stderr, /Id must be a positive integer/); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('issue comment rejects a missing body before any network call', () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`); + fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' })); + try { + const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1'], { STOKE_CONFIG_FILE: cfg }); + assert.equal(res.status, 1); + assert.match(res.stderr, /Comment body is required/); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('issue comment rejects a whitespace-only body before any network call', () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}.json`); + fs.writeFileSync(cfg, JSON.stringify({ url: 'https://forge.test', token: 'tok' })); + try { + const res = run(['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '1', '-b', ' '], { STOKE_CONFIG_FILE: cfg }); + assert.equal(res.status, 1); + assert.match(res.stderr, /Comment body is required/); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('issue comment posts to the issue comments endpoint', async () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-ic.json`); + const TIMEOUT_MS = 5000; + let timer; + const result = await new Promise((resolve, reject) => { + const fail = (err) => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + reject(err instanceof Error ? err : new Error(String(err))); + }; + let request = null; + const server = http.createServer((req, res) => { + let data = ''; + req.on('data', (c) => { data += c; }); + req.on('end', () => { + request = { url: req.url, body: data }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 9, html_url: 'https://forge.test/issues/7#issuecomment-9' })); + }); + }); + timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS); + server.listen(0, '127.0.0.1', async () => { + const { port } = server.address(); + fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' })); + try { + const res = await spawnAsync( + ['issue', 'comment', '-o', 'o', '-r', 'r', '-n', '7', '-b', 'Confirmed.'], + { STOKE_CONFIG_FILE: cfg }, + ); + clearTimeout(timer); + server.close(() => resolve({ res, request })); + } catch (err) { + fail(err); + } + }); + }).finally(() => clearTimeout(timer)); + + try { + assert.equal(result.res.status, 0, result.res.stderr); + assert.equal(result.request.url, '/api/v1/repos/o/r/issues/7/comments'); + assert.equal(JSON.parse(result.request.body).body, 'Confirmed.'); + assert.match(result.res.stdout, /Comment added to #7\./); + assert.match(result.res.stdout, /URL: https:\/\/forge\.test\/issues\/7#issuecomment-9/); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('read commands print the raw API JSON with --json', async () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-json.json`); + const payloads = { + '/api/v1/user': { login: 'bot' }, + '/api/v1/user/repos': [{ full_name: 'o/r' }], + '/api/v1/repos/o/r/issues': [{ number: 7, title: 'Bug' }], + '/api/v1/repos/o/r/issues/7': { number: 7, title: 'Bug', state: 'open' }, + '/api/v1/repos/o/r/pulls': [{ number: 3, title: 'Fix' }], + '/api/v1/repos/o/r/pulls/3': { number: 3, title: 'Fix', state: 'open' }, + }; + const commands = [ + [['auth', 'status', '--json'], { login: 'bot' }], + [['repo', 'list', '--json'], [{ full_name: 'o/r' }]], + [['issue', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 7, title: 'Bug' }]], + [['issue', 'show', '-o', 'o', '-r', 'r', '-n', '7', '--json'], { number: 7, title: 'Bug', state: 'open' }], + [['pr', 'list', '-o', 'o', '-r', 'r', '--json'], [{ number: 3, title: 'Fix' }]], + [['pr', 'show', '-o', 'o', '-r', 'r', '-n', '3', '--json'], { number: 3, title: 'Fix', state: 'open' }], + ]; + const TIMEOUT_MS = 5000; + let timer; + const results = await new Promise((resolve, reject) => { + const fail = (err) => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + reject(err instanceof Error ? err : new Error(String(err))); + }; + const server = http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + const payload = payloads[pathname]; + if (!payload) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: `no mock for ${pathname}` })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }); + timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS); + server.listen(0, '127.0.0.1', async () => { + const { port } = server.address(); + fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' })); + try { + const out = []; + for (const [args] of commands) { + out.push(await spawnAsync(args, { STOKE_CONFIG_FILE: cfg })); + } + clearTimeout(timer); + server.close(() => resolve(out)); + } catch (err) { + fail(err); + } + }); + }).finally(() => clearTimeout(timer)); + + try { + results.forEach((res, i) => { + const [args, expected] = commands[i]; + assert.equal(res.status, 0, `${args.join(' ')}: ${res.stderr}`); + assert.deepEqual(JSON.parse(res.stdout), expected); + }); + } finally { + fs.unlinkSync(cfg); + } +}); + +test('pr review --commit sends commit_id only when given', async () => { + const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-commit.json`); + const TIMEOUT_MS = 5000; + let timer; + const bodies = []; + const result = await new Promise((resolve, reject) => { + const fail = (err) => { + clearTimeout(timer); + try { server.close(); } catch { /* already closed */ } + reject(err instanceof Error ? err : new Error(String(err))); + }; + const server = http.createServer((req, res) => { + let data = ''; + req.on('data', (c) => { data += c; }); + req.on('end', () => { + bodies.push(JSON.parse(data)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 42 })); + }); + }); + timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS); + server.listen(0, '127.0.0.1', async () => { + const { port } = server.address(); + fs.writeFileSync(cfg, JSON.stringify({ url: `http://127.0.0.1:${port}`, token: 'tok' })); + try { + const withCommit = await spawnAsync( + ['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve', '--commit', 'abc123'], + { STOKE_CONFIG_FILE: cfg }, + ); + const withoutCommit = await spawnAsync( + ['pr', 'review', '-o', 'o', '-r', 'r', '-n', '7', '--event', 'approve'], + { STOKE_CONFIG_FILE: cfg }, + ); + clearTimeout(timer); + server.close(() => resolve({ withCommit, withoutCommit })); + } catch (err) { + fail(err); + } + }); + }).finally(() => clearTimeout(timer)); + + try { + assert.equal(result.withCommit.status, 0, result.withCommit.stderr); + assert.equal(result.withoutCommit.status, 0, result.withoutCommit.stderr); + assert.equal(bodies[0].commit_id, 'abc123'); + assert.ok(!('commit_id' in bodies[1])); + } finally { + fs.unlinkSync(cfg); + } +});