Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@

### Added

- **Issue / PR comment methods on the `GitProvider` contract (`@apicircle/git`).**
`GitHubClient` gains `listIssueComments` / `createIssueComment` /
`updateIssueComment` (thin REST wrappers over the issue-comments API — a PR is
an issue for this endpoint), and the three join the `GitProviderMethod` union so
any host provider can implement them. Additive; no existing method or type
changed. This is the write capability an edition's PR-review write-back (post the
review as an idempotent PR comment) composes on top of.
- **`readAttachmentBytes(slotId)` public accessor (`@apicircle/ui-components`).**
A narrow, additive export that reads a stored attachment's raw bytes by its
`GlobalFileAsset.slotId` (or `null` when absent), so an edition / overlay can
Expand Down
72 changes: 72 additions & 0 deletions packages/git/src/github/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,78 @@ describe('GitHubClient.getBinaryContents', () => {
});
});

describe('GitHubClient — issue/PR comments', () => {
it('listIssueComments GETs the comments and returns normalized summaries', async () => {
const fetchImpl: typeof fetch = vi.fn(async () =>
jsonResponse([
{
id: 1,
html_url: 'https://github.com/me/api/pull/12#issuecomment-1',
body: '<!-- m -->hello',
},
{ id: 2, html_url: 'https://github.com/me/api/pull/12#issuecomment-2', body: 'other' },
]),
);
const client = new GitHubClient({ fetchImpl });
const comments = await client.listIssueComments('tok', 'me', 'api', 12);
expect(comments).toEqual([
{
id: 1,
htmlUrl: 'https://github.com/me/api/pull/12#issuecomment-1',
body: '<!-- m -->hello',
},
{ id: 2, htmlUrl: 'https://github.com/me/api/pull/12#issuecomment-2', body: 'other' },
]);
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://api.github.com/repos/me/api/issues/12/comments?per_page=100');
expect((init as RequestInit).method).toBe('GET');
});

it('createIssueComment POSTs the body and returns the created comment', async () => {
const fetchImpl: typeof fetch = vi.fn(async () =>
jsonResponse({
id: 5,
html_url: 'https://github.com/me/api/pull/12#issuecomment-5',
body: 'the review',
}),
);
const client = new GitHubClient({ fetchImpl });
const c = await client.createIssueComment('tok', 'me', 'api', 12, 'the review');
expect(c).toEqual({
id: 5,
htmlUrl: 'https://github.com/me/api/pull/12#issuecomment-5',
body: 'the review',
});
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://api.github.com/repos/me/api/issues/12/comments');
expect((init as RequestInit).method).toBe('POST');
expect(JSON.parse(((init as RequestInit).body as string) ?? '')).toEqual({
body: 'the review',
});
});

it('updateIssueComment PATCHes an existing comment by id', async () => {
const fetchImpl: typeof fetch = vi.fn(async () =>
jsonResponse({
id: 5,
html_url: 'https://github.com/me/api/pull/12#issuecomment-5',
body: 'updated',
}),
);
const client = new GitHubClient({ fetchImpl });
const c = await client.updateIssueComment('tok', 'me', 'api', 5, 'updated');
expect(c).toEqual({
id: 5,
htmlUrl: 'https://github.com/me/api/pull/12#issuecomment-5',
body: 'updated',
});
const [url, init] = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://api.github.com/repos/me/api/issues/comments/5');
expect((init as RequestInit).method).toBe('PATCH');
expect(JSON.parse(((init as RequestInit).body as string) ?? '')).toEqual({ body: 'updated' });
});
});

describe('GitHubClient.createPullRequest', () => {
it('POSTs title/body/head/base and returns the normalized PR summary', async () => {
const fetchImpl: typeof fetch = vi.fn(async () =>
Expand Down
68 changes: 68 additions & 0 deletions packages/git/src/github/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export interface PullRequestSummary {
title: string;
}

/** A pull-request / issue comment (PRs are issues for the comments API). */
export interface IssueCommentSummary {
id: number;
/** GitHub UI URL of the comment. */
htmlUrl: string;
body: string;
}

export interface MarketplaceRepo {
fullName: string;
owner: string;
Expand Down Expand Up @@ -1053,6 +1061,60 @@ export class GitHubClient {
};
}

/**
* List a pull request's / issue's comments (a PR is an issue for this API). Used
* to find a prior bot comment to UPDATE in place — the caller matches a marker in
* `body` — so re-running a review edits its comment rather than spamming new ones.
*/
async listIssueComments(
token: string,
owner: string,
name: string,
issueNumber: number,
opts: CallOptions = {},
): Promise<IssueCommentSummary[]> {
const { json } = await this.call<RawIssueComment[]>(
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${issueNumber}/comments?per_page=100`,
{ ...opts, requiredScopes: ['repo'] },
);
return json.map((c) => ({ id: c.id, htmlUrl: c.html_url, body: c.body }));
}

/** Create a comment on a pull request / issue. */
async createIssueComment(
token: string,
owner: string,
name: string,
issueNumber: number,
body: string,
opts: CallOptions = {},
): Promise<IssueCommentSummary> {
const { json } = await this.call<RawIssueComment>(
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${issueNumber}/comments`,
{ ...opts, method: 'POST', body: { body }, requiredScopes: ['repo'] },
);
return { id: json.id, htmlUrl: json.html_url, body: json.body };
}

/** Update an existing issue / pull-request comment by its id (idempotent re-runs). */
async updateIssueComment(
token: string,
owner: string,
name: string,
commentId: number,
body: string,
opts: CallOptions = {},
): Promise<IssueCommentSummary> {
const { json } = await this.call<RawIssueComment>(
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/comments/${commentId}`,
{ ...opts, method: 'PATCH', body: { body }, requiredScopes: ['repo'] },
);
return { id: json.id, htmlUrl: json.html_url, body: json.body };
}

// --- low-level call ----------------------------------------------------

private async call<T>(
Expand Down Expand Up @@ -1169,6 +1231,12 @@ interface RawPullRequest {
title: string;
}

interface RawIssueComment {
id: number;
html_url: string;
body: string;
}

interface RawSearchRepo {
full_name: string;
name: string;
Expand Down
1 change: 1 addition & 0 deletions packages/git/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
GitHubRepo,
GitHubViewer,
GitRef,
IssueCommentSummary,
MarketplaceRepo,
PullRequestSummary,
ScopeInfo,
Expand Down
5 changes: 4 additions & 1 deletion packages/git/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export type GitProviderMethod =
| 'getBinaryContents'
| 'getPullRequest'
| 'listPullRequests'
| 'createPullRequest';
| 'createPullRequest'
| 'listIssueComments'
| 'createIssueComment'
| 'updateIssueComment';

/**
* Host-agnostic Git provider contract — the surface the workspace
Expand Down
Loading