Initial commit

This commit is contained in:
Lina Tawfik
2025-05-19 08:32:32 -07:00
commit f66f337f4e
58 changed files with 8913 additions and 0 deletions

149
test/branch-cleanup.test.ts Normal file
View File

@@ -0,0 +1,149 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
import type { Octokits } from "../src/github/api/client";
import { GITHUB_SERVER_URL } from "../src/github/api/config";
describe("checkAndDeleteEmptyBranch", () => {
let consoleLogSpy: any;
let consoleErrorSpy: any;
beforeEach(() => {
// Spy on console methods
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
const createMockOctokit = (
compareResponse?: any,
deleteRefError?: Error,
): Octokits => {
return {
rest: {
repos: {
compareCommitsWithBasehead: async () => ({
data: compareResponse || { total_commits: 0 },
}),
},
git: {
deleteRef: async () => {
if (deleteRefError) {
throw deleteRefError;
}
return { data: {} };
},
},
},
} as any as Octokits;
};
test("should return no branch link and not delete when branch is undefined", async () => {
const mockOctokit = createMockOctokit();
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
undefined,
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe("");
expect(consoleLogSpy).not.toHaveBeenCalled();
});
test("should delete branch and return no link when branch has no commits", async () => {
const mockOctokit = createMockOctokit({ total_commits: 0 });
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101_123456",
"main",
);
expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe("");
expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
);
});
test("should not delete branch and return link when branch has commits", async () => {
const mockOctokit = createMockOctokit({ total_commits: 3 });
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101_123456",
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe(
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`,
);
expect(consoleLogSpy).not.toHaveBeenCalledWith(
expect.stringContaining("has no commits"),
);
});
test("should handle branch comparison errors gracefully", async () => {
const mockOctokit = {
rest: {
repos: {
compareCommitsWithBasehead: async () => {
throw new Error("API error");
},
},
git: {
deleteRef: async () => ({ data: {} }),
},
},
} as any as Octokits;
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101_123456",
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe(
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`,
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking for commits on Claude branch:",
expect.any(Error),
);
});
test("should handle branch deletion errors gracefully", async () => {
const deleteError = new Error("Delete failed");
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101_123456",
"main",
);
expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe("");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to delete branch claude/issue-123-20240101_123456:",
deleteError,
);
});
});

402
test/comment-logic.test.ts Normal file
View File

@@ -0,0 +1,402 @@
import { describe, it, expect } from "bun:test";
import { updateCommentBody } from "../src/github/operations/comment-logic";
describe("updateCommentBody", () => {
const baseInput = {
currentBody: "Initial comment body",
actionFailed: false,
executionDetails: null,
jobUrl: "https://github.com/owner/repo/actions/runs/123",
branchName: undefined,
triggerUsername: undefined,
};
describe("working message replacement", () => {
it("includes success message header with duration", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
executionDetails: { duration_ms: 74000 }, // 1m 14s
triggerUsername: "trigger-user",
};
const result = updateCommentBody(input);
expect(result).toContain(
"**Claude finished @trigger-user's task in 1m 14s**",
);
expect(result).not.toContain("Claude Code is working");
});
it("includes error message header with duration", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working...",
actionFailed: true,
executionDetails: { duration_ms: 45000 }, // 45s
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
});
it("handles username extraction from content when not provided", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working… <img src='spinner.gif' />\n\nI'll work on this task @testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task**");
});
});
describe("job link", () => {
it("includes job link in header", () => {
const input = {
...baseInput,
currentBody: "Some comment",
};
const result = updateCommentBody(input);
expect(result).toContain(`—— [View job](${baseInput.jobUrl})`);
});
it("always includes job link in header, even if present in body", () => {
const input = {
...baseInput,
currentBody: `Some comment with [View job run](${baseInput.jobUrl})`,
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
// Check it's in the header with the new format
expect(result).toContain(`—— [View job](${baseInput.jobUrl})`);
// The old link in body is removed
expect(result).not.toContain("View job run");
});
});
describe("branch link", () => {
it("adds branch name with link to header when provided", () => {
const input = {
...baseInput,
branchName: "claude/issue-123-20240101_120000",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
);
});
it("extracts branch name from branchLink if branchName not provided", () => {
const input = {
...baseInput,
branchLink:
"\n[View branch](https://github.com/owner/repo/tree/branch-name)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`branch-name`](https://github.com/owner/repo/tree/branch-name)",
);
});
it("removes old branch links from body", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [View branch](https://github.com/owner/repo/tree/branch-name)",
branchName: "new-branch-name",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`new-branch-name`](https://github.com/owner/repo/tree/new-branch-name)",
);
expect(result).not.toContain("View branch");
});
});
describe("PR link", () => {
it("adds PR link to header when provided", () => {
const input = {
...baseInput,
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
);
});
it("moves PR link from body to header", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
);
// Original Create a PR link is removed from body
expect(result).not.toContain("[Create a PR]");
});
it("handles both body and provided PR links", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url-from-body)",
prLink:
"\n[Create a PR](https://github.com/owner/repo/pr-url-provided)",
};
const result = updateCommentBody(input);
// Prefers the link found in content over the provided one
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-from-body)",
);
});
it("handles complex PR URLs with encoded characters", () => {
const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20important%20bug%20fix&body=Fixes%20%23123%0A%0A%23%23%20Description%0AThis%20PR%20fixes%20an%20important%20bug%20that%20was%20causing%20issues%20with%20the%20application.%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
const input = {
...baseInput,
currentBody: `Some comment with [Create a PR](${complexUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${complexUrl})`);
// Original link should be removed from body
expect(result).not.toContain("[Create a PR]");
});
it("handles PR links with encoded URLs containing parentheses", () => {
const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${complexUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${complexUrl})`);
// Original link should be removed from body completely
expect(result).not.toContain("[Create a PR]");
// Body content shouldn't have stray closing parens
expect(result).toContain("This PR was created.");
// Body part should be clean with no stray parens
const bodyAfterSeparator = result.split("---")[1]?.trim();
expect(bodyAfterSeparator).toBe("This PR was created.");
});
it("handles PR links with unencoded spaces and special characters", () => {
const unEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)";
const expectedEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A+update+welcome+message&body=Generated+with+%5BClaude+Code%5D%28https%3A%2F%2Fclaude.ai%2Fcode%29";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${unEncodedUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${expectedEncodedUrl})`);
// Original link should be removed from body completely
expect(result).not.toContain("[Create a PR]");
// Body content should be preserved
expect(result).toContain("This PR was created.");
});
it("falls back to prLink parameter when PR link in content cannot be encoded", () => {
const invalidUrl = "not-a-valid-url-at-all";
const fallbackPrUrl = "https://github.com/owner/repo/pull/123";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${invalidUrl})`,
prLink: `\n[Create a PR](${fallbackPrUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${fallbackPrUrl})`);
// Original link with invalid URL should still be in body since encoding failed
expect(result).toContain("[Create a PR](not-a-valid-url-at-all)");
expect(result).toContain("This PR was created.");
});
});
describe("execution details", () => {
it("includes duration in header for success", () => {
const input = {
...baseInput,
executionDetails: {
cost_usd: 0.13382595,
duration_ms: 31033,
duration_api_ms: 31034,
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task in 31s**");
});
it("formats duration in minutes and seconds in header", () => {
const input = {
...baseInput,
executionDetails: {
duration_ms: 75000, // 1 minute 15 seconds
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain(
"**Claude finished @testuser's task in 1m 15s**",
);
});
it("includes duration in error header", () => {
const input = {
...baseInput,
actionFailed: true,
executionDetails: {
duration_ms: 45000, // 45 seconds
},
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
});
it("handles missing duration gracefully", () => {
const input = {
...baseInput,
executionDetails: {
cost_usd: 0.25,
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task**");
expect(result).not.toContain(" in ");
});
});
describe("combined updates", () => {
it("combines all updates in correct order", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working…\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer",
actionFailed: false,
branchName: "claude-branch-123",
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
executionDetails: {
cost_usd: 0.01,
duration_ms: 65000, // 1 minute 5 seconds
},
triggerUsername: "trigger-user",
};
const result = updateCommentBody(input);
// Check the header structure
expect(result).toContain(
"**Claude finished @trigger-user's task in 1m 5s**",
);
expect(result).toContain("—— [View job]");
expect(result).toContain(
"• [`claude-branch-123`](https://github.com/owner/repo/tree/claude-branch-123)",
);
expect(result).toContain("• [Create PR ➔]");
// Check order - header comes before separator with blank line
const headerIndex = result.indexOf("**Claude finished");
const blankLineAndSeparatorPattern = /\n\n---\n/;
expect(result).toMatch(blankLineAndSeparatorPattern);
const separatorIndex = result.indexOf("---");
const todoIndex = result.indexOf("### Todo List:");
expect(headerIndex).toBeLessThan(separatorIndex);
expect(separatorIndex).toBeLessThan(todoIndex);
// Check content is preserved
expect(result).toContain("### Todo List:");
expect(result).toContain("- [x] Read README.md");
expect(result).toContain("- [x] Add disclaimer");
});
it("handles PR link extraction from content", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working…\n\nI've made changes.\n[Create a PR](https://github.com/owner/repo/pr-url-in-content)\n\n@john-doe",
branchName: "feature-branch",
triggerUsername: "john-doe",
};
const result = updateCommentBody(input);
// PR link should be moved to header
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-in-content)",
);
// Original link should be removed from body
expect(result).not.toContain("[Create a PR]");
// Username should come from argument, not extraction
expect(result).toContain("**Claude finished @john-doe's task**");
// Content should be preserved
expect(result).toContain("I've made changes.");
});
it("includes PR link for new branches (issues and closed PRs)", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working… <img src='spinner.gif' />",
branchName: "claude/pr-456-20240101_120000",
prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
triggerUsername: "jane-doe",
};
const result = updateCommentBody(input);
// Should include the PR link in the formatted style
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
);
expect(result).toContain("**Claude finished @jane-doe's task**");
});
it("includes both branch link and PR link for new branches", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
branchName: "claude/issue-123-20240101_120000",
branchLink:
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
};
const result = updateCommentBody(input);
// Should include both links in formatted style
expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
);
});
});
});

725
test/create-prompt.test.ts Normal file
View File

@@ -0,0 +1,725 @@
#!/usr/bin/env bun
import { describe, test, expect } from "bun:test";
import {
generatePrompt,
getEventTypeAndContext,
buildAllowedToolsString,
buildDisallowedToolsString,
} from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt";
import type { EventData } from "../src/create-prompt/types";
describe("generatePrompt", () => {
const mockGitHubData = {
contextData: {
title: "Test PR",
body: "This is a test PR",
author: { login: "testuser" },
state: "OPEN",
createdAt: "2023-01-01T00:00:00Z",
additions: 15,
deletions: 5,
baseRefName: "main",
headRefName: "feature-branch",
headRefOid: "abc123",
commits: {
totalCount: 2,
nodes: [
{
commit: {
oid: "commit1",
message: "Add feature",
author: {
name: "John Doe",
email: "john@example.com",
},
},
},
],
},
files: {
nodes: [
{
path: "src/file1.ts",
additions: 10,
deletions: 5,
changeType: "MODIFIED",
},
],
},
comments: {
nodes: [
{
id: "comment1",
databaseId: "123456",
body: "First comment",
author: { login: "user1" },
createdAt: "2023-01-01T01:00:00Z",
},
],
},
reviews: {
nodes: [
{
id: "review1",
author: { login: "reviewer1" },
body: "LGTM",
state: "APPROVED",
submittedAt: "2023-01-01T02:00:00Z",
comments: {
nodes: [],
},
},
],
},
},
comments: [
{
id: "comment1",
databaseId: "123456",
body: "First comment",
author: { login: "user1" },
createdAt: "2023-01-01T01:00:00Z",
},
{
id: "comment2",
databaseId: "123457",
body: "@claude help me",
author: { login: "user2" },
createdAt: "2023-01-01T01:30:00Z",
},
],
changedFiles: [],
changedFilesWithSHA: [
{
path: "src/file1.ts",
additions: 10,
deletions: 5,
changeType: "MODIFIED",
sha: "abc123",
},
],
reviewData: {
nodes: [
{
id: "review1",
databaseId: "400001",
author: { login: "reviewer1" },
body: "LGTM",
state: "APPROVED",
submittedAt: "2023-01-01T02:00:00Z",
comments: {
nodes: [],
},
},
],
},
imageUrlMap: new Map<string, string>(),
};
test("should generate prompt for issue_comment event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: false,
defaultBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
issueNumber: "67890",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
expect(prompt).toContain("<is_pr>false</is_pr>");
expect(prompt).toContain(
"<trigger_context>issue comment with '@claude'</trigger_context>",
);
expect(prompt).toContain("<repository>owner/repo</repository>");
expect(prompt).toContain("<claude_comment_id>12345</claude_comment_id>");
expect(prompt).toContain("<trigger_username>Unknown</trigger_username>");
expect(prompt).toContain("[user1 at 2023-01-01T01:00:00Z]: First comment"); // from formatted comments
expect(prompt).not.toContain("filename\tstatus\tadditions\tdeletions\tsha"); // since it's not a PR
});
test("should generate prompt for pull_request_review event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review",
isPR: true,
prNumber: "456",
commentBody: "@claude please fix this bug",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
expect(prompt).toContain("<pr_number>456</pr_number>");
expect(prompt).toContain("- src/file1.ts (MODIFIED) +10/-5 SHA: abc123"); // from formatted changed files
expect(prompt).toContain(
"[Review by reviewer1 at 2023-01-01T02:00:00Z]: APPROVED",
); // from review comments
});
test("should generate prompt for issue opened event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "789",
defaultBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
expect(prompt).toContain(
"<trigger_context>new issue with '@claude' in body</trigger_context>",
);
expect(prompt).toContain(
"[Create a PR](https://github.com/owner/repo/compare/main",
);
expect(prompt).toContain("The target-branch should be 'main'");
});
test("should generate prompt for issue assigned event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "assigned",
isPR: false,
issueNumber: "999",
defaultBranch: "develop",
claudeBranch: "claude/issue-999-20240101_120000",
assigneeTrigger: "claude-bot",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain(
"<trigger_context>issue assigned to 'claude-bot'</trigger_context>",
);
expect(prompt).toContain(
"[Create a PR](https://github.com/owner/repo/compare/develop",
);
});
test("should include direct prompt when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
directPrompt: "Fix the bug in the login form",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "789",
defaultBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<direct_prompt>");
expect(prompt).toContain("Fix the bug in the login form");
expect(prompt).toContain("</direct_prompt>");
expect(prompt).toContain(
"DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above",
);
});
test("should generate prompt for pull_request event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request",
eventAction: "opened",
isPR: true,
prNumber: "999",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
expect(prompt).toContain("<pr_number>999</pr_number>");
expect(prompt).toContain("pull request opened");
});
test("should include custom instructions when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
customInstructions: "Always use TypeScript",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: false,
issueNumber: "123",
defaultBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
});
test("should include trigger username when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
triggerUsername: "johndoe",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: false,
issueNumber: "123",
defaultBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
expect(prompt).toContain(
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
);
});
test("should include PR-specific instructions only for PR events", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review",
isPR: true,
prNumber: "456",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain PR-specific instructions
expect(prompt).toContain(
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
// Should NOT contain Issue-specific instructions
expect(prompt).not.toContain("You are already on the correct branch (");
expect(prompt).not.toContain(
"IMPORTANT: You are already on the correct branch (",
);
expect(prompt).not.toContain("Create a PR](https://github.com/");
});
test("should include Issue-specific instructions only for Issue events", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "789",
defaultBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain Issue-specific instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/issue-789-20240101_120000)",
);
expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain(
"If you created anything in your branch, your comment must include the PR URL",
);
// Should NOT contain PR-specific instructions
expect(prompt).not.toContain(
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
expect(prompt).not.toContain(
"Always push to the existing branch when triggered on a PR",
);
});
test("should use actual branch name for issue comments", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: false,
issueNumber: "123",
defaultBranch: "main",
claudeBranch: "claude/issue-123-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain the actual branch name with timestamp
expect(prompt).toContain(
"You are already on the correct branch (claude/issue-123-20240101_120000)",
);
expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)",
);
expect(prompt).toContain(
"The branch-name is the current branch: claude/issue-123-20240101_120000",
);
});
test("should handle closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: true,
prNumber: "456",
commentBody: "@claude please fix this",
claudeBranch: "claude/pr-456-20240101_120000",
defaultBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain branch-specific instructions like issues
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-456-20240101_120000)",
);
expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/main",
);
expect(prompt).toContain(
"The branch-name is the current branch: claude/pr-456-20240101_120000",
);
expect(prompt).toContain("Reference to the original PR");
expect(prompt).toContain(
"If you created anything in your branch, your comment must include the PR URL",
);
// Should NOT contain open PR instructions
expect(prompt).not.toContain(
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
});
test("should handle open PR without new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: true,
prNumber: "456",
commentBody: "@claude please fix this",
// No claudeBranch or defaultBranch for open PRs
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain open PR instructions
expect(prompt).toContain(
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
// Should NOT contain new branch instructions
expect(prompt).not.toContain("Create a PR](https://github.com/");
expect(prompt).not.toContain("You are already on the correct branch");
expect(prompt).not.toContain(
"If you created anything in your branch, your comment must include the PR URL",
);
});
test("should handle PR review on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review",
isPR: true,
prNumber: "789",
commentBody: "@claude please update this",
claudeBranch: "claude/pr-789-20240101_123000",
defaultBranch: "develop",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-789-20240101_123000)",
);
expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/develop",
);
expect(prompt).toContain("Reference to the original PR");
});
test("should handle PR review comment on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "999",
commentId: "review-comment-123",
commentBody: "@claude fix this issue",
claudeBranch: "claude/pr-999-20240101_140000",
defaultBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-999-20240101_140000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR");
expect(prompt).toContain(
"If you created anything in your branch, your comment must include the PR URL",
);
});
test("should handle pull_request event on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request",
eventAction: "closed",
isPR: true,
prNumber: "555",
claudeBranch: "claude/pr-555-20240101_150000",
defaultBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-555-20240101_150000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR");
});
});
describe("getEventTypeAndContext", () => {
test("should return correct type and context for pull_request_review_comment", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "123",
commentBody: "@claude please fix this",
},
};
const result = getEventTypeAndContext(envVars);
expect(result.eventType).toBe("REVIEW_COMMENT");
expect(result.triggerContext).toBe("PR review comment with '@claude'");
});
test("should return correct type and context for issue assigned", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "assigned",
isPR: false,
issueNumber: "999",
defaultBranch: "main",
claudeBranch: "claude/issue-999-20240101_120000",
assigneeTrigger: "claude-bot",
},
};
const result = getEventTypeAndContext(envVars);
expect(result.eventType).toBe("ISSUE_ASSIGNED");
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
});
});
describe("buildAllowedToolsString", () => {
test("should return issue comment tool for regular events", () => {
const mockEventData: EventData = {
eventName: "issue_comment",
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const result = buildAllowedToolsString(mockEventData);
// The base tools should be in the result
expect(result).toContain("Edit");
expect(result).toContain("Glob");
expect(result).toContain("Grep");
expect(result).toContain("LS");
expect(result).toContain("Read");
expect(result).toContain("Write");
expect(result).toContain("mcp__github__update_issue_comment");
expect(result).not.toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files");
});
test("should return PR comment tool for inline review comments", () => {
const mockEventData: EventData = {
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "456",
commentBody: "Test review comment",
commentId: "789",
};
const result = buildAllowedToolsString(mockEventData);
// The base tools should be in the result
expect(result).toContain("Edit");
expect(result).toContain("Glob");
expect(result).toContain("Grep");
expect(result).toContain("LS");
expect(result).toContain("Read");
expect(result).toContain("Write");
expect(result).not.toContain("mcp__github__update_issue_comment");
expect(result).toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files");
});
test("should append custom tools when provided", () => {
const mockEventData: EventData = {
eventName: "issue_comment",
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const customTools = "Tool1,Tool2,Tool3";
const result = buildAllowedToolsString(mockEventData, customTools);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
// Custom tools should be appended
expect(result).toContain("Tool1");
expect(result).toContain("Tool2");
expect(result).toContain("Tool3");
// Verify format with comma separation
const basePlusCustom = result.split(",");
expect(basePlusCustom.length).toBeGreaterThan(10); // At least the base tools plus custom
expect(basePlusCustom).toContain("Tool1");
expect(basePlusCustom).toContain("Tool2");
expect(basePlusCustom).toContain("Tool3");
});
});
describe("buildDisallowedToolsString", () => {
test("should return base disallowed tools when no custom tools provided", () => {
const result = buildDisallowedToolsString();
// The base disallowed tools should be in the result
expect(result).toContain("WebSearch");
expect(result).toContain("WebFetch");
});
test("should append custom disallowed tools when provided", () => {
const customDisallowedTools = "BadTool1,BadTool2";
const result = buildDisallowedToolsString(customDisallowedTools);
// Base disallowed tools should be present
expect(result).toContain("WebSearch");
// Custom disallowed tools should be appended
expect(result).toContain("BadTool1");
expect(result).toContain("BadTool2");
// Verify format with comma separation
const parts = result.split(",");
expect(parts).toContain("WebSearch");
expect(parts).toContain("BadTool1");
expect(parts).toContain("BadTool2");
});
});

580
test/data-formatter.test.ts Normal file
View File

@@ -0,0 +1,580 @@
import { expect, test, describe } from "bun:test";
import {
formatContext,
formatBody,
formatComments,
formatReviewComments,
formatChangedFiles,
formatChangedFilesWithSHA,
} from "../src/github/data/formatter";
import type {
GitHubPullRequest,
GitHubIssue,
GitHubComment,
GitHubFile,
} from "../src/github/types";
import type { GitHubFileWithSHA } from "../src/github/data/fetcher";
describe("formatContext", () => {
test("formats PR context correctly", () => {
const prData: GitHubPullRequest = {
title: "Test PR",
body: "PR body",
author: { login: "test-user" },
baseRefName: "main",
headRefName: "feature/test",
headRefOid: "abc123",
createdAt: "2023-01-01T00:00:00Z",
additions: 50,
deletions: 30,
state: "OPEN",
commits: {
totalCount: 3,
nodes: [],
},
files: {
nodes: [{} as GitHubFile, {} as GitHubFile],
},
comments: {
nodes: [],
},
reviews: {
nodes: [],
},
};
const result = formatContext(prData, true);
expect(result).toBe(
`PR Title: Test PR
PR Author: test-user
PR Branch: feature/test -> main
PR State: OPEN
PR Additions: 50
PR Deletions: 30
Total Commits: 3
Changed Files: 2 files`,
);
});
test("formats Issue context correctly", () => {
const issueData: GitHubIssue = {
title: "Test Issue",
body: "Issue body",
author: { login: "test-user" },
createdAt: "2023-01-01T00:00:00Z",
state: "OPEN",
comments: {
nodes: [],
},
};
const result = formatContext(issueData, false);
expect(result).toBe(
`Issue Title: Test Issue
Issue Author: test-user
Issue State: OPEN`,
);
});
});
describe("formatBody", () => {
test("replaces image URLs with local paths", () => {
const body = `Here is some text with an image: ![screenshot](https://github.com/user-attachments/assets/test-image.png)
And another one: ![another](https://github.com/user-attachments/assets/another-image.jpg)
Some more text.`;
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/test-image.png",
"/tmp/github-images/image-1234-0.png",
],
[
"https://github.com/user-attachments/assets/another-image.jpg",
"/tmp/github-images/image-1234-1.jpg",
],
]);
const result = formatBody(body, imageUrlMap);
expect(result)
.toBe(`Here is some text with an image: ![screenshot](/tmp/github-images/image-1234-0.png)
And another one: ![another](/tmp/github-images/image-1234-1.jpg)
Some more text.`);
});
test("handles empty image map", () => {
const body = "No images here";
const imageUrlMap = new Map<string, string>();
const result = formatBody(body, imageUrlMap);
expect(result).toBe("No images here");
});
test("preserves body when no images match", () => {
const body = "![image](https://example.com/image.png)";
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/different.png",
"/tmp/github-images/image-1234-0.png",
],
]);
const result = formatBody(body, imageUrlMap);
expect(result).toBe("![image](https://example.com/image.png)");
});
test("handles multiple occurrences of same image", () => {
const body = `First: ![img](https://github.com/user-attachments/assets/test.png)
Second: ![img](https://github.com/user-attachments/assets/test.png)`;
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/test.png",
"/tmp/github-images/image-1234-0.png",
],
]);
const result = formatBody(body, imageUrlMap);
expect(result).toBe(`First: ![img](/tmp/github-images/image-1234-0.png)
Second: ![img](/tmp/github-images/image-1234-0.png)`);
});
});
describe("formatComments", () => {
test("formats comments correctly", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "First comment",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
},
{
id: "2",
databaseId: "100002",
body: "Second comment",
author: { login: "user2" },
createdAt: "2023-01-02T00:00:00Z",
},
];
const result = formatComments(comments);
expect(result).toBe(
`[user1 at 2023-01-01T00:00:00Z]: First comment\n\n[user2 at 2023-01-02T00:00:00Z]: Second comment`,
);
});
test("returns empty string for empty comments array", () => {
const result = formatComments([]);
expect(result).toBe("");
});
test("replaces image URLs in comments", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "Check out this screenshot: ![screenshot](https://github.com/user-attachments/assets/screenshot.png)",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
},
{
id: "2",
databaseId: "100002",
body: "Here's another image: ![bug](https://github.com/user-attachments/assets/bug-report.jpg)",
author: { login: "user2" },
createdAt: "2023-01-02T00:00:00Z",
},
];
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/screenshot.png",
"/tmp/github-images/image-1234-0.png",
],
[
"https://github.com/user-attachments/assets/bug-report.jpg",
"/tmp/github-images/image-1234-1.jpg",
],
]);
const result = formatComments(comments, imageUrlMap);
expect(result).toBe(
`[user1 at 2023-01-01T00:00:00Z]: Check out this screenshot: ![screenshot](/tmp/github-images/image-1234-0.png)\n\n[user2 at 2023-01-02T00:00:00Z]: Here's another image: ![bug](/tmp/github-images/image-1234-1.jpg)`,
);
});
test("handles comments with multiple images", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "Two images: ![first](https://github.com/user-attachments/assets/first.png) and ![second](https://github.com/user-attachments/assets/second.png)",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
},
];
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/first.png",
"/tmp/github-images/image-1234-0.png",
],
[
"https://github.com/user-attachments/assets/second.png",
"/tmp/github-images/image-1234-1.png",
],
]);
const result = formatComments(comments, imageUrlMap);
expect(result).toBe(
`[user1 at 2023-01-01T00:00:00Z]: Two images: ![first](/tmp/github-images/image-1234-0.png) and ![second](/tmp/github-images/image-1234-1.png)`,
);
});
test("preserves comments when imageUrlMap is undefined", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "Image: ![test](https://github.com/user-attachments/assets/test.png)",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
},
];
const result = formatComments(comments);
expect(result).toBe(
`[user1 at 2023-01-01T00:00:00Z]: Image: ![test](https://github.com/user-attachments/assets/test.png)`,
);
});
});
describe("formatReviewComments", () => {
test("formats review with body and comments correctly", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "This is a great PR! LGTM.",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Nice implementation",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
},
{
id: "comment2",
databaseId: "200002",
body: "Consider adding error handling",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/utils.ts",
line: null,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\n [Comment on src/index.ts:42]: Nice implementation\n [Comment on src/utils.ts:?]: Consider adding error handling`,
);
});
test("formats review with only body (no comments) correctly", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300002",
author: { login: "reviewer1" },
body: "Looks good to me!",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED`,
);
});
test("formats review without body correctly", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300003",
author: { login: "reviewer1" },
body: "",
state: "COMMENTED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200003",
body: "Small suggestion here",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/main.ts",
line: 15,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: COMMENTED\n [Comment on src/main.ts:15]: Small suggestion here`,
);
});
test("formats multiple reviews correctly", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300004",
author: { login: "reviewer1" },
body: "Needs changes",
state: "CHANGES_REQUESTED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],
},
},
{
id: "review2",
databaseId: "300005",
author: { login: "reviewer2" },
body: "LGTM",
state: "APPROVED",
submittedAt: "2023-01-02T00:00:00Z",
comments: {
nodes: [],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: CHANGES_REQUESTED\n\n[Review by reviewer2 at 2023-01-02T00:00:00Z]: APPROVED`,
);
});
test("returns empty string for null reviewData", () => {
const result = formatReviewComments(null);
expect(result).toBe("");
});
test("returns empty string for empty reviewData", () => {
const result = formatReviewComments({ nodes: [] });
expect(result).toBe("");
});
test("replaces image URLs in review comments", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "Review with image: ![review-img](https://github.com/user-attachments/assets/review.png)",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Comment with image: ![comment-img](https://github.com/user-attachments/assets/comment.png)",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
},
],
},
},
],
};
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/review.png",
"/tmp/github-images/image-1234-0.png",
],
[
"https://github.com/user-attachments/assets/comment.png",
"/tmp/github-images/image-1234-1.png",
],
]);
const result = formatReviewComments(reviewData, imageUrlMap);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\n [Comment on src/index.ts:42]: Comment with image: ![comment-img](/tmp/github-images/image-1234-1.png)`,
);
});
test("handles multiple images in review comments", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "Good work",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Two issues: ![issue1](https://github.com/user-attachments/assets/issue1.png) and ![issue2](https://github.com/user-attachments/assets/issue2.png)",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/main.ts",
line: 15,
},
],
},
},
],
};
const imageUrlMap = new Map([
[
"https://github.com/user-attachments/assets/issue1.png",
"/tmp/github-images/image-1234-0.png",
],
[
"https://github.com/user-attachments/assets/issue2.png",
"/tmp/github-images/image-1234-1.png",
],
]);
const result = formatReviewComments(reviewData, imageUrlMap);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\n [Comment on src/main.ts:15]: Two issues: ![issue1](/tmp/github-images/image-1234-0.png) and ![issue2](/tmp/github-images/image-1234-1.png)`,
);
});
test("preserves review comments when imageUrlMap is undefined", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "Review body",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Image: ![test](https://github.com/user-attachments/assets/test.png)",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\n [Comment on src/index.ts:42]: Image: ![test](https://github.com/user-attachments/assets/test.png)`,
);
});
});
describe("formatChangedFiles", () => {
test("formats changed files correctly", () => {
const files: GitHubFile[] = [
{
path: "src/index.ts",
additions: 10,
deletions: 5,
changeType: "MODIFIED",
},
{
path: "src/utils.ts",
additions: 20,
deletions: 0,
changeType: "ADDED",
},
];
const result = formatChangedFiles(files);
expect(result).toBe(
`- src/index.ts (MODIFIED) +10/-5\n- src/utils.ts (ADDED) +20/-0`,
);
});
test("returns empty string for empty files array", () => {
const result = formatChangedFiles([]);
expect(result).toBe("");
});
});
describe("formatChangedFilesWithSHA", () => {
test("formats changed files with SHA correctly", () => {
const files: GitHubFileWithSHA[] = [
{
path: "src/index.ts",
additions: 10,
deletions: 5,
changeType: "MODIFIED",
sha: "abc123",
},
{
path: "src/utils.ts",
additions: 20,
deletions: 0,
changeType: "ADDED",
sha: "def456",
},
];
const result = formatChangedFilesWithSHA(files);
expect(result).toBe(
`- src/index.ts (MODIFIED) +10/-5 SHA: abc123\n- src/utils.ts (ADDED) +20/-0 SHA: def456`,
);
});
test("returns empty string for empty files array", () => {
const result = formatChangedFilesWithSHA([]);
expect(result).toBe("");
});
});

View File

@@ -0,0 +1,665 @@
import {
describe,
test,
expect,
spyOn,
beforeEach,
afterEach,
jest,
setSystemTime,
} from "bun:test";
import fs from "fs/promises";
import { downloadCommentImages } from "../src/github/utils/image-downloader";
import type { CommentWithImages } from "../src/github/utils/image-downloader";
import type { Octokits } from "../src/github/api/client";
describe("downloadCommentImages", () => {
let consoleLogSpy: any;
let consoleWarnSpy: any;
let consoleErrorSpy: any;
let fsMkdirSpy: any;
let fsWriteFileSpy: any;
let fetchSpy: any;
beforeEach(() => {
// Spy on console methods
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
// Spy on fs methods
fsMkdirSpy = spyOn(fs, "mkdir").mockResolvedValue(undefined);
fsWriteFileSpy = spyOn(fs, "writeFile").mockResolvedValue(undefined);
// Set fake system time for consistent filenames
setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
fsMkdirSpy.mockRestore();
fsWriteFileSpy.mockRestore();
if (fetchSpy) fetchSpy.mockRestore();
setSystemTime(); // Reset to real time
});
const createMockOctokit = (): Octokits => {
return {
rest: {
issues: {
getComment: jest.fn(),
get: jest.fn(),
},
pulls: {
getReviewComment: jest.fn(),
getReview: jest.fn(),
get: jest.fn(),
},
},
} as any as Octokits;
};
test("should create download directory", async () => {
const mockOctokit = createMockOctokit();
const comments: CommentWithImages[] = [];
await downloadCommentImages(mockOctokit, "owner", "repo", comments);
expect(fsMkdirSpy).toHaveBeenCalledWith("/tmp/github-images", {
recursive: true,
});
});
test("should handle comments without images", async () => {
const mockOctokit = createMockOctokit();
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "123",
body: "This is a comment without images",
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(result.size).toBe(0);
expect(consoleLogSpy).not.toHaveBeenCalledWith(
expect.stringContaining("Found"),
);
});
test("should detect and download images from issue comments", async () => {
const mockOctokit = createMockOctokit();
const imageUrl =
"https://github.com/user-attachments/assets/test-image.png";
const signedUrl =
"https://private-user-images.githubusercontent.com/test.png?jwt=token";
// Mock octokit response
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
// Mock fetch for image download
const mockArrayBuffer = new ArrayBuffer(8);
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => mockArrayBuffer,
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "123",
body: `Here's an image: ![test](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
comment_id: 123,
mediaType: { format: "full+json" },
});
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
expect(fsWriteFileSpy).toHaveBeenCalledWith(
"/tmp/github-images/image-1704067200000-0.png",
Buffer.from(mockArrayBuffer),
);
expect(result.size).toBe(1);
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.png",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Found 1 image(s) in issue_comment 123",
);
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
expect(consoleLogSpy).toHaveBeenCalledWith(
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
);
});
test("should handle review comments", async () => {
const mockOctokit = createMockOctokit();
const imageUrl =
"https://github.com/user-attachments/assets/review-image.jpg";
const signedUrl =
"https://private-user-images.githubusercontent.com/review.jpg?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "review_comment",
id: "456",
body: `Review comment with image: ![review](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
comment_id: 456,
mediaType: { format: "full+json" },
});
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.jpg",
);
});
test("should handle review bodies", async () => {
const mockOctokit = createMockOctokit();
const imageUrl =
"https://github.com/user-attachments/assets/review-body.png";
const signedUrl =
"https://private-user-images.githubusercontent.com/body.png?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "review_body",
id: "789",
pullNumber: "100",
body: `Review body: ![body](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
pull_number: 100,
review_id: 789,
mediaType: { format: "full+json" },
});
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.png",
);
});
test("should handle issue bodies", async () => {
const mockOctokit = createMockOctokit();
const imageUrl =
"https://github.com/user-attachments/assets/issue-body.gif";
const signedUrl =
"https://private-user-images.githubusercontent.com/issue.gif?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_body",
issueNumber: "200",
body: `Issue description: ![issue](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
issue_number: 200,
mediaType: { format: "full+json" },
});
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.gif",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Found 1 image(s) in issue_body 200",
);
});
test("should handle PR bodies", async () => {
const mockOctokit = createMockOctokit();
const imageUrl = "https://github.com/user-attachments/assets/pr-body.webp";
const signedUrl =
"https://private-user-images.githubusercontent.com/pr.webp?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "pr_body",
pullNumber: "300",
body: `PR description: ![pr](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
owner: "owner",
repo: "repo",
pull_number: 300,
mediaType: { format: "full+json" },
});
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.webp",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Found 1 image(s) in pr_body 300",
);
});
test("should handle multiple images in a single comment", async () => {
const mockOctokit = createMockOctokit();
const imageUrl1 = "https://github.com/user-attachments/assets/image1.png";
const imageUrl2 = "https://github.com/user-attachments/assets/image2.jpg";
const signedUrl1 =
"https://private-user-images.githubusercontent.com/1.png?jwt=token1";
const signedUrl2 =
"https://private-user-images.githubusercontent.com/2.jpg?jwt=token2";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "999",
body: `Two images: ![img1](${imageUrl1}) and ![img2](${imageUrl2})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(result.size).toBe(2);
expect(result.get(imageUrl1)).toBe(
"/tmp/github-images/image-1704067200000-0.png",
);
expect(result.get(imageUrl2)).toBe(
"/tmp/github-images/image-1704067200000-1.jpg",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Found 2 image(s) in issue_comment 999",
);
});
test("should skip already downloaded images", async () => {
const mockOctokit = createMockOctokit();
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
const signedUrl =
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "111",
body: `First: ![dup](${imageUrl})`,
},
{
type: "issue_comment",
id: "222",
body: `Second: ![dup](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
expect(result.size).toBe(1);
expect(result.get(imageUrl)).toBe(
"/tmp/github-images/image-1704067200000-0.png",
);
});
test("should handle missing HTML body", async () => {
const mockOctokit = createMockOctokit();
const imageUrl = "https://github.com/user-attachments/assets/missing.png";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: null,
},
});
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "333",
body: `Missing HTML: ![missing](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(result.size).toBe(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"No HTML body found for issue_comment 333",
);
});
test("should handle fetch errors", async () => {
const mockOctokit = createMockOctokit();
const imageUrl = "https://github.com/user-attachments/assets/error.png";
const signedUrl =
"https://private-user-images.githubusercontent.com/error.png?jwt=token";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "444",
body: `Error image: ![error](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(result.size).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`✗ Failed to download ${imageUrl}:`,
expect.any(Error),
);
});
test("should handle API errors gracefully", async () => {
const mockOctokit = createMockOctokit();
const imageUrl = "https://github.com/user-attachments/assets/api-error.png";
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest
.fn()
.mockRejectedValue(new Error("API rate limit exceeded"));
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "555",
body: `API error: ![api-error](${imageUrl})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(result.size).toBe(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to process images for issue_comment 555:",
expect.any(Error),
);
});
test("should extract correct file extensions", async () => {
const mockOctokit = createMockOctokit();
const extensions = [
{
url: "https://github.com/user-attachments/assets/test.png",
ext: ".png",
},
{
url: "https://github.com/user-attachments/assets/test.jpg",
ext: ".jpg",
},
{
url: "https://github.com/user-attachments/assets/test.jpeg",
ext: ".jpeg",
},
{
url: "https://github.com/user-attachments/assets/test.gif",
ext: ".gif",
},
{
url: "https://github.com/user-attachments/assets/test.webp",
ext: ".webp",
},
{
url: "https://github.com/user-attachments/assets/test.svg",
ext: ".svg",
},
{
// default
url: "https://github.com/user-attachments/assets/no-extension",
ext: ".png",
},
];
let callIndex = 0;
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="https://private-user-images.githubusercontent.com/test?jwt=token">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
for (const { url, ext } of extensions) {
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: `${1000 + callIndex}`,
body: `Test: ![test](${url})`,
},
];
setSystemTime(new Date(1704067200000 + callIndex));
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(result.get(url)).toBe(
`/tmp/github-images/image-${1704067200000 + callIndex}-0${ext}`,
);
// Reset for next iteration
fsWriteFileSpy.mockClear();
callIndex++;
}
});
test("should handle mismatched signed URL count", async () => {
const mockOctokit = createMockOctokit();
const imageUrl1 = "https://github.com/user-attachments/assets/img1.png";
const imageUrl2 = "https://github.com/user-attachments/assets/img2.png";
const signedUrl1 =
"https://private-user-images.githubusercontent.com/1.png?jwt=token";
// Only one signed URL for two images
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
data: {
body_html: `<img src="${signedUrl1}">`,
},
});
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
ok: true,
arrayBuffer: async () => new ArrayBuffer(8),
} as Response);
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "666",
body: `Two images: ![img1](${imageUrl1}) ![img2](${imageUrl2})`,
},
];
const result = await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(result.size).toBe(1);
expect(result.get(imageUrl1)).toBe(
"/tmp/github-images/image-1704067200000-0.png",
);
expect(result.get(imageUrl2)).toBeUndefined();
});
});

345
test/mockContext.ts Normal file
View File

@@ -0,0 +1,345 @@
import type { ParsedGitHubContext } from "../src/github/context";
import type {
IssuesEvent,
IssueCommentEvent,
PullRequestEvent,
PullRequestReviewEvent,
PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types";
const defaultInputs = {
triggerPhrase: "/claude",
assigneeTrigger: "",
anthropicModel: "claude-3-7-sonnet-20250219",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
directPrompt: "",
useBedrock: false,
useVertex: false,
timeoutMinutes: 30,
};
const defaultRepository = {
owner: "test-owner",
repo: "test-repo",
full_name: "test-owner/test-repo",
};
export const createMockContext = (
overrides: Partial<ParsedGitHubContext> = {},
): ParsedGitHubContext => {
const baseContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "",
eventAction: "",
repository: defaultRepository,
actor: "test-actor",
payload: {} as any,
entityNumber: 1,
isPR: false,
inputs: defaultInputs,
};
if (overrides.inputs) {
overrides.inputs = { ...defaultInputs, ...overrides.inputs };
}
return { ...baseContext, ...overrides };
};
export const mockIssueOpenedContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issues",
eventAction: "opened",
repository: defaultRepository,
actor: "john-doe",
payload: {
action: "opened",
issue: {
number: 42,
title: "Bug: Application crashes on startup",
body: "## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this",
assignee: null,
created_at: "2024-01-15T10:30:00Z",
updated_at: "2024-01-15T10:30:00Z",
html_url: "https://github.com/test-owner/test-repo/issues/42",
user: {
login: "john-doe",
id: 12345,
},
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssuesEvent,
entityNumber: 42,
isPR: false,
inputs: defaultInputs,
};
export const mockIssueAssignedContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issues",
eventAction: "assigned",
repository: defaultRepository,
actor: "admin-user",
payload: {
action: "assigned",
issue: {
number: 123,
title: "Feature: Add dark mode support",
body: "We need dark mode for better user experience",
user: {
login: "jane-smith",
id: 67890,
avatar_url: "https://avatars.githubusercontent.com/u/67890",
html_url: "https://github.com/jane-smith",
},
assignee: {
login: "claude-bot",
id: 11111,
avatar_url: "https://avatars.githubusercontent.com/u/11111",
html_url: "https://github.com/claude-bot",
},
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssuesEvent,
entityNumber: 123,
isPR: false,
inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" },
};
// Issue comment on issue event
export const mockIssueCommentContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issue_comment",
eventAction: "created",
repository: defaultRepository,
actor: "contributor-user",
payload: {
action: "created",
comment: {
id: 12345678,
body: "@claude can you help explain how to configure the logging system?",
user: {
login: "contributor-user",
id: 88888,
avatar_url: "https://avatars.githubusercontent.com/u/88888",
html_url: "https://github.com/contributor-user",
},
created_at: "2024-01-15T12:30:00Z",
updated_at: "2024-01-15T12:30:00Z",
html_url:
"https://github.com/test-owner/test-repo/issues/55#issuecomment-12345678",
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssueCommentEvent,
entityNumber: 55,
isPR: false,
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
};
export const mockPullRequestCommentContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issue_comment",
eventAction: "created",
repository: defaultRepository,
actor: "reviewer-user",
payload: {
action: "created",
issue: {
number: 789,
title: "Fix: Memory leak in user service",
body: "This PR fixes the memory leak issue reported in #788",
user: {
login: "developer-user",
id: 77777,
avatar_url: "https://avatars.githubusercontent.com/u/77777",
html_url: "https://github.com/developer-user",
},
pull_request: {
url: "https://api.github.com/repos/test-owner/test-repo/pulls/789",
html_url: "https://github.com/test-owner/test-repo/pull/789",
diff_url: "https://github.com/test-owner/test-repo/pull/789.diff",
patch_url: "https://github.com/test-owner/test-repo/pull/789.patch",
},
},
comment: {
id: 87654321,
body: "/claude please review the changes and ensure we're not introducing any new memory issues",
user: {
login: "reviewer-user",
id: 66666,
avatar_url: "https://avatars.githubusercontent.com/u/66666",
html_url: "https://github.com/reviewer-user",
},
created_at: "2024-01-15T13:15:00Z",
updated_at: "2024-01-15T13:15:00Z",
html_url:
"https://github.com/test-owner/test-repo/pull/789#issuecomment-87654321",
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssueCommentEvent,
entityNumber: 789,
isPR: true,
inputs: defaultInputs,
};
export const mockPullRequestOpenedContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "pull_request",
eventAction: "opened",
repository: defaultRepository,
actor: "feature-developer",
payload: {
action: "opened",
number: 456,
pull_request: {
number: 456,
title: "Feature: Add user authentication",
body: "## Summary\n\nThis PR adds JWT-based authentication to the API.\n\n## Changes\n\n- Added auth middleware\n- Added login endpoint\n- Added JWT token generation\n\n/claude please review the security aspects",
user: {
login: "feature-developer",
id: 55555,
avatar_url: "https://avatars.githubusercontent.com/u/55555",
html_url: "https://github.com/feature-developer",
},
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as PullRequestEvent,
entityNumber: 456,
isPR: true,
inputs: defaultInputs,
};
export const mockPullRequestReviewContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "pull_request_review",
eventAction: "submitted",
repository: defaultRepository,
actor: "senior-developer",
payload: {
action: "submitted",
review: {
id: 11122233,
body: "@claude can you check if the error handling is comprehensive enough in this PR?",
user: {
login: "senior-developer",
id: 44444,
avatar_url: "https://avatars.githubusercontent.com/u/44444",
html_url: "https://github.com/senior-developer",
},
state: "approved",
html_url:
"https://github.com/test-owner/test-repo/pull/321#pullrequestreview-11122233",
submitted_at: "2024-01-15T15:30:00Z",
},
pull_request: {
number: 321,
title: "Refactor: Improve error handling in API layer",
body: "This PR improves error handling across all API endpoints",
user: {
login: "backend-developer",
id: 33333,
avatar_url: "https://avatars.githubusercontent.com/u/33333",
html_url: "https://github.com/backend-developer",
},
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as PullRequestReviewEvent,
entityNumber: 321,
isPR: true,
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
};
export const mockPullRequestReviewCommentContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "pull_request_review_comment",
eventAction: "created",
repository: defaultRepository,
actor: "code-reviewer",
payload: {
action: "created",
comment: {
id: 99988877,
body: "/claude is this the most efficient way to implement this algorithm?",
user: {
login: "code-reviewer",
id: 22222,
avatar_url: "https://avatars.githubusercontent.com/u/22222",
html_url: "https://github.com/code-reviewer",
},
path: "src/utils/algorithm.js",
position: 25,
line: 42,
commit_id: "xyz789abc123",
created_at: "2024-01-15T16:45:00Z",
updated_at: "2024-01-15T16:45:00Z",
html_url:
"https://github.com/test-owner/test-repo/pull/999#discussion_r99988877",
},
pull_request: {
number: 999,
title: "Performance: Optimize search algorithm",
body: "This PR optimizes the search algorithm for better performance",
user: {
login: "performance-dev",
id: 11111,
avatar_url: "https://avatars.githubusercontent.com/u/11111",
html_url: "https://github.com/performance-dev",
},
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as PullRequestReviewCommentEvent,
entityNumber: 999,
isPR: true,
inputs: defaultInputs,
};

162
test/permissions.test.ts Normal file
View File

@@ -0,0 +1,162 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test";
import * as core from "@actions/core";
import { checkWritePermissions } from "../src/github/validation/permissions";
import type { ParsedGitHubContext } from "../src/github/context";
describe("checkWritePermissions", () => {
let coreInfoSpy: any;
let coreWarningSpy: any;
let coreErrorSpy: any;
beforeEach(() => {
// Spy on core methods
coreInfoSpy = spyOn(core, "info").mockImplementation(() => {});
coreWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
coreErrorSpy = spyOn(core, "error").mockImplementation(() => {});
});
afterEach(() => {
coreInfoSpy.mockRestore();
coreWarningSpy.mockRestore();
coreErrorSpy.mockRestore();
});
const createMockOctokit = (permission: string) => {
return {
repos: {
getCollaboratorPermissionLevel: async () => ({
data: { permission },
}),
},
} as any;
};
const createContext = (): ParsedGitHubContext => ({
runId: "1234567890",
eventName: "issue_comment",
eventAction: "created",
repository: {
full_name: "test-owner/test-repo",
owner: "test-owner",
repo: "test-repo",
},
actor: "test-user",
payload: {
action: "created",
issue: {
number: 1,
title: "Test Issue",
body: "Test body",
user: { login: "test-user" },
},
comment: {
id: 123,
body: "@claude test",
user: { login: "test-user" },
html_url:
"https://github.com/test-owner/test-repo/issues/1#issuecomment-123",
},
} as any,
entityNumber: 1,
isPR: false,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
directPrompt: "",
},
});
test("should return true for admin permissions", async () => {
const mockOctokit = createMockOctokit("admin");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Checking permissions for actor: test-user",
);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Permission level retrieved: admin",
);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: admin");
});
test("should return true for write permissions", async () => {
const mockOctokit = createMockOctokit("write");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: write");
});
test("should return false for read permissions", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should return false for none permissions", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: none",
);
});
test("should throw error when permission check fails", async () => {
const error = new Error("API error");
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async () => {
throw error;
},
},
} as any;
const context = createContext();
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
"Failed to check permissions for test-user: Error: API error",
);
expect(coreErrorSpy).toHaveBeenCalledWith(
"Failed to check permissions: Error: API error",
);
});
test("should call API with correct parameters", async () => {
let capturedParams: any;
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async (params: any) => {
capturedParams = params;
return { data: { permission: "write" } };
},
},
} as any;
const context = createContext();
await checkWritePermissions(mockOctokit, context);
expect(capturedParams).toEqual({
owner: "test-owner",
repo: "test-repo",
username: "test-user",
});
});
});

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env bun
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { prepareContext } from "../src/create-prompt";
import {
createMockContext,
mockIssueOpenedContext,
mockIssueAssignedContext,
mockIssueCommentContext,
mockPullRequestCommentContext,
mockPullRequestReviewContext,
mockPullRequestReviewCommentContext,
} from "./mockContext";
const BASE_ENV = {
CLAUDE_COMMENT_ID: "12345",
GITHUB_TOKEN: "test-token",
};
describe("parseEnvVarsWithContext", () => {
let originalEnv: typeof process.env;
beforeEach(() => {
originalEnv = { ...process.env };
process.env = {};
});
afterEach(() => {
process.env = originalEnv;
});
describe("issue_comment event", () => {
describe("on issue", () => {
beforeEach(() => {
process.env = {
...BASE_ENV,
DEFAULT_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-67890-20240101_120000",
};
});
test("should parse issue_comment event correctly", () => {
const result = prepareContext(
mockIssueCommentContext,
"12345",
"main",
"claude/issue-67890-20240101_120000",
);
expect(result.repository).toBe("test-owner/test-repo");
expect(result.claudeCommentId).toBe("12345");
expect(result.triggerPhrase).toBe("@claude");
expect(result.triggerUsername).toBe("contributor-user");
expect(result.eventData.eventName).toBe("issue_comment");
expect(result.eventData.isPR).toBe(false);
if (
result.eventData.eventName === "issue_comment" &&
!result.eventData.isPR
) {
expect(result.eventData.issueNumber).toBe("55");
expect(result.eventData.commentId).toBe("12345678");
expect(result.eventData.claudeBranch).toBe(
"claude/issue-67890-20240101_120000",
);
expect(result.eventData.defaultBranch).toBe("main");
expect(result.eventData.commentBody).toBe(
"@claude can you help explain how to configure the logging system?",
);
}
});
test("should throw error when CLAUDE_BRANCH is missing", () => {
expect(() =>
prepareContext(mockIssueCommentContext, "12345", "main"),
).toThrow("CLAUDE_BRANCH is required for issue_comment event");
});
test("should throw error when DEFAULT_BRANCH is missing", () => {
expect(() =>
prepareContext(
mockIssueCommentContext,
"12345",
undefined,
"claude/issue-67890-20240101_120000",
),
).toThrow("DEFAULT_BRANCH is required for issue_comment event");
});
});
describe("on PR", () => {
test("should parse PR issue_comment event correctly", () => {
process.env = BASE_ENV;
const result = prepareContext(mockPullRequestCommentContext, "12345");
expect(result.eventData.eventName).toBe("issue_comment");
expect(result.eventData.isPR).toBe(true);
expect(result.triggerUsername).toBe("reviewer-user");
if (
result.eventData.eventName === "issue_comment" &&
result.eventData.isPR
) {
expect(result.eventData.prNumber).toBe("789");
expect(result.eventData.commentId).toBe("87654321");
expect(result.eventData.commentBody).toBe(
"/claude please review the changes and ensure we're not introducing any new memory issues",
);
}
});
});
});
describe("pull_request_review event", () => {
test("should parse pull_request_review event correctly", () => {
process.env = BASE_ENV;
const result = prepareContext(mockPullRequestReviewContext, "12345");
expect(result.eventData.eventName).toBe("pull_request_review");
expect(result.eventData.isPR).toBe(true);
expect(result.triggerUsername).toBe("senior-developer");
if (result.eventData.eventName === "pull_request_review") {
expect(result.eventData.prNumber).toBe("321");
expect(result.eventData.commentBody).toBe(
"@claude can you check if the error handling is comprehensive enough in this PR?",
);
}
});
});
describe("pull_request_review_comment event", () => {
test("should parse pull_request_review_comment event correctly", () => {
process.env = BASE_ENV;
const result = prepareContext(
mockPullRequestReviewCommentContext,
"12345",
);
expect(result.eventData.eventName).toBe("pull_request_review_comment");
expect(result.eventData.isPR).toBe(true);
expect(result.triggerUsername).toBe("code-reviewer");
if (result.eventData.eventName === "pull_request_review_comment") {
expect(result.eventData.prNumber).toBe("999");
expect(result.eventData.commentId).toBe("99988877");
expect(result.eventData.commentBody).toBe(
"/claude is this the most efficient way to implement this algorithm?",
);
}
});
});
describe("issues event", () => {
beforeEach(() => {
process.env = {
...BASE_ENV,
DEFAULT_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-42-20240101_120000",
};
});
test("should parse issue opened event correctly", () => {
const result = prepareContext(
mockIssueOpenedContext,
"12345",
"main",
"claude/issue-42-20240101_120000",
);
expect(result.eventData.eventName).toBe("issues");
expect(result.eventData.isPR).toBe(false);
expect(result.triggerUsername).toBe("john-doe");
if (
result.eventData.eventName === "issues" &&
result.eventData.eventAction === "opened"
) {
expect(result.eventData.issueNumber).toBe("42");
expect(result.eventData.defaultBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe(
"claude/issue-42-20240101_120000",
);
}
});
test("should parse issue assigned event correctly", () => {
const result = prepareContext(
mockIssueAssignedContext,
"12345",
"main",
"claude/issue-123-20240101_120000",
);
expect(result.eventData.eventName).toBe("issues");
expect(result.eventData.isPR).toBe(false);
expect(result.triggerUsername).toBe("jane-smith");
if (
result.eventData.eventName === "issues" &&
result.eventData.eventAction === "assigned"
) {
expect(result.eventData.issueNumber).toBe("123");
expect(result.eventData.defaultBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe(
"claude/issue-123-20240101_120000",
);
expect(result.eventData.assigneeTrigger).toBe("@claude-bot");
}
});
test("should throw error when CLAUDE_BRANCH is missing for issues", () => {
expect(() =>
prepareContext(mockIssueOpenedContext, "12345", "main"),
).toThrow("CLAUDE_BRANCH is required for issues event");
});
test("should throw error when DEFAULT_BRANCH is missing for issues", () => {
expect(() =>
prepareContext(
mockIssueOpenedContext,
"12345",
undefined,
"claude/issue-42-20240101_120000",
),
).toThrow("DEFAULT_BRANCH is required for issues event");
});
});
describe("optional fields", () => {
test("should include custom instructions when provided", () => {
process.env = BASE_ENV;
const contextWithCustomInstructions = createMockContext({
...mockPullRequestCommentContext,
inputs: {
...mockPullRequestCommentContext.inputs,
customInstructions: "Be concise",
},
});
const result = prepareContext(contextWithCustomInstructions, "12345");
expect(result.customInstructions).toBe("Be concise");
});
test("should include allowed tools when provided", () => {
process.env = BASE_ENV;
const contextWithAllowedTools = createMockContext({
...mockPullRequestCommentContext,
inputs: {
...mockPullRequestCommentContext.inputs,
allowedTools: "Tool1,Tool2",
},
});
const result = prepareContext(contextWithAllowedTools, "12345");
expect(result.allowedTools).toBe("Tool1,Tool2");
});
});
test("should throw error for unsupported event type", () => {
process.env = BASE_ENV;
const unsupportedContext = createMockContext({
eventName: "unsupported_event",
eventAction: "whatever",
});
expect(() => prepareContext(unsupportedContext, "12345")).toThrow(
"Unsupported event type: unsupported_event",
);
});
});

View File

@@ -0,0 +1,431 @@
import {
checkContainsTrigger,
escapeRegExp,
} from "../src/github/validation/trigger";
import { describe, it, expect } from "bun:test";
import {
createMockContext,
mockIssueAssignedContext,
mockIssueCommentContext,
mockIssueOpenedContext,
mockPullRequestReviewContext,
mockPullRequestReviewCommentContext,
} from "./mockContext";
import type {
IssueCommentEvent,
IssuesAssignedEvent,
IssuesEvent,
PullRequestEvent,
PullRequestReviewEvent,
} from "@octokit/webhooks-types";
import type { ParsedGitHubContext } from "../src/github/context";
describe("checkContainsTrigger", () => {
describe("direct prompt trigger", () => {
it("should return true when direct prompt is provided", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
inputs: {
triggerPhrase: "/claude",
assigneeTrigger: "",
directPrompt: "Fix the bug in the login form",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when direct prompt is empty", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
payload: {
action: "opened",
issue: {
number: 1,
title: "Test Issue",
body: "Test body without trigger",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as IssuesEvent,
inputs: {
triggerPhrase: "/claude",
assigneeTrigger: "",
directPrompt: "",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("assignee trigger", () => {
it("should return true when issue is assigned to the trigger user", () => {
const context = mockIssueAssignedContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should add @ symbol from assignee trigger", () => {
const context = {
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "claude-bot",
},
};
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when issue is assigned to a different user", () => {
const context = {
...mockIssueAssignedContext,
payload: {
...mockIssueAssignedContext.payload,
issue: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
assignee: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue
.assignee,
login: "otherUser",
},
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("issue body and title trigger", () => {
it("should return true when issue body contains trigger phrase", () => {
const context = mockIssueOpenedContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true when issue title contains trigger phrase", () => {
const context = {
...mockIssueOpenedContext,
payload: {
...mockIssueOpenedContext.payload,
issue: {
...(mockIssueOpenedContext.payload as IssuesEvent).issue,
title: "/claude Fix the login bug",
body: "The login page is broken",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should handle trigger phrase with punctuation", () => {
const baseContext = {
...mockIssueOpenedContext,
inputs: {
...mockIssueOpenedContext.inputs,
triggerPhrase: "@claude",
},
};
// Test various punctuation marks
const testCases = [
{ issueBody: "@claude, can you help?", expected: true },
{ issueBody: "@claude. Please look at this", expected: true },
{ issueBody: "@claude! This is urgent", expected: true },
{ issueBody: "@claude? What do you think?", expected: true },
{ issueBody: "@claude: here's the issue", expected: true },
{ issueBody: "@claude; and another thing", expected: true },
{ issueBody: "Hey @claude, can you help?", expected: true },
{ issueBody: "claudette contains claude", expected: false },
{ issueBody: "email@claude.com", expected: false },
];
testCases.forEach(({ issueBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
issue: {
...(baseContext.payload as IssuesEvent).issue,
body: issueBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
it("should return false when trigger phrase is part of another word", () => {
const context = {
...mockIssueOpenedContext,
payload: {
...mockIssueOpenedContext.payload,
issue: {
...(mockIssueOpenedContext.payload as IssuesEvent).issue,
body: "claudette helped me with this",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle trigger phrase in title with punctuation", () => {
const baseContext = {
...mockIssueOpenedContext,
inputs: {
...mockIssueOpenedContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ issueTitle: "@claude, can you help?", expected: true },
{ issueTitle: "@claude: Fix this bug", expected: true },
{ issueTitle: "Bug: @claude please review", expected: true },
{ issueTitle: "email@claude.com issue", expected: false },
{ issueTitle: "claudette needs help", expected: false },
];
testCases.forEach(({ issueTitle, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
issue: {
...(baseContext.payload as IssuesEvent).issue,
title: issueTitle,
body: "No trigger in body",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
});
describe("pull request body and title trigger", () => {
it("should return true when PR body contains trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "Test PR",
body: "@claude can you review this?",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
directPrompt: "",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true when PR title contains trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "@claude Review this PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
directPrompt: "",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when PR body doesn't contain trigger phrase", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "opened",
isPR: true,
payload: {
action: "opened",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
},
} as PullRequestEvent,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
directPrompt: "",
allowedTools: "",
disallowedTools: "",
customInstructions: "",
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("comment trigger", () => {
it("should return true for issue_comment with trigger phrase", () => {
const context = mockIssueCommentContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review_comment with trigger phrase", () => {
const context = mockPullRequestReviewCommentContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review with submitted action and trigger phrase", () => {
const context = mockPullRequestReviewContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for pull_request_review with edited action and trigger phrase", () => {
const context = {
...mockPullRequestReviewContext,
eventAction: "edited",
payload: {
...mockPullRequestReviewContext.payload,
action: "edited",
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false for pull_request_review with different action", () => {
const context = {
...mockPullRequestReviewContext,
eventAction: "dismissed",
payload: {
...mockPullRequestReviewContext.payload,
action: "dismissed",
review: {
...(mockPullRequestReviewContext.payload as PullRequestReviewEvent)
.review,
body: "/claude please review this PR",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle pull_request_review with punctuation", () => {
const baseContext = {
...mockPullRequestReviewContext,
inputs: {
...mockPullRequestReviewContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ commentBody: "@claude, please review", expected: true },
{ commentBody: "@claude. fix this", expected: true },
{ commentBody: "@claude!", expected: true },
{ commentBody: "claude@example.com", expected: false },
{ commentBody: "claudette", expected: false },
];
testCases.forEach(({ commentBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
review: {
...(baseContext.payload as PullRequestReviewEvent).review,
body: commentBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
it("should handle comment trigger with punctuation", () => {
const baseContext = {
...mockIssueCommentContext,
inputs: {
...mockIssueCommentContext.inputs,
triggerPhrase: "@claude",
},
};
const testCases = [
{ commentBody: "@claude, please review", expected: true },
{ commentBody: "@claude. fix this", expected: true },
{ commentBody: "@claude!", expected: true },
{ commentBody: "claude@example.com", expected: false },
{ commentBody: "claudette", expected: false },
];
testCases.forEach(({ commentBody, expected }) => {
const context = {
...baseContext,
payload: {
...baseContext.payload,
comment: {
...(baseContext.payload as IssueCommentEvent).comment,
body: commentBody,
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(expected);
});
});
});
describe("non-matching events", () => {
it("should return false for non-matching event type", () => {
const context = createMockContext({
eventName: "push",
eventAction: "created",
payload: {} as any,
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
});
describe("escapeRegExp", () => {
it("should escape special regex characters", () => {
expect(escapeRegExp(".*+?^${}()|[]\\")).toBe(
"\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
);
});
it("should not escape regular characters", () => {
expect(escapeRegExp("abc123")).toBe("abc123");
});
it("should handle mixed characters", () => {
expect(escapeRegExp("hello.world")).toBe("hello\\.world");
expect(escapeRegExp("test[123]")).toBe("test\\[123\\]");
});
});

76
test/url-encoding.test.ts Normal file
View File

@@ -0,0 +1,76 @@
import { expect, describe, it } from "bun:test";
import { ensureProperlyEncodedUrl } from "../src/github/operations/comment-logic";
describe("ensureProperlyEncodedUrl", () => {
it("should handle URLs with spaces", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix: update message&body=Description here";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A+update+message&body=Description+here";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should handle URLs with unencoded colons", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix: update message";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A+update+message";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should handle URLs that are already properly encoded", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A%20update%20message&body=Description%20here";
expect(ensureProperlyEncodedUrl(url)).toBe(url);
});
it("should handle URLs with partially encoded content", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A update message&body=Description here";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A+update+message&body=Description+here";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should handle URLs with special characters", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=feat(scope): add new feature!&body=This is a description with #123";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=feat%28scope%29%3A+add+new+feature%21&body=This+is+a+description+with+%23123";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should not encode the base URL", () => {
const url =
"https://github.com/owner/repo/compare/main...feature/new-branch?quick_pull=1&title=fix: test";
const expected =
"https://github.com/owner/repo/compare/main...feature/new-branch?quick_pull=1&title=fix%3A+test";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should handle malformed URLs gracefully", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix: test&body=";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A+test&body=";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should handle URLs with line breaks in parameters", () => {
const url =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix: test&body=Line 1\nLine 2";
const expected =
"https://github.com/owner/repo/compare/main...branch?quick_pull=1&title=fix%3A+test&body=Line+1%0ALine+2";
expect(ensureProperlyEncodedUrl(url)).toBe(expected);
});
it("should return null for completely invalid URLs", () => {
const url = "not-a-url-at-all";
expect(ensureProperlyEncodedUrl(url)).toBe(null);
});
it("should handle URLs with severe malformation", () => {
const url = "https://[invalid:url:format]/path";
expect(ensureProperlyEncodedUrl(url)).toBe(null);
});
});