Forgejo-patched claude-code-gitea-action (View-job URL uses per-repo run index)
Some checks failed
Test Custom Executables / test-custom-executables (push) Has been cancelled
CI / test (push) Has been cancelled
CI / prettier (push) Has been cancelled
CI / typecheck (push) Has been cancelled
Sync Base Action to claude-code-base-action / Sync base-action to claude-code-base-action repository (push) Has been cancelled
Test Claude Code Action / test-inline-prompt (push) Has been cancelled
Test Claude Code Action / test-prompt-file (push) Has been cancelled
Test Claude Env Feature / test-claude-env-with-comments (push) Has been cancelled
Test MCP Servers / test-mcp-integration (push) Has been cancelled
Test MCP Servers / test-mcp-config-flag (push) Has been cancelled
Test Settings Feature / test-settings-inline-allow (push) Has been cancelled
Test Settings Feature / test-settings-inline-deny (push) Has been cancelled
Test Settings Feature / test-settings-file-allow (push) Has been cancelled
Test Settings Feature / test-settings-file-deny (push) Has been cancelled

Fork of markwylde/claude-code-gitea-action@b744372 (v1.0.21) with the
'View job' link fixed to use GITHUB_RUN_NUMBER (per-repo index) instead of
GITHUB_RUN_ID (global id), because Forgejo routes run pages by per-repo index.
See VENDOR.txt.
This commit is contained in:
claude (ops) 2026-07-01 06:42:55 +00:00
commit 32685a740f
151 changed files with 23555 additions and 0 deletions

121
test/branch-cleanup.test.ts Normal file
View file

@ -0,0 +1,121 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
import type { GitHubClient } from "../src/github/api/client";
import { GITEA_SERVER_URL } from "../src/github/api/config";
describe("checkAndDeleteEmptyBranch", () => {
let consoleLogSpy: any;
let consoleErrorSpy: any;
const originalEnv = { ...process.env };
beforeEach(() => {
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
delete process.env.GITEA_API_URL; // ensure GitHub mode for predictable behaviour
});
afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
process.env = { ...originalEnv };
});
const createMockClient = (
options: { branchSha?: string; baseSha?: string; error?: Error } = {},
): GitHubClient => {
const { branchSha = "branch-sha", baseSha = "base-sha", error } = options;
return {
api: {
getBranch: async (_owner: string, _repo: string, branch: string) => {
if (error) {
throw error;
}
return {
data: {
commit: {
sha: branch.includes("claude/") ? branchSha : baseSha,
},
},
};
},
},
} as unknown as GitHubClient;
};
test("returns defaults when no claude branch provided", async () => {
const client = createMockClient();
const result = await checkAndDeleteEmptyBranch(
client,
"owner",
"repo",
undefined,
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe("");
expect(consoleLogSpy).not.toHaveBeenCalled();
});
test("marks branch for deletion when SHAs match", async () => {
const client = createMockClient({ branchSha: "same", baseSha: "same" });
const result = await checkAndDeleteEmptyBranch(
client,
"owner",
"repo",
"claude/issue-123",
"main",
);
expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe("");
expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123 has same SHA as base, marking for deletion",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Skipping branch deletion - not reliably supported across all Git platforms: claude/issue-123",
);
});
test("returns branch link when branch has commits", async () => {
const client = createMockClient({ branchSha: "feature", baseSha: "main" });
const result = await checkAndDeleteEmptyBranch(
client,
"owner",
"repo",
"claude/issue-123",
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe(
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123 appears to have commits (different SHA from base)",
);
});
test("falls back to branch link when API call fails", async () => {
const client = createMockClient({ error: Object.assign(new Error("boom"), { status: 500 }) });
const result = await checkAndDeleteEmptyBranch(
client,
"owner",
"repo",
"claude/issue-123",
"main",
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe(
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking branch:",
expect.any(Error),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Assuming branch exists due to non-404 error",
);
});
});

435
test/comment-logic.test.ts Normal file
View file

@ -0,0 +1,435 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { updateCommentBody } from "../src/github/operations/comment-logic";
describe("updateCommentBody", () => {
const GITEA_SERVER_URL = "https://gitea.example.com";
const JOB_URL = `${GITEA_SERVER_URL}/owner/repo/actions/runs/123`;
const BRANCH_BASE_URL = `${GITEA_SERVER_URL}/owner/repo/src/branch`;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
process.env.GITEA_SERVER_URL = GITEA_SERVER_URL;
process.env.GITEA_API_URL = `${GITEA_SERVER_URL}/api/v1`;
});
afterEach(() => {
process.env = originalEnv;
});
const baseInput = {
currentBody: "Initial comment body",
actionFailed: false,
executionDetails: null,
jobUrl: JOB_URL,
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("includes error details when provided", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working...",
actionFailed: true,
executionDetails: { duration_ms: 45000 },
errorDetails: "Failed to fetch issue data",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
expect(result).toContain("[View job]");
expect(result).toContain("```\nFailed to fetch issue data\n```");
// Ensure error details come after the header/links
const errorIndex = result.indexOf("```");
const headerIndex = result.indexOf("**Claude encountered an error");
expect(errorIndex).toBeGreaterThan(headerIndex);
});
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\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
);
});
it("extracts branch name from branchLink if branchName not provided", () => {
const input = {
...baseInput,
branchLink: `\n[View branch](${BRANCH_BASE_URL}/branch-name)`,
};
const result = updateCommentBody(input);
expect(result).toContain(
`• [\`branch-name\`](${BRANCH_BASE_URL}/branch-name)`,
);
});
it("removes old branch links from body", () => {
const input = {
...baseInput,
currentBody:
`Some comment with [View branch](${BRANCH_BASE_URL}/branch-name)` ,
branchName: "new-branch-name",
};
const result = updateCommentBody(input);
expect(result).toContain(
`• [\`new-branch-name\`](${BRANCH_BASE_URL}/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://gitea.example.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url)",
);
});
it("moves PR link from body to header", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [Create a PR](https://gitea.example.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://gitea.example.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://gitea.example.com/owner/repo/pr-url-from-body)",
prLink:
"\n[Create a PR](https://gitea.example.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://gitea.example.com/owner/repo/pr-url-from-body)",
);
});
it("handles complex PR URLs with encoded characters", () => {
const complexUrl =
"https://gitea.example.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://gitea.example.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://gitea.example.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://gitea.example.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://gitea.example.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://gitea.example.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\`](${BRANCH_BASE_URL}/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://gitea.example.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://gitea.example.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://gitea.example.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://gitea.example.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](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
prLink:
"\n[Create a PR](https://gitea.example.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\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
);
expect(result).toContain(
"• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
);
});
});
});

589
test/create-prompt.test.ts Normal file
View file

@ -0,0 +1,589 @@
#!/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";
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,
baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
issueNumber: "67890",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
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, false);
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",
baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
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("mcp__gitea__update_issue_comment");
expect(prompt).toContain("mcp__gitea__list_branches");
});
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",
baseBranch: "develop",
claudeBranch: "claude/issue-999-20240101_120000",
assigneeTrigger: "claude-bot",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain(
"<trigger_context>issue assigned to 'claude-bot'</trigger_context>",
);
expect(prompt).toContain("mcp__gitea__list_branches");
expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
});
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",
baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
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, false);
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",
baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
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",
baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
expect(prompt).toContain(
"<trigger_display_name>johndoe</trigger_display_name>",
);
});
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, false);
// Should contain PR-specific instructions
expect(prompt).toContain(
"Commit changes using mcp__local_git_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",
baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain Issue-specific instructions
expect(prompt).toContain("mcp__gitea__update_issue_comment");
expect(prompt).toContain("mcp__gitea__list_branches");
expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
// Should NOT contain PR-specific instructions
expect(prompt).not.toContain(
"Commit changes using mcp__local_git_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",
baseBranch: "main",
claudeBranch: "claude/issue-123-20240101_120000",
commentBody: "@claude please fix this",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should surface the issue number and comment metadata
expect(prompt).toContain("<issue_number>123</issue_number>");
expect(prompt).toContain("<claude_comment_id>12345</claude_comment_id>");
});
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 baseBranch for open PRs
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain open PR instructions
expect(prompt).toContain(
"Commit changes using mcp__local_git_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",
);
});
});
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",
baseBranch: "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 include base tools", () => {
const result = buildAllowedToolsString();
expect(result).toContain("Edit");
expect(result).toContain("Glob");
expect(result).toContain("mcp__gitea__update_issue_comment");
expect(result).toContain("mcp__gitea__update_pull_request_comment");
});
test("should include commit signing tools when enabled", () => {
const result = buildAllowedToolsString(undefined, false, true);
expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files");
});
test("should include actions tools when actions read permission granted", () => {
const result = buildAllowedToolsString([], true, false);
expect(result).toContain("mcp__github_actions__get_ci_status");
expect(result).toContain("mcp__github_actions__download_job_log");
});
test("should append custom tools when provided", () => {
const customTools = "Tool1,Tool2,Tool3";
const result = buildAllowedToolsString(customTools);
expect(result).toContain("Tool1");
expect(result).toContain("Tool2");
expect(result).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");
});
test("should remove hardcoded disallowed tools if they are in allowed tools", () => {
const customDisallowedTools = "BadTool1,BadTool2";
const allowedTools = "WebSearch,SomeOtherTool";
const result = buildDisallowedToolsString(
customDisallowedTools,
allowedTools,
);
// WebSearch should be removed from disallowed since it's in allowed
expect(result).not.toContain("WebSearch");
// WebFetch should still be disallowed since it's not in allowed
expect(result).toContain("WebFetch");
// Custom disallowed tools should still be present
expect(result).toContain("BadTool1");
expect(result).toContain("BadTool2");
});
test("should remove all hardcoded disallowed tools if they are all in allowed tools", () => {
const allowedTools = "WebSearch,WebFetch,SomeOtherTool";
const result = buildDisallowedToolsString(undefined, allowedTools);
// Both hardcoded disallowed tools should be removed
expect(result).not.toContain("WebSearch");
expect(result).not.toContain("WebFetch");
// Result should be empty since no custom disallowed tools provided
expect(result).toBe("");
});
test("should handle custom disallowed tools when all hardcoded tools are overridden", () => {
const customDisallowedTools = "BadTool1,BadTool2";
const allowedTools = "WebSearch,WebFetch";
const result = buildDisallowedToolsString(
customDisallowedTools,
allowedTools,
);
// Hardcoded tools should be removed
expect(result).not.toContain("WebSearch");
expect(result).not.toContain("WebFetch");
// Only custom disallowed tools should remain
expect(result).toBe("BadTool1,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: ![](/tmp/github-images/image-1234-0.png)
And another one: ![](/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("![](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: ![](/tmp/github-images/image-1234-0.png)
Second: ![](/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: ![](/tmp/github-images/image-1234-0.png)\n\n[user2 at 2023-01-02T00:00:00Z]: Here's another image: ![](/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: ![](/tmp/github-images/image-1234-0.png) and ![](/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: ![](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\nThis is a great PR! LGTM.\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\nLooks good to me!`,
);
});
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\nNeeds changes\n\n[Review by reviewer2 at 2023-01-02T00:00:00Z]: APPROVED\nLGTM`,
);
});
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\nReview with image: ![](/tmp/github-images/image-1234-0.png)\n [Comment on src/index.ts:42]: Comment with image: ![](/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\nGood work\n [Comment on src/main.ts:15]: Two issues: ![](/tmp/github-images/image-1234-0.png) and ![](/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\nReview body\n [Comment on src/index.ts:42]: Image: ![](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,95 @@
## Claude Code Report
## 🚀 System Initialization
**Available Tools:** 8 tools loaded
---
I'll help you with this task. Let me start by examining the file to understand what needs to be changed.
### 🔧 `Read`
**Parameters:**
```json
{
"file_path": "/path/to/sample/file.py"
}
```
**Result:**
```python
def example_function():
print("Debug message") # This should be removed
return "Hello World"
if __name__ == "__main__":
result = example_function()
print(result)
```
*Token usage: 100 input, 75 output*
---
I can see the debug print statement that needs to be removed. Let me fix this by editing the file.
### 🔧 `Edit`
**Parameters:**
```json
{
"file_path": "/path/to/sample/file.py",
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
"new_string": "def example_function():\n return \"Hello World\""
}
```
**→** File successfully edited. The debug print statement has been removed.
*Token usage: 200 input, 50 output*
---
Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change.
### 🔧 `mcp__github__add_pull_request_review_comment`
**Parameters:**
```json
{
"owner": "example-org",
"repo": "example-repo",
"pull_number": 123,
"body": "Removed debug print statement as requested.",
"commit_id": "abc123def456",
"path": "sample/file.py",
"line": 2
}
```
**→** Successfully posted review comment to PR #123
*Token usage: 150 input, 80 output*
---
Great! I've successfully completed the requested task:
1. ✅ Located the debug print statement in the file
2. ✅ Removed the print statement while preserving the function logic
3. ✅ Added a review comment documenting the change
The debug print statement has been removed as requested by the reviewers.
*Token usage: 180 input, 60 output*
---
## ✅ Final Result
Successfully removed debug print statement from file and added review comment to document the change.
**Cost:** $0.0347 | **Duration:** 18.8s

196
test/fixtures/sample-turns.json vendored Normal file
View file

@ -0,0 +1,196 @@
[
{
"type": "system",
"subtype": "init",
"session_id": "sample-session-id",
"tools": [
"Task",
"Bash",
"Read",
"Edit",
"Write",
"mcp__github__get_file_contents",
"mcp__github__create_or_update_file",
"mcp__github__add_pull_request_review_comment"
],
"mcp_servers": [
{
"name": "github",
"status": "connected"
}
]
},
{
"type": "assistant",
"message": {
"id": "msg_sample123",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "I'll help you with this task. Let me start by examining the file to understand what needs to be changed."
},
{
"type": "tool_use",
"id": "tool_call_1",
"name": "Read",
"input": {
"file_path": "/path/to/sample/file.py"
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 50,
"output_tokens": 75
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_1",
"content": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"\n\nif __name__ == \"__main__\":\n result = example_function()\n print(result)",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample124",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "I can see the debug print statement that needs to be removed. Let me fix this by editing the file."
},
{
"type": "tool_use",
"id": "tool_call_2",
"name": "Edit",
"input": {
"file_path": "/path/to/sample/file.py",
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
"new_string": "def example_function():\n return \"Hello World\""
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 200,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 100,
"output_tokens": 50
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_2",
"content": "File successfully edited. The debug print statement has been removed.",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample125",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change."
},
{
"type": "tool_use",
"id": "tool_call_3",
"name": "mcp__github__add_pull_request_review_comment",
"input": {
"owner": "example-org",
"repo": "example-repo",
"pull_number": 123,
"body": "Removed debug print statement as requested.",
"commit_id": "abc123def456",
"path": "sample/file.py",
"line": 2
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 150,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 75,
"output_tokens": 80
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_3",
"content": "Successfully posted review comment to PR #123",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample126",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "Great! I've successfully completed the requested task:\n\n1. ✅ Located the debug print statement in the file\n2. ✅ Removed the print statement while preserving the function logic\n3. ✅ Added a review comment documenting the change\n\nThe debug print statement has been removed as requested by the reviewers."
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 180,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 90,
"output_tokens": 60
}
},
"session_id": "sample-session-id"
},
{
"type": "result",
"cost_usd": 0.0347,
"duration_ms": 18750,
"result": "Successfully removed debug print statement from file and added review comment to document the change."
}
]

439
test/format-turns.test.ts Normal file
View file

@ -0,0 +1,439 @@
import { expect, test, describe } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
import {
formatTurnsFromData,
groupTurnsNaturally,
formatGroupedContent,
detectContentType,
formatResultContent,
formatToolWithResult,
type Turn,
type ToolUse,
type ToolResult,
} from "../src/entrypoints/format-turns";
describe("detectContentType", () => {
test("detects JSON objects", () => {
expect(detectContentType('{"key": "value"}')).toBe("json");
expect(detectContentType('{"number": 42}')).toBe("json");
});
test("detects JSON arrays", () => {
expect(detectContentType("[1, 2, 3]")).toBe("json");
expect(detectContentType('["a", "b"]')).toBe("json");
});
test("detects Python code", () => {
expect(detectContentType("def hello():\n pass")).toBe("python");
expect(detectContentType("import os")).toBe("python");
expect(detectContentType("from math import pi")).toBe("python");
});
test("detects JavaScript code", () => {
expect(detectContentType("function test() {}")).toBe("javascript");
expect(detectContentType("const x = 5")).toBe("javascript");
expect(detectContentType("let y = 10")).toBe("javascript");
expect(detectContentType("const fn = () => console.log()")).toBe(
"javascript",
);
});
test("detects bash/shell content", () => {
expect(detectContentType("/usr/bin/test")).toBe("bash");
expect(detectContentType("Error: command not found")).toBe("bash");
expect(detectContentType("ls -la")).toBe("bash");
expect(detectContentType("$ echo hello")).toBe("bash");
});
test("detects diff format", () => {
expect(detectContentType("@@ -1,3 +1,3 @@")).toBe("diff");
expect(detectContentType("+++ file.txt")).toBe("diff");
expect(detectContentType("--- file.txt")).toBe("diff");
});
test("detects HTML/XML", () => {
expect(detectContentType("<div>hello</div>")).toBe("html");
expect(detectContentType("<xml>content</xml>")).toBe("html");
});
test("detects markdown", () => {
expect(detectContentType("- List item")).toBe("markdown");
expect(detectContentType("* List item")).toBe("markdown");
expect(detectContentType("```code```")).toBe("markdown");
});
test("defaults to text", () => {
expect(detectContentType("plain text")).toBe("text");
expect(detectContentType("just some words")).toBe("text");
});
});
describe("formatResultContent", () => {
test("handles empty content", () => {
expect(formatResultContent("")).toBe("*(No output)*\n\n");
expect(formatResultContent(null)).toBe("*(No output)*\n\n");
expect(formatResultContent(undefined)).toBe("*(No output)*\n\n");
});
test("formats short text without code blocks", () => {
const result = formatResultContent("success");
expect(result).toBe("**→** success\n\n");
});
test("formats long text with code blocks", () => {
const longText =
"This is a longer piece of text that should be formatted in a code block because it exceeds the short text threshold";
const result = formatResultContent(longText);
expect(result).toContain("**Result:**");
expect(result).toContain("```text");
expect(result).toContain(longText);
});
test("pretty prints JSON content", () => {
const jsonContent = '{"key": "value", "number": 42}';
const result = formatResultContent(jsonContent);
expect(result).toContain("```json");
expect(result).toContain('"key": "value"');
expect(result).toContain('"number": 42');
});
test("truncates very long content", () => {
const veryLongContent = "A".repeat(4000);
const result = formatResultContent(veryLongContent);
expect(result).toContain("...");
// Should not contain the full long content
expect(result.length).toBeLessThan(veryLongContent.length);
});
test("handles type:text structure", () => {
const structuredContent = [{ type: "text", text: "Hello world" }];
const result = formatResultContent(JSON.stringify(structuredContent));
expect(result).toBe("**→** Hello world\n\n");
});
});
describe("formatToolWithResult", () => {
test("formats tool with parameters and result", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "read_file",
input: { file_path: "/path/to/file.txt" },
id: "tool_123",
};
const toolResult: ToolResult = {
type: "tool_result",
tool_use_id: "tool_123",
content: "File content here",
is_error: false,
};
const result = formatToolWithResult(toolUse, toolResult);
expect(result).toContain("### 🔧 `read_file`");
expect(result).toContain("**Parameters:**");
expect(result).toContain('"file_path": "/path/to/file.txt"');
expect(result).toContain("**→** File content here");
});
test("formats tool with error result", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "failing_tool",
input: { param: "value" },
};
const toolResult: ToolResult = {
type: "tool_result",
content: "Permission denied",
is_error: true,
};
const result = formatToolWithResult(toolUse, toolResult);
expect(result).toContain("### 🔧 `failing_tool`");
expect(result).toContain("❌ **Error:** `Permission denied`");
});
test("formats tool without parameters", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "simple_tool",
};
const result = formatToolWithResult(toolUse);
expect(result).toContain("### 🔧 `simple_tool`");
expect(result).not.toContain("**Parameters:**");
});
test("handles unknown tool name", () => {
const toolUse: ToolUse = {
type: "tool_use",
};
const result = formatToolWithResult(toolUse);
expect(result).toContain("### 🔧 `unknown_tool`");
});
});
describe("groupTurnsNaturally", () => {
test("groups system initialization", () => {
const data: Turn[] = [
{
type: "system",
subtype: "init",
tools: [{ name: "tool1" }, { name: "tool2" }],
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("system_init");
expect(result[0]?.tools_count).toBe(2);
});
test("groups assistant actions with tool calls", () => {
const data: Turn[] = [
{
type: "assistant",
message: {
content: [
{ type: "text", text: "I'll help you" },
{
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { file_path: "/test.txt" },
},
],
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_123",
content: "file content",
is_error: false,
},
],
},
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("assistant_action");
expect(result[0]?.text_parts).toEqual(["I'll help you"]);
expect(result[0]?.tool_calls).toHaveLength(1);
expect(result[0]?.tool_calls?.[0]?.tool_use.name).toBe("read_file");
expect(result[0]?.tool_calls?.[0]?.tool_result?.content).toBe(
"file content",
);
expect(result[0]?.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
});
test("groups user messages", () => {
const data: Turn[] = [
{
type: "user",
message: {
content: [{ type: "text", text: "Please help me" }],
},
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("user_message");
expect(result[0]?.text_parts).toEqual(["Please help me"]);
});
test("groups final results", () => {
const data: Turn[] = [
{
type: "result",
cost_usd: 0.1234,
duration_ms: 5000,
result: "Task completed",
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("final_result");
expect(result[0]?.data).toEqual(data[0]!);
});
});
describe("formatGroupedContent", () => {
test("formats system initialization", () => {
const groupedContent = [
{
type: "system_init",
tools_count: 3,
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## Claude Code Report");
expect(result).toContain("## 🚀 System Initialization");
expect(result).toContain("**Available Tools:** 3 tools loaded");
});
test("formats assistant actions", () => {
const groupedContent = [
{
type: "assistant_action",
text_parts: ["I'll help you with that"],
tool_calls: [
{
tool_use: {
type: "tool_use",
name: "test_tool",
input: { param: "value" },
},
tool_result: {
type: "tool_result",
content: "result",
is_error: false,
},
},
],
usage: { input_tokens: 100, output_tokens: 50 },
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("I'll help you with that");
expect(result).toContain("### 🔧 `test_tool`");
expect(result).toContain("*Token usage: 100 input, 50 output*");
});
test("formats user messages", () => {
const groupedContent = [
{
type: "user_message",
text_parts: ["Help me please"],
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## 👤 User");
expect(result).toContain("Help me please");
});
test("formats final results", () => {
const groupedContent = [
{
type: "final_result",
data: {
type: "result",
cost_usd: 0.1234,
duration_ms: 5678,
result: "Success!",
} as Turn,
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## ✅ Final Result");
expect(result).toContain("Success!");
expect(result).toContain("**Cost:** $0.1234");
expect(result).toContain("**Duration:** 5.7s");
});
});
describe("formatTurnsFromData", () => {
test("handles empty data", () => {
const result = formatTurnsFromData([]);
expect(result).toBe("## Claude Code Report\n\n");
});
test("formats complete conversation", () => {
const data: Turn[] = [
{
type: "system",
subtype: "init",
tools: [{ name: "tool1" }],
},
{
type: "assistant",
message: {
content: [
{ type: "text", text: "I'll help you" },
{
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { file_path: "/test.txt" },
},
],
},
},
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_123",
content: "file content",
is_error: false,
},
],
},
},
{
type: "result",
cost_usd: 0.05,
duration_ms: 2000,
result: "Done",
},
];
const result = formatTurnsFromData(data);
expect(result).toContain("## Claude Code Report");
expect(result).toContain("## 🚀 System Initialization");
expect(result).toContain("I'll help you");
expect(result).toContain("### 🔧 `read_file`");
expect(result).toContain("## ✅ Final Result");
expect(result).toContain("Done");
});
});
describe("integration tests", () => {
test("formats real conversation data correctly", () => {
// Load the sample JSON data
const jsonPath = join(__dirname, "fixtures", "sample-turns.json");
const expectedPath = join(
__dirname,
"fixtures",
"sample-turns-expected-output.md",
);
const jsonData = JSON.parse(readFileSync(jsonPath, "utf-8"));
const expectedOutput = readFileSync(expectedPath, "utf-8").trim();
// Format the data using our function
const actualOutput = formatTurnsFromData(jsonData).trim();
// Compare the outputs
expect(actualOutput).toBe(expectedOutput);
});
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
describe("GITEA_SERVER_URL configuration", () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset environment variables
process.env = { ...originalEnv };
delete process.env.GITEA_SERVER_URL;
delete process.env.GITHUB_SERVER_URL;
// Clear module cache to force re-evaluation
delete require.cache[require.resolve("../src/github/api/config")];
});
afterEach(() => {
process.env = originalEnv;
});
it("should prioritize GITEA_SERVER_URL over GITHUB_SERVER_URL", async () => {
process.env.GITEA_SERVER_URL = "https://gitea.example.com";
process.env.GITHUB_SERVER_URL = "http://gitea:3000";
const { GITEA_SERVER_URL } = await import("../src/github/api/config");
expect(GITEA_SERVER_URL).toBe("https://gitea.example.com");
});
it("should fall back to GITHUB_SERVER_URL when GITEA_SERVER_URL is not set", async () => {
process.env.GITHUB_SERVER_URL = "http://gitea:3000";
const { GITEA_SERVER_URL } = await import("../src/github/api/config");
expect(GITEA_SERVER_URL).toBe("http://gitea:3000");
});
it("should use default when neither GITEA_SERVER_URL nor GITHUB_SERVER_URL is set", async () => {
const { GITEA_SERVER_URL } = await import("../src/github/api/config");
expect(GITEA_SERVER_URL).toBe("https://github.com");
});
it("should ignore empty GITEA_SERVER_URL and use GITHUB_SERVER_URL", async () => {
process.env.GITEA_SERVER_URL = "";
process.env.GITHUB_SERVER_URL = "http://gitea:3000";
const { GITEA_SERVER_URL } = await import("../src/github/api/config");
expect(GITEA_SERVER_URL).toBe("http://gitea:3000");
});
it("should derive correct API URL from custom GITEA_SERVER_URL", async () => {
process.env.GITEA_SERVER_URL = "https://gitea.example.com";
const { GITEA_API_URL } = await import("../src/github/api/config");
expect(GITEA_API_URL).toBe("https://gitea.example.com/api/v1");
});
it("should handle GitHub.com URLs correctly", async () => {
process.env.GITEA_SERVER_URL = "https://github.com";
const { GITEA_API_URL } = await import("../src/github/api/config");
expect(GITEA_API_URL).toBe("https://api.github.com");
});
it("should create correct job run links with custom GITEA_SERVER_URL", async () => {
process.env.GITEA_SERVER_URL = "https://gitea.example.com";
// Clear module cache and re-import
delete require.cache[require.resolve("../src/github/operations/comments/common")];
const { createJobRunLink } = await import("../src/github/operations/comments/common");
const link = createJobRunLink("owner", "repo", "123");
expect(link).toBe("[View job run](https://gitea.example.com/owner/repo/actions/runs/123)");
});
it("should create correct branch links with custom GITEA_SERVER_URL", async () => {
process.env.GITEA_SERVER_URL = "https://gitea.example.com";
// Clear module cache and re-import
delete require.cache[require.resolve("../src/github/operations/comments/common")];
const { createBranchLink } = await import("../src/github/operations/comments/common");
const link = createBranchLink("owner", "repo", "feature-branch");
expect(link).toBe("\n[View branch](https://gitea.example.com/owner/repo/src/branch/feature-branch/)");
});
});

115
test/github/context.test.ts Normal file
View file

@ -0,0 +1,115 @@
import { describe, it, expect } from "bun:test";
import {
parseMultilineInput,
parseAdditionalPermissions,
} from "../../src/github/context";
describe("parseMultilineInput", () => {
it("should parse a comma-separated string", () => {
const input = `Bash(bun install),Bash(bun test:*),Bash(bun typecheck)`;
const result = parseMultilineInput(input);
expect(result).toEqual([
"Bash(bun install)",
"Bash(bun test:*)",
"Bash(bun typecheck)",
]);
});
it("should parse multiline string", () => {
const input = `Bash(bun install)
Bash(bun test:*)
Bash(bun typecheck)`;
const result = parseMultilineInput(input);
expect(result).toEqual([
"Bash(bun install)",
"Bash(bun test:*)",
"Bash(bun typecheck)",
]);
});
it("should parse comma-separated multiline line", () => {
const input = `Bash(bun install),Bash(bun test:*)
Bash(bun typecheck)`;
const result = parseMultilineInput(input);
expect(result).toEqual([
"Bash(bun install)",
"Bash(bun test:*)",
"Bash(bun typecheck)",
]);
});
it("should ignore comments", () => {
const input = `Bash(bun install),
Bash(bun test:*) # For testing
# For type checking
Bash(bun typecheck)
`;
const result = parseMultilineInput(input);
expect(result).toEqual([
"Bash(bun install)",
"Bash(bun test:*)",
"Bash(bun typecheck)",
]);
});
it("should parse an empty string", () => {
const input = "";
const result = parseMultilineInput(input);
expect(result).toEqual([]);
});
});
describe("parseAdditionalPermissions", () => {
it("should parse single permission", () => {
const input = "actions: read";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
it("should parse multiple permissions", () => {
const input = `actions: read
packages: write
contents: read`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.get("contents")).toBe("read");
expect(result.size).toBe(3);
});
it("should handle empty string", () => {
const input = "";
const result = parseAdditionalPermissions(input);
expect(result.size).toBe(0);
});
it("should handle whitespace and empty lines", () => {
const input = `
actions: read
packages: write
`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should ignore lines without colon separator", () => {
const input = `actions: read
invalid line
packages: write`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should trim whitespace around keys and values", () => {
const input = " actions : read ";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
});

View file

@ -0,0 +1,48 @@
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test";
import {
downloadCommentImages,
type CommentWithImages,
} from "../src/github/utils/image-downloader";
const noopClient = { api: {} } as any;
describe("downloadCommentImages", () => {
let consoleLogSpy: any;
beforeEach(() => {
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
test("returns empty map and logs disabled message", async () => {
const result = await downloadCommentImages(
noopClient,
"owner",
"repo",
[] as CommentWithImages[],
);
expect(result.size).toBe(0);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Image downloading temporarily disabled during Octokit migration",
);
});
test("ignores provided comments while feature disabled", async () => {
const comments: CommentWithImages[] = [
{
type: "issue_comment",
id: "123",
body: "![img](https://example.com/image.png)",
},
];
const result = await downloadCommentImages(noopClient, "owner", "repo", comments);
expect(result.size).toBe(0);
expect(consoleLogSpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
const originalEnv = { ...process.env };
describe("prepareMcpConfig", () => {
beforeEach(() => {
process.env.GITHUB_ACTION_PATH = "/action/path";
process.env.GITHUB_WORKSPACE = "/workspace";
process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
});
afterEach(() => {
process.env = { ...originalEnv };
});
test("returns base gitea and local git MCP servers", async () => {
const result = await prepareMcpConfig({
githubToken: "token",
owner: "owner",
repo: "repo",
branch: "branch",
});
const parsed = JSON.parse(result);
expect(Object.keys(parsed.mcpServers)).toEqual(["gitea", "local_git_ops"]);
expect(parsed.mcpServers.gitea).toEqual({
command: "bun",
args: ["run", "/action/path/src/mcp/gitea-mcp-server.ts"],
env: {
GITHUB_TOKEN: "token",
REPO_OWNER: "owner",
REPO_NAME: "repo",
BRANCH_NAME: "branch",
REPO_DIR: "/workspace",
GITEA_API_URL: "https://gitea.example.com/api/v1",
},
});
expect(parsed.mcpServers.local_git_ops.args[1]).toBe(
"/action/path/src/mcp/local-git-ops-server.ts",
);
});
test("falls back to process.cwd when workspace not provided", async () => {
delete process.env.GITHUB_WORKSPACE;
const result = await prepareMcpConfig({
githubToken: "token",
owner: "owner",
repo: "repo",
branch: "branch",
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.gitea.env.REPO_DIR).toBe(process.cwd());
});
});

View file

@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import { formatBody, formatComments } from "../src/github/data/formatter";
import type { GitHubComment } from "../src/github/types";
describe("Sanitization Integration", () => {
it("should sanitize complete issue/PR body with various hidden content patterns", () => {
const issueBody = `
# Feature Request: Add user dashboard
## Description
We need a new dashboard for users to track their activity.
<!-- HTML comment that should be removed -->
## Technical Details
The dashboard should display:
- User statistics ![dashboard mockup with hiddentext](dashboard.png)
- Activity graphs <img alt="example graph description" src="graph.jpg">
- Recent actions
## Implementation Notes
See [documentation](https://docs.example.com "internal docs title") for API details.
<div data-instruction="example instruction" aria-label="dashboard label" title="hover text">
The implementation should follow our standard patterns.
</div>
Additional notes: Text­with­soft­hyphens and &#72;&#105;&#100;&#100;&#101;&#110; encoded content.
<input placeholder="search placeholder" type="text" />
Direction override test: reversed text should be normalized.`;
const imageUrlMap = new Map<string, string>();
const result = formatBody(issueBody, imageUrlMap);
// Verify hidden content is removed
expect(result).not.toContain("<!-- HTML comment");
expect(result).not.toContain("hiddentext");
expect(result).not.toContain("example graph description");
expect(result).not.toContain("internal docs title");
expect(result).not.toContain("example instruction");
expect(result).not.toContain("dashboard label");
expect(result).not.toContain("hover text");
expect(result).not.toContain("search placeholder");
expect(result).not.toContain("\u200B");
expect(result).not.toContain("\u200C");
expect(result).not.toContain("\u200D");
expect(result).not.toContain("\u00AD");
expect(result).not.toContain("\u202E");
expect(result).not.toContain("&#72;");
// Verify legitimate content is preserved
expect(result).toContain("# Feature Request: Add user dashboard");
expect(result).toContain("## Description");
expect(result).toContain("We need a new dashboard");
expect(result).toContain("User statistics");
expect(result).toContain("![](dashboard.png)");
expect(result).toContain('<img src="graph.jpg">');
expect(result).toContain("[documentation](https://docs.example.com)");
expect(result).toContain(
"The implementation should follow our standard patterns",
);
expect(result).toContain("Hidden encoded content");
expect(result).toContain('<input type="text" />');
});
it("should sanitize GitHub comments preserving discussion flow", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: `Great idea! Here are my thoughts:
1. We should consider the performance impact
2. The UI mockup looks good: ![ui design](mockup.png)
3. Check the [API docs](https://api.example.com "api reference") for rate limits
<div aria-label="comment metadata" data-comment-type="review">
This change would affect multiple systems.
</div>
Note: Implementationshouldfollowbestpractices.`,
author: { login: "reviewer1" },
createdAt: "2023-01-01T10:00:00Z",
},
{
id: "2",
databaseId: "100002",
body: `Thanks for the feedback!
<!-- Internal note: discussed with team -->
I've updated the proposal based on your suggestions.
&#84;&#101;&#115;&#116; &#110;&#111;&#116;&#101;: All systems checked.
<span title="status update" data-status="approved">Ready for implementation</span>`,
author: { login: "author1" },
createdAt: "2023-01-01T12:00:00Z",
},
];
const result = formatComments(comments);
// Verify hidden content is removed
expect(result).not.toContain("<!-- Internal note");
expect(result).not.toContain("api reference");
expect(result).not.toContain("comment metadata");
expect(result).not.toContain('data-comment-type="review"');
expect(result).not.toContain("status update");
expect(result).not.toContain('data-status="approved"');
expect(result).not.toContain("\u200B");
expect(result).not.toContain("&#84;");
// Verify discussion flow is preserved
expect(result).toContain("Great idea! Here are my thoughts:");
expect(result).toContain("1. We should consider the performance impact");
expect(result).toContain("2. The UI mockup looks good: ![](mockup.png)");
expect(result).toContain(
"3. Check the [API docs](https://api.example.com)",
);
expect(result).toContain("This change would affect multiple systems.");
expect(result).toContain("Implementationshouldfollowbestpractices");
expect(result).toContain("Thanks for the feedback!");
expect(result).toContain(
"I've updated the proposal based on your suggestions.",
);
expect(result).toContain("Test note: All systems checked.");
expect(result).toContain("Ready for implementation");
expect(result).toContain("[reviewer1 at");
expect(result).toContain("[author1 at");
});
});

398
test/mockContext.ts Normal file
View file

@ -0,0 +1,398 @@
import type { ParsedGitHubContext } from "../src/github/context";
import type {
IssuesEvent,
IssueCommentEvent,
PullRequestEvent,
PullRequestReviewEvent,
PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types";
const defaultInputs = {
mode: "tag" as const,
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
anthropicModel: "claude-3-7-sonnet-20250219",
allowedTools: [] as string[],
disallowedTools: [] as string[],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
useBedrock: false,
useVertex: false,
timeoutMinutes: 30,
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map<string, string>(),
useCommitSigning: false,
};
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",
assignee: {
login: "claude-bot",
id: 11111,
avatar_url: "https://avatars.githubusercontent.com/u/11111",
html_url: "https://github.com/claude-bot",
},
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" },
};
export const mockIssueLabeledContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issues",
eventAction: "labeled",
repository: defaultRepository,
actor: "admin-user",
payload: {
action: "labeled",
issue: {
number: 1234,
title: "Enhancement: Improve search functionality",
body: "The current search is too slow and needs optimization",
user: {
login: "alice-wonder",
id: 54321,
avatar_url: "https://avatars.githubusercontent.com/u/54321",
html_url: "https://github.com/alice-wonder",
},
assignee: null,
},
label: {
id: 987654321,
name: "claude-task",
color: "f29513",
description: "Label for Claude AI interactions",
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssuesEvent,
entityNumber: 1234,
isPR: false,
inputs: { ...defaultInputs, labelTrigger: "claude-task" },
};
// 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,
};

82
test/modes/agent.test.ts Normal file
View file

@ -0,0 +1,82 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { agentMode } from "../../src/modes/agent";
import type { ParsedGitHubContext } from "../../src/github/context";
import { createMockContext } from "../mockContext";
describe("Agent Mode", () => {
let mockContext: ParsedGitHubContext;
beforeEach(() => {
mockContext = createMockContext({
eventName: "workflow_dispatch",
isPR: false,
});
});
test("agent mode has correct properties and behavior", () => {
// Basic properties
expect(agentMode.name).toBe("agent");
expect(agentMode.description).toBe(
"Automation mode that always runs without trigger checking",
);
expect(agentMode.shouldCreateTrackingComment()).toBe(false);
// Tool methods return empty arrays
expect(agentMode.getAllowedTools()).toEqual([]);
expect(agentMode.getDisallowedTools()).toEqual([]);
// Always triggers regardless of context
const contextWithoutTrigger = createMockContext({
eventName: "workflow_dispatch",
isPR: false,
inputs: {
...createMockContext().inputs,
triggerPhrase: "@claude",
},
payload: {} as any,
});
expect(agentMode.shouldTrigger(contextWithoutTrigger)).toBe(true);
});
test("prepareContext includes all required data", () => {
const data = {
commentId: 789,
baseBranch: "develop",
claudeBranch: "claude/automated-task",
};
const context = agentMode.prepareContext(mockContext, data);
expect(context.mode).toBe("agent");
expect(context.githubContext).toBe(mockContext);
expect(context.commentId).toBe(789);
expect(context.baseBranch).toBe("develop");
expect(context.claudeBranch).toBe("claude/automated-task");
});
test("prepareContext works without data", () => {
const context = agentMode.prepareContext(mockContext);
expect(context.mode).toBe("agent");
expect(context.githubContext).toBe(mockContext);
expect(context.commentId).toBeUndefined();
expect(context.baseBranch).toBeUndefined();
expect(context.claudeBranch).toBeUndefined();
});
test("agent mode triggers for all event types", () => {
const events = [
"push",
"schedule",
"workflow_dispatch",
"repository_dispatch",
"issue_comment",
"pull_request",
];
events.forEach((eventName) => {
const context = createMockContext({ eventName, isPR: false });
expect(agentMode.shouldTrigger(context)).toBe(true);
});
});
});

View file

@ -0,0 +1,36 @@
import { describe, test, expect } from "bun:test";
import { getMode, isValidMode } from "../../src/modes/registry";
import type { ModeName } from "../../src/modes/types";
import { tagMode } from "../../src/modes/tag";
import { agentMode } from "../../src/modes/agent";
describe("Mode Registry", () => {
test("getMode returns tag mode by default", () => {
const mode = getMode("tag");
expect(mode).toBe(tagMode);
expect(mode.name).toBe("tag");
});
test("getMode returns agent mode", () => {
const mode = getMode("agent");
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode throws error for invalid mode", () => {
const invalidMode = "invalid" as unknown as ModeName;
expect(() => getMode(invalidMode)).toThrow(
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent'. Please check your workflow configuration.",
);
});
test("isValidMode returns true for all valid modes", () => {
expect(isValidMode("tag")).toBe(true);
expect(isValidMode("agent")).toBe(true);
});
test("isValidMode returns false for invalid mode", () => {
expect(isValidMode("invalid")).toBe(false);
expect(isValidMode("review")).toBe(false);
});
});

92
test/modes/tag.test.ts Normal file
View file

@ -0,0 +1,92 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { tagMode } from "../../src/modes/tag";
import type { ParsedGitHubContext } from "../../src/github/context";
import type { IssueCommentEvent } from "@octokit/webhooks-types";
import { createMockContext } from "../mockContext";
describe("Tag Mode", () => {
let mockContext: ParsedGitHubContext;
beforeEach(() => {
mockContext = createMockContext({
eventName: "issue_comment",
isPR: false,
});
});
test("tag mode has correct properties", () => {
expect(tagMode.name).toBe("tag");
expect(tagMode.description).toBe(
"Traditional implementation mode triggered by @claude mentions",
);
expect(tagMode.shouldCreateTrackingComment()).toBe(true);
});
test("shouldTrigger delegates to checkContainsTrigger", () => {
const contextWithTrigger = createMockContext({
eventName: "issue_comment",
isPR: false,
inputs: {
...createMockContext().inputs,
triggerPhrase: "@claude",
},
payload: {
comment: {
body: "Hey @claude, can you help?",
},
} as IssueCommentEvent,
});
expect(tagMode.shouldTrigger(contextWithTrigger)).toBe(true);
const contextWithoutTrigger = createMockContext({
eventName: "issue_comment",
isPR: false,
inputs: {
...createMockContext().inputs,
triggerPhrase: "@claude",
},
payload: {
comment: {
body: "This is just a regular comment",
},
} as IssueCommentEvent,
});
expect(tagMode.shouldTrigger(contextWithoutTrigger)).toBe(false);
});
test("prepareContext includes all required data", () => {
const data = {
commentId: 123,
baseBranch: "main",
claudeBranch: "claude/fix-bug",
};
const context = tagMode.prepareContext(mockContext, data);
expect(context.mode).toBe("tag");
expect(context.githubContext).toBe(mockContext);
expect(context.commentId).toBe(123);
expect(context.baseBranch).toBe("main");
expect(context.claudeBranch).toBe("claude/fix-bug");
});
test("prepareContext works without data", () => {
const context = tagMode.prepareContext(mockContext);
expect(context.mode).toBe("tag");
expect(context.githubContext).toBe(mockContext);
expect(context.commentId).toBeUndefined();
expect(context.baseBranch).toBeUndefined();
expect(context.claudeBranch).toBeUndefined();
});
test("getAllowedTools returns empty array", () => {
expect(tagMode.getAllowedTools()).toEqual([]);
});
test("getDisallowedTools returns empty array", () => {
expect(tagMode.getDisallowedTools()).toEqual([]);
});
});

63
test/permissions.test.ts Normal file
View file

@ -0,0 +1,63 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test";
import * as core from "@actions/core";
import { checkWritePermissions } from "../src/github/validation/permissions";
import type { ParsedGitHubContext } from "../src/github/context";
const baseContext: ParsedGitHubContext = {
runId: "123",
eventName: "issue_comment",
eventAction: "created",
repository: {
owner: "owner",
repo: "repo",
full_name: "owner/repo",
},
actor: "tester",
payload: {
action: "created",
issue: { number: 1, body: "", title: "", user: { login: "owner" } },
comment: { id: 1, body: "@claude ping", user: { login: "tester" } },
} as any,
entityNumber: 1,
isPR: false,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
};
describe("checkWritePermissions", () => {
let infoSpy: any;
const originalEnv = { ...process.env };
beforeEach(() => {
infoSpy = spyOn(core, "info").mockImplementation(() => {});
process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
});
afterEach(() => {
infoSpy.mockRestore();
process.env = { ...originalEnv };
});
test("returns true immediately in Gitea environments", async () => {
const client = { api: { getBaseUrl: () => "https://gitea.example.com/api/v1" } } as any;
const result = await checkWritePermissions(client, baseContext);
expect(result).toBe(true);
expect(infoSpy).toHaveBeenCalledWith(
"Detected Gitea environment (https://gitea.example.com/api/v1), assuming actor has permissions",
);
});
});

View file

@ -0,0 +1,324 @@
#!/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,
BASE_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-67890-20240101-1200",
};
});
test("should parse issue_comment event correctly", () => {
const result = prepareContext(
mockIssueCommentContext,
"12345",
"main",
"claude/issue-67890-20240101-1200",
);
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-1200",
);
expect(result.eventData.baseBranch).toBe("main");
expect(result.eventData.commentBody).toBe(
"@claude can you help explain how to configure the logging system?",
);
}
});
test("should allow missing CLAUDE_BRANCH and omit it from event data", () => {
const result = prepareContext(
mockIssueCommentContext,
"12345",
"main",
);
if (
result.eventData.eventName === "issue_comment" &&
!result.eventData.isPR
) {
expect(result.eventData.claudeBranch).toBeUndefined();
}
});
test("should throw error when BASE_BRANCH is missing", () => {
expect(() =>
prepareContext(
mockIssueCommentContext,
"12345",
undefined,
"claude/issue-67890-20240101-1200",
),
).toThrow("BASE_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,
BASE_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-42-20240101-1200",
};
});
test("should parse issue opened event correctly", () => {
const result = prepareContext(
mockIssueOpenedContext,
"12345",
"main",
"claude/issue-42-20240101-1200",
);
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.baseBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe(
"claude/issue-42-20240101-1200",
);
}
});
test("should parse issue assigned event correctly", () => {
const result = prepareContext(
mockIssueAssignedContext,
"12345",
"main",
"claude/issue-123-20240101-1200",
);
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.baseBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe(
"claude/issue-123-20240101-1200",
);
expect(result.eventData.assigneeTrigger).toBe("@claude-bot");
}
});
test("should allow issues event without CLAUDE_BRANCH", () => {
const result = prepareContext(mockIssueOpenedContext, "12345", "main");
if (result.eventData.eventName === "issues") {
expect(result.eventData.claudeBranch).toBeUndefined();
}
});
test("should throw error when BASE_BRANCH is missing for issues", () => {
expect(() =>
prepareContext(
mockIssueOpenedContext,
"12345",
undefined,
"claude/issue-42-20240101-1200",
),
).toThrow("BASE_BRANCH is required for issues event");
});
test("should allow issue assigned event with direct_prompt and no assigneeTrigger", () => {
const contextWithDirectPrompt = createMockContext({
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "", // No assignee trigger
directPrompt: "Please assess this issue", // But direct prompt is provided
},
});
const result = prepareContext(
contextWithDirectPrompt,
"12345",
"main",
"claude/issue-123-20240101-1200",
);
expect(result.eventData.eventName).toBe("issues");
expect(result.eventData.isPR).toBe(false);
expect(result.directPrompt).toBe("Please assess this issue");
if (
result.eventData.eventName === "issues" &&
result.eventData.eventAction === "assigned"
) {
expect(result.eventData.issueNumber).toBe("123");
expect(result.eventData.assigneeTrigger).toBeUndefined();
}
});
test("should throw error when neither assigneeTrigger nor directPrompt provided for issue assigned event", () => {
const contextWithoutTriggers = createMockContext({
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "", // No assignee trigger
directPrompt: "", // No direct prompt
},
});
expect(() =>
prepareContext(
contextWithoutTriggers,
"12345",
"main",
"claude/issue-123-20240101-1200",
),
).toThrow("ASSIGNEE_TRIGGER is required for issue assigned 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",
);
});
});

259
test/sanitizer.test.ts Normal file
View file

@ -0,0 +1,259 @@
import { describe, expect, it } from "bun:test";
import {
stripInvisibleCharacters,
stripMarkdownImageAltText,
stripMarkdownLinkTitles,
stripHiddenAttributes,
normalizeHtmlEntities,
sanitizeContent,
stripHtmlComments,
} from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => {
it("should remove zero-width characters", () => {
expect(stripInvisibleCharacters("Hello\u200BWorld")).toBe("HelloWorld");
expect(stripInvisibleCharacters("Text\u200C\u200D")).toBe("Text");
expect(stripInvisibleCharacters("\uFEFFStart")).toBe("Start");
});
it("should remove control characters", () => {
expect(stripInvisibleCharacters("Hello\u0000World")).toBe("HelloWorld");
expect(stripInvisibleCharacters("Text\u001F\u007F")).toBe("Text");
});
it("should preserve common whitespace", () => {
expect(stripInvisibleCharacters("Hello\nWorld")).toBe("Hello\nWorld");
expect(stripInvisibleCharacters("Tab\there")).toBe("Tab\there");
expect(stripInvisibleCharacters("Carriage\rReturn")).toBe(
"Carriage\rReturn",
);
});
it("should remove soft hyphens", () => {
expect(stripInvisibleCharacters("Soft\u00ADHyphen")).toBe("SoftHyphen");
});
it("should remove Unicode direction overrides", () => {
expect(stripInvisibleCharacters("Text\u202A\u202BMore")).toBe("TextMore");
expect(stripInvisibleCharacters("\u2066Isolated\u2069")).toBe("Isolated");
});
});
describe("stripMarkdownImageAltText", () => {
it("should remove alt text from markdown images", () => {
expect(stripMarkdownImageAltText("![example alt text](image.png)")).toBe(
"![](image.png)",
);
expect(
stripMarkdownImageAltText("Text ![description](pic.jpg) more text"),
).toBe("Text ![](pic.jpg) more text");
});
it("should handle multiple images", () => {
expect(stripMarkdownImageAltText("![one](1.png) ![two](2.png)")).toBe(
"![](1.png) ![](2.png)",
);
});
it("should handle empty alt text", () => {
expect(stripMarkdownImageAltText("![](image.png)")).toBe("![](image.png)");
});
});
describe("stripMarkdownLinkTitles", () => {
it("should remove titles from markdown links", () => {
expect(stripMarkdownLinkTitles('[Link](url.com "example title")')).toBe(
"[Link](url.com)",
);
expect(stripMarkdownLinkTitles("[Link](url.com 'example title')")).toBe(
"[Link](url.com)",
);
});
it("should handle multiple links", () => {
expect(
stripMarkdownLinkTitles('[One](1.com "first") [Two](2.com "second")'),
).toBe("[One](1.com) [Two](2.com)");
});
it("should preserve links without titles", () => {
expect(stripMarkdownLinkTitles("[Link](url.com)")).toBe("[Link](url.com)");
});
});
describe("stripHiddenAttributes", () => {
it("should remove alt attributes", () => {
expect(
stripHiddenAttributes('<img alt="example text" src="pic.jpg">'),
).toBe('<img src="pic.jpg">');
expect(stripHiddenAttributes("<img alt='example' src=\"pic.jpg\">")).toBe(
'<img src="pic.jpg">',
);
expect(stripHiddenAttributes('<img alt=example src="pic.jpg">')).toBe(
'<img src="pic.jpg">',
);
});
it("should remove title attributes", () => {
expect(
stripHiddenAttributes('<a title="example text" href="#">Link</a>'),
).toBe('<a href="#">Link</a>');
expect(stripHiddenAttributes("<div title='example'>Content</div>")).toBe(
"<div>Content</div>",
);
});
it("should remove aria-label attributes", () => {
expect(
stripHiddenAttributes('<button aria-label="example">Click</button>'),
).toBe("<button>Click</button>");
});
it("should remove data-* attributes", () => {
expect(
stripHiddenAttributes(
'<div data-test="example" data-info="more example">Text</div>',
),
).toBe("<div>Text</div>");
});
it("should remove placeholder attributes", () => {
expect(
stripHiddenAttributes('<input placeholder="example text" type="text">'),
).toBe('<input type="text">');
});
it("should handle multiple attributes", () => {
expect(
stripHiddenAttributes(
'<img alt="example" title="test" src="pic.jpg" class="image">',
),
).toBe('<img src="pic.jpg" class="image">');
});
});
describe("normalizeHtmlEntities", () => {
it("should decode numeric entities", () => {
expect(normalizeHtmlEntities("&#72;&#101;&#108;&#108;&#111;")).toBe(
"Hello",
);
expect(normalizeHtmlEntities("&#65;&#66;&#67;")).toBe("ABC");
});
it("should decode hex entities", () => {
expect(normalizeHtmlEntities("&#x48;&#x65;&#x6C;&#x6C;&#x6F;")).toBe(
"Hello",
);
expect(normalizeHtmlEntities("&#x41;&#x42;&#x43;")).toBe("ABC");
});
it("should remove non-printable entities", () => {
expect(normalizeHtmlEntities("&#0;&#31;")).toBe("");
expect(normalizeHtmlEntities("&#x00;&#x1F;")).toBe("");
});
it("should preserve normal text", () => {
expect(normalizeHtmlEntities("Normal text")).toBe("Normal text");
});
});
describe("sanitizeContent", () => {
it("should apply all sanitization measures", () => {
const testContent = `
<!-- This is a comment -->
<img alt="example alt text" src="image.jpg">
![example image description](screenshot.png)
[click here](https://example.com "example title")
<div data-prompt="example data" aria-label="example label">
Normal text with hidden\u200Bcharacters
</div>
&#72;&#105;&#100;&#100;&#101;&#110; message
`;
const sanitized = sanitizeContent(testContent);
expect(sanitized).not.toContain("<!-- This is a comment -->");
expect(sanitized).not.toContain("example alt text");
expect(sanitized).not.toContain("example image description");
expect(sanitized).not.toContain("example title");
expect(sanitized).not.toContain("example data");
expect(sanitized).not.toContain("example label");
expect(sanitized).not.toContain("\u200B");
expect(sanitized).not.toContain("alt=");
expect(sanitized).not.toContain("data-prompt=");
expect(sanitized).not.toContain("aria-label=");
expect(sanitized).toContain("Normal text with hiddencharacters");
expect(sanitized).toContain("Hidden message");
expect(sanitized).toContain('<img src="image.jpg">');
expect(sanitized).toContain("![](screenshot.png)");
expect(sanitized).toContain("[click here](https://example.com)");
});
it("should handle complex nested patterns", () => {
const complexContent = `
Text with ![alt \u200B text](image.png) and more.
<a href="#" title="example\u00ADtitle">Link</a>
<div data-x="&#72;&#105;">Content</div>
`;
const sanitized = sanitizeContent(complexContent);
expect(sanitized).not.toContain("\u200B");
expect(sanitized).not.toContain("\u00AD");
expect(sanitized).not.toContain("alt ");
expect(sanitized).not.toContain('title="');
expect(sanitized).not.toContain('data-x="');
expect(sanitized).toContain("![](image.png)");
expect(sanitized).toContain('<a href="#">Link</a>');
});
it("should preserve legitimate markdown and HTML", () => {
const legitimateContent = `
# Heading
This is **bold** and *italic* text.
Here's a normal image: ![](normal.jpg)
And a normal link: [Click here](https://example.com)
<div class="container">
<p id="para">Normal paragraph</p>
<input type="text" name="field">
</div>
`;
const sanitized = sanitizeContent(legitimateContent);
expect(sanitized).toBe(legitimateContent);
});
it("should handle entity-encoded text", () => {
const encodedText = `
&#72;&#105;&#100;&#100;&#101;&#110; &#109;&#101;&#115;&#115;&#97;&#103;&#101;
<div title="&#101;&#120;&#97;&#109;&#112;&#108;&#101;">Test</div>
`;
const sanitized = sanitizeContent(encodedText);
expect(sanitized).toContain("Hidden message");
expect(sanitized).not.toContain('title="');
expect(sanitized).toContain("<div>Test</div>");
});
});
describe("stripHtmlComments (legacy)", () => {
it("should remove HTML comments", () => {
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
"Hello World",
);
expect(stripHtmlComments("<!-- comment -->Text")).toBe("Text");
expect(stripHtmlComments("Text<!-- comment -->")).toBe("Text");
});
it("should handle multiline comments", () => {
expect(stripHtmlComments("Hello <!-- \nexample\n -->World")).toBe(
"Hello World",
);
});
});

View file

@ -0,0 +1,954 @@
import {
checkContainsTrigger,
escapeRegExp,
} from "../src/github/validation/trigger";
import { describe, it, expect } from "bun:test";
import {
createMockContext,
mockIssueAssignedContext,
mockIssueLabeledContext,
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: {
mode: "tag",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "Fix the bug in the login form",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
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: {
mode: "tag",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
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,
assignee: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent)
.assignee,
login: "otherUser",
},
issue: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
assignee: {
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue
.assignee,
login: "otherUser",
},
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("label trigger", () => {
it("should return true when issue is labeled with the trigger label", () => {
const context = mockIssueLabeledContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when issue is labeled with a different label", () => {
const context = {
...mockIssueLabeledContext,
payload: {
...mockIssueLabeledContext.payload,
label: {
...(mockIssueLabeledContext.payload as any).label,
name: "bug",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should return false for non-labeled events", () => {
const context = {
...mockIssueLabeledContext,
eventAction: "opened",
payload: {
...mockIssueLabeledContext.payload,
action: "opened",
},
} 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: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
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: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
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: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("pull request reviewer trigger", () => {
it("should return true when PR has trigger user as requested reviewer (same as text mention)", () => {
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" },
requested_reviewers: [
{ login: "claude", id: 1, type: "User" },
{ login: "other-reviewer", id: 2, type: "User" },
],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return true for synchronized PR with trigger user as reviewer", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "synchronized",
isPR: true,
payload: {
action: "synchronized",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
requested_reviewers: [
{ login: "claude", id: 1, type: "User" },
],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when PR has no matching requested reviewers", () => {
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" },
requested_reviewers: [
{ login: "other-reviewer", id: 2, type: "User" },
],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle trigger phrase without @ symbol", () => {
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" },
requested_reviewers: [
{ login: "claude", id: 1, type: "User" },
],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "claude", // No @ symbol
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
});
it("should return true when PR has trigger user as requested reviewer for synchronized event", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "synchronized",
isPR: true,
payload: {
action: "synchronized",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
requested_reviewers: [
{ login: "claude", id: 1, type: "User" },
],
requested_teams: [],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when PR has no matching requested reviewers", () => {
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" },
requested_reviewers: [
{ login: "other-reviewer", id: 2, type: "User" },
],
requested_teams: [],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle trigger phrase without @ symbol", () => {
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" },
requested_reviewers: [
{ login: "claude", id: 1, type: "User" },
],
requested_teams: [],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "claude", // No @ symbol
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should handle empty requested_reviewers and requested_teams arrays", () => {
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" },
requested_reviewers: [],
requested_teams: [],
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle missing requested_reviewers and requested_teams fields", () => {
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" },
// requested_reviewers and requested_teams are undefined
},
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
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("pull request review_requested action", () => {
it("should return true when trigger user is requested as reviewer", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "review_requested",
isPR: true,
payload: {
action: "review_requested",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
requested_reviewers: [{ login: "claude", id: 1, type: "User" }],
requested_teams: [],
},
requested_reviewer: { login: "claude", id: 1, type: "User" },
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when different user is requested as reviewer", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "review_requested",
isPR: true,
payload: {
action: "review_requested",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
requested_reviewers: [{ login: "john", id: 2, type: "User" }],
requested_teams: [],
},
requested_reviewer: { login: "john", id: 2, type: "User" },
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(false);
});
it("should handle trigger phrase without @ symbol", () => {
const context = createMockContext({
eventName: "pull_request",
eventAction: "review_requested",
isPR: true,
payload: {
action: "review_requested",
pull_request: {
number: 123,
title: "Test PR",
body: "This PR fixes a bug",
created_at: "2023-01-01T00:00:00Z",
user: { login: "testuser" },
requested_reviewers: [{ login: "claude", id: 1, type: "User" }],
requested_teams: [],
},
requested_reviewer: { login: "claude", id: 1, type: "User" },
} as unknown as PullRequestEvent,
inputs: {
mode: "tag",
triggerPhrase: "claude", // no @ symbol
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
expect(checkContainsTrigger(context)).toBe(true);
});
});
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\\]");
});
});

View file

@ -0,0 +1,401 @@
import { describe, test, expect, jest, beforeEach } from "bun:test";
import {
updateClaudeComment,
type UpdateClaudeCommentParams,
} from "../src/github/operations/comments/update-claude-comment";
describe("updateClaudeComment", () => {
let mockOctokit: any;
beforeEach(() => {
mockOctokit = {
rest: {
issues: {
updateComment: jest.fn(),
},
pulls: {
updateReviewComment: jest.fn(),
},
},
};
});
test("should update issue comment successfully", async () => {
const mockResponse = {
data: {
id: 123456,
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
updated_at: "2024-01-01T00:00:00Z",
body: "Updated comment",
},
};
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 123456,
body: "Updated comment",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 123456,
body: "Updated comment",
});
expect(result).toEqual({
id: 123456,
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
updated_at: "2024-01-01T00:00:00Z",
});
});
test("should update PR comment successfully", async () => {
const mockResponse = {
data: {
id: 789012,
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
updated_at: "2024-01-02T00:00:00Z",
body: "Updated PR comment",
},
};
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 789012,
body: "Updated PR comment",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 789012,
body: "Updated PR comment",
});
expect(result).toEqual({
id: 789012,
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
updated_at: "2024-01-02T00:00:00Z",
});
});
test("should update PR review comment successfully", async () => {
const mockResponse = {
data: {
id: 345678,
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
updated_at: "2024-01-03T00:00:00Z",
body: "Updated review comment",
},
};
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 345678,
body: "Updated review comment",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 345678,
body: "Updated review comment",
});
expect(result).toEqual({
id: 345678,
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
updated_at: "2024-01-03T00:00:00Z",
});
});
test("should fallback to issue comment API when PR review comment update fails with 404", async () => {
const mockError = new Error("Not Found") as any;
mockError.status = 404;
const mockResponse = {
data: {
id: 456789,
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
updated_at: "2024-01-04T00:00:00Z",
body: "Updated via fallback",
},
};
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockRejectedValue(mockError);
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 456789,
body: "Updated via fallback",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 456789,
body: "Updated via fallback",
});
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 456789,
body: "Updated via fallback",
});
expect(result).toEqual({
id: 456789,
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
updated_at: "2024-01-04T00:00:00Z",
});
});
test("should propagate error when PR review comment update fails with non-404 error", async () => {
const mockError = new Error("Internal Server Error") as any;
mockError.status = 500;
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockRejectedValue(mockError);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 567890,
body: "This will fail",
isPullRequestReviewComment: true,
};
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
mockError,
);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 567890,
body: "This will fail",
});
// Ensure fallback wasn't attempted
expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
});
test("should propagate error when issue comment update fails", async () => {
const mockError = new Error("Forbidden");
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockRejectedValue(mockError);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 678901,
body: "This will also fail",
isPullRequestReviewComment: false,
};
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
mockError,
);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 678901,
body: "This will also fail",
});
});
test("should handle empty body", async () => {
const mockResponse = {
data: {
id: 111222,
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
updated_at: "2024-01-05T00:00:00Z",
body: "",
},
};
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 111222,
body: "",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(result).toEqual({
id: 111222,
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
updated_at: "2024-01-05T00:00:00Z",
});
});
test("should handle very long body", async () => {
const longBody = "x".repeat(10000);
const mockResponse = {
data: {
id: 333444,
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
updated_at: "2024-01-06T00:00:00Z",
body: longBody,
},
};
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 333444,
body: longBody,
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 333444,
body: longBody,
});
expect(result).toEqual({
id: 333444,
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
updated_at: "2024-01-06T00:00:00Z",
});
});
test("should handle markdown formatting in body", async () => {
const markdownBody = `
# Header
- List item 1
- List item 2
\`\`\`typescript
const code = "example";
\`\`\`
[Link](https://example.com)
`.trim();
const mockResponse = {
data: {
id: 555666,
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
updated_at: "2024-01-07T00:00:00Z",
body: markdownBody,
},
};
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 555666,
body: markdownBody,
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 555666,
body: markdownBody,
});
expect(result).toEqual({
id: 555666,
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
updated_at: "2024-01-07T00:00:00Z",
});
});
test("should handle different response data fields", async () => {
const mockResponse = {
data: {
id: 777888,
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
updated_at: "2024-01-08T12:30:45Z",
body: "Updated",
// Additional fields that might be in the response
created_at: "2024-01-01T00:00:00Z",
user: { login: "bot" },
node_id: "MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDc3Nzg4OA==",
},
};
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 777888,
body: "Updated",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
// Should only return the specific fields we care about
expect(result).toEqual({
id: 777888,
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
updated_at: "2024-01-08T12:30:45Z",
});
});
});

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);
});
});