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
5 changes: 5 additions & 0 deletions .changeset/dirty-plums-speak.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/adapter-github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
144 changes: 144 additions & 0 deletions packages/adapter-github/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ function makeWebhookRequest(
});
}

function createMockState() {
const cache = new Map<string, unknown>();

return {
get: vi.fn(async <T>(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", () => {
Expand Down Expand Up @@ -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());
Expand Down
42 changes: 40 additions & 2 deletions packages/adapter-github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
RawMessage,
StreamChunk,
StreamOptions,
Thread,
ThreadInfo,
WebhookOptions,
} from "chat";
Expand Down Expand Up @@ -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<number, Octokit>();

Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down Expand Up @@ -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 },
Expand All @@ -218,6 +224,8 @@ export class GitHubAdapter
}
}
}

this.fixedInstallationId = fixedInstallationId;
}

/**
Expand Down Expand Up @@ -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<number | undefined> {
Expand All @@ -327,6 +335,36 @@ export class GitHubAdapter
return (await this.chat.getState().get<number>(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<number | undefined> {
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.
*/
Expand Down Expand Up @@ -614,7 +652,7 @@ export class GitHubAdapter
owner: string,
repo: string
): Promise<Octokit> {
const installationId = await this.getInstallationId(owner, repo);
const installationId = await this.getStoredInstallationId(owner, repo);
return this.getOctokit(installationId);
}

Expand Down
Loading