From 530dbf12579efbdd614493aa7192e8db800bc13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 28 Mar 2026 12:22:15 +0100 Subject: [PATCH 1/4] Implement getInstallationId --- .changeset/dirty-plums-speak.md | 5 ++++ packages/adapter-github/README.md | 32 ++++++++++++++++++++ packages/adapter-github/src/index.ts | 44 ++++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 .changeset/dirty-plums-speak.md diff --git a/.changeset/dirty-plums-speak.md b/.changeset/dirty-plums-speak.md new file mode 100644 index 00000000..1341fb58 --- /dev/null +++ b/.changeset/dirty-plums-speak.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/github": minor +--- + +Expose `getInstallationId(threadOrMessage)` on the GitHub adapter so callers can resolve the GitHub App installation associated with GitHub thread and message context. diff --git a/packages/adapter-github/README.md b/packages/adapter-github/README.md index 101f7ee6..10037b1d 100644 --- a/packages/adapter-github/README.md +++ b/packages/adapter-github/README.md @@ -97,6 +97,38 @@ createGitHubAdapter({ The adapter automatically extracts installation IDs from webhooks and caches API clients per-installation. +## Installation lookup + +You can resolve the GitHub App installation ID associated with a `Thread` or `Message`: + +```typescript +import { Chat } from "chat"; +import { createGitHubAdapter } from "@chat-adapter/github"; + +const github = createGitHubAdapter({ + appId: process.env.GITHUB_APP_ID!, + privateKey: process.env.GITHUB_PRIVATE_KEY!, + webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!, +}); + +const bot = new Chat({ + adapters: { github }, +}); + +bot.onNewMention(async (thread, message) => { + const installationIdFromThread = await github.getInstallationId(thread); + const installationIdFromMessage = await github.getInstallationId(message.threadId); + + await thread.post( + `Thread install: ${installationIdFromThread}, message install: ${installationIdFromMessage}` + ); +}); +``` + +- Single-tenant GitHub App mode returns the fixed configured installation ID. +- PAT mode returns `undefined`. +- Multi-tenant mode only succeeds after the adapter has received a webhook for that repository and cached the installation mapping. Use a persistent state adapter so the mapping survives restarts. + ## Webhook setup For repository or organization webhooks: diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index 8f5bf058..e1bb3a0c 100644 --- a/packages/adapter-github/src/index.ts +++ b/packages/adapter-github/src/index.ts @@ -18,6 +18,7 @@ import type { RawMessage, StreamChunk, StreamOptions, + Thread, ThreadInfo, WebhookOptions, } from "chat"; @@ -109,6 +110,8 @@ export class GitHubAdapter appId: string; privateKey: string; } | null = null; + // Fixed installation for single-tenant GitHub App mode + private readonly fixedInstallationId: number | null; // Cache of Octokit instances per installation (for multi-tenant) private readonly installationClients = new Map(); @@ -142,6 +145,7 @@ export class GitHubAdapter this.userName = config.userName ?? process.env.GITHUB_BOT_USERNAME ?? "github-bot"; this._botUserId = config.botUserId ?? null; + let fixedInstallationId: number | null = null; // Create Octokit instance based on auth method. // Only fall back to env vars when NO auth field was explicitly provided, @@ -163,6 +167,7 @@ export class GitHubAdapter ) { if ("installationId" in config && config.installationId) { // Single-tenant app mode - fixed installation + fixedInstallationId = config.installationId; this.octokit = new Octokit({ authStrategy: createAppAuth, auth: { @@ -200,6 +205,7 @@ export class GitHubAdapter ? Number.parseInt(process.env.GITHUB_INSTALLATION_ID, 10) : undefined; if (installationIdRaw) { + fixedInstallationId = installationIdRaw; this.octokit = new Octokit({ authStrategy: createAppAuth, auth: { appId, privateKey, installationId: installationIdRaw }, @@ -218,6 +224,8 @@ export class GitHubAdapter } } } + + this.fixedInstallationId = fixedInstallationId; } /** @@ -315,11 +323,11 @@ export class GitHubAdapter /** * Get the installation ID for a repository (for multi-tenant mode). */ - private async getInstallationId( + private async getStoredInstallationId( owner: string, repo: string ): Promise { - if (!(this.chat && this.isMultiTenant)) { + if (!this.chat || !this.isMultiTenant) { return undefined; } @@ -327,6 +335,36 @@ export class GitHubAdapter return (await this.chat.getState().get(key)) ?? undefined; } + /** + * Get the GitHub App installation ID associated with a thread. + * + * Returns the fixed installation ID in single-tenant app mode, the cached + * repository installation in multi-tenant mode, or undefined in PAT mode. + */ + async getInstallationId( + thread: Thread | string + ): Promise { + if (this.fixedInstallationId !== null) { + return this.fixedInstallationId; + } + + if (!this.isMultiTenant) { + return undefined; + } + + const threadId = typeof thread === "string" ? thread : thread.id; + const { owner, repo } = this.decodeThreadId(threadId); + + if (!this.chat) { + throw new ValidationError( + "github", + "Adapter not initialized. Ensure chat.initialize() has been called first." + ); + } + + return this.getStoredInstallationId(owner, repo); + } + /** * Handle incoming webhook from GitHub. */ @@ -614,7 +652,7 @@ export class GitHubAdapter owner: string, repo: string ): Promise { - const installationId = await this.getInstallationId(owner, repo); + const installationId = await this.getStoredInstallationId(owner, repo); return this.getOctokit(installationId); } From 505328728322a658dd40d19a7a474d6b136dedbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 28 Mar 2026 12:30:46 +0100 Subject: [PATCH 2/4] Add tests --- packages/adapter-github/src/index.test.ts | 154 ++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index 46de77ad..28665bbe 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -168,6 +168,19 @@ function makeWebhookRequest( }); } +function createMockState() { + const cache = new Map(); + + return { + get: vi.fn(async (key: string) => { + return (cache.get(key) as T | undefined) ?? null; + }), + set: vi.fn(async (key: string, value: unknown) => { + cache.set(key, value); + }), + }; +} + // ─── Tests ─────────────────────────────────────────────────────────────────── describe("GitHubAdapter", () => { @@ -313,6 +326,147 @@ describe("GitHubAdapter", () => { }); }); + describe("getInstallationId", () => { + it("should return the fixed installation ID from a thread in single-tenant app mode", async () => { + const singleTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + installationId: 456, + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + + await expect( + singleTenantAdapter.getInstallationId( + "github:acme/app:42" + ) + ).resolves.toBe(456); + }); + + it("should return the fixed installation ID from a thread ID in single-tenant app mode", async () => { + const singleTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + installationId: 456, + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + + await expect( + singleTenantAdapter.getInstallationId( + "github:acme/app:42" + ) + ).resolves.toBe(456); + }); + + it("should return undefined in PAT mode", async () => { + await expect( + adapter.getInstallationId("github:acme/app:42") + ).resolves.toBeUndefined(); + }); + + it("should return the cached installation ID in multi-tenant mode after a webhook", async () => { + const multiTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + const state = createMockState(); + const chat = { + getLogger: vi.fn(), + getState: vi.fn(() => state), + getUserName: vi.fn(), + handleIncomingMessage: vi.fn(), + processMessage: vi.fn(), + }; + await multiTenantAdapter.initialize(chat); + + const payload = makeIssueCommentPayload({ + installation: { id: 789 }, + }); + const body = JSON.stringify(payload); + const signature = signPayload(body); + const request = makeWebhookRequest(body, "issue_comment", signature); + + await multiTenantAdapter.handleWebhook(request); + + await expect( + multiTenantAdapter.getInstallationId( + "github:acme/app:42" + ) + ).resolves.toBe(789); + }); + + it("should return undefined when the multi-tenant installation is not cached", async () => { + const multiTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + const state = createMockState(); + const chat = { + getLogger: vi.fn(), + getState: vi.fn(() => state), + getUserName: vi.fn(), + handleIncomingMessage: vi.fn(), + processMessage: vi.fn(), + }; + await multiTenantAdapter.initialize(chat); + + await expect( + multiTenantAdapter.getInstallationId( + "github:acme/app:42" + ) + ).resolves.toBeUndefined(); + }); + + it("should throw for non-GitHub thread or message context", async () => { + const multiTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + + await expect( + multiTenantAdapter.getInstallationId( + "slack:C123:1234.5678" + ) + ).rejects.toThrow("Invalid GitHub thread ID"); + }); + + it("should throw before initialization in multi-tenant mode", async () => { + const multiTenantAdapter = new GitHubAdapter({ + appId: "12345", + privateKey: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + webhookSecret: WEBHOOK_SECRET, + userName: "test-bot[bot]", + logger: mockLogger, + }); + + await expect( + multiTenantAdapter.getInstallationId( + "github:acme/app:42" + ) + ).rejects.toThrow( + "Adapter not initialized. Ensure chat.initialize() has been called first." + ); + }); + }); + describe("handleWebhook", () => { it("should return 401 for missing signature", async () => { const body = JSON.stringify(makeIssueCommentPayload()); From aa633c5f1726ae8c3d9470fe81074a26aa416f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sat, 28 Mar 2026 12:31:13 +0100 Subject: [PATCH 3/4] Format --- packages/adapter-github/src/index.test.ts | 24 ++++++----------------- packages/adapter-github/src/index.ts | 10 +++++----- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index 28665bbe..c6e81b0e 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -339,9 +339,7 @@ describe("GitHubAdapter", () => { }); await expect( - singleTenantAdapter.getInstallationId( - "github:acme/app:42" - ) + singleTenantAdapter.getInstallationId("github:acme/app:42") ).resolves.toBe(456); }); @@ -357,9 +355,7 @@ describe("GitHubAdapter", () => { }); await expect( - singleTenantAdapter.getInstallationId( - "github:acme/app:42" - ) + singleTenantAdapter.getInstallationId("github:acme/app:42") ).resolves.toBe(456); }); @@ -398,9 +394,7 @@ describe("GitHubAdapter", () => { await multiTenantAdapter.handleWebhook(request); await expect( - multiTenantAdapter.getInstallationId( - "github:acme/app:42" - ) + multiTenantAdapter.getInstallationId("github:acme/app:42") ).resolves.toBe(789); }); @@ -424,9 +418,7 @@ describe("GitHubAdapter", () => { await multiTenantAdapter.initialize(chat); await expect( - multiTenantAdapter.getInstallationId( - "github:acme/app:42" - ) + multiTenantAdapter.getInstallationId("github:acme/app:42") ).resolves.toBeUndefined(); }); @@ -441,9 +433,7 @@ describe("GitHubAdapter", () => { }); await expect( - multiTenantAdapter.getInstallationId( - "slack:C123:1234.5678" - ) + multiTenantAdapter.getInstallationId("slack:C123:1234.5678") ).rejects.toThrow("Invalid GitHub thread ID"); }); @@ -458,9 +448,7 @@ describe("GitHubAdapter", () => { }); await expect( - multiTenantAdapter.getInstallationId( - "github:acme/app:42" - ) + multiTenantAdapter.getInstallationId("github:acme/app:42") ).rejects.toThrow( "Adapter not initialized. Ensure chat.initialize() has been called first." ); diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index e1bb3a0c..0f0e38ae 100644 --- a/packages/adapter-github/src/index.ts +++ b/packages/adapter-github/src/index.ts @@ -327,7 +327,7 @@ export class GitHubAdapter owner: string, repo: string ): Promise { - if (!this.chat || !this.isMultiTenant) { + if (!(this.chat && this.isMultiTenant)) { return undefined; } @@ -356,10 +356,10 @@ export class GitHubAdapter const { owner, repo } = this.decodeThreadId(threadId); if (!this.chat) { - throw new ValidationError( - "github", - "Adapter not initialized. Ensure chat.initialize() has been called first." - ); + throw new ValidationError( + "github", + "Adapter not initialized. Ensure chat.initialize() has been called first." + ); } return this.getStoredInstallationId(owner, repo); From cebab8b4329750853436e9bdae7f0cab33dd8595 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 31 Mar 2026 12:59:49 +1100 Subject: [PATCH 4/4] Fix duplicate test to cover Thread object input path --- packages/adapter-github/src/index.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index c6e81b0e..676ab534 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -343,7 +343,7 @@ describe("GitHubAdapter", () => { ).resolves.toBe(456); }); - it("should return the fixed installation ID from a thread ID in single-tenant app mode", async () => { + it("should accept a Thread object and extract its id", async () => { const singleTenantAdapter = new GitHubAdapter({ appId: "12345", privateKey: @@ -354,8 +354,10 @@ describe("GitHubAdapter", () => { logger: mockLogger, }); + const mockThread = { id: "github:acme/app:42" } as { id: string }; + await expect( - singleTenantAdapter.getInstallationId("github:acme/app:42") + singleTenantAdapter.getInstallationId(mockThread as never) ).resolves.toBe(456); });