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.test.ts b/packages/adapter-github/src/index.test.ts index 46de77ad..676ab534 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,137 @@ 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 accept a Thread object and extract its id", 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, + }); + + const mockThread = { id: "github:acme/app:42" } as { id: string }; + + await expect( + singleTenantAdapter.getInstallationId(mockThread as never) + ).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()); diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index 8f5bf058..0f0e38ae 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,7 +323,7 @@ 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 { @@ -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); }