diff --git a/test/branch-cleanup.test.ts b/test/branch-cleanup.test.ts index eef7fad..99730a3 100644 --- a/test/branch-cleanup.test.ts +++ b/test/branch-cleanup.test.ts @@ -1,50 +1,51 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup"; -import type { Octokits } from "../src/github/api/client"; +import 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(() => { - // Spy on console methods 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 createMockOctokit = ( - compareResponse?: any, - deleteRefError?: Error, - ): Octokits => { + const createMockClient = ( + options: { branchSha?: string; baseSha?: string; error?: Error } = {}, + ): GitHubClient => { + const { branchSha = "branch-sha", baseSha = "base-sha", error } = options; return { - rest: { - repos: { - compareCommitsWithBasehead: async () => ({ - data: compareResponse || { total_commits: 0 }, - }), - }, - git: { - deleteRef: async () => { - if (deleteRefError) { - throw deleteRefError; - } - return { data: {} }; - }, + api: { + getBranch: async (_owner: string, _repo: string, branch: string) => { + if (error) { + throw error; + } + return { + data: { + commit: { + sha: branch.includes("claude/") ? branchSha : baseSha, + }, + }, + }; }, }, - } as any as Octokits; + } as unknown as GitHubClient; }; - test("should return no branch link and not delete when branch is undefined", async () => { - const mockOctokit = createMockOctokit(); + test("returns defaults when no claude branch provided", async () => { + const client = createMockClient(); const result = await checkAndDeleteEmptyBranch( - mockOctokit, + client, "owner", "repo", undefined, @@ -56,94 +57,65 @@ describe("checkAndDeleteEmptyBranch", () => { expect(consoleLogSpy).not.toHaveBeenCalled(); }); - test("should delete branch and return no link when branch has no commits", async () => { - const mockOctokit = createMockOctokit({ total_commits: 0 }); + test("marks branch for deletion when SHAs match", async () => { + const client = createMockClient({ branchSha: "same", baseSha: "same" }); const result = await checkAndDeleteEmptyBranch( - mockOctokit, + client, "owner", "repo", - "claude/issue-123-20240101_123456", + "claude/issue-123", "main", ); expect(result.shouldDeleteBranch).toBe(true); expect(result.branchLink).toBe(""); expect(consoleLogSpy).toHaveBeenCalledWith( - "Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it", + "Branch claude/issue-123 has same SHA as base, marking for deletion", ); 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 () => { - const mockOctokit = createMockOctokit({ total_commits: 3 }); + test("returns branch link when branch has commits", async () => { + const client = createMockClient({ branchSha: "feature", baseSha: "main" }); const result = await checkAndDeleteEmptyBranch( - mockOctokit, + client, "owner", "repo", - "claude/issue-123-20240101_123456", + "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-20240101_123456)`, + `\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`, ); - expect(consoleLogSpy).not.toHaveBeenCalledWith( - expect.stringContaining("has no commits"), + expect(consoleLogSpy).toHaveBeenCalledWith( + "Branch claude/issue-123 appears to have commits (different SHA from base)", ); }); - test("should handle branch comparison errors gracefully", async () => { - const mockOctokit = { - rest: { - repos: { - compareCommitsWithBasehead: async () => { - throw new Error("API error"); - }, - }, - git: { - deleteRef: async () => ({ data: {} }), - }, - }, - } as any as Octokits; - + 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( - mockOctokit, + client, "owner", "repo", - "claude/issue-123-20240101_123456", + "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-20240101_123456)`, + `\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123)`, ); expect(consoleErrorSpy).toHaveBeenCalledWith( - "Error checking for commits on Claude branch:", + "Error checking branch:", expect.any(Error), ); - }); - - test("should handle branch deletion errors gracefully", async () => { - const deleteError = new Error("Delete failed"); - const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError); - - const result = await checkAndDeleteEmptyBranch( - mockOctokit, - "owner", - "repo", - "claude/issue-123-20240101_123456", - "main", - ); - - expect(result.shouldDeleteBranch).toBe(true); - expect(result.branchLink).toBe(""); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Failed to delete branch claude/issue-123-20240101_123456:", - deleteError, + expect(consoleLogSpy).toHaveBeenCalledWith( + "Assuming branch exists due to non-404 error", ); }); }); diff --git a/test/comment-logic.test.ts b/test/comment-logic.test.ts index ed2afa2..7599715 100644 --- a/test/comment-logic.test.ts +++ b/test/comment-logic.test.ts @@ -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"; 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: "https://github.com/owner/repo/actions/runs/123", + jobUrl: JOB_URL, branchName: undefined, triggerUsername: undefined, }; @@ -105,20 +121,19 @@ describe("updateCommentBody", () => { const result = updateCommentBody(input); 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", () => { const input = { ...baseInput, - branchLink: - "\n[View branch](https://github.com/owner/repo/src/branch/branch-name)", + branchLink: `\n[View branch](${BRANCH_BASE_URL}/branch-name)`, }; const result = updateCommentBody(input); 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 = { ...baseInput, 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", }; const result = updateCommentBody(input); 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"); }); @@ -142,12 +157,12 @@ describe("updateCommentBody", () => { it("adds PR link to header when provided", () => { const input = { ...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); 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 = { ...baseInput, 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); 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 expect(result).not.toContain("[Create a PR]"); @@ -170,21 +185,21 @@ describe("updateCommentBody", () => { const input = { ...baseInput, 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: - "\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); // Prefers the link found in content over the provided one 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", () => { 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 = { ...baseInput, currentBody: `Some comment with [Create a PR](${complexUrl})`, @@ -198,7 +213,7 @@ describe("updateCommentBody", () => { it("handles PR links with encoded URLs containing parentheses", () => { const complexUrl = - "https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)"; + "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})`, @@ -217,9 +232,9 @@ describe("updateCommentBody", () => { it("handles PR links with unencoded spaces and special characters", () => { const unEncodedUrl = - "https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)"; + "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://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 = { ...baseInput, 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", () => { 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 = { ...baseInput, 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", actionFailed: false, 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: { cost_usd: 0.01, duration_ms: 65000, // 1 minute 5 seconds @@ -333,7 +348,7 @@ describe("updateCommentBody", () => { ); expect(result).toContain("—— [View job]"); 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 ➔]"); @@ -358,7 +373,7 @@ describe("updateCommentBody", () => { const input = { ...baseInput, currentBody: - "Claude Code is working…\n\nI've made changes.\n[Create a PR](https://github.com/owner/repo/pr-url-in-content)\n\n@john-doe", + "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", }; @@ -367,7 +382,7 @@ describe("updateCommentBody", () => { // PR link should be moved to header 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 expect(result).not.toContain("[Create a PR]"); @@ -383,7 +398,7 @@ describe("updateCommentBody", () => { currentBody: "Claude Code is working… ", branchName: "claude/pr-456-20240101_120000", 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", }; @@ -391,7 +406,7 @@ describe("updateCommentBody", () => { // Should include the PR link in the formatted style expect(result).toContain( - "• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", + "• [Create PR ➔](https://gitea.example.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", ); expect(result).toContain("**Claude finished @jane-doe's task**"); }); @@ -401,20 +416,19 @@ describe("updateCommentBody", () => { ...baseInput, currentBody: "Claude Code is working…", branchName: "claude/issue-123-20240101_120000", - branchLink: - "\n[View branch](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)", + branchLink: `\n[View branch](${BRANCH_BASE_URL}/claude/issue-123-20240101_120000)`, 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); // Should include both links in formatted style 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( - "• [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)", ); }); }); diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 665a229..3f9e43c 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -8,7 +8,6 @@ import { buildDisallowedToolsString, } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt"; -import type { EventData } from "../src/create-prompt/types"; describe("generatePrompt", () => { 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("GENERAL_COMMENT"); @@ -162,7 +161,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData); + const prompt = generatePrompt(envVars, mockGitHubData, false); expect(prompt).toContain("PR_REVIEW"); expect(prompt).toContain("true"); @@ -188,16 +187,14 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData); + const prompt = generatePrompt(envVars, mockGitHubData, false); expect(prompt).toContain("ISSUE_CREATED"); expect(prompt).toContain( "new issue with '@claude' in body", ); - expect(prompt).toContain( - "[Create a PR](https://github.com/owner/repo/compare/main", - ); - expect(prompt).toContain("The target-branch should be 'main'"); + expect(prompt).toContain("mcp__gitea__update_issue_comment"); + expect(prompt).toContain("mcp__gitea__list_branches"); }); 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("ISSUE_ASSIGNED"); expect(prompt).toContain( "issue assigned to 'claude-bot'", ); - expect(prompt).toContain( - "[Create a PR](https://github.com/owner/repo/compare/develop", - ); + expect(prompt).toContain("mcp__gitea__list_branches"); + expect(prompt).toContain("mcp__local_git_ops__checkout_branch"); }); 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(""); 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("PULL_REQUEST"); expect(prompt).toContain("true"); @@ -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"); }); @@ -313,11 +309,11 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData); + const prompt = generatePrompt(envVars, mockGitHubData, false); expect(prompt).toContain("johndoe"); expect(prompt).toContain( - "Co-authored-by: johndoe ", + "johndoe", ); }); @@ -334,7 +330,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData); + const prompt = generatePrompt(envVars, mockGitHubData, false); // Should contain PR-specific instructions 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 - expect(prompt).toContain( - "You are already on the correct branch (claude/issue-789-20240101_120000)", - ); - expect(prompt).toContain( - "IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)", - ); - expect(prompt).toContain("Create a PR](https://github.com/"); - expect(prompt).toContain( - "If you created a branch and made changes, your comment must include the PR URL", - ); + 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( @@ -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 - expect(prompt).toContain( - "You are already on the correct branch (claude/issue-123-20240101_120000)", - ); - expect(prompt).toContain( - "IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)", - ); - expect(prompt).toContain( - "The branch-name is the current branch: claude/issue-123-20240101_120000", - ); - }); - - test("should handle closed PR with new branch", () => { - const envVars: PreparedContext = { - repository: "owner/repo", - claudeCommentId: "12345", - triggerPhrase: "@claude", - eventData: { - eventName: "issue_comment", - commentId: "67890", - isPR: true, - prNumber: "456", - commentBody: "@claude please fix this", - claudeBranch: "claude/pr-456-20240101_120000", - 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", - ); + // Should surface the issue number and comment metadata + expect(prompt).toContain("123"); + expect(prompt).toContain("12345"); }); 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 expect(prompt).toContain( @@ -488,84 +434,6 @@ describe("generatePrompt", () => { "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", () => { @@ -612,81 +480,36 @@ describe("getEventTypeAndContext", () => { }); describe("buildAllowedToolsString", () => { - test("should return issue comment tool for regular events", () => { - const mockEventData: EventData = { - eventName: "issue_comment", - commentId: "123", - isPR: true, - prNumber: "456", - commentBody: "Test comment", - }; + test("should include base tools", () => { + const result = buildAllowedToolsString(); - const result = buildAllowedToolsString(mockEventData); - - // The base tools should be in the result expect(result).toContain("Edit"); expect(result).toContain("Glob"); - expect(result).toContain("Grep"); - expect(result).toContain("LS"); - expect(result).toContain("Read"); - expect(result).toContain("Write"); - expect(result).toContain("mcp__github__update_issue_comment"); - expect(result).not.toContain("mcp__github__update_pull_request_comment"); - expect(result).toContain("mcp__local_git_ops__commit_files"); - expect(result).toContain("mcp__local_git_ops__delete_files"); + expect(result).toContain("mcp__gitea__update_issue_comment"); + expect(result).toContain("mcp__gitea__update_pull_request_comment"); }); - test("should return PR comment tool for inline review comments", () => { - const mockEventData: EventData = { - eventName: "pull_request_review_comment", - isPR: true, - prNumber: "456", - commentBody: "Test review comment", - commentId: "789", - }; + test("should include commit signing tools when enabled", () => { + const result = buildAllowedToolsString(undefined, false, true); - 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 - expect(result).toContain("Edit"); - expect(result).toContain("Glob"); - expect(result).toContain("Grep"); - expect(result).toContain("LS"); - expect(result).toContain("Read"); - expect(result).toContain("Write"); - expect(result).not.toContain("mcp__github__update_issue_comment"); - expect(result).toContain("mcp__github__update_pull_request_comment"); - expect(result).toContain("mcp__local_git_ops__commit_files"); - expect(result).toContain("mcp__local_git_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 mockEventData: EventData = { - eventName: "issue_comment", - commentId: "123", - isPR: true, - prNumber: "456", - commentBody: "Test comment", - }; - 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("Tool2"); expect(result).toContain("Tool3"); - - // Verify format with comma separation - const basePlusCustom = result.split(","); - expect(basePlusCustom.length).toBeGreaterThan(10); // At least the base tools plus custom - expect(basePlusCustom).toContain("Tool1"); - expect(basePlusCustom).toContain("Tool2"); - expect(basePlusCustom).toContain("Tool3"); }); }); diff --git a/test/image-downloader.test.ts b/test/image-downloader.test.ts index 01f30fa..6488e65 100644 --- a/test/image-downloader.test.ts +++ b/test/image-downloader.test.ts @@ -1,665 +1,48 @@ +import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"; import { - describe, - test, - expect, - spyOn, - beforeEach, - afterEach, - jest, - setSystemTime, -} from "bun:test"; -import fs from "fs/promises"; -import { downloadCommentImages } from "../src/github/utils/image-downloader"; -import type { CommentWithImages } from "../src/github/utils/image-downloader"; -import type { Octokits } from "../src/github/api/client"; + downloadCommentImages, + type CommentWithImages, +} from "../src/github/utils/image-downloader"; + +const noopClient = { api: {} } as any; describe("downloadCommentImages", () => { let consoleLogSpy: any; - let consoleWarnSpy: any; - let consoleErrorSpy: any; - let fsMkdirSpy: any; - let fsWriteFileSpy: any; - let fetchSpy: any; beforeEach(() => { - // Spy on console methods consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); - consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {}); - consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); - - // Spy on fs methods - fsMkdirSpy = spyOn(fs, "mkdir").mockResolvedValue(undefined); - fsWriteFileSpy = spyOn(fs, "writeFile").mockResolvedValue(undefined); - - // Set fake system time for consistent filenames - setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000 }); afterEach(() => { consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - fsMkdirSpy.mockRestore(); - fsWriteFileSpy.mockRestore(); - if (fetchSpy) fetchSpy.mockRestore(); - setSystemTime(); // Reset to real time }); - const createMockOctokit = (): Octokits => { - return { - rest: { - issues: { - getComment: jest.fn(), - get: jest.fn(), - }, - pulls: { - getReviewComment: jest.fn(), - getReview: jest.fn(), - get: jest.fn(), - }, - }, - } as any as Octokits; - }; + test("returns empty map and logs disabled message", async () => { + const result = await downloadCommentImages( + noopClient, + "owner", + "repo", + [] as CommentWithImages[], + ); - test("should create download directory", async () => { - const mockOctokit = createMockOctokit(); - const comments: CommentWithImages[] = []; - - await downloadCommentImages(mockOctokit, "owner", "repo", comments); - - expect(fsMkdirSpy).toHaveBeenCalledWith("/tmp/github-images", { - recursive: true, - }); + expect(result.size).toBe(0); + expect(consoleLogSpy).toHaveBeenCalledWith( + "Image downloading temporarily disabled during Octokit migration", + ); }); - test("should handle comments without images", async () => { - const mockOctokit = createMockOctokit(); + test("ignores provided comments while feature disabled", async () => { const comments: CommentWithImages[] = [ { type: "issue_comment", id: "123", - body: "This is a comment without images", + body: "![img](https://example.com/image.png)", }, ]; - const result = await downloadCommentImages( - mockOctokit, - "owner", - "repo", - comments, - ); + const result = await downloadCommentImages(noopClient, "owner", "repo", comments); expect(result.size).toBe(0); - expect(consoleLogSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Found"), - ); - }); - - test("should detect and download images from issue comments", async () => { - const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/test-image.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/test.png?jwt=token"; - - // Mock octokit response - // @ts-expect-error Mock implementation doesn't match full type signature - mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ - data: { - body_html: ``, - }, - }); - - // 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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: ``, - }, - }); - - 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(); + expect(consoleLogSpy).toHaveBeenCalled(); }); }); diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index ac8c11e..4c2b958 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -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 * as core from "@actions/core"; -import type { ParsedGitHubContext } from "../src/github/context"; + +const originalEnv = { ...process.env }; 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(() => { - consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); - consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); - setFailedSpy = spyOn(core, "setFailed").mockImplementation(() => {}); - 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"; - } + process.env.GITHUB_ACTION_PATH = "/action/path"; + process.env.GITHUB_WORKSPACE = "/workspace"; + process.env.GITEA_API_URL = "https://gitea.example.com/api/v1"; }); afterEach(() => { - consoleInfoSpy.mockRestore(); - consoleWarningSpy.mockRestore(); - setFailedSpy.mockRestore(); - processExitSpy.mockRestore(); + process.env = { ...originalEnv }; }); - 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({ - githubToken: "test-token", - owner: "test-owner", - repo: "test-repo", - branch: "test-branch", - baseBranch: "main", - allowedTools: [], - context: mockContext, + githubToken: "token", + owner: "owner", + repo: "repo", + branch: "branch", }); 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(); - 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"); - }); + expect(Object.keys(parsed.mcpServers)).toEqual(["gitea", "local_git_ops"]); - test("should return file ops server when commit signing is enabled", 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: [], - 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", - }, - }, + 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", }, }); - 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.local_git_ops.args[1]).toBe( + "/action/path/src/mcp/local-git-ops-server.ts", ); - 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 () => { - 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; + test("falls back to process.cwd when workspace not provided", async () => { delete process.env.GITHUB_WORKSPACE; const result = await prepareMcpConfig({ - githubToken: "test-token", - owner: "test-owner", - repo: "test-repo", - branch: "test-branch", - baseBranch: "main", - allowedTools: [], - context: mockContextWithSigning, + githubToken: "token", + owner: "owner", + repo: "repo", + branch: "branch", }); const parsed = JSON.parse(result); - expect(parsed.mcpServers.github_file_ops.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; + expect(parsed.mcpServers.gitea.env.REPO_DIR).toBe(process.cwd()); }); }); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 2caaaf8..10e244e 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -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 { 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 coreInfoSpy: any; - let coreWarningSpy: any; - let coreErrorSpy: any; + let infoSpy: any; + const originalEnv = { ...process.env }; beforeEach(() => { - // Spy on core methods - coreInfoSpy = spyOn(core, "info").mockImplementation(() => {}); - coreWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); - coreErrorSpy = spyOn(core, "error").mockImplementation(() => {}); + infoSpy = spyOn(core, "info").mockImplementation(() => {}); + process.env.GITEA_API_URL = "https://gitea.example.com/api/v1"; }); afterEach(() => { - coreInfoSpy.mockRestore(); - coreWarningSpy.mockRestore(); - coreErrorSpy.mockRestore(); + infoSpy.mockRestore(); + process.env = { ...originalEnv }; }); - const createMockOctokit = (permission: string) => { - return { - repos: { - getCollaboratorPermissionLevel: async () => ({ - data: { permission }, - }), - }, - } as any; - }; - - const createContext = (): ParsedGitHubContext => ({ - runId: "1234567890", - eventName: "issue_comment", - eventAction: "created", - repository: { - full_name: "test-owner/test-repo", - owner: "test-owner", - repo: "test-repo", - }, - actor: "test-user", - payload: { - action: "created", - issue: { - number: 1, - title: "Test Issue", - body: "Test body", - user: { login: "test-user" }, - }, - comment: { - id: 123, - body: "@claude test", - user: { login: "test-user" }, - html_url: - "https://github.com/test-owner/test-repo/issues/1#issuecomment-123", - }, - } as any, - entityNumber: 1, - isPR: false, - inputs: { - 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); + 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(coreInfoSpy).toHaveBeenCalledWith( - "Checking permissions for actor: test-user", + expect(infoSpy).toHaveBeenCalledWith( + "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", - }); }); }); diff --git a/test/update-claude-comment.test.ts b/test/update-claude-comment.test.ts index e56c039..0f16713 100644 --- a/test/update-claude-comment.test.ts +++ b/test/update-claude-comment.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect, jest, beforeEach } from "bun:test"; -import { Octokit } from "@octokit/rest"; import { updateClaudeComment, type UpdateClaudeCommentParams, } from "../src/github/operations/comments/update-claude-comment"; describe("updateClaudeComment", () => { - let mockOctokit: Octokit; + let mockOctokit: any; beforeEach(() => { mockOctokit = { @@ -18,7 +17,7 @@ describe("updateClaudeComment", () => { updateReviewComment: jest.fn(), }, }, - } as any as Octokit; + }; }); 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 .fn() .mockResolvedValue(mockResponse); @@ -70,7 +68,6 @@ describe("updateClaudeComment", () => { }, }; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.updateComment = jest .fn() .mockResolvedValue(mockResponse); @@ -109,7 +106,6 @@ describe("updateClaudeComment", () => { }, }; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.updateReviewComment = jest .fn() .mockResolvedValue(mockResponse); @@ -151,11 +147,9 @@ describe("updateClaudeComment", () => { }, }; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.updateReviewComment = jest .fn() .mockRejectedValue(mockError); - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.updateComment = jest .fn() .mockResolvedValue(mockResponse); @@ -195,7 +189,6 @@ describe("updateClaudeComment", () => { const mockError = new Error("Internal Server Error") as any; mockError.status = 500; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.updateReviewComment = jest .fn() .mockRejectedValue(mockError); @@ -226,7 +219,6 @@ describe("updateClaudeComment", () => { test("should propagate error when issue comment update fails", async () => { const mockError = new Error("Forbidden"); - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.updateComment = jest .fn() .mockRejectedValue(mockError); @@ -261,7 +253,6 @@ describe("updateClaudeComment", () => { }, }; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.updateComment = jest .fn() .mockResolvedValue(mockResponse); @@ -294,7 +285,6 @@ describe("updateClaudeComment", () => { }, }; - // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.updateComment = jest .fn() .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 .fn() .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 .fn() .mockResolvedValue(mockResponse);