Merge pull request 'Add issue show/comment, --json output, and pr review --commit' (#20) from feat/issue-cmds-json-review-commit into main

This commit is contained in:
grok-reviewer-andresmgsl 2026-07-26 22:01:19 +00:00
commit ee0cb85c7b
5 changed files with 419 additions and 6 deletions

View file

@ -104,6 +104,8 @@ Example stored config:
## Commands ## 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 ### Global options
```text ```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`. Calls `POST /api/v1/repos/{owner}/{repo}/issues`.
### `stoke issue show`
Show details of an issue.
```text
Options:
-o, --owner <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <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 <owner> repository owner (required)
-r, --repo <repo> repository name (required)
-n, --number <number> issue number (required)
-b, --body <body> comment body (markdown; required unless --body-file)
--body-file <path> 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` ### `stoke pr list`
List pull requests in a repository. List pull requests in a repository.
@ -547,14 +586,16 @@ Options:
--event <event> approve|approved, request-changes|request_changes, comment (required) --event <event> approve|approved, request-changes|request_changes, comment (required)
-b, --body <body> review body (markdown; required for request-changes and comment) -b, --body <body> review body (markdown; required for request-changes and comment)
--body-file <path> read the review body from a file (wins over -b) --body-file <path> read the review body from a file (wins over -b)
--commit <sha> commit SHA the review applies to (sent as commit_id)
``` ```
```bash ```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 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 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` ### `stoke release list`

View file

@ -170,6 +170,14 @@ class ForgejoClient {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, payload); 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 = {}) { async listPullRequests(owner, repo, opts = {}) {
return this.getAll(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`, 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 }); return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${index}/comments`, { body });
} }
async createPullRequestReview(owner, repo, index, event, body) { async createPullRequestReview(owner, repo, index, event, body, { commitId } = {}) {
return this.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${index}/reviews`, { const payload = {
event, event,
body: body || '', 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) { async mergePullRequest(owner, repo, index, payload) {

View file

@ -92,6 +92,12 @@ function parseDepth(value) {
return n; 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() { function makeTokenName() {
const host = require('node:os').hostname() || 'unknown'; const host = require('node:os').hostname() || 'unknown';
return `stoke-${host}-${Date.now()}`; return `stoke-${host}-${Date.now()}`;
@ -270,7 +276,8 @@ auth
auth auth
.command('status') .command('status')
.description('Show the current authentication status') .description('Show the current authentication status')
.action(async () => { .option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
if (!config || !config.token) { if (!config || !config.token) {
@ -280,6 +287,10 @@ auth
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const me = await client.get('/user'); const me = await client.get('/user');
if (options.json) {
printJson(me);
return;
}
console.log('Instance: ', config.url); console.log('Instance: ', config.url);
console.log('Login: ', me.login); console.log('Login: ', me.login);
console.log('Username: ', me.username); console.log('Username: ', me.username);
@ -320,11 +331,16 @@ repo
.command('list') .command('list')
.description('List repositories for the authenticated user') .description('List repositories for the authenticated user')
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const repos = await client.listRepos(); const repos = await client.listRepos();
if (options.json) {
printJson(repos);
return;
}
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos; const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
if (!display.length) { if (!display.length) {
console.log('No repositories found.'); console.log('No repositories found.');
@ -631,6 +647,7 @@ issue
.option('-s, --state <state>', 'issue state: open, closed, all', 'open') .option('-s, --state <state>', 'issue state: open, closed, all', 'open')
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues') .option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
.option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum issues to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
@ -639,6 +656,10 @@ issue
state: options.state, state: options.state,
type: options.type, type: options.type,
}); });
if (options.json) {
printJson(issues);
return;
}
const display = options.limit > 0 ? issues.slice(0, options.limit) : issues; const display = options.limit > 0 ? issues.slice(0, options.limit) : issues;
if (!display.length) { if (!display.length) {
console.log('No issues found.'); console.log('No issues found.');
@ -686,6 +707,64 @@ issue
} }
}); });
issue
.command('show')
.description('Show details of an issue')
.requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <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 <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'issue number', parseId)
.option('-b, --body <body>', 'comment body (markdown; required unless --body-file)')
.option('--body-file <path>', '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 const pr = program
.command('pr') .command('pr')
.description('Manage pull requests'); .description('Manage pull requests');
@ -697,6 +776,7 @@ pr
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.option('-s, --state <state>', 'PR state: open, closed, all', 'open') .option('-s, --state <state>', 'PR state: open, closed, all', 'open')
.option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum pull requests to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
@ -704,6 +784,10 @@ pr
const pulls = await client.listPullRequests(options.owner, options.repo, { const pulls = await client.listPullRequests(options.owner, options.repo, {
state: options.state, state: options.state,
}); });
if (options.json) {
printJson(pulls);
return;
}
const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls; const display = options.limit > 0 ? pulls.slice(0, options.limit) : pulls;
if (!display.length) { if (!display.length) {
console.log('No pull requests found.'); console.log('No pull requests found.');
@ -801,11 +885,16 @@ pr
.requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('-n, --number <number>', 'pull request number', parseId) .requiredOption('-n, --number <number>', 'pull request number', parseId)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const prData = await client.getPullRequest(options.owner, options.repo, options.number); const prData = await client.getPullRequest(options.owner, options.repo, options.number);
if (options.json) {
printJson(prData);
return;
}
const author = prData.user?.login || '(unknown)'; const author = prData.user?.login || '(unknown)';
const headRef = prData.head?.ref || '?'; const headRef = prData.head?.ref || '?';
const baseRef = prData.base?.ref || '?'; const baseRef = prData.base?.ref || '?';
@ -862,6 +951,7 @@ pr
.requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment') .requiredOption('--event <event>', 'review event: approve|approved, request-changes|request_changes, comment')
.option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)') .option('-b, --body <body>', 'review body (markdown; required for request-changes and comment)')
.option('--body-file <path>', 'read the review body from a file (wins over -b)') .option('--body-file <path>', 'read the review body from a file (wins over -b)')
.option('--commit <sha>', 'commit SHA the review applies to (sent as commit_id)')
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); 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.`); console.error(`Review event ${options.event} requires a non-empty body. Use -b/--body or --body-file.`);
process.exit(1); 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}.`); console.log(`Review submitted on !${options.number}: ${event}.`);
if (result && result.html_url) { if (result && result.html_url) {
console.log(`URL: ${result.html_url}`); console.log(`URL: ${result.html_url}`);
@ -898,11 +988,16 @@ release
.requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum releases to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const releases = await client.listReleases(options.owner, options.repo); 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; const display = options.limit > 0 ? releases.slice(0, options.limit) : releases;
if (!display.length) { if (!display.length) {
console.log('No releases found.'); console.log('No releases found.');
@ -928,11 +1023,16 @@ release
.requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.requiredOption('--tag <tag>', 'tag name of the release') .requiredOption('--tag <tag>', 'tag name of the release')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const rel = await client.getReleaseByTag(options.owner, options.repo, options.tag); 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('|'); const flags = [rel.draft && 'draft', rel.prerelease && 'prerelease'].filter(Boolean).join('|');
console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd()); console.log(`${rel.tag_name}${flags ? ` [${flags}]` : ''} ${rel.name || ''}`.trimEnd());
console.log(`URL: ${rel.html_url}`); console.log(`URL: ${rel.html_url}`);
@ -1015,11 +1115,16 @@ label
.requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum labels to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const labels = await client.listLabels(options.owner, options.repo); 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; const display = options.limit > 0 ? labels.slice(0, options.limit) : labels;
if (!display.length) { if (!display.length) {
console.log('No labels found.'); console.log('No labels found.');
@ -1147,11 +1252,16 @@ branchCmd
.requiredOption('-o, --owner <owner>', 'repository owner') .requiredOption('-o, --owner <owner>', 'repository owner')
.requiredOption('-r, --repo <repo>', 'repository name') .requiredOption('-r, --repo <repo>', 'repository name')
.option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum branches to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const branches = await client.listBranches(options.owner, options.repo); 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; const display = options.limit > 0 ? branches.slice(0, options.limit) : branches;
if (!display.length) { if (!display.length) {
console.log('No branches found.'); console.log('No branches found.');
@ -1234,11 +1344,16 @@ org
.description('List repositories owned by an organization') .description('List repositories owned by an organization')
.requiredOption('-o, --org <org>', 'organization name') .requiredOption('-o, --org <org>', 'organization name')
.option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum repositories to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const repos = await client.listOrgRepos(options.org); const repos = await client.listOrgRepos(options.org);
if (options.json) {
printJson(repos);
return;
}
const display = options.limit > 0 ? repos.slice(0, options.limit) : repos; const display = options.limit > 0 ? repos.slice(0, options.limit) : repos;
if (!display.length) { if (!display.length) {
console.log('No repositories found.'); console.log('No repositories found.');
@ -1289,11 +1404,16 @@ team
.command('list') .command('list')
.description('List teams in an organization') .description('List teams in an organization')
.requiredOption('-o, --org <org>', 'organization name') .requiredOption('-o, --org <org>', 'organization name')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const teams = await client.listOrgTeams(options.org); const teams = await client.listOrgTeams(options.org);
if (options.json) {
printJson(teams);
return;
}
if (!teams.length) { if (!teams.length) {
console.log('No teams found.'); console.log('No teams found.');
return; return;
@ -1342,11 +1462,16 @@ team
.command('member-list') .command('member-list')
.description('List members of a team') .description('List members of a team')
.requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId) .requiredOption('--team-id <id>', 'team id (see `stoke org team list`)', parseId)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const members = await client.listTeamMembers(options.teamId); const members = await client.listTeamMembers(options.teamId);
if (options.json) {
printJson(members);
return;
}
if (!members.length) { if (!members.length) {
console.log('No members found.'); console.log('No members found.');
return; return;
@ -1406,11 +1531,16 @@ user
.description('Search/list users on the Forgejo instance') .description('Search/list users on the Forgejo instance')
.option('-q, --query <query>', 'search query (empty lists all visible users)', '') .option('-q, --query <query>', 'search query (empty lists all visible users)', '')
.option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50) .option('-l, --limit <number>', 'maximum users to return (0 for all)', parseLimit, 50)
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const users = await client.searchUsers(options.query); const users = await client.searchUsers(options.query);
if (options.json) {
printJson(users);
return;
}
const display = options.limit > 0 ? users.slice(0, options.limit) : users; const display = options.limit > 0 ? users.slice(0, options.limit) : users;
if (!display.length) { if (!display.length) {
console.log('No users found.'); console.log('No users found.');
@ -1433,11 +1563,16 @@ user
.command('show') .command('show')
.description('Show a single user profile') .description('Show a single user profile')
.requiredOption('-u, --user <username>', 'username to look up') .requiredOption('-u, --user <username>', 'username to look up')
.option('--json', 'print raw JSON instead of human-readable output', false)
.action(async (options) => { .action(async (options) => {
try { try {
const config = loadConfig(); const config = loadConfig();
const client = ForgejoClient.fromConfig(config); const client = ForgejoClient.fromConfig(config);
const u = await client.getUser(options.user); const u = await client.getUser(options.user);
if (options.json) {
printJson(u);
return;
}
console.log(`Login: ${u.login}`); console.log(`Login: ${u.login}`);
console.log(`Full name: ${u.full_name || '-'}`); console.log(`Full name: ${u.full_name || '-'}`);
console.log(`Email: ${u.email || '-'}`); console.log(`Email: ${u.email || '-'}`);

View file

@ -151,6 +151,24 @@ test('getPullRequest fetches a single pull request', async () => {
assert.equal(calls[0].opts.method, 'GET'); 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 () => { test('createPullRequestComment posts to the issue comments endpoint', async () => {
const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' })); const calls = mockFetch(() => jsonResponse({ id: 99, html_url: 'https://forge.test/comment/99' }));
const client = new ForgejoClient('https://forge.test', 'tok'); 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); 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 () => { test('getAll joins pagination with & when the endpoint already has a query', async () => {
const calls = mockFetch(() => jsonResponse([])); const calls = mockFetch(() => jsonResponse([]));
const client = new ForgejoClient('https://forge.test', 'tok'); const client = new ForgejoClient('https://forge.test', 'tok');

View file

@ -520,3 +520,203 @@ test('auth login rejects --full-scopes together with --scopes before any network
assert.equal(res.status, 1); assert.equal(res.status, 1);
assert.match(res.stderr, /either --full-scopes or --scopes, not both/); 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);
}
});