feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata

- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle)
- ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
2026-08-16 16:29:24 +00:00
parent 92f0eced34
commit 8f5cfdcd62
44 changed files with 5383 additions and 62 deletions
@@ -266,6 +266,70 @@ describe('pull request write methods', () => {
});
});
describe('issue, review, and repo write methods', () => {
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7/comments');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
expect(result.status).toBe(201);
});
test('updateIssue PATCHes params to the issue endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ number: 7, title: 'Updated' }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7');
expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ state: 'closed', labels: ['bug'], milestone: 33 });
});
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
const fetchMock = vi.fn(async () => jsonResponse({ id: 101, state: 'APPROVED' }, { status: 201 }));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
const [url, options] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/12/reviews');
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
});
test('milestones GETs the repo milestones list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/milestones?state=all&limit=50');
});
test('repoLabels GETs the repo labels list', async () => {
const fetchMock = vi.fn(async () => jsonResponse([{ id: 1, name: 'bug', color: 'd73a4a' }]));
globalThis.fetch = fetchMock;
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
await client.repoLabels('owner', 'repo', { limit: 100 });
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/labels?limit=100');
});
});
describe('rate limiting', () => {
// NOTE: these tests run last in this file. The rate-limit cooldown is
// module-level and has no reset export, so earlier tests must not set one.