From 626a8f5ae815ffdf1a26f2d91e90889a5f03c5f9 Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 11:52:22 -0700 Subject: [PATCH] Add GitHub integration plugin Introduce a new official plugin package (github-integration) that summarizes pull requests, generates release notes, and handles GitHub webhook events. Implements core features: PR summarization and release-note prompt builders, AI completion helper (callAI) using the internal completions endpoint, GitHub REST API helpers, HMAC-SHA256 webhook signature verification, and DB key helpers. Registers six endpoints (POST /connect, GET /repos, GET /repos/:owner/:repo/prs, POST /summarize-pr, POST /release-notes, POST /webhook) and provides plugin settings (githubToken, webhookSecret, defaultSummaryModel). Adds manifest, package.json, TypeScript source, and a comprehensive Jest test suite covering helpers, endpoints, webhook handling, caching/force-refresh, and lifecycle hooks. --- .../__tests__/index.test.ts | 1104 +++++++++++++++++ .../__tests__/tsconfig.json | 14 + .../official/github-integration/manifest.json | 10 + .../official/github-integration/package.json | 41 + .../official/github-integration/src/index.ts | 822 ++++++++++++ .../official/github-integration/tsconfig.json | 21 + .../github-integration/tsconfig.test.json | 14 + pnpm-lock.yaml | 237 +++- 8 files changed, 2252 insertions(+), 11 deletions(-) create mode 100644 packages/plugins/official/github-integration/__tests__/index.test.ts create mode 100644 packages/plugins/official/github-integration/__tests__/tsconfig.json create mode 100644 packages/plugins/official/github-integration/manifest.json create mode 100644 packages/plugins/official/github-integration/package.json create mode 100644 packages/plugins/official/github-integration/src/index.ts create mode 100644 packages/plugins/official/github-integration/tsconfig.json create mode 100644 packages/plugins/official/github-integration/tsconfig.test.json diff --git a/packages/plugins/official/github-integration/__tests__/index.test.ts b/packages/plugins/official/github-integration/__tests__/index.test.ts new file mode 100644 index 0000000..1ebef2f --- /dev/null +++ b/packages/plugins/official/github-integration/__tests__/index.test.ts @@ -0,0 +1,1104 @@ +/// +/** + * GitHub Integration — Unit Tests + * + * Covers: DB key helpers, signature verification, prompt builders, callAI, + * plugin manifest/settings, and all six endpoint handlers (including caching, + * force-refresh, webhook event handling, and error paths). + */ +import plugin, { + buildRepoKey, + buildSummaryKey, + buildTokenKey, + buildWebhookLogKey, + verifyGitHubSignature, + buildPrSummaryPrompt, + buildReleaseNotesPrompt, + callAI, + GITHUB_API_BASE, + AI_COMPLETIONS_PATH, + DEFAULT_MODEL, + SUPPORTED_MODELS, + HANDLED_EVENTS, + GitHubPR, + GitHubFile, + GitHubCommit, + PRSummaryRecord, + WebhookEventRecord, +} from "../src/index"; +import { + PluginContext, + PluginAPI, + PluginDatabaseAPI, + PluginEventBus, + EndpointDefinition, + EndpointRequest, + EndpointResponse, +} from "@agentbase/plugin-sdk"; +import { createHmac } from "crypto"; + +// ── Mock factory ────────────────────────────────────────────────────────────── + +function createMockAPI(): PluginAPI & { _endpoints: EndpointDefinition[] } { + const store = new Map(); + const _endpoints: EndpointDefinition[] = []; + + const db: PluginDatabaseAPI = { + set: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => store.set(k, v)), + get: jest + .fn() + .mockImplementation(async (k: string) => store.get(k) ?? null), + delete: jest.fn().mockImplementation(async (k: string) => { + const had = store.has(k); + store.delete(k); + return had; + }), + keys: jest + .fn() + .mockImplementation(async (prefix?: string) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + + const events: PluginEventBus = { + emit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + }; + + return { + _endpoints, + getConfig: jest.fn().mockReturnValue(undefined), + setConfig: jest.fn().mockResolvedValue(undefined), + makeRequest: jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }), + log: jest.fn(), + db, + events, + registerEndpoint: jest + .fn() + .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)), + registerCronJob: jest.fn(), + registerWebhook: jest.fn(), + registerAdminPage: jest.fn(), + } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] }; +} + +type MockCtx = PluginContext & { api: ReturnType }; + +function makeCtx(overrides: Partial = {}): MockCtx { + const api = createMockAPI(); + return { + appId: "app-1", + userId: "user-1", + config: {}, + api, + ...overrides, + } as MockCtx; +} + +interface MockRes { + status: jest.Mock; + json: jest.Mock; + send: jest.Mock; + _status: number; + _body: unknown; +} + +function makeRes(): MockRes { + const r: MockRes = { + _status: 200, + _body: undefined, + status: jest.fn(), + json: jest.fn(), + send: jest.fn(), + }; + r.status.mockImplementation((code: number) => { + r._status = code; + return r; + }); + r.json.mockImplementation((body: unknown) => { + r._body = body; + }); + r.send.mockImplementation((body: unknown) => { + r._body = body; + }); + return r; +} + +function makeReq(overrides: Partial = {}): EndpointRequest { + return { + method: "POST", + path: "/", + params: {}, + query: {}, + body: {}, + headers: {}, + user: { id: "user-1", email: "test@example.com", role: "user" }, + ...overrides, + }; +} + +function getEndpoint( + ctx: MockCtx, + method: string, + path: string, +): EndpointDefinition { + const ep = ctx.api._endpoints.find( + (e) => e.method === method && e.path === path, + ); + if (!ep) throw new Error(`Endpoint ${method} ${path} not found`); + return ep; +} + +async function initPlugin(ctx: MockCtx): Promise { + await plugin.definition.hooks?.["app:init"]?.(ctx); +} + +// Convenience: build a valid GitHub HMAC-SHA256 signature +function makeValidSig(secret: string, body: string): string { + return ( + "sha256=" + createHmac("sha256", secret).update(body, "utf8").digest("hex") + ); +} + +// ── DB key helpers ──────────────────────────────────────────────────────────── + +describe("DB key helpers", () => { + it("buildRepoKey formats owner/repo correctly", () => { + expect(buildRepoKey("octocat", "Hello-World")).toBe( + "repo:octocat/Hello-World", + ); + }); + + it("buildSummaryKey includes owner, repo, and PR number", () => { + expect(buildSummaryKey("octocat", "Hello-World", 42)).toBe( + "summary:octocat/Hello-World:42", + ); + }); + + it("buildTokenKey returns a fixed constant", () => { + expect(buildTokenKey()).toBe("connected:token"); + }); + + it("buildWebhookLogKey prefixes id with 'webhook:'", () => { + expect(buildWebhookLogKey("delivery-abc")).toBe("webhook:delivery-abc"); + }); +}); + +// ── Signature verification ──────────────────────────────────────────────────── + +describe("verifyGitHubSignature", () => { + it("returns true for a valid signature", () => { + const body = '{"action":"opened"}'; + const sig = makeValidSig("my-secret", body); + expect(verifyGitHubSignature("my-secret", body, sig)).toBe(true); + }); + + it("returns false when the secret is wrong", () => { + const body = '{"action":"opened"}'; + const sig = makeValidSig("wrong-secret", body); + expect(verifyGitHubSignature("my-secret", body, sig)).toBe(false); + }); + + it("returns false when the body has been tampered with", () => { + const sig = makeValidSig("my-secret", '{"action":"opened"}'); + expect(verifyGitHubSignature("my-secret", '{"action":"closed"}', sig)).toBe( + false, + ); + }); + + it("returns false when sigHeader is undefined", () => { + expect(verifyGitHubSignature("secret", "body", undefined)).toBe(false); + }); + + it("returns false when sigHeader has wrong prefix (sha1 instead of sha256)", () => { + expect(verifyGitHubSignature("secret", "body", "sha1=abc")).toBe(false); + }); + + it("returns false for an empty header string", () => { + expect(verifyGitHubSignature("secret", "body", "")).toBe(false); + }); + + it("returns false for a malformed hex value (wrong length)", () => { + expect(verifyGitHubSignature("secret", "body", "sha256=tooshort")).toBe( + false, + ); + }); +}); + +// ── Prompt builders ─────────────────────────────────────────────────────────── + +const BASE_PR: GitHubPR = { + number: 42, + title: "Add authentication middleware", + body: "Adds JWT to all routes", + state: "open", + html_url: "https://github.com/org/repo/pull/42", + user: { login: "alice" }, + head: { ref: "feature/auth", sha: "abc123" }, + base: { ref: "main", sha: "def456" }, + merged_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const BASE_FILES: GitHubFile[] = [ + { + filename: "src/auth.ts", + status: "added", + additions: 120, + deletions: 0, + changes: 120, + patch: "+export function verifyToken(t: string) { return t; }", + }, + { + filename: "src/index.ts", + status: "modified", + additions: 5, + deletions: 2, + changes: 7, + }, +]; + +describe("buildPrSummaryPrompt", () => { + it("includes PR number and title", () => { + const p = buildPrSummaryPrompt(BASE_PR, BASE_FILES); + expect(p).toContain("#42"); + expect(p).toContain("Add authentication middleware"); + }); + + it("includes author and branch names", () => { + const p = buildPrSummaryPrompt(BASE_PR, BASE_FILES); + expect(p).toContain("alice"); + expect(p).toContain("feature/auth"); + expect(p).toContain("main"); + }); + + it("lists changed filenames", () => { + const p = buildPrSummaryPrompt(BASE_PR, BASE_FILES); + expect(p).toContain("src/auth.ts"); + expect(p).toContain("src/index.ts"); + }); + + it("substitutes '(none provided)' for null body", () => { + const p = buildPrSummaryPrompt({ ...BASE_PR, body: null }, BASE_FILES); + expect(p).toContain("(none provided)"); + }); + + it("includes diff patch sample for files that have one", () => { + const p = buildPrSummaryPrompt(BASE_PR, BASE_FILES); + expect(p).toContain("verifyToken"); + }); + + it("handles 30 files without crashing and shows total count", () => { + const manyFiles: GitHubFile[] = Array.from({ length: 30 }, (_, i) => ({ + filename: `src/module${i}.ts`, + status: "added" as const, + additions: 1, + deletions: 0, + changes: 1, + })); + const p = buildPrSummaryPrompt(BASE_PR, manyFiles); + expect(p).toContain("30 total"); + }); +}); + +describe("buildReleaseNotesPrompt", () => { + const commits: GitHubCommit[] = [ + { + sha: "abc1234def", + commit: { + message: "feat: add dark mode\n\nFull body text that should be omitted", + author: { name: "alice", date: "2026-01-01" }, + }, + }, + { + sha: "def5678abc", + commit: { + message: "fix: resolve login crash", + author: { name: "bob", date: "2026-01-02" }, + }, + }, + ]; + + it("includes repo name and both refs", () => { + const p = buildReleaseNotesPrompt("v1.0.0", "v1.1.0", commits, "org/repo"); + expect(p).toContain("org/repo"); + expect(p).toContain("v1.0.0"); + expect(p).toContain("v1.1.0"); + }); + + it("uses only the first line of each commit message", () => { + const p = buildReleaseNotesPrompt("v1.0.0", "v1.1.0", commits, "org/repo"); + expect(p).toContain("feat: add dark mode"); + expect(p).not.toContain("Full body text that should be omitted"); + }); + + it("truncates commit SHA to 7 chars", () => { + const p = buildReleaseNotesPrompt("v1.0.0", "v1.1.0", commits, "org/repo"); + expect(p).toContain("abc1234"); + expect(p).not.toContain("abc1234def"); + }); + + it("shows total commit count", () => { + const p = buildReleaseNotesPrompt("v1.0.0", "v1.1.0", commits, "org/repo"); + expect(p).toContain("2 total"); + }); + + it("limits listed commits to 50 but shows real total", () => { + const manyCommits: GitHubCommit[] = Array.from({ length: 60 }, (_, i) => ({ + sha: `sha${i.toString().padStart(7, "0")}`, + commit: { + message: `chore: commit ${i}`, + author: { name: "dev", date: "2026-01-01" }, + }, + })); + const p = buildReleaseNotesPrompt("v1", "v2", manyCommits, "org/repo"); + expect(p).toContain("60 total"); + expect(p).not.toContain("commit 50"); + }); +}); + +// ── callAI helper ───────────────────────────────────────────────────────────── + +describe("callAI", () => { + it("calls the correct internal path with POST method", async () => { + const makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "Result" } }], + }), + text: jest.fn().mockResolvedValue(""), + }); + await callAI( + makeRequest as unknown as MockCtx["api"]["makeRequest"], + "gpt-4o", + "prompt", + ); + expect(makeRequest).toHaveBeenCalledWith( + AI_COMPLETIONS_PATH, + expect.objectContaining({ method: "POST" }), + ); + }); + + it("returns content from OpenAI-style choices array", async () => { + const makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "OpenAI result" } }], + }), + text: jest.fn().mockResolvedValue(""), + }); + const result = await callAI( + makeRequest as unknown as MockCtx["api"]["makeRequest"], + "gpt-4o", + "prompt", + ); + expect(result).toBe("OpenAI result"); + }); + + it("returns content from Agentbase-native response shape", async () => { + const makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ content: "Native result" }), + text: jest.fn().mockResolvedValue(""), + }); + const result = await callAI( + makeRequest as unknown as MockCtx["api"]["makeRequest"], + "claude-3-5-sonnet", + "prompt", + ); + expect(result).toBe("Native result"); + }); + + it("throws an error when response is not ok", async () => { + const makeRequest = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Internal Server Error"), + }); + await expect( + callAI( + makeRequest as unknown as MockCtx["api"]["makeRequest"], + "gpt-4o", + "prompt", + ), + ).rejects.toThrow("AI completions error 500"); + }); + + it("includes model, messages, and temperature in request body", async () => { + const makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ content: "" }), + text: jest.fn().mockResolvedValue(""), + }); + await callAI( + makeRequest as unknown as MockCtx["api"]["makeRequest"], + "gpt-4o-mini", + "test prompt", + ); + const [, options] = makeRequest.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string) as { + model: string; + messages: Array<{ role: string; content: string }>; + temperature: number; + }; + expect(body.model).toBe("gpt-4o-mini"); + expect(body.messages[0]).toMatchObject({ + role: "user", + content: "test prompt", + }); + expect(body.temperature).toBe(0.3); + }); +}); + +// ── Constants ───────────────────────────────────────────────────────────────── + +describe("constants", () => { + it("GITHUB_API_BASE points to the GitHub REST API", () => { + expect(GITHUB_API_BASE).toBe("https://api.github.com"); + }); + + it("DEFAULT_MODEL is in SUPPORTED_MODELS", () => { + expect(SUPPORTED_MODELS).toContain(DEFAULT_MODEL); + }); + + it("HANDLED_EVENTS includes push, pull_request, issues, release", () => { + expect(HANDLED_EVENTS).toEqual( + expect.arrayContaining(["push", "pull_request", "issues", "release"]), + ); + }); +}); + +// ── Plugin manifest & settings ──────────────────────────────────────────────── + +describe("plugin manifest", () => { + it("has the correct name and version", () => { + expect(plugin.manifest.name).toBe("github-integration"); + expect(plugin.manifest.version).toBe("1.0.0"); + }); + + it("defines githubToken as encrypted", () => { + expect(plugin.definition.settings?.["githubToken"]).toMatchObject({ + encrypted: true, + }); + }); + + it("defines webhookSecret as encrypted", () => { + expect(plugin.definition.settings?.["webhookSecret"]).toMatchObject({ + encrypted: true, + }); + }); + + it("defines defaultSummaryModel as a select with all supported models", () => { + const setting = plugin.definition.settings?.["defaultSummaryModel"]; + expect(setting?.type).toBe("select"); + expect(setting?.options).toEqual( + expect.arrayContaining([...SUPPORTED_MODELS]), + ); + expect(setting?.default).toBe(DEFAULT_MODEL); + }); +}); + +// ── app:init ────────────────────────────────────────────────────────────────── + +describe("app:init", () => { + it("registers exactly 6 endpoints", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + expect(ctx.api._endpoints).toHaveLength(6); + }); + + it("registers endpoints for all expected paths", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const paths = ctx.api._endpoints.map((e) => `${e.method} ${e.path}`); + expect(paths).toContain("POST /connect"); + expect(paths).toContain("GET /repos"); + expect(paths).toContain("GET /repos/:owner/:repo/prs"); + expect(paths).toContain("POST /summarize-pr"); + expect(paths).toContain("POST /release-notes"); + expect(paths).toContain("POST /webhook"); + }); + + it("logs initialization message", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("GitHub Integration initialized"), + ); + }); + + it("sets auth:false only on/webhook", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const webhook = ctx.api._endpoints.find((e) => e.path === "/webhook"); + expect(webhook?.auth).toBe(false); + const connect = ctx.api._endpoints.find((e) => e.path === "/connect"); + expect(connect?.auth).toBe(true); + }); +}); + +// ── POST /connect ───────────────────────────────────────────────────────────── + +describe("POST /connect", () => { + async function call(ctx: MockCtx, body: unknown): Promise { + const ep = getEndpoint(ctx, "POST", "/connect"); + const res = makeRes(); + await ep.handler(makeReq({ body }), res as unknown as EndpointResponse); + return res; + } + + it("returns 400 when token is missing from body", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, {}); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain( + "token is required", + ); + }); + + it("stores token record and returns login on success", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + login: "octocat", + name: "OctoCat", + avatar_url: "https://example.com/avatar", + }), + text: jest.fn().mockResolvedValue(""), + }); + const res = await call(ctx, { token: "ghp_testtoken" }); + expect(res._body).toMatchObject({ connected: true, login: "octocat" }); + const stored = (await ctx.api.db.get(buildTokenKey())) as { + token: string; + login: string; + }; + expect(stored?.token).toBe("ghp_testtoken"); + expect(stored?.login).toBe("octocat"); + }); + + it("returns 400 when GitHub rejects the token", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Bad credentials"), + }); + const res = await call(ctx, { token: "invalid_token" }); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain( + "GitHub token validation failed", + ); + }); +}); + +// ── GET /repos ──────────────────────────────────────────────────────────────── + +describe("GET /repos", () => { + async function call(ctx: MockCtx): Promise { + const ep = getEndpoint(ctx, "GET", "/repos"); + const res = makeRes(); + await ep.handler( + makeReq({ method: "GET" }), + res as unknown as EndpointResponse, + ); + return res; + } + + it("returns 401 when no token is configured", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx); + expect(res._status).toBe(401); + }); + + it("returns repo list and stores each repo in plugin DB", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + const mockRepos = [ + { + full_name: "octocat/Hello-World", + name: "Hello-World", + owner: { login: "octocat" }, + description: "A test repo", + private: false, + html_url: "https://github.com/octocat/Hello-World", + default_branch: "main", + }, + ]; + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockRepos), + text: jest.fn().mockResolvedValue(""), + }); + const res = await call(ctx); + expect((res._body as { total: number }).total).toBe(1); + expect( + (res._body as { repos: Array<{ fullName: string }> }).repos[0].fullName, + ).toBe("octocat/Hello-World"); + const stored = (await ctx.api.db.get( + buildRepoKey("octocat", "Hello-World"), + )) as { owner: string; repo: string }; + expect(stored?.owner).toBe("octocat"); + expect(stored?.repo).toBe("Hello-World"); + }); + + it("returns 502 when GitHub API call fails", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue("Forbidden"), + }); + const res = await call(ctx); + expect(res._status).toBe(502); + }); +}); + +// ── GET /repos/:owner/:repo/prs ─────────────────────────────────────────────── + +describe("GET /repos/:owner/:repo/prs", () => { + async function call( + ctx: MockCtx, + owner: string, + repo: string, + ): Promise { + const ep = getEndpoint(ctx, "GET", "/repos/:owner/:repo/prs"); + const res = makeRes(); + await ep.handler( + makeReq({ method: "GET", params: { owner, repo } }), + res as unknown as EndpointResponse, + ); + return res; + } + + it("returns 401 when no token configured", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, "octocat", "Hello-World"); + expect(res._status).toBe(401); + }); + + it("returns a mapped PR list", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + const mockPRs: GitHubPR[] = [ + { + number: 7, + title: "Fix the thing", + body: null, + state: "open", + html_url: "https://github.com/octocat/Hello-World/pull/7", + user: { login: "alice" }, + head: { ref: "fix/thing", sha: "aaa" }, + base: { ref: "main", sha: "bbb" }, + merged_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + ]; + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockPRs), + text: jest.fn().mockResolvedValue(""), + }); + const res = await call(ctx, "octocat", "Hello-World"); + expect((res._body as { total: number }).total).toBe(1); + expect( + (res._body as { prs: Array<{ number: number; author: string }> }).prs[0], + ).toMatchObject({ number: 7, author: "alice" }); + }); +}); + +// ── POST /summarize-pr ──────────────────────────────────────────────────────── + +describe("POST /summarize-pr", () => { + async function call(ctx: MockCtx, body: unknown): Promise { + const ep = getEndpoint(ctx, "POST", "/summarize-pr"); + const res = makeRes(); + await ep.handler(makeReq({ body }), res as unknown as EndpointResponse); + return res; + } + + it("returns 400 when required fields are missing", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, { owner: "octocat" }); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("required"); + }); + + it("returns 401 when no token configured (cache miss)", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + prNumber: 1, + }); + expect(res._status).toBe(401); + }); + + it("returns cached summary without hitting GitHub or AI", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const cached: PRSummaryRecord = { + owner: "octocat", + repo: "Hello-World", + prNumber: 1, + summary: "Cached summary text", + model: "gpt-4o", + generatedAt: 1000, + }; + await ctx.api.db.set(buildSummaryKey("octocat", "Hello-World", 1), cached); + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + prNumber: 1, + }); + expect((res._body as { cached: boolean }).cached).toBe(true); + expect((res._body as { summary: string }).summary).toBe( + "Cached summary text", + ); + expect(ctx.api.makeRequest).not.toHaveBeenCalled(); + }); + + it("generates and caches a fresh summary on cache miss", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + const mockPR: GitHubPR = { + ...BASE_PR, + number: 99, + }; + ctx.api.makeRequest = jest + .fn() + .mockResolvedValueOnce({ + // fetchPRDetails + ok: true, + json: jest.fn().mockResolvedValue(mockPR), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + // fetchPRFiles + ok: true, + json: jest.fn().mockResolvedValue(BASE_FILES), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + // callAI + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "Fresh summary" } }], + }), + text: jest.fn().mockResolvedValue(""), + }); + + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + prNumber: 99, + }); + expect((res._body as { cached: boolean }).cached).toBe(false); + expect((res._body as { summary: string }).summary).toBe("Fresh summary"); + // Verify stored in DB + const stored = (await ctx.api.db.get( + buildSummaryKey("octocat", "Hello-World", 99), + )) as PRSummaryRecord; + expect(stored?.summary).toBe("Fresh summary"); + }); + + it("force:true bypasses the cache", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + await ctx.api.db.set(buildSummaryKey("octocat", "Hello-World", 1), { + summary: "Old cached", + model: "gpt-4o", + generatedAt: 0, + }); + ctx.api.makeRequest = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(BASE_PR), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([]), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [{ message: { content: "Refreshed" } }], + }), + text: jest.fn().mockResolvedValue(""), + }); + + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + prNumber: 1, + force: true, + }); + expect((res._body as { summary: string }).summary).toBe("Refreshed"); + expect((res._body as { cached: boolean }).cached).toBe(false); + }); +}); + +// ── POST /release-notes ─────────────────────────────────────────────────────── + +describe("POST /release-notes", () => { + async function call(ctx: MockCtx, body: unknown): Promise { + const ep = getEndpoint(ctx, "POST", "/release-notes"); + const res = makeRes(); + await ep.handler(makeReq({ body }), res as unknown as EndpointResponse); + return res; + } + + it("returns 400 when required fields are missing", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, { owner: "octocat", repo: "Hello-World" }); + expect(res._status).toBe(400); + }); + + it("returns placeholder message when no commits found", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + ctx.api.makeRequest = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ commits: [] }), + text: jest.fn().mockResolvedValue(""), + }); + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + fromRef: "v1.0.0", + toRef: "v1.0.1", + }); + expect((res._body as { commitCount: number }).commitCount).toBe(0); + expect((res._body as { notes: string }).notes).toContain("No commits"); + }); + + it("generates release notes from commits", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + await ctx.api.db.set(buildTokenKey(), { token: "ghp_valid" }); + const mockCommits: GitHubCommit[] = [ + { + sha: "abc1234", + commit: { + message: "feat: new login page", + author: { name: "alice", date: "2026-01-01" }, + }, + }, + ]; + ctx.api.makeRequest = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ commits: mockCommits }), + text: jest.fn().mockResolvedValue(""), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + choices: [ + { message: { content: "## What's New\n- New login page" } }, + ], + }), + text: jest.fn().mockResolvedValue(""), + }); + + const res = await call(ctx, { + owner: "octocat", + repo: "Hello-World", + fromRef: "v1.0.0", + toRef: "v1.1.0", + }); + expect((res._body as { commitCount: number }).commitCount).toBe(1); + expect((res._body as { fromRef: string }).fromRef).toBe("v1.0.0"); + expect((res._body as { toRef: string }).toRef).toBe("v1.1.0"); + expect((res._body as { notes: string }).notes).toContain("What's New"); + }); +}); + +// ── POST /webhook ───────────────────────────────────────────────────────────── + +describe("POST /webhook", () => { + async function call( + ctx: MockCtx, + headers: Record, + body: unknown, + ): Promise { + const ep = getEndpoint(ctx, "POST", "/webhook"); + const res = makeRes(); + await ep.handler( + makeReq({ method: "POST", headers, body }), + res as unknown as EndpointResponse, + ); + return res; + } + + it("returns 400 when X-GitHub-Event header is absent", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const res = await call(ctx, {}, { action: "opened" }); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("X-GitHub-Event"); + }); + + it("returns 401 when signature is invalid and secret is configured", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.getConfig = jest + .fn() + .mockImplementation((k: string) => + k === "webhookSecret" ? "my-secret" : undefined, + ); + const res = await call( + ctx, + { + "x-github-event": "push", + "x-hub-signature-256": + "sha256=invalidsig0000000000000000000000000000000000000000000000000000", + }, + { repository: { full_name: "org/repo" } }, + ); + expect(res._status).toBe(401); + }); + + it("processes event when no webhook secret is configured", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.getConfig = jest.fn().mockReturnValue(undefined); + const body = { repository: { full_name: "org/repo" }, action: "opened" }; + const res = await call( + ctx, + { "x-github-event": "push", "x-github-delivery": "del-001" }, + body, + ); + expect(res._body).toMatchObject({ + received: true, + processed: true, + event: "push", + }); + expect(ctx.api.events.emit).toHaveBeenCalledWith( + "github:push", + expect.objectContaining({ repoFullName: "org/repo" }), + ); + }); + + it("processes event when signature is valid", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + const secret = "webhook-secret-abc"; + const body = { repository: { full_name: "org/repo" } }; + const rawBody = JSON.stringify(body); + const sig = makeValidSig(secret, rawBody); + ctx.api.getConfig = jest + .fn() + .mockImplementation((k: string) => + k === "webhookSecret" ? secret : undefined, + ); + const res = await call( + ctx, + { "x-github-event": "pull_request", "x-hub-signature-256": sig }, + body, + ); + expect(res._body).toMatchObject({ + received: true, + processed: true, + event: "pull_request", + }); + }); + + it("logs the event to plugin DB using x-github-delivery as key", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.getConfig = jest.fn().mockReturnValue(undefined); + const body = { repository: { full_name: "org/repo" }, action: "created" }; + await call( + ctx, + { "x-github-event": "issues", "x-github-delivery": "delivery-xyz" }, + body, + ); + const stored = (await ctx.api.db.get( + buildWebhookLogKey("delivery-xyz"), + )) as WebhookEventRecord; + expect(stored?.event).toBe("issues"); + expect(stored?.repoFullName).toBe("org/repo"); + expect(stored?.action).toBe("created"); + }); + + it("returns processed:false for unsupported event types", async () => { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.getConfig = jest.fn().mockReturnValue(undefined); + const res = await call(ctx, { "x-github-event": "star" }, {}); + expect(res._body).toMatchObject({ received: true, processed: false }); + expect(ctx.api.events.emit).not.toHaveBeenCalled(); + }); + + it("emits on inter-plugin bus for all HANDLED_EVENTS", async () => { + for (const event of HANDLED_EVENTS) { + const ctx = makeCtx(); + await initPlugin(ctx); + ctx.api.getConfig = jest.fn().mockReturnValue(undefined); + const body = { + repository: { full_name: "org/repo" }, + action: "published", + }; + await call(ctx, { "x-github-event": event }, body); + expect(ctx.api.events.emit).toHaveBeenCalledWith( + `github:${event}`, + expect.any(Object), + ); + } + }); +}); + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +describe("lifecycle", () => { + it("onActivate logs an activation message", async () => { + const ctx = makeCtx(); + await plugin.definition.onActivate?.(ctx); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("activated"), + ); + }); + + it("onDeactivate logs a deactivation message", async () => { + const ctx = makeCtx(); + await plugin.definition.onDeactivate?.(ctx); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("deactivated"), + ); + }); +}); diff --git a/packages/plugins/official/github-integration/__tests__/tsconfig.json b/packages/plugins/official/github-integration/__tests__/tsconfig.json new file mode 100644 index 0000000..a05feed --- /dev/null +++ b/packages/plugins/official/github-integration/__tests__/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": "..", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"] + } + }, + "include": ["../src/**/*", "./**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/official/github-integration/manifest.json b/packages/plugins/official/github-integration/manifest.json new file mode 100644 index 0000000..ea88b53 --- /dev/null +++ b/packages/plugins/official/github-integration/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "github-integration", + "version": "1.0.0", + "description": "Summarize pull requests, generate release notes, and respond to GitHub webhook events.", + "entryPoint": "dist/index.js", + "author": "Agentbase Team", + "agentbaseVersion": ">=1.0.0", + "permissions": [], + "peerDependencies": {} +} diff --git a/packages/plugins/official/github-integration/package.json b/packages/plugins/official/github-integration/package.json new file mode 100644 index 0000000..0baa73c --- /dev/null +++ b/packages/plugins/official/github-integration/package.json @@ -0,0 +1,41 @@ +{ + "name": "@agentbase/plugin-github-integration", + "version": "1.0.0", + "description": "Summarize pull requests, generate release notes, and respond to GitHub webhook events.", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0-or-later", + "scripts": { + "build": "tsc", + "test": "jest --passWithNoTests", + "test:cov": "jest --coverage --passWithNoTests" + }, + "dependencies": { + "@agentbase/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^25.5.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.7.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/__tests__/**/*.test.ts" + ], + "globals": { + "ts-jest": { + "tsconfig": "./tsconfig.test.json" + } + }, + "coverageThreshold": { + "global": { + "lines": 80 + } + } + } +} diff --git a/packages/plugins/official/github-integration/src/index.ts b/packages/plugins/official/github-integration/src/index.ts new file mode 100644 index 0000000..cf7e347 --- /dev/null +++ b/packages/plugins/official/github-integration/src/index.ts @@ -0,0 +1,822 @@ +/** + * GitHub Integration + * + * Summarize pull requests, generate release notes, and respond to GitHub + * webhook events. Uses the GitHub REST API v3 via makeRequest (external URLs). + * AI summaries are generated via the platform's internal AI completions endpoint. + * + * Webhook signature verification uses HMAC-SHA256 (X-Hub-Signature-256) with + * Node's built-in `crypto` module — no eval/exec/child_process used. + * + * @package @agentbase/plugin-github-integration + * @version 1.0.0 + */ +import { createPlugin, PluginContext } from "@agentbase/plugin-sdk"; +import { createHmac, timingSafeEqual } from "crypto"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const GITHUB_API_BASE = "https://api.github.com"; + +/** Internal platform AI completions endpoint (same pattern as content-generator). */ +export const AI_COMPLETIONS_PATH = "/api/v1/internal/ai/completions"; + +export const SUPPORTED_MODELS = [ + "gpt-4o", + "gpt-4o-mini", + "claude-3-5-sonnet", + "gemini-2-0-flash", +] as const; + +export type SupportedModel = (typeof SUPPORTED_MODELS)[number]; +export const DEFAULT_MODEL: SupportedModel = "gpt-4o"; + +/** Webhook events this plugin handles and re-emits on the inter-plugin bus. */ +export const HANDLED_EVENTS = ["push", "pull_request", "issues", "release"]; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface GitHubUser { + login: string; + name: string | null; + avatar_url: string; +} + +export interface GitHubRepo { + full_name: string; + name: string; + owner: { login: string }; + description: string | null; + private: boolean; + html_url: string; + default_branch: string; +} + +export interface GitHubPR { + number: number; + title: string; + body: string | null; + state: "open" | "closed" | "merged"; + html_url: string; + user: { login: string }; + head: { ref: string; sha: string }; + base: { ref: string; sha: string }; + merged_at: string | null; + created_at: string; + updated_at: string; +} + +export interface GitHubFile { + filename: string; + status: "added" | "removed" | "modified" | "renamed"; + additions: number; + deletions: number; + changes: number; + patch?: string; +} + +export interface GitHubCommit { + sha: string; + commit: { + message: string; + author: { name: string; date: string }; + }; +} + +export interface ConnectedRepo { + owner: string; + repo: string; + fullName: string; + connectedAt: number; +} + +export interface PRSummaryRecord { + owner: string; + repo: string; + prNumber: number; + summary: string; + model: string; + generatedAt: number; +} + +export interface WebhookEventRecord { + event: string; + action?: string; + repoFullName: string; + receivedAt: number; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +export function buildRepoKey(owner: string, repo: string): string { + return `repo:${owner}/${repo}`; +} + +export function buildSummaryKey( + owner: string, + repo: string, + prNumber: number, +): string { + return `summary:${owner}/${repo}:${prNumber}`; +} + +export function buildTokenKey(): string { + return "connected:token"; +} + +export function buildWebhookLogKey(id: string): string { + return `webhook:${id}`; +} + +// ── Signature Verification ──────────────────────────────────────────────────── + +/** + * Verify a GitHub webhook X-Hub-Signature-256 header using HMAC-SHA256. + * Uses timing-safe comparison to prevent timing-attack leaks. + * + * @param secret The webhook secret configured in GitHub. + * @param rawBody The raw request body string (pre-parse or re-stringified). + * @param sigHeader The value of the X-Hub-Signature-256 header. + */ +export function verifyGitHubSignature( + secret: string, + rawBody: string, + sigHeader: string | undefined, +): boolean { + if (!sigHeader || !sigHeader.startsWith("sha256=")) return false; + const received = sigHeader.slice("sha256=".length); + const hmac = createHmac("sha256", secret); + hmac.update(rawBody, "utf8"); + const expected = hmac.digest("hex"); + // timingSafeEqual requires equal-length buffers; guard against malformed headers + if (received.length !== expected.length) return false; + return timingSafeEqual(Buffer.from(received), Buffer.from(expected)); +} + +// ── Prompt Builders ─────────────────────────────────────────────────────────── + +/** Build an AI prompt to summarize a pull request. */ +export function buildPrSummaryPrompt( + pr: GitHubPR, + files: GitHubFile[], +): string { + const fileLines = files + .slice(0, 20) + .map( + (f) => + ` ${f.status.padEnd(8)} ${f.filename} (+${f.additions}/-${f.deletions})`, + ) + .join("\n"); + + const patchSamples = files + .filter((f) => f.patch) + .slice(0, 5) + .map( + (f) => + `### ${f.filename}\n\`\`\`diff\n${(f.patch ?? "").slice(0, 600)}\n\`\`\``, + ) + .join("\n\n"); + + return `You are a senior software engineer reviewing a pull request. Provide a concise, technical summary. + +## Pull Request: #${pr.number} — ${pr.title} + +**Author:** ${pr.user.login} +**Branch:** \`${pr.head.ref}\` → \`${pr.base.ref}\` +**Description:** ${pr.body ?? "(none provided)"} + +## Changed Files (${files.length} total) +${fileLines} + +${patchSamples ? `## Key Diffs (sample)\n${patchSamples}` : ""} + +Please provide: +1. **Summary** (2–3 sentences): What does this PR do? +2. **Key Changes** (bullet list): The most important modifications +3. **Potential Concerns** (if any): Review flags, breaking changes, missing tests +4. **Suggested Review Focus**: Which files/sections deserve the most attention + +Be concise and technical.`; +} + +/** Build an AI prompt to generate release notes from a list of commits. */ +export function buildReleaseNotesPrompt( + fromRef: string, + toRef: string, + commits: GitHubCommit[], + repoName: string, +): string { + const commitLines = commits + .slice(0, 50) + .map( + (c) => + `- ${c.commit.message.split("\n")[0]} (${c.sha.slice(0, 7)}) by ${c.commit.author.name}`, + ) + .join("\n"); + + return `You are a technical writer generating release notes for a software project. + +## Repository: ${repoName} +## Release: ${toRef} (changes since ${fromRef}) + +## Commits included (${commits.length} total): +${commitLines} + +Please generate professional release notes in GitHub Flavored Markdown. Include: +1. **What's New** — notable features and enhancements +2. **Bug Fixes** — fixes grouped logically +3. **Breaking Changes** — (if any) with migration guidance +4. **Internal / Maintenance** — refactors, dependency updates, CI changes + +Group related commits. Skip merge commits and trivial changes. Use imperative mood (e.g., "Add", "Fix", "Remove").`; +} + +// ── GitHub API Helpers ──────────────────────────────────────────────────────── + +type MakeRequest = PluginContext["api"]["makeRequest"]; + +/** Call the GitHub REST API with a PAT for authentication. */ +export async function githubGet( + makeRequest: MakeRequest, + token: string, + path: string, +): Promise { + const resp = await makeRequest(`${GITHUB_API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GitHub API error ${resp.status}: ${text}`); + } + return resp.json() as Promise; +} + +/** Fetch the authenticated user's profile — used to verify the token. */ +export async function fetchGitHubUser( + makeRequest: MakeRequest, + token: string, +): Promise { + return githubGet(makeRequest, token, "/user"); +} + +/** List repos accessible to the token (first page, sorted by updated). */ +export async function fetchUserRepos( + makeRequest: MakeRequest, + token: string, + perPage = 100, +): Promise { + return githubGet( + makeRequest, + token, + `/user/repos?per_page=${perPage}&sort=updated`, + ); +} + +/** List pull requests for a repository. */ +export async function fetchRepoPRs( + makeRequest: MakeRequest, + token: string, + owner: string, + repo: string, + state: "open" | "closed" | "all" = "open", +): Promise { + return githubGet( + makeRequest, + token, + `/repos/${owner}/${repo}/pulls?state=${state}&per_page=50`, + ); +} + +/** Fetch a single pull request's details. */ +export async function fetchPRDetails( + makeRequest: MakeRequest, + token: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + return githubGet( + makeRequest, + token, + `/repos/${owner}/${repo}/pulls/${prNumber}`, + ); +} + +/** Fetch the list of files changed in a pull request (up to 100). */ +export async function fetchPRFiles( + makeRequest: MakeRequest, + token: string, + owner: string, + repo: string, + prNumber: number, +): Promise { + return githubGet( + makeRequest, + token, + `/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100`, + ); +} + +/** Fetch commits between two refs (tags, branches, or SHAs). */ +export async function fetchCompareCommits( + makeRequest: MakeRequest, + token: string, + owner: string, + repo: string, + base: string, + head: string, +): Promise { + const data = await githubGet<{ commits: GitHubCommit[] }>( + makeRequest, + token, + `/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`, + ); + return data.commits ?? []; +} + +// ── AI Completion Helper ────────────────────────────────────────────────────── + +/** Send a prompt to the platform's internal AI completions endpoint. */ +export async function callAI( + makeRequest: MakeRequest, + model: string, + prompt: string, +): Promise { + const resp = await makeRequest(AI_COMPLETIONS_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + temperature: 0.3, + }), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`AI completions error ${resp.status}: ${text}`); + } + const data = (await resp.json()) as { + choices?: Array<{ message?: { content?: string } }>; + content?: string; + }; + // Support both OpenAI-style and Agentbase-native response shapes + return data.choices?.[0]?.message?.content ?? data.content ?? ""; +} + +// ── Plugin ──────────────────────────────────────────────────────────────────── + +export default createPlugin({ + name: "github-integration", + version: "1.0.0", + description: + "Summarize pull requests, generate release notes, and respond to GitHub webhook events.", + author: "Agentbase Team", + + settings: { + githubToken: { + type: "string", + label: "GitHub Personal Access Token", + encrypted: true, + }, + webhookSecret: { + type: "string", + label: "GitHub Webhook Secret", + encrypted: true, + }, + defaultSummaryModel: { + type: "select", + label: "Default Summary Model", + options: [...SUPPORTED_MODELS], + default: DEFAULT_MODEL, + }, + }, + + hooks: { + /** + * app:init — register all endpoints. Handlers close over `context` so + * they have access to the plugin DB, config, and makeRequest. + */ + "app:init": async (context: PluginContext) => { + context.api.log("GitHub Integration initialized"); + + // ── POST /connect ──────────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/connect", + auth: true, + description: + "Store a GitHub Personal Access Token and verify it with the GitHub API.", + handler: async (req, res) => { + const { token } = (req.body ?? {}) as { token?: string }; + if (!token) { + res.status(400).json({ error: "token is required" }); + return; + } + try { + const user = await fetchGitHubUser(context.api.makeRequest, token); + await context.api.db.set(buildTokenKey(), { + token, + login: user.login, + connectedAt: Date.now(), + }); + res.json({ connected: true, login: user.login }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ + error: `GitHub token validation failed: ${message}`, + }); + } + }, + }); + + // ── GET /repos ─────────────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "GET", + path: "/repos", + auth: true, + description: + "List GitHub repos accessible to the configured token. Stores each repo in plugin DB.", + handler: async (_req, res) => { + const tokenRecord = (await context.api.db.get(buildTokenKey())) as { + token: string; + } | null; + if (!tokenRecord?.token) { + res.status(401).json({ + error: "No GitHub token configured. POST /connect first.", + }); + return; + } + try { + const repos = await fetchUserRepos( + context.api.makeRequest, + tokenRecord.token, + ); + // Cache each repo for reference by other endpoints + await Promise.all( + repos.map((r) => + context.api.db.set(buildRepoKey(r.owner.login, r.name), { + owner: r.owner.login, + repo: r.name, + fullName: r.full_name, + connectedAt: Date.now(), + } satisfies ConnectedRepo), + ), + ); + res.json({ + repos: repos.map((r) => ({ + fullName: r.full_name, + private: r.private, + description: r.description, + defaultBranch: r.default_branch, + })), + total: repos.length, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }, + }); + + // ── GET /repos/:owner/:repo/prs ────────────────────────────────────── + context.api.registerEndpoint({ + method: "GET", + path: "/repos/:owner/:repo/prs", + auth: true, + description: + "List pull requests for a repository. Query ?state=open|closed|all (default: open).", + handler: async (req, res) => { + const owner = req.params["owner"]; + const repo = req.params["repo"]; + const state = + (req.query["state"] as "open" | "closed" | "all") ?? "open"; + if (!owner || !repo) { + res + .status(400) + .json({ error: "owner and repo path params are required" }); + return; + } + const tokenRecord = (await context.api.db.get(buildTokenKey())) as { + token: string; + } | null; + if (!tokenRecord?.token) { + res.status(401).json({ error: "No GitHub token configured" }); + return; + } + try { + const prs = await fetchRepoPRs( + context.api.makeRequest, + tokenRecord.token, + owner, + repo, + state, + ); + res.json({ + prs: prs.map((pr) => ({ + number: pr.number, + title: pr.title, + state: pr.state, + author: pr.user.login, + head: pr.head.ref, + base: pr.base.ref, + url: pr.html_url, + createdAt: pr.created_at, + })), + total: prs.length, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }, + }); + + // ── POST /summarize-pr ─────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/summarize-pr", + auth: true, + description: + "Generate an AI summary for a pull request. Results are cached; pass force:true to regenerate.", + handler: async (req, res) => { + const { + owner, + repo, + prNumber, + model: requestModel, + force, + } = (req.body ?? {}) as { + owner?: string; + repo?: string; + prNumber?: number; + model?: string; + force?: boolean; + }; + + if (!owner || !repo || prNumber === undefined) { + res + .status(400) + .json({ error: "owner, repo, and prNumber are required" }); + return; + } + + // Return cached summary unless force-refresh is requested + if (!force) { + const cached = (await context.api.db.get( + buildSummaryKey(owner, repo, prNumber), + )) as PRSummaryRecord | null; + if (cached) { + res.json({ + summary: cached.summary, + model: cached.model, + cached: true, + generatedAt: cached.generatedAt, + }); + return; + } + } + + const tokenRecord = (await context.api.db.get(buildTokenKey())) as { + token: string; + } | null; + if (!tokenRecord?.token) { + res.status(401).json({ error: "No GitHub token configured" }); + return; + } + + try { + const [pr, files] = await Promise.all([ + fetchPRDetails( + context.api.makeRequest, + tokenRecord.token, + owner, + repo, + prNumber, + ), + fetchPRFiles( + context.api.makeRequest, + tokenRecord.token, + owner, + repo, + prNumber, + ), + ]); + + const model = + requestModel ?? + (context.api.getConfig("defaultSummaryModel") as + | string + | undefined) ?? + DEFAULT_MODEL; + const prompt = buildPrSummaryPrompt(pr, files); + const summary = await callAI( + context.api.makeRequest, + model, + prompt, + ); + + const record: PRSummaryRecord = { + owner, + repo, + prNumber, + summary, + model, + generatedAt: Date.now(), + }; + await context.api.db.set( + buildSummaryKey(owner, repo, prNumber), + record, + ); + + res.json({ + summary, + model, + cached: false, + generatedAt: record.generatedAt, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }, + }); + + // ── POST /release-notes ────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/release-notes", + auth: true, + description: + "Generate AI release notes between two Git refs (tags, branches, or SHAs).", + handler: async (req, res) => { + const { + owner, + repo, + fromRef, + toRef, + model: requestModel, + } = (req.body ?? {}) as { + owner?: string; + repo?: string; + fromRef?: string; + toRef?: string; + model?: string; + }; + + if (!owner || !repo || !fromRef || !toRef) { + res.status(400).json({ + error: "owner, repo, fromRef, and toRef are required", + }); + return; + } + + const tokenRecord = (await context.api.db.get(buildTokenKey())) as { + token: string; + } | null; + if (!tokenRecord?.token) { + res.status(401).json({ error: "No GitHub token configured" }); + return; + } + + try { + const commits = await fetchCompareCommits( + context.api.makeRequest, + tokenRecord.token, + owner, + repo, + fromRef, + toRef, + ); + + if (commits.length === 0) { + res.json({ + notes: "_(No commits found between the specified refs.)_", + commitCount: 0, + fromRef, + toRef, + }); + return; + } + + const model = + requestModel ?? + (context.api.getConfig("defaultSummaryModel") as + | string + | undefined) ?? + DEFAULT_MODEL; + const prompt = buildReleaseNotesPrompt( + fromRef, + toRef, + commits, + `${owner}/${repo}`, + ); + const notes = await callAI(context.api.makeRequest, model, prompt); + + res.json({ + notes, + commitCount: commits.length, + fromRef, + toRef, + model, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }, + }); + + // ── POST /webhook ──────────────────────────────────────────────────── + context.api.registerEndpoint({ + method: "POST", + path: "/webhook", + auth: false, // GitHub sends webhooks without user auth + description: + "Receive GitHub webhook events. Verifies X-Hub-Signature-256 when webhookSecret is configured.", + handler: async (req, res) => { + const event = req.headers["x-github-event"] as string | undefined; + const deliveryId = req.headers["x-github-delivery"] as + | string + | undefined; + const sigHeader = req.headers["x-hub-signature-256"] as + | string + | undefined; + + if (!event) { + res.status(400).json({ error: "Missing X-GitHub-Event header" }); + return; + } + + // Verify HMAC signature if a webhook secret is configured + const webhookSecret = context.api.getConfig("webhookSecret") as + | string + | undefined; + if (webhookSecret) { + // Re-stringify for HMAC — best-effort when SDK provides parsed body + const rawBody = + typeof req.body === "string" + ? req.body + : JSON.stringify(req.body); + if (!verifyGitHubSignature(webhookSecret, rawBody, sigHeader)) { + res.status(401).json({ error: "Invalid webhook signature" }); + return; + } + } + + const payload = req.body as Record; + const repoFullName = + (payload["repository"] as { full_name?: string } | undefined) + ?.full_name ?? "unknown"; + const action = payload["action"] as string | undefined; + + // Log the event to plugin DB for audit / debugging + const logId = + deliveryId ?? + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + await context.api.db.set(buildWebhookLogKey(logId), { + event, + action, + repoFullName, + receivedAt: Date.now(), + } satisfies WebhookEventRecord); + + if (!HANDLED_EVENTS.includes(event)) { + res.json({ + received: true, + processed: false, + reason: `Event '${event}' not handled`, + }); + return; + } + + // Emit on the inter-plugin event bus (e.g., Knowledge Base can refresh on push) + await context.api.events.emit(`github:${event}`, { + repoFullName, + action, + payload, + }); + + res.json({ + received: true, + processed: true, + event, + deliveryId: logId, + }); + }, + }); + }, + }, + + onActivate: async (context: PluginContext) => { + context.api.log("GitHub Integration activated"); + }, + + onDeactivate: async (context: PluginContext) => { + context.api.log("GitHub Integration deactivated"); + }, +}); diff --git a/packages/plugins/official/github-integration/tsconfig.json b/packages/plugins/official/github-integration/tsconfig.json new file mode 100644 index 0000000..7f8ba5d --- /dev/null +++ b/packages/plugins/official/github-integration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noEmit": true, + "types": ["node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/plugins/official/github-integration/tsconfig.test.json b/packages/plugins/official/github-integration/tsconfig.test.json new file mode 100644 index 0000000..1801816 --- /dev/null +++ b/packages/plugins/official/github-integration/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": ".", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959c40c..df549a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,13 +351,13 @@ importers: version: 8.18.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.2)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -373,10 +373,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -392,10 +392,32 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + packages/plugins/official/github-integration: + dependencies: + '@agentbase/plugin-sdk': + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -411,10 +433,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -432,10 +454,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -1830,6 +1852,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/nodemailer@7.0.10': resolution: {integrity: sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==} @@ -5616,6 +5641,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unified@10.1.2: resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} @@ -6283,6 +6311,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.11 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/diff-sequences@30.3.0': {} '@jest/environment-jsdom-abstract@30.2.0(jsdom@26.1.0)': @@ -7475,6 +7538,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@25.5.2': + dependencies: + undici-types: 7.18.2 + '@types/nodemailer@7.0.10': dependencies: '@types/node': 22.19.11 @@ -8218,6 +8285,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-require@1.1.1: {} cron@4.4.0: @@ -9414,6 +9496,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 @@ -9445,6 +9546,68 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.11 + ts-node: 10.9.2(@types/node@25.5.2)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.5.2 + ts-node: 10.9.2(@types/node@25.5.2)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -9760,6 +9923,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jiti@1.21.7: {} joycon@3.1.1: {} @@ -11845,6 +12020,26 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -11881,6 +12076,24 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.5.2 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -11957,6 +12170,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + unified@10.1.2: dependencies: '@types/unist': 2.0.11