From 4b509f937db92972d164f465e96cca60777c5afa Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Thu, 23 Jul 2026 20:16:46 +0530 Subject: [PATCH] feat(git): issue/PR comment methods on the GitProvider contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `listIssueComments` / `createIssueComment` / `updateIssueComment` to `GitHubClient` (thin REST wrappers over the GitHub issue-comments API — a PR is an issue for `/repos/{o}/{r}/issues/{n}/comments`), returning a normalized `IssueCommentSummary` { id, htmlUrl, body }. All three join the `GitProviderMethod` union, so they become part of the host-agnostic `GitProvider` contract (`Pick`) any alternate host can satisfy. Additive: no existing method, type, or behaviour changed. This is the write capability an edition's PR-review write-back — post the review as an idempotent PR comment (list → find a marker → update, else create) — composes on top of; nothing in Studio calls it yet. 100% patch coverage (3 new tests in api.test.ts covering URL/method/body/return for each); git tsc + eslint + knip clean. --- CHANGELOG.md | 7 +++ packages/git/src/github/api.test.ts | 72 +++++++++++++++++++++++++++++ packages/git/src/github/api.ts | 68 +++++++++++++++++++++++++++ packages/git/src/index.ts | 1 + packages/git/src/provider.ts | 5 +- 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d4f144..a4dbf5ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/git/src/github/api.test.ts b/packages/git/src/github/api.test.ts index 067ebe4e..3c930b33 100644 --- a/packages/git/src/github/api.test.ts +++ b/packages/git/src/github/api.test.ts @@ -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: '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: 'hello', + }, + { id: 2, htmlUrl: 'https://github.com/me/api/pull/12#issuecomment-2', body: 'other' }, + ]); + const [url, init] = (fetchImpl as ReturnType).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).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).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 () => diff --git a/packages/git/src/github/api.ts b/packages/git/src/github/api.ts index 38546a20..b7a2c5df 100644 --- a/packages/git/src/github/api.ts +++ b/packages/git/src/github/api.ts @@ -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; @@ -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 { + const { json } = await this.call( + 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 { + const { json } = await this.call( + 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 { + const { json } = await this.call( + 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( @@ -1169,6 +1231,12 @@ interface RawPullRequest { title: string; } +interface RawIssueComment { + id: number; + html_url: string; + body: string; +} + interface RawSearchRepo { full_name: string; name: string; diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index ea4c3f9c..38d80951 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -11,6 +11,7 @@ export type { GitHubRepo, GitHubViewer, GitRef, + IssueCommentSummary, MarketplaceRepo, PullRequestSummary, ScopeInfo, diff --git a/packages/git/src/provider.ts b/packages/git/src/provider.ts index 8da45935..e6db6387 100644 --- a/packages/git/src/provider.ts +++ b/packages/git/src/provider.ts @@ -49,7 +49,10 @@ export type GitProviderMethod = | 'getBinaryContents' | 'getPullRequest' | 'listPullRequests' - | 'createPullRequest'; + | 'createPullRequest' + | 'listIssueComments' + | 'createIssueComment' + | 'updateIssueComment'; /** * Host-agnostic Git provider contract — the surface the workspace