Skip to content
14 changes: 14 additions & 0 deletions app/api/coding-agent/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NextRequest } from "next/server";
import { handleGitHubWebhook } from "@/lib/coding-agent/handleGitHubWebhook";

/**
* POST /api/coding-agent/github
*
* Webhook endpoint for GitHub PR comment feedback.
* Receives issue_comment events and triggers update-pr when the bot is mentioned.
*
* @param request - The incoming GitHub webhook request
*/
export async function POST(request: NextRequest) {
return handleGitHubWebhook(request);
}
33 changes: 33 additions & 0 deletions lib/coding-agent/__tests__/bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ vi.mock("@chat-adapter/slack", () => ({
})),
}));

vi.mock("@chat-adapter/github", () => ({
createGitHubAdapter: vi.fn().mockReturnValue({
name: "github",
}),
}));

vi.mock("@chat-adapter/state-ioredis", () => ({
createIoRedisState: vi.fn().mockReturnValue({
connect: vi.fn(),
Expand Down Expand Up @@ -39,6 +45,7 @@ describe("createCodingAgentBot", () => {
process.env.SLACK_BOT_TOKEN = "xoxb-test";
process.env.SLACK_SIGNING_SECRET = "test-signing-secret";
process.env.GITHUB_TOKEN = "ghp_test";
process.env.GITHUB_WEBHOOK_SECRET = "test-webhook-secret";
process.env.REDIS_URL = "redis://localhost:6379";
process.env.CODING_AGENT_CALLBACK_SECRET = "test-callback-secret";
});
Expand Down Expand Up @@ -84,6 +91,32 @@ describe("createCodingAgentBot", () => {
);
});

it("creates a Chat instance with github adapter", async () => {
const { Chat } = await import("chat");
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

const lastCall = vi.mocked(Chat).mock.calls.at(-1)!;
const config = lastCall[0];
expect(config.adapters).toHaveProperty("github");
});

it("creates GitHub adapter with correct config", async () => {
const { createGitHubAdapter } = await import("@chat-adapter/github");
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

expect(createGitHubAdapter).toHaveBeenCalledWith(
expect.objectContaining({
token: "ghp_test",
webhookSecret: "test-webhook-secret",
userName: "recoup-coding-agent",
}),
);
});

it("sets userName to Recoup Agent", async () => {
const { Chat } = await import("chat");
const { createCodingAgentBot } = await import("../bot");
Expand Down
21 changes: 21 additions & 0 deletions lib/coding-agent/__tests__/encodeGitHubThreadId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { encodeGitHubThreadId } from "../encodeGitHubThreadId";

describe("encodeGitHubThreadId", () => {
it("encodes PR-level thread ID", () => {
expect(
encodeGitHubThreadId({ owner: "recoupable", repo: "tasks", prNumber: 68 }),
).toBe("github:recoupable/tasks:68");
});

it("encodes review comment thread ID", () => {
expect(
encodeGitHubThreadId({
owner: "recoupable",
repo: "api",
prNumber: 266,
reviewCommentId: 2898626443,
}),
).toBe("github:recoupable/api:266:rc:2898626443");
});
});
85 changes: 85 additions & 0 deletions lib/coding-agent/__tests__/extractPRComment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { extractPRComment } from "../extractPRComment";

const BASE_PAYLOAD = {
action: "created",
issue: {
number: 66,
pull_request: { url: "https://api.github.com/repos/recoupable/tasks/pulls/66" },
},
comment: {
id: 123,
body: "@recoup-coding-agent make the button blue",
user: { login: "sweetmantech" },
},
repository: {
full_name: "recoupable/tasks",
},
};

describe("extractPRComment", () => {
it("returns null for unsupported events", () => {
expect(extractPRComment("push", BASE_PAYLOAD)).toBeNull();
});

it("returns null when action is not created", () => {
expect(extractPRComment("issue_comment", { ...BASE_PAYLOAD, action: "deleted" })).toBeNull();
});

it("returns null when bot is not mentioned", () => {
const payload = {
...BASE_PAYLOAD,
comment: { body: "just a regular comment" },
};
expect(extractPRComment("issue_comment", payload)).toBeNull();
});

it("returns null when issue has no pull_request", () => {
const payload = {
...BASE_PAYLOAD,
issue: { number: 66 },
};
expect(extractPRComment("issue_comment", payload)).toBeNull();
});

it("extracts from issue_comment with GitHubThreadId", () => {
const result = extractPRComment("issue_comment", BASE_PAYLOAD);
expect(result).toEqual({
thread: { owner: "recoupable", repo: "tasks", prNumber: 66 },
branch: "",
commentBody: "@recoup-coding-agent make the button blue",
});
});

it("extracts from pull_request_review_comment with branch and reviewCommentId", () => {
const payload = {
action: "created",
pull_request: {
number: 266,
head: { ref: "feature/my-branch" },
},
comment: {
id: 2898626443,
body: "@recoup-coding-agent fix the typo",
},
repository: {
full_name: "recoupable/api",
},
};
const result = extractPRComment("pull_request_review_comment", payload);
expect(result).toEqual({
thread: { owner: "recoupable", repo: "api", prNumber: 266, reviewCommentId: 2898626443 },
branch: "feature/my-branch",
commentBody: "@recoup-coding-agent fix the typo",
});
});

it("returns null for pull_request_review_comment without pull_request", () => {
const payload = {
action: "created",
comment: { body: "@recoup-coding-agent test" },
repository: { full_name: "recoupable/api" },
};
expect(extractPRComment("pull_request_review_comment", payload)).toBeNull();
});
});
Loading