test: update suites for gitea mode flow

This commit is contained in:
Mark Wylde
2025-09-30 17:31:54 +01:00
parent 3305a16297
commit 5bcd15c520
7 changed files with 228 additions and 1777 deletions

View File

@@ -1,50 +1,51 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup"; import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
import type { Octokits } from "../src/github/api/client"; import type { GitHubClient } from "../src/github/api/client";
import { GITEA_SERVER_URL } from "../src/github/api/config"; import { GITEA_SERVER_URL } from "../src/github/api/config";
describe("checkAndDeleteEmptyBranch", () => { describe("checkAndDeleteEmptyBranch", () => {
let consoleLogSpy: any; let consoleLogSpy: any;
let consoleErrorSpy: any; let consoleErrorSpy: any;
const originalEnv = { ...process.env };
beforeEach(() => { beforeEach(() => {
// Spy on console methods
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
delete process.env.GITEA_API_URL; // ensure GitHub mode for predictable behaviour
}); });
afterEach(() => { afterEach(() => {
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
process.env = { ...originalEnv };
}); });
const createMockOctokit = ( const createMockClient = (
compareResponse?: any, options: { branchSha?: string; baseSha?: string; error?: Error } = {},
deleteRefError?: Error, ): GitHubClient => {
): Octokits => { const { branchSha = "branch-sha", baseSha = "base-sha", error } = options;
return { return {
rest: { api: {
repos: { getBranch: async (_owner: string, _repo: string, branch: string) => {
compareCommitsWithBasehead: async () => ({ if (error) {
data: compareResponse || { total_commits: 0 }, throw error;
}), }
}, return {
git: { data: {
deleteRef: async () => { commit: {
if (deleteRefError) { sha: branch.includes("claude/") ? branchSha : baseSha,
throw deleteRefError; },
} },
return { data: {} }; };
},
}, },
}, },
} as any as Octokits; } as unknown as GitHubClient;
}; };
test("should return no branch link and not delete when branch is undefined", async () => { test("returns defaults when no claude branch provided", async () => {
const mockOctokit = createMockOctokit(); const client = createMockClient();
const result = await checkAndDeleteEmptyBranch( const result = await checkAndDeleteEmptyBranch(
mockOctokit, client,
"owner", "owner",
"repo", "repo",
undefined, undefined,
@@ -56,94 +57,65 @@ describe("checkAndDeleteEmptyBranch", () => {
expect(consoleLogSpy).not.toHaveBeenCalled(); expect(consoleLogSpy).not.toHaveBeenCalled();
}); });
test("should delete branch and return no link when branch has no commits", async () => { test("marks branch for deletion when SHAs match", async () => {
const mockOctokit = createMockOctokit({ total_commits: 0 }); const client = createMockClient({ branchSha: "same", baseSha: "same" });
const result = await checkAndDeleteEmptyBranch( const result = await checkAndDeleteEmptyBranch(
mockOctokit, client,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123",
"main", "main",
); );
expect(result.shouldDeleteBranch).toBe(true); expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe(""); expect(result.branchLink).toBe("");
expect(consoleLogSpy).toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it", "Branch claude/issue-123 has same SHA as base, marking for deletion",
); );
expect(consoleLogSpy).toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
"✅ Deleted empty branch: claude/issue-123-20240101_123456", "Skipping branch deletion - not reliably supported across all Git platforms: claude/issue-123",
); );
}); });
test("should not delete branch and return link when branch has commits", async () => { test("returns branch link when branch has commits", async () => {
const mockOctokit = createMockOctokit({ total_commits: 3 }); const client = createMockClient({ branchSha: "feature", baseSha: "main" });
const result = await checkAndDeleteEmptyBranch( const result = await checkAndDeleteEmptyBranch(
mockOctokit, client,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123",
"main", "main",
); );
expect(result.shouldDeleteBranch).toBe(false); expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe( expect(result.branchLink).toBe(
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`, `\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
); );
expect(consoleLogSpy).not.toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("has no commits"), "Branch claude/issue-123 appears to have commits (different SHA from base)",
); );
}); });
test("should handle branch comparison errors gracefully", async () => { test("falls back to branch link when API call fails", async () => {
const mockOctokit = { const client = createMockClient({ error: Object.assign(new Error("boom"), { status: 500 }) });
rest: {
repos: {
compareCommitsWithBasehead: async () => {
throw new Error("API error");
},
},
git: {
deleteRef: async () => ({ data: {} }),
},
},
} as any as Octokits;
const result = await checkAndDeleteEmptyBranch( const result = await checkAndDeleteEmptyBranch(
mockOctokit, client,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123",
"main", "main",
); );
expect(result.shouldDeleteBranch).toBe(false); expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe( expect(result.branchLink).toBe(
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`, `\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`,
); );
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking for commits on Claude branch:", "Error checking branch:",
expect.any(Error), expect.any(Error),
); );
}); expect(consoleLogSpy).toHaveBeenCalledWith(
"Assuming branch exists due to non-404 error",
test("should handle branch deletion errors gracefully", async () => {
const deleteError = new Error("Delete failed");
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
const result = await checkAndDeleteEmptyBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101_123456",
"main",
);
expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe("");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to delete branch claude/issue-123-20240101_123456:",
deleteError,
); );
}); });
}); });

View File

@@ -1,12 +1,28 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { updateCommentBody } from "../src/github/operations/comment-logic"; import { updateCommentBody } from "../src/github/operations/comment-logic";
describe("updateCommentBody", () => { 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 = { const baseInput = {
currentBody: "Initial comment body", currentBody: "Initial comment body",
actionFailed: false, actionFailed: false,
executionDetails: null, executionDetails: null,
jobUrl: "https://github.com/owner/repo/actions/runs/123", jobUrl: JOB_URL,
branchName: undefined, branchName: undefined,
triggerUsername: undefined, triggerUsername: undefined,
}; };
@@ -105,20 +121,19 @@ describe("updateCommentBody", () => {
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)", `• [\`claude/issue-123-20240101_120000\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
); );
}); });
it("extracts branch name from branchLink if branchName not provided", () => { it("extracts branch name from branchLink if branchName not provided", () => {
const input = { const input = {
...baseInput, ...baseInput,
branchLink: branchLink: `\n[View branch](${BRANCH_BASE_URL}/branch-name)`,
"\n[View branch](https://github.com/owner/repo/src/branch/branch-name)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [`branch-name`](https://github.com/owner/repo/src/branch/branch-name)", `• [\`branch-name\`](${BRANCH_BASE_URL}/branch-name)`,
); );
}); });
@@ -126,13 +141,13 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: currentBody:
"Some comment with [View branch](https://github.com/owner/repo/src/branch/branch-name)", `Some comment with [View branch](${BRANCH_BASE_URL}/branch-name)` ,
branchName: "new-branch-name", branchName: "new-branch-name",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [`new-branch-name`](https://github.com/owner/repo/src/branch/new-branch-name)", `• [\`new-branch-name\`](${BRANCH_BASE_URL}/new-branch-name)`,
); );
expect(result).not.toContain("View branch"); expect(result).not.toContain("View branch");
}); });
@@ -142,12 +157,12 @@ describe("updateCommentBody", () => {
it("adds PR link to header when provided", () => { it("adds PR link to header when provided", () => {
const input = { const input = {
...baseInput, ...baseInput,
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)", prLink: "\n[Create a PR](https://gitea.example.com/owner/repo/pr-url)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)", "• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url)",
); );
}); });
@@ -155,12 +170,12 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url)", "Some comment with [Create a PR](https://gitea.example.com/owner/repo/pr-url)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)", "• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url)",
); );
// Original Create a PR link is removed from body // Original Create a PR link is removed from body
expect(result).not.toContain("[Create a PR]"); expect(result).not.toContain("[Create a PR]");
@@ -170,21 +185,21 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url-from-body)", "Some comment with [Create a PR](https://gitea.example.com/owner/repo/pr-url-from-body)",
prLink: prLink:
"\n[Create a PR](https://github.com/owner/repo/pr-url-provided)", "\n[Create a PR](https://gitea.example.com/owner/repo/pr-url-provided)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
// Prefers the link found in content over the provided one // Prefers the link found in content over the provided one
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-from-body)", "• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url-from-body)",
); );
}); });
it("handles complex PR URLs with encoded characters", () => { it("handles complex PR URLs with encoded characters", () => {
const complexUrl = const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20important%20bug%20fix&body=Fixes%20%23123%0A%0A%23%23%20Description%0AThis%20PR%20fixes%20an%20important%20bug%20that%20was%20causing%20issues%20with%20the%20application.%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)"; "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 = { const input = {
...baseInput, ...baseInput,
currentBody: `Some comment with [Create a PR](${complexUrl})`, currentBody: `Some comment with [Create a PR](${complexUrl})`,
@@ -198,7 +213,7 @@ describe("updateCommentBody", () => {
it("handles PR links with encoded URLs containing parentheses", () => { it("handles PR links with encoded URLs containing parentheses", () => {
const complexUrl = const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)"; "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 = { const input = {
...baseInput, ...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${complexUrl})`, currentBody: `This PR was created.\n\n[Create a PR](${complexUrl})`,
@@ -217,9 +232,9 @@ describe("updateCommentBody", () => {
it("handles PR links with unencoded spaces and special characters", () => { it("handles PR links with unencoded spaces and special characters", () => {
const unEncodedUrl = const unEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)"; "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 = const expectedEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A+update+welcome+message&body=Generated+with+%5BClaude+Code%5D%28https%3A%2F%2Fclaude.ai%2Fcode%29"; "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 = { const input = {
...baseInput, ...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${unEncodedUrl})`, currentBody: `This PR was created.\n\n[Create a PR](${unEncodedUrl})`,
@@ -235,7 +250,7 @@ describe("updateCommentBody", () => {
it("falls back to prLink parameter when PR link in content cannot be encoded", () => { it("falls back to prLink parameter when PR link in content cannot be encoded", () => {
const invalidUrl = "not-a-valid-url-at-all"; const invalidUrl = "not-a-valid-url-at-all";
const fallbackPrUrl = "https://github.com/owner/repo/pull/123"; const fallbackPrUrl = "https://gitea.example.com/owner/repo/pull/123";
const input = { const input = {
...baseInput, ...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${invalidUrl})`, currentBody: `This PR was created.\n\n[Create a PR](${invalidUrl})`,
@@ -317,7 +332,7 @@ describe("updateCommentBody", () => {
"Claude Code is working…\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer", "Claude Code is working…\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer",
actionFailed: false, actionFailed: false,
branchName: "claude-branch-123", branchName: "claude-branch-123",
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)", prLink: "\n[Create a PR](https://gitea.example.com/owner/repo/pr-url)",
executionDetails: { executionDetails: {
cost_usd: 0.01, cost_usd: 0.01,
duration_ms: 65000, // 1 minute 5 seconds duration_ms: 65000, // 1 minute 5 seconds
@@ -333,7 +348,7 @@ describe("updateCommentBody", () => {
); );
expect(result).toContain("—— [View job]"); expect(result).toContain("—— [View job]");
expect(result).toContain( expect(result).toContain(
"• [`claude-branch-123`](https://github.com/owner/repo/src/branch/claude-branch-123)", `• [\`claude-branch-123\`](${BRANCH_BASE_URL}/claude-branch-123)`,
); );
expect(result).toContain("• [Create PR ➔]"); expect(result).toContain("• [Create PR ➔]");
@@ -358,7 +373,7 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: currentBody:
"Claude Code is working…\n\nI've made changes.\n[Create a PR](https://github.com/owner/repo/pr-url-in-content)\n\n@john-doe", "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", branchName: "feature-branch",
triggerUsername: "john-doe", triggerUsername: "john-doe",
}; };
@@ -367,7 +382,7 @@ describe("updateCommentBody", () => {
// PR link should be moved to header // PR link should be moved to header
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-in-content)", "• [Create PR ➔](https://gitea.example.com/owner/repo/pr-url-in-content)",
); );
// Original link should be removed from body // Original link should be removed from body
expect(result).not.toContain("[Create a PR]"); expect(result).not.toContain("[Create a PR]");
@@ -383,7 +398,7 @@ describe("updateCommentBody", () => {
currentBody: "Claude Code is working… <img src='spinner.gif' />", currentBody: "Claude Code is working… <img src='spinner.gif' />",
branchName: "claude/pr-456-20240101_120000", branchName: "claude/pr-456-20240101_120000",
prLink: prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", "\n[Create a PR](https://gitea.example.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
triggerUsername: "jane-doe", triggerUsername: "jane-doe",
}; };
@@ -391,7 +406,7 @@ describe("updateCommentBody", () => {
// Should include the PR link in the formatted style // Should include the PR link in the formatted style
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", "• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
); );
expect(result).toContain("**Claude finished @jane-doe's task**"); expect(result).toContain("**Claude finished @jane-doe's task**");
}); });
@@ -401,20 +416,19 @@ describe("updateCommentBody", () => {
...baseInput, ...baseInput,
currentBody: "Claude Code is working…", currentBody: "Claude Code is working…",
branchName: "claude/issue-123-20240101_120000", branchName: "claude/issue-123-20240101_120000",
branchLink: branchLink: `\n[View branch](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
"\n[View branch](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
prLink: prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)", "\n[Create a PR](https://gitea.example.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
// Should include both links in formatted style // Should include both links in formatted style
expect(result).toContain( expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)", `• [\`claude/issue-123-20240101_120000\`](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`,
); );
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)", "• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
); );
}); });
}); });

View File

@@ -8,7 +8,6 @@ import {
buildDisallowedToolsString, buildDisallowedToolsString,
} from "../src/create-prompt"; } from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt";
import type { EventData } from "../src/create-prompt/types";
describe("generatePrompt", () => { describe("generatePrompt", () => {
const mockGitHubData = { const mockGitHubData = {
@@ -134,7 +133,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("You are Claude, an AI assistant"); expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>"); expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
@@ -162,7 +161,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>"); expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -188,16 +187,14 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>"); expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
"<trigger_context>new issue with '@claude' in body</trigger_context>", "<trigger_context>new issue with '@claude' in body</trigger_context>",
); );
expect(prompt).toContain( expect(prompt).toContain("mcp__gitea__update_issue_comment");
"[Create a PR](https://github.com/owner/repo/compare/main", expect(prompt).toContain("mcp__gitea__list_branches");
);
expect(prompt).toContain("The target-branch should be 'main'");
}); });
test("should generate prompt for issue assigned event", () => { test("should generate prompt for issue assigned event", () => {
@@ -216,15 +213,14 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>"); expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
"<trigger_context>issue assigned to 'claude-bot'</trigger_context>", "<trigger_context>issue assigned to 'claude-bot'</trigger_context>",
); );
expect(prompt).toContain( expect(prompt).toContain("mcp__gitea__list_branches");
"[Create a PR](https://github.com/owner/repo/compare/develop", expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
);
}); });
test("should include direct prompt when provided", () => { test("should include direct prompt when provided", () => {
@@ -243,7 +239,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<direct_prompt>"); expect(prompt).toContain("<direct_prompt>");
expect(prompt).toContain("Fix the bug in the login form"); expect(prompt).toContain("Fix the bug in the login form");
@@ -266,7 +262,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>"); expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -291,7 +287,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript"); expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
}); });
@@ -313,11 +309,11 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>"); expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
expect(prompt).toContain( expect(prompt).toContain(
"Co-authored-by: johndoe <johndoe@users.noreply.local>", "<trigger_display_name>johndoe</trigger_display_name>",
); );
}); });
@@ -334,7 +330,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain PR-specific instructions // Should contain PR-specific instructions
expect(prompt).toContain( expect(prompt).toContain(
@@ -367,19 +363,12 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain Issue-specific instructions // Should contain Issue-specific instructions
expect(prompt).toContain( expect(prompt).toContain("mcp__gitea__update_issue_comment");
"You are already on the correct branch (claude/issue-789-20240101_120000)", expect(prompt).toContain("mcp__gitea__list_branches");
); expect(prompt).toContain("mcp__local_git_ops__checkout_branch");
expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain(
"If you created a branch and made changes, your comment must include the PR URL",
);
// Should NOT contain PR-specific instructions // Should NOT contain PR-specific instructions
expect(prompt).not.toContain( expect(prompt).not.toContain(
@@ -406,54 +395,11 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain the actual branch name with timestamp // Should surface the issue number and comment metadata
expect(prompt).toContain( expect(prompt).toContain("<issue_number>123</issue_number>");
"You are already on the correct branch (claude/issue-123-20240101_120000)", expect(prompt).toContain("<claude_comment_id>12345</claude_comment_id>");
);
expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)",
);
expect(prompt).toContain(
"The branch-name is the current branch: claude/issue-123-20240101_120000",
);
});
test("should handle closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: true,
prNumber: "456",
commentBody: "@claude please fix this",
claudeBranch: "claude/pr-456-20240101_120000",
baseBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain branch-specific instructions like issues
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-456-20240101_120000)",
);
expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/main",
);
expect(prompt).toContain(
"The branch-name is the current branch: claude/pr-456-20240101_120000",
);
expect(prompt).toContain("Reference to the original PR");
// Should NOT contain open PR instructions
expect(prompt).not.toContain(
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
);
}); });
test("should handle open PR without new branch", () => { test("should handle open PR without new branch", () => {
@@ -471,7 +417,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain open PR instructions // Should contain open PR instructions
expect(prompt).toContain( expect(prompt).toContain(
@@ -488,84 +434,6 @@ describe("generatePrompt", () => {
"If you created anything in your branch, your comment must include the PR URL", "If you created anything in your branch, your comment must include the PR URL",
); );
}); });
test("should handle PR review on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review",
isPR: true,
prNumber: "789",
commentBody: "@claude please update this",
claudeBranch: "claude/pr-789-20240101_123000",
baseBranch: "develop",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-789-20240101_123000)",
);
expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/develop",
);
expect(prompt).toContain("Reference to the original PR");
});
test("should handle PR review comment on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "999",
commentId: "review-comment-123",
commentBody: "@claude fix this issue",
claudeBranch: "claude/pr-999-20240101_140000",
baseBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-999-20240101_140000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR");
});
test("should handle pull_request event on closed PR with new branch", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request",
eventAction: "closed",
isPR: true,
prNumber: "555",
claudeBranch: "claude/pr-555-20240101_150000",
baseBranch: "main",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
// Should contain new branch instructions
expect(prompt).toContain(
"You are already on the correct branch (claude/pr-555-20240101_150000)",
);
expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR");
});
}); });
describe("getEventTypeAndContext", () => { describe("getEventTypeAndContext", () => {
@@ -612,81 +480,36 @@ describe("getEventTypeAndContext", () => {
}); });
describe("buildAllowedToolsString", () => { describe("buildAllowedToolsString", () => {
test("should return issue comment tool for regular events", () => { test("should include base tools", () => {
const mockEventData: EventData = { const result = buildAllowedToolsString();
eventName: "issue_comment",
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const result = buildAllowedToolsString(mockEventData);
// The base tools should be in the result
expect(result).toContain("Edit"); expect(result).toContain("Edit");
expect(result).toContain("Glob"); expect(result).toContain("Glob");
expect(result).toContain("Grep"); expect(result).toContain("mcp__gitea__update_issue_comment");
expect(result).toContain("LS"); expect(result).toContain("mcp__gitea__update_pull_request_comment");
expect(result).toContain("Read");
expect(result).toContain("Write");
expect(result).toContain("mcp__github__update_issue_comment");
expect(result).not.toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__local_git_ops__commit_files");
expect(result).toContain("mcp__local_git_ops__delete_files");
}); });
test("should return PR comment tool for inline review comments", () => { test("should include commit signing tools when enabled", () => {
const mockEventData: EventData = { const result = buildAllowedToolsString(undefined, false, true);
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "456",
commentBody: "Test review comment",
commentId: "789",
};
const result = buildAllowedToolsString(mockEventData); expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files");
});
// The base tools should be in the result test("should include actions tools when actions read permission granted", () => {
expect(result).toContain("Edit"); const result = buildAllowedToolsString([], true, false);
expect(result).toContain("Glob");
expect(result).toContain("Grep"); expect(result).toContain("mcp__github_actions__get_ci_status");
expect(result).toContain("LS"); expect(result).toContain("mcp__github_actions__download_job_log");
expect(result).toContain("Read");
expect(result).toContain("Write");
expect(result).not.toContain("mcp__github__update_issue_comment");
expect(result).toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__local_git_ops__commit_files");
expect(result).toContain("mcp__local_git_ops__delete_files");
}); });
test("should append custom tools when provided", () => { test("should append custom tools when provided", () => {
const mockEventData: EventData = {
eventName: "issue_comment",
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const customTools = "Tool1,Tool2,Tool3"; const customTools = "Tool1,Tool2,Tool3";
const result = buildAllowedToolsString(mockEventData, customTools); const result = buildAllowedToolsString(customTools);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
// Custom tools should be appended
expect(result).toContain("Tool1"); expect(result).toContain("Tool1");
expect(result).toContain("Tool2"); expect(result).toContain("Tool2");
expect(result).toContain("Tool3"); expect(result).toContain("Tool3");
// Verify format with comma separation
const basePlusCustom = result.split(",");
expect(basePlusCustom.length).toBeGreaterThan(10); // At least the base tools plus custom
expect(basePlusCustom).toContain("Tool1");
expect(basePlusCustom).toContain("Tool2");
expect(basePlusCustom).toContain("Tool3");
}); });
}); });

View File

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

View File

@@ -1,682 +1,59 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server"; import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../src/github/context"; const originalEnv = { ...process.env };
describe("prepareMcpConfig", () => { describe("prepareMcpConfig", () => {
let consoleInfoSpy: any;
let consoleWarningSpy: any;
let setFailedSpy: any;
let processExitSpy: any;
// Create a mock context for tests
const mockContext: ParsedGitHubContext = {
runId: "test-run-id",
eventName: "issue_comment",
eventAction: "created",
repository: {
owner: "test-owner",
repo: "test-repo",
full_name: "test-owner/test-repo",
},
actor: "test-actor",
payload: {} as any,
entityNumber: 123,
isPR: false,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
};
const mockPRContext: ParsedGitHubContext = {
...mockContext,
eventName: "pull_request",
isPR: true,
entityNumber: 456,
};
const mockContextWithSigning: ParsedGitHubContext = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
const mockPRContextWithSigning: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
useCommitSigning: true,
},
};
beforeEach(() => { beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); process.env.GITHUB_ACTION_PATH = "/action/path";
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); process.env.GITHUB_WORKSPACE = "/workspace";
setFailedSpy = spyOn(core, "setFailed").mockImplementation(() => {}); process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process exit");
});
// Set up required environment variables
if (!process.env.GITHUB_ACTION_PATH) {
process.env.GITHUB_ACTION_PATH = "/test/action/path";
}
}); });
afterEach(() => { afterEach(() => {
consoleInfoSpy.mockRestore(); process.env = { ...originalEnv };
consoleWarningSpy.mockRestore();
setFailedSpy.mockRestore();
processExitSpy.mockRestore();
}); });
test("should return comment server when commit signing is disabled", async () => { test("returns base gitea and local git MCP servers", async () => {
const result = await prepareMcpConfig({ const result = await prepareMcpConfig({
githubToken: "test-token", githubToken: "token",
owner: "test-owner", owner: "owner",
repo: "test-repo", repo: "repo",
branch: "test-branch", branch: "branch",
baseBranch: "main",
allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(Object.keys(parsed.mcpServers)).toEqual(["gitea", "local_git_ops"]);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
});
test("should return file ops server when commit signing is enabled", async () => { expect(parsed.mcpServers.gitea).toEqual({
const contextWithSigning = { command: "bun",
...mockContext, args: ["run", "/action/path/src/mcp/gitea-mcp-server.ts"],
inputs: { env: {
...mockContext.inputs, GITHUB_TOKEN: "token",
useCommitSigning: true, REPO_OWNER: "owner",
}, REPO_NAME: "repo",
}; BRANCH_NAME: "branch",
REPO_DIR: "/workspace",
const result = await prepareMcpConfig({ GITEA_API_URL: "https://gitea.example.com/api/v1",
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
"test-branch",
);
});
test("should include github MCP server when mcp__github__ tools are allowed", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
"test-token",
);
});
test("should not include github MCP server when only file_ops tools are allowed", async () => {
const contextWithSigning = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__update_claude_comment",
],
context: contextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: ["Edit", "Read", "Write"],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
});
test("should return base config when additional config is empty string", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: "",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should return base config when additional config is whitespace only", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: " \n\t ",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should merge valid additional config with base config", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
custom_server: {
command: "custom-command",
args: ["arg1", "arg2"],
env: {
CUSTOM_ENV: "custom-value",
},
},
}, },
}); });
const result = await prepareMcpConfig({ expect(parsed.mcpServers.local_git_ops.args[1]).toBe(
githubToken: "test-token", "/action/path/src/mcp/local-git-ops-server.ts",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
); );
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
}); });
test("should override built-in servers when additional config has same server names", async () => { test("falls back to process.cwd when workspace not provided", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
github: {
command: "overridden-command",
args: ["overridden-arg"],
env: {
OVERRIDDEN_ENV: "overridden-value",
},
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github.command).toBe("overridden-command");
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
"overridden-value",
);
expect(
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
).toBeUndefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should merge additional root-level properties", async () => {
const additionalConfig = JSON.stringify({
customProperty: "custom-value",
anotherProperty: {
nested: "value",
},
mcpServers: {
custom_server: {
command: "custom",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.customProperty).toBe("custom-value");
expect(parsed.anotherProperty).toEqual({ nested: "value" });
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
});
test("should handle invalid JSON gracefully", async () => {
const invalidJson = "{ invalid json }";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: invalidJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle non-object JSON values", async () => {
const nonObjectJson = JSON.stringify("string value");
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: nonObjectJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle null JSON value", async () => {
const nullJson = JSON.stringify(null);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: nullJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle array JSON value", async () => {
const arrayJson = JSON.stringify([1, 2, 3]);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: arrayJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
// Arrays are objects in JavaScript, so they pass the object check
// But they'll fail when trying to spread or access mcpServers property
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
expect(parsed[0]).toBe(1);
expect(parsed[1]).toBe(2);
expect(parsed[2]).toBe(3);
});
test("should merge complex nested configurations", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
server1: {
command: "cmd1",
env: { KEY1: "value1" },
},
server2: {
command: "cmd2",
env: { KEY2: "value2" },
},
github_file_ops: {
command: "overridden",
env: { CUSTOM: "value" },
},
},
otherConfig: {
nested: {
deeply: "value",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.server1).toBeDefined();
expect(parsed.mcpServers.server2).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
expect(parsed.otherConfig.nested.deeply).toBe("value");
});
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
const oldEnv = process.env.GITHUB_ACTION_PATH;
process.env.GITHUB_ACTION_PATH = "/test/action/path";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
"/test/action/path/src/mcp/github-file-ops-server.ts",
);
process.env.GITHUB_ACTION_PATH = oldEnv;
});
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
const oldEnv = process.env.GITHUB_WORKSPACE;
delete process.env.GITHUB_WORKSPACE; delete process.env.GITHUB_WORKSPACE;
const result = await prepareMcpConfig({ const result = await prepareMcpConfig({
githubToken: "test-token", githubToken: "token",
owner: "test-owner", owner: "owner",
repo: "test-repo", repo: "repo",
branch: "test-branch", branch: "branch",
baseBranch: "main",
allowedTools: [],
context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd()); expect(parsed.mcpServers.gitea.env.REPO_DIR).toBe(process.cwd());
process.env.GITHUB_WORKSPACE = oldEnv;
});
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
const oldEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
useCommitSigning: true,
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldEnv;
});
test("should not include github_ci server when context.isPR is false", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should not include github_ci server when actions:read permission is not granted", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: mockPRContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should parse additional_permissions with multiple lines correctly", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([
["actions", "read"],
["future", "permission"],
]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should warn when actions:read is requested but token lacks permission", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "invalid-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining(
"The github_ci MCP server requires 'actions: read' permission",
),
);
process.env.ACTIONS_TOKEN = oldTokenEnv;
}); });
}); });

View File

@@ -1,169 +1,63 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"; import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { checkWritePermissions } from "../src/github/validation/permissions"; import { checkWritePermissions } from "../src/github/validation/permissions";
import type { ParsedGitHubContext } from "../src/github/context"; 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", () => { describe("checkWritePermissions", () => {
let coreInfoSpy: any; let infoSpy: any;
let coreWarningSpy: any; const originalEnv = { ...process.env };
let coreErrorSpy: any;
beforeEach(() => { beforeEach(() => {
// Spy on core methods infoSpy = spyOn(core, "info").mockImplementation(() => {});
coreInfoSpy = spyOn(core, "info").mockImplementation(() => {}); process.env.GITEA_API_URL = "https://gitea.example.com/api/v1";
coreWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
coreErrorSpy = spyOn(core, "error").mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
coreInfoSpy.mockRestore(); infoSpy.mockRestore();
coreWarningSpy.mockRestore(); process.env = { ...originalEnv };
coreErrorSpy.mockRestore();
}); });
const createMockOctokit = (permission: string) => { test("returns true immediately in Gitea environments", async () => {
return { const client = { api: { getBaseUrl: () => "https://gitea.example.com/api/v1" } } as any;
repos: { const result = await checkWritePermissions(client, baseContext);
getCollaboratorPermissionLevel: async () => ({
data: { permission },
}),
},
} as any;
};
const createContext = (): ParsedGitHubContext => ({
runId: "1234567890",
eventName: "issue_comment",
eventAction: "created",
repository: {
full_name: "test-owner/test-repo",
owner: "test-owner",
repo: "test-repo",
},
actor: "test-user",
payload: {
action: "created",
issue: {
number: 1,
title: "Test Issue",
body: "Test body",
user: { login: "test-user" },
},
comment: {
id: 123,
body: "@claude test",
user: { login: "test-user" },
html_url:
"https://github.com/test-owner/test-repo/issues/1#issuecomment-123",
},
} as any,
entityNumber: 1,
isPR: false,
inputs: {
mode: "tag",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
},
});
test("should return true for admin permissions", async () => {
const mockOctokit = createMockOctokit("admin");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true); expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
"Checking permissions for actor: test-user", "Detected Gitea environment (https://gitea.example.com/api/v1), assuming actor has permissions",
); );
expect(coreInfoSpy).toHaveBeenCalledWith(
"Permission level retrieved: admin",
);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: admin");
});
test("should return true for write permissions", async () => {
const mockOctokit = createMockOctokit("write");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith("Actor has write access: write");
});
test("should return false for read permissions", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should return false for none permissions", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: none",
);
});
test("should throw error when permission check fails", async () => {
const error = new Error("API error");
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async () => {
throw error;
},
},
} as any;
const context = createContext();
await expect(checkWritePermissions(mockOctokit, context)).rejects.toThrow(
"Failed to check permissions for test-user: Error: API error",
);
expect(coreErrorSpy).toHaveBeenCalledWith(
"Failed to check permissions: Error: API error",
);
});
test("should call API with correct parameters", async () => {
let capturedParams: any;
const mockOctokit = {
repos: {
getCollaboratorPermissionLevel: async (params: any) => {
capturedParams = params;
return { data: { permission: "write" } };
},
},
} as any;
const context = createContext();
await checkWritePermissions(mockOctokit, context);
expect(capturedParams).toEqual({
owner: "test-owner",
repo: "test-repo",
username: "test-user",
});
}); });
}); });

View File

@@ -1,12 +1,11 @@
import { describe, test, expect, jest, beforeEach } from "bun:test"; import { describe, test, expect, jest, beforeEach } from "bun:test";
import { Octokit } from "@octokit/rest";
import { import {
updateClaudeComment, updateClaudeComment,
type UpdateClaudeCommentParams, type UpdateClaudeCommentParams,
} from "../src/github/operations/comments/update-claude-comment"; } from "../src/github/operations/comments/update-claude-comment";
describe("updateClaudeComment", () => { describe("updateClaudeComment", () => {
let mockOctokit: Octokit; let mockOctokit: any;
beforeEach(() => { beforeEach(() => {
mockOctokit = { mockOctokit = {
@@ -18,7 +17,7 @@ describe("updateClaudeComment", () => {
updateReviewComment: jest.fn(), updateReviewComment: jest.fn(),
}, },
}, },
} as any as Octokit; };
}); });
test("should update issue comment successfully", async () => { test("should update issue comment successfully", async () => {
@@ -31,7 +30,6 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -70,7 +68,6 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -109,7 +106,6 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest mockOctokit.rest.pulls.updateReviewComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -151,11 +147,9 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest mockOctokit.rest.pulls.updateReviewComment = jest
.fn() .fn()
.mockRejectedValue(mockError); .mockRejectedValue(mockError);
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -195,7 +189,6 @@ describe("updateClaudeComment", () => {
const mockError = new Error("Internal Server Error") as any; const mockError = new Error("Internal Server Error") as any;
mockError.status = 500; mockError.status = 500;
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest mockOctokit.rest.pulls.updateReviewComment = jest
.fn() .fn()
.mockRejectedValue(mockError); .mockRejectedValue(mockError);
@@ -226,7 +219,6 @@ describe("updateClaudeComment", () => {
test("should propagate error when issue comment update fails", async () => { test("should propagate error when issue comment update fails", async () => {
const mockError = new Error("Forbidden"); const mockError = new Error("Forbidden");
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockRejectedValue(mockError); .mockRejectedValue(mockError);
@@ -261,7 +253,6 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -294,7 +285,6 @@ describe("updateClaudeComment", () => {
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -345,7 +335,6 @@ const code = "example";
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest mockOctokit.rest.issues.updateComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);
@@ -388,7 +377,6 @@ const code = "example";
}, },
}; };
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest mockOctokit.rest.pulls.updateReviewComment = jest
.fn() .fn()
.mockResolvedValue(mockResponse); .mockResolvedValue(mockResponse);