Add issue show/comment, --json output, and pr review --commit
Some checks failed
ci / test (pull_request) Has been cancelled
Some checks failed
ci / test (pull_request) Has been cancelled
This commit is contained in:
parent
87b3cf98d9
commit
0531bde366
5 changed files with 419 additions and 6 deletions
43
README.md
43
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 <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`
|
||||
|
||||
List pull requests in a repository.
|
||||
|
|
@ -547,14 +586,16 @@ Options:
|
|||
--event <event> approve|approved, request-changes|request_changes, comment (required)
|
||||
-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)
|
||||
--commit <sha> 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`
|
||||
|
||||
|
|
|
|||
16
src/api.js
16
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) {
|
||||
|
|
|
|||
139
src/cli.js
139
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 <number>', '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 <state>', 'issue state: open, closed, all', 'open')
|
||||
.option('-t, --type <type>', 'issue type filter: issues, pulls', 'issues')
|
||||
.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) => {
|
||||
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 <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
|
||||
.command('pr')
|
||||
.description('Manage pull requests');
|
||||
|
|
@ -697,6 +776,7 @@ pr
|
|||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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('--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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('-n, --number <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 <event>', 'review event: approve|approved, request-changes|request_changes, 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('--commit <sha>', '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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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) => {
|
||||
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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--tag <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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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) => {
|
||||
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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.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) => {
|
||||
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 <org>', 'organization name')
|
||||
.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) => {
|
||||
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 <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 <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 <query>', 'search query (empty lists all visible users)', '')
|
||||
.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) => {
|
||||
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>', '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 || '-'}`);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
200
test/cli.test.js
200
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue