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