* refactor: unify action into single composite step with run.ts entrypoint Consolidate the prepare and base-action phases into a single composite step that runs src/entrypoints/run.ts. This simplifies the action.yml from multiple steps to one execution step, while keeping the same behavior. Key changes: - Add src/entrypoints/run.ts as unified entrypoint - Simplify action.yml to single 'Run Claude Code Action' step - Pass all inputs via environment variables - Update base-action to accept inputs via env vars - Support agent mode auto-detection from prompt input * refactor: keep SSH signing cleanup and token revocation as separate action steps Move SSH signing key cleanup and app token revocation back to separate composite action steps in action.yml with always() conditions, rather than handling them inside run.ts. This keeps these cleanup concerns as independently visible steps in the workflow. * fix: address PR review feedback - Use path.dirname() instead of manual string slicing for executable path - Differentiate prepare vs execution errors in catch block so tracking comment accurately reflects which phase failed - Update CLAUDE.md architecture docs to reflect unified run.ts entrypoint and four-phase design * fix: address PR review feedback - Use path.dirname() instead of manual string slicing for executable path - Differentiate prepare vs execution errors in catch block so tracking comment accurately reflects which phase failed - Rewrite CLAUDE.md to focus on mental model, key concepts, and gotchas instead of exhaustive file listings
1267 lines
38 KiB
TypeScript
1267 lines
38 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, test, expect } from "bun:test";
|
|
import {
|
|
generatePrompt,
|
|
generateDefaultPrompt,
|
|
getEventTypeAndContext,
|
|
buildAllowedToolsString,
|
|
buildDisallowedToolsString,
|
|
} from "../src/create-prompt";
|
|
import type { PreparedContext } from "../src/create-prompt";
|
|
import type { Mode } from "../src/modes/types";
|
|
|
|
describe("generatePrompt", () => {
|
|
// Create a mock tag mode that uses the default prompt
|
|
const mockTagMode: Mode = {
|
|
name: "tag",
|
|
description: "Tag mode",
|
|
shouldTrigger: () => true,
|
|
prepareContext: (context) => ({ mode: "tag", githubContext: context }),
|
|
getAllowedTools: () => [],
|
|
getDisallowedTools: () => [],
|
|
shouldCreateTrackingComment: () => true,
|
|
generatePrompt: (context, githubData, useCommitSigning) =>
|
|
generateDefaultPrompt(context, githubData, useCommitSigning),
|
|
prepare: async () => ({
|
|
commentId: 123,
|
|
branchInfo: {
|
|
baseBranch: "main",
|
|
currentBranch: "main",
|
|
claudeBranch: undefined,
|
|
},
|
|
mcpConfig: "{}",
|
|
claudeArgs: "",
|
|
}),
|
|
};
|
|
|
|
// Create a mock agent mode that passes through prompts
|
|
const mockAgentMode: Mode = {
|
|
name: "agent",
|
|
description: "Agent mode",
|
|
shouldTrigger: () => true,
|
|
prepareContext: (context) => ({ mode: "agent", githubContext: context }),
|
|
getAllowedTools: () => [],
|
|
getDisallowedTools: () => [],
|
|
shouldCreateTrackingComment: () => false,
|
|
generatePrompt: (context) => context.prompt || "",
|
|
prepare: async () => ({
|
|
commentId: undefined,
|
|
branchInfo: {
|
|
baseBranch: "main",
|
|
currentBranch: "main",
|
|
claudeBranch: undefined,
|
|
},
|
|
mcpConfig: "{}",
|
|
claudeArgs: "",
|
|
}),
|
|
};
|
|
|
|
const mockGitHubData = {
|
|
contextData: {
|
|
title: "Test PR",
|
|
body: "This is a test PR",
|
|
author: { login: "testuser" },
|
|
state: "OPEN",
|
|
labels: { nodes: [] },
|
|
createdAt: "2023-01-01T00:00:00Z",
|
|
additions: 15,
|
|
deletions: 5,
|
|
baseRefName: "main",
|
|
headRefName: "feature-branch",
|
|
headRefOid: "abc123",
|
|
commits: {
|
|
totalCount: 2,
|
|
nodes: [
|
|
{
|
|
commit: {
|
|
oid: "commit1",
|
|
message: "Add feature",
|
|
author: {
|
|
name: "John Doe",
|
|
email: "john@example.com",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
files: {
|
|
nodes: [
|
|
{
|
|
path: "src/file1.ts",
|
|
additions: 10,
|
|
deletions: 5,
|
|
changeType: "MODIFIED",
|
|
},
|
|
],
|
|
},
|
|
comments: {
|
|
nodes: [
|
|
{
|
|
id: "comment1",
|
|
databaseId: "123456",
|
|
body: "First comment",
|
|
author: { login: "user1" },
|
|
createdAt: "2023-01-01T01:00:00Z",
|
|
},
|
|
],
|
|
},
|
|
reviews: {
|
|
nodes: [
|
|
{
|
|
id: "review1",
|
|
author: { login: "reviewer1" },
|
|
body: "LGTM",
|
|
state: "APPROVED",
|
|
submittedAt: "2023-01-01T02:00:00Z",
|
|
comments: {
|
|
nodes: [],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
comments: [
|
|
{
|
|
id: "comment1",
|
|
databaseId: "123456",
|
|
body: "First comment",
|
|
author: { login: "user1" },
|
|
createdAt: "2023-01-01T01:00:00Z",
|
|
},
|
|
{
|
|
id: "comment2",
|
|
databaseId: "123457",
|
|
body: "@claude help me",
|
|
author: { login: "user2" },
|
|
createdAt: "2023-01-01T01:30:00Z",
|
|
},
|
|
],
|
|
changedFiles: [],
|
|
changedFilesWithSHA: [
|
|
{
|
|
path: "src/file1.ts",
|
|
additions: 10,
|
|
deletions: 5,
|
|
changeType: "MODIFIED",
|
|
sha: "abc123",
|
|
},
|
|
],
|
|
reviewData: {
|
|
nodes: [
|
|
{
|
|
id: "review1",
|
|
databaseId: "400001",
|
|
author: { login: "reviewer1" },
|
|
body: "LGTM",
|
|
state: "APPROVED",
|
|
submittedAt: "2023-01-01T02:00:00Z",
|
|
comments: {
|
|
nodes: [],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
imageUrlMap: new Map<string, string>(),
|
|
};
|
|
|
|
test("should generate prompt for issue_comment event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: false,
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-67890-20240101-1200",
|
|
issueNumber: "67890",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("You are Claude, an AI assistant");
|
|
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
|
expect(prompt).toContain("<is_pr>false</is_pr>");
|
|
expect(prompt).toContain(
|
|
"<trigger_context>issue comment with '@claude'</trigger_context>",
|
|
);
|
|
expect(prompt).toContain("<repository>owner/repo</repository>");
|
|
expect(prompt).toContain("<claude_comment_id>12345</claude_comment_id>");
|
|
expect(prompt).toContain("<trigger_username>Unknown</trigger_username>");
|
|
expect(prompt).toContain("[user1 at 2023-01-01T01:00:00Z]: First comment"); // from formatted comments
|
|
expect(prompt).not.toContain("filename\tstatus\tadditions\tdeletions\tsha"); // since it's not a PR
|
|
});
|
|
|
|
test("should generate prompt for pull_request_review event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "pull_request_review",
|
|
isPR: true,
|
|
prNumber: "456",
|
|
commentBody: "@claude please fix this bug",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
|
expect(prompt).toContain("<is_pr>true</is_pr>");
|
|
expect(prompt).toContain("<pr_number>456</pr_number>");
|
|
expect(prompt).toContain("- src/file1.ts (MODIFIED) +10/-5 SHA: abc123"); // from formatted changed files
|
|
expect(prompt).toContain(
|
|
"[Review by reviewer1 at 2023-01-01T02:00:00Z]: APPROVED",
|
|
); // from review comments
|
|
});
|
|
|
|
test("should generate prompt for issue opened event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "opened",
|
|
isPR: false,
|
|
issueNumber: "789",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-789-20240101-1200",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
|
expect(prompt).toContain(
|
|
"<trigger_context>new issue with '@claude' in body</trigger_context>",
|
|
);
|
|
expect(prompt).toContain(
|
|
"[Create a PR](https://github.com/owner/repo/compare/main",
|
|
);
|
|
expect(prompt).toContain("The target-branch should be 'main'");
|
|
});
|
|
|
|
test("should generate prompt for issue assigned event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "assigned",
|
|
isPR: false,
|
|
issueNumber: "999",
|
|
baseBranch: "develop",
|
|
claudeBranch: "claude/issue-999-20240101-1200",
|
|
assigneeTrigger: "claude-bot",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
|
expect(prompt).toContain(
|
|
"<trigger_context>issue assigned to 'claude-bot'</trigger_context>",
|
|
);
|
|
expect(prompt).toContain(
|
|
"[Create a PR](https://github.com/owner/repo/compare/develop",
|
|
);
|
|
});
|
|
|
|
test("should generate prompt for issue labeled event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "labeled",
|
|
isPR: false,
|
|
issueNumber: "888",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-888-20240101-1200",
|
|
labelTrigger: "claude-task",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
|
expect(prompt).toContain(
|
|
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
|
|
);
|
|
expect(prompt).toContain(
|
|
"[Create a PR](https://github.com/owner/repo/compare/main",
|
|
);
|
|
});
|
|
|
|
// Removed test - direct_prompt field no longer supported in v1.0
|
|
|
|
test("should generate prompt for pull_request event", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "pull_request",
|
|
eventAction: "opened",
|
|
isPR: true,
|
|
prNumber: "999",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
|
expect(prompt).toContain("<is_pr>true</is_pr>");
|
|
expect(prompt).toContain("<pr_number>999</pr_number>");
|
|
expect(prompt).toContain("pull request opened");
|
|
});
|
|
|
|
test("should generate prompt for issue comment without custom fields", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: false,
|
|
issueNumber: "123",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-67890-20240101-1200",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Verify prompt generates successfully without custom instructions
|
|
expect(prompt).toContain("@claude please fix this");
|
|
expect(prompt).not.toContain("CUSTOM INSTRUCTIONS");
|
|
});
|
|
|
|
test("should use override_prompt when provided", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
prompt: "Simple prompt for reviewing PR",
|
|
eventData: {
|
|
eventName: "pull_request",
|
|
eventAction: "opened",
|
|
isPR: true,
|
|
prNumber: "123",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockAgentMode,
|
|
);
|
|
|
|
// Agent mode: Prompt is passed through as-is
|
|
expect(prompt).toBe("Simple prompt for reviewing PR");
|
|
expect(prompt).not.toContain("You are Claude, an AI assistant");
|
|
});
|
|
|
|
test("should pass through prompt without variable substitution", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "test/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
triggerUsername: "john-doe",
|
|
prompt: `Repository: $REPOSITORY
|
|
PR: $PR_NUMBER
|
|
Title: $PR_TITLE
|
|
Body: $PR_BODY
|
|
Comments: $PR_COMMENTS
|
|
Review Comments: $REVIEW_COMMENTS
|
|
Changed Files: $CHANGED_FILES
|
|
Trigger Comment: $TRIGGER_COMMENT
|
|
Username: $TRIGGER_USERNAME
|
|
Branch: $BRANCH_NAME
|
|
Base: $BASE_BRANCH
|
|
Event: $EVENT_TYPE
|
|
Is PR: $IS_PR`,
|
|
eventData: {
|
|
eventName: "pull_request_review_comment",
|
|
isPR: true,
|
|
prNumber: "456",
|
|
commentBody: "Please review this code",
|
|
claudeBranch: "feature-branch",
|
|
baseBranch: "main",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockAgentMode,
|
|
);
|
|
|
|
// v1.0: Variables are NOT substituted - prompt is passed as-is to Claude Code
|
|
expect(prompt).toContain("Repository: $REPOSITORY");
|
|
expect(prompt).toContain("PR: $PR_NUMBER");
|
|
expect(prompt).toContain("Title: $PR_TITLE");
|
|
expect(prompt).toContain("Body: $PR_BODY");
|
|
expect(prompt).toContain("Branch: $BRANCH_NAME");
|
|
expect(prompt).toContain("Base: $BASE_BRANCH");
|
|
expect(prompt).toContain("Username: $TRIGGER_USERNAME");
|
|
expect(prompt).toContain("Comment: $TRIGGER_COMMENT");
|
|
});
|
|
|
|
test("should handle override_prompt for issues", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
prompt: "Review issue and provide feedback",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "opened",
|
|
isPR: false,
|
|
issueNumber: "789",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-789-20240101-1200",
|
|
},
|
|
};
|
|
|
|
const issueGitHubData = {
|
|
...mockGitHubData,
|
|
contextData: {
|
|
title: "Bug: Login form broken",
|
|
body: "The login form is not working",
|
|
author: { login: "testuser" },
|
|
state: "OPEN",
|
|
labels: { nodes: [] },
|
|
createdAt: "2023-01-01T00:00:00Z",
|
|
comments: {
|
|
nodes: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
issueGitHubData,
|
|
false,
|
|
mockAgentMode,
|
|
);
|
|
|
|
// Agent mode: Prompt is passed through as-is
|
|
expect(prompt).toBe("Review issue and provide feedback");
|
|
});
|
|
|
|
test("should handle prompt without substitution", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
prompt: "PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
|
|
eventData: {
|
|
eventName: "pull_request",
|
|
eventAction: "opened",
|
|
isPR: true,
|
|
prNumber: "123",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockAgentMode,
|
|
);
|
|
|
|
// Agent mode: No substitution - passed as-is
|
|
expect(prompt).toBe(
|
|
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
|
|
);
|
|
});
|
|
|
|
test("should not substitute variables when override_prompt is not provided", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "opened",
|
|
isPR: false,
|
|
issueNumber: "123",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-123-20240101-1200",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("You are Claude, an AI assistant");
|
|
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
|
});
|
|
|
|
test("should include trigger username when provided", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
triggerUsername: "johndoe",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: false,
|
|
issueNumber: "123",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-67890-20240101-1200",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
|
// With commit signing disabled, co-author info appears in git commit instructions
|
|
expect(prompt).toContain(
|
|
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
|
);
|
|
});
|
|
|
|
test("should include PR-specific instructions only for PR events", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "pull_request_review",
|
|
isPR: true,
|
|
prNumber: "456",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain PR-specific instructions (git commands when not using signing)
|
|
expect(prompt).toContain("git push");
|
|
expect(prompt).toContain(
|
|
"Always push to the existing branch when triggered on a PR",
|
|
);
|
|
|
|
// Should NOT contain Issue-specific instructions
|
|
expect(prompt).not.toContain("You are already on the correct branch (");
|
|
expect(prompt).not.toContain(
|
|
"IMPORTANT: You are already on the correct branch (",
|
|
);
|
|
expect(prompt).not.toContain("Create a PR](https://github.com/");
|
|
});
|
|
|
|
test("should include Issue-specific instructions only for Issue events", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "opened",
|
|
isPR: false,
|
|
issueNumber: "789",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-789-20240101-1200",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain Issue-specific instructions
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/issue-789-20240101-1200)",
|
|
);
|
|
expect(prompt).toContain(
|
|
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101-1200)",
|
|
);
|
|
expect(prompt).toContain("Create a PR](https://github.com/");
|
|
expect(prompt).toContain(
|
|
"If you created anything in your branch, your comment must include the PR URL",
|
|
);
|
|
|
|
// Should NOT contain PR-specific instructions
|
|
expect(prompt).not.toContain(
|
|
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
|
);
|
|
expect(prompt).not.toContain(
|
|
"Always push to the existing branch when triggered on a PR",
|
|
);
|
|
});
|
|
|
|
test("should use actual branch name for issue comments", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: false,
|
|
issueNumber: "123",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-123-20240101-1200",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain the actual branch name with timestamp
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/issue-123-20240101-1200)",
|
|
);
|
|
expect(prompt).toContain(
|
|
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101-1200)",
|
|
);
|
|
expect(prompt).toContain(
|
|
"The branch-name is the current branch: claude/issue-123-20240101-1200",
|
|
);
|
|
});
|
|
|
|
test("should handle closed PR with new branch", async () => {
|
|
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-1200",
|
|
baseBranch: "main",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain branch-specific instructions like issues
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/pr-456-20240101-1200)",
|
|
);
|
|
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-1200",
|
|
);
|
|
expect(prompt).toContain("Reference to the original PR");
|
|
expect(prompt).toContain(
|
|
"If you created anything in your branch, your comment must include the PR URL",
|
|
);
|
|
|
|
// Should NOT contain open PR instructions
|
|
expect(prompt).not.toContain(
|
|
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
|
);
|
|
});
|
|
|
|
test("should handle open PR without new branch", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: true,
|
|
prNumber: "456",
|
|
commentBody: "@claude please fix this",
|
|
// No claudeBranch or baseBranch for open PRs
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain open PR instructions (git commands when not using signing)
|
|
expect(prompt).toContain("git push");
|
|
expect(prompt).toContain(
|
|
"Always push to the existing branch when triggered on a PR",
|
|
);
|
|
|
|
// Should NOT contain new branch instructions
|
|
expect(prompt).not.toContain("Create a PR](https://github.com/");
|
|
expect(prompt).not.toContain("You are already on the correct branch");
|
|
expect(prompt).not.toContain(
|
|
"If you created anything in your branch, your comment must include the PR URL",
|
|
);
|
|
});
|
|
|
|
test("should handle PR review on closed PR with new branch", async () => {
|
|
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-1230",
|
|
baseBranch: "develop",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain new branch instructions
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/pr-789-20240101-1230)",
|
|
);
|
|
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", async () => {
|
|
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-1400",
|
|
baseBranch: "main",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain new branch instructions
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/pr-999-20240101-1400)",
|
|
);
|
|
expect(prompt).toContain("Create a PR](https://github.com/");
|
|
expect(prompt).toContain("Reference to the original PR");
|
|
expect(prompt).toContain(
|
|
"If you created anything in your branch, your comment must include the PR URL",
|
|
);
|
|
});
|
|
|
|
test("should handle pull_request event on closed PR with new branch", async () => {
|
|
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-1500",
|
|
baseBranch: "main",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should contain new branch instructions
|
|
expect(prompt).toContain(
|
|
"You are already on the correct branch (claude/pr-555-20240101-1500)",
|
|
);
|
|
expect(prompt).toContain("Create a PR](https://github.com/");
|
|
expect(prompt).toContain("Reference to the original PR");
|
|
});
|
|
|
|
test("should include git commands when useCommitSigning is false", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: true,
|
|
prNumber: "123",
|
|
commentBody: "@claude fix the bug",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
false,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should have git command instructions
|
|
expect(prompt).toContain("Use git commands via the Bash tool");
|
|
expect(prompt).toContain("git add");
|
|
expect(prompt).toContain("git commit");
|
|
expect(prompt).toContain("git push");
|
|
|
|
// Should use the minimal comment tool
|
|
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Should not have commit signing tool references
|
|
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
|
|
});
|
|
|
|
test("should include commit signing tools when useCommitSigning is true", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issue_comment",
|
|
commentId: "67890",
|
|
isPR: true,
|
|
prNumber: "123",
|
|
commentBody: "@claude fix the bug",
|
|
},
|
|
};
|
|
|
|
const prompt = await generatePrompt(
|
|
envVars,
|
|
mockGitHubData,
|
|
true,
|
|
mockTagMode,
|
|
);
|
|
|
|
// Should have commit signing tool instructions
|
|
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
|
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
|
// Comment tool should always be from comment server, not file ops
|
|
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Should not have git command instructions
|
|
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
|
});
|
|
});
|
|
|
|
describe("getEventTypeAndContext", () => {
|
|
test("should return correct type and context for pull_request_review_comment", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "pull_request_review_comment",
|
|
isPR: true,
|
|
prNumber: "123",
|
|
commentBody: "@claude please fix this",
|
|
},
|
|
};
|
|
|
|
const result = getEventTypeAndContext(envVars);
|
|
|
|
expect(result.eventType).toBe("REVIEW_COMMENT");
|
|
expect(result.triggerContext).toBe("PR review comment with '@claude'");
|
|
});
|
|
|
|
test("should return correct type and context for issue assigned", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "assigned",
|
|
isPR: false,
|
|
issueNumber: "999",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-999-20240101-1200",
|
|
assigneeTrigger: "claude-bot",
|
|
},
|
|
};
|
|
|
|
const result = getEventTypeAndContext(envVars);
|
|
|
|
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
|
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
|
|
});
|
|
|
|
test("should return correct type and context for issue labeled", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "labeled",
|
|
isPR: false,
|
|
issueNumber: "888",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-888-20240101-1200",
|
|
labelTrigger: "claude-task",
|
|
},
|
|
};
|
|
|
|
const result = getEventTypeAndContext(envVars);
|
|
|
|
expect(result.eventType).toBe("ISSUE_LABELED");
|
|
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
|
|
});
|
|
|
|
test("should return correct type and context for issue assigned without assigneeTrigger", async () => {
|
|
const envVars: PreparedContext = {
|
|
repository: "owner/repo",
|
|
claudeCommentId: "12345",
|
|
triggerPhrase: "@claude",
|
|
prompt: "Please assess this issue",
|
|
eventData: {
|
|
eventName: "issues",
|
|
eventAction: "assigned",
|
|
isPR: false,
|
|
issueNumber: "999",
|
|
baseBranch: "main",
|
|
claudeBranch: "claude/issue-999-20240101-1200",
|
|
// No assigneeTrigger when using prompt
|
|
},
|
|
};
|
|
|
|
const result = getEventTypeAndContext(envVars);
|
|
|
|
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
|
expect(result.triggerContext).toBe("issue assigned event");
|
|
});
|
|
});
|
|
|
|
describe("buildAllowedToolsString", () => {
|
|
test("should return correct tools for regular events (default no signing)", async () => {
|
|
const result = buildAllowedToolsString();
|
|
|
|
// 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");
|
|
|
|
// Default is no commit signing, so should have specific Bash git commands
|
|
expect(result).toContain("Bash(git add:*)");
|
|
expect(result).toContain("Bash(git commit:*)");
|
|
expect(result).toContain("Bash(git push:*)");
|
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Should not have commit signing tools
|
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
|
});
|
|
|
|
test("should return correct tools with default parameters", async () => {
|
|
const result = buildAllowedToolsString([], false, false);
|
|
|
|
// 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");
|
|
|
|
// Should have specific Bash git commands for non-signing mode
|
|
expect(result).toContain("Bash(git add:*)");
|
|
expect(result).toContain("Bash(git commit:*)");
|
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Should not have commit signing tools
|
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
|
});
|
|
|
|
test("should append custom tools when provided", async () => {
|
|
const customTools = ["Tool1", "Tool2", "Tool3"];
|
|
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");
|
|
});
|
|
|
|
test("should include GitHub Actions tools when includeActionsTools is true", async () => {
|
|
const result = buildAllowedToolsString([], true);
|
|
|
|
// Base tools should be present
|
|
expect(result).toContain("Edit");
|
|
expect(result).toContain("Glob");
|
|
|
|
// GitHub Actions tools should be included
|
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
|
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
|
expect(result).toContain("mcp__github_ci__download_job_log");
|
|
});
|
|
|
|
test("should include both custom and Actions tools when both provided", async () => {
|
|
const customTools = ["Tool1", "Tool2"];
|
|
const result = buildAllowedToolsString(customTools, true);
|
|
|
|
// Base tools should be present
|
|
expect(result).toContain("Edit");
|
|
|
|
// Custom tools should be included
|
|
expect(result).toContain("Tool1");
|
|
expect(result).toContain("Tool2");
|
|
|
|
// GitHub Actions tools should be included
|
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
|
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
|
expect(result).toContain("mcp__github_ci__download_job_log");
|
|
});
|
|
|
|
test("should include commit signing tools when useCommitSigning is true", async () => {
|
|
const result = buildAllowedToolsString([], false, true);
|
|
|
|
// Base tools should be present
|
|
expect(result).toContain("Edit");
|
|
expect(result).toContain("Glob");
|
|
expect(result).toContain("Grep");
|
|
expect(result).toContain("LS");
|
|
expect(result).toContain("Read");
|
|
expect(result).toContain("Write");
|
|
|
|
// Commit signing tools should be included
|
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
|
expect(result).toContain("mcp__github_file_ops__delete_files");
|
|
// Comment tool should always be from github_comment server
|
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Bash should NOT be included when using commit signing (except in comment tool name)
|
|
expect(result).not.toContain("Bash(");
|
|
});
|
|
|
|
test("should include specific Bash git commands when useCommitSigning is false", async () => {
|
|
const result = buildAllowedToolsString([], false, false);
|
|
|
|
// Base tools should be present
|
|
expect(result).toContain("Edit");
|
|
expect(result).toContain("Glob");
|
|
expect(result).toContain("Grep");
|
|
expect(result).toContain("LS");
|
|
expect(result).toContain("Read");
|
|
expect(result).toContain("Write");
|
|
|
|
// Specific Bash git commands should be included
|
|
expect(result).toContain("Bash(git add:*)");
|
|
expect(result).toContain("Bash(git commit:*)");
|
|
expect(result).toContain("Bash(git push:*)");
|
|
expect(result).toContain("Bash(git status:*)");
|
|
expect(result).toContain("Bash(git diff:*)");
|
|
expect(result).toContain("Bash(git log:*)");
|
|
expect(result).toContain("Bash(git rm:*)");
|
|
|
|
// Comment tool from minimal server should be included
|
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Commit signing tools should NOT be included
|
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
|
});
|
|
|
|
test("should handle all combinations of options", async () => {
|
|
const customTools = ["CustomTool1", "CustomTool2"];
|
|
const result = buildAllowedToolsString(customTools, true, false);
|
|
|
|
// Base tools should be present
|
|
expect(result).toContain("Edit");
|
|
expect(result).toContain("Bash(git add:*)");
|
|
|
|
// Custom tools should be included
|
|
expect(result).toContain("CustomTool1");
|
|
expect(result).toContain("CustomTool2");
|
|
|
|
// GitHub Actions tools should be included
|
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
|
|
|
// Comment tool from minimal server should be included
|
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
|
|
|
// Commit signing tools should NOT be included
|
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
|
});
|
|
});
|
|
|
|
describe("buildDisallowedToolsString", () => {
|
|
test("should return base disallowed tools when no custom tools provided", async () => {
|
|
const result = buildDisallowedToolsString();
|
|
|
|
// The base disallowed tools should be in the result
|
|
expect(result).toContain("WebSearch");
|
|
expect(result).toContain("WebFetch");
|
|
});
|
|
|
|
test("should append custom disallowed tools when provided", async () => {
|
|
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
|
const result = buildDisallowedToolsString(customDisallowedTools);
|
|
|
|
// Base disallowed tools should be present
|
|
expect(result).toContain("WebSearch");
|
|
|
|
// Custom disallowed tools should be appended
|
|
expect(result).toContain("BadTool1");
|
|
expect(result).toContain("BadTool2");
|
|
|
|
// Verify format with comma separation
|
|
const parts = result.split(",");
|
|
expect(parts).toContain("WebSearch");
|
|
expect(parts).toContain("BadTool1");
|
|
expect(parts).toContain("BadTool2");
|
|
});
|
|
|
|
test("should remove hardcoded disallowed tools if they are in allowed tools", async () => {
|
|
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
|
const allowedTools = ["WebSearch", "SomeOtherTool"];
|
|
const result = buildDisallowedToolsString(
|
|
customDisallowedTools,
|
|
allowedTools,
|
|
);
|
|
|
|
// WebSearch should be removed from disallowed since it's in allowed
|
|
expect(result).not.toContain("WebSearch");
|
|
|
|
// WebFetch should still be disallowed since it's not in allowed
|
|
expect(result).toContain("WebFetch");
|
|
|
|
// Custom disallowed tools should still be present
|
|
expect(result).toContain("BadTool1");
|
|
expect(result).toContain("BadTool2");
|
|
});
|
|
|
|
test("should remove all hardcoded disallowed tools if they are all in allowed tools", async () => {
|
|
const allowedTools = ["WebSearch", "WebFetch", "SomeOtherTool"];
|
|
const result = buildDisallowedToolsString(undefined, allowedTools);
|
|
|
|
// Both hardcoded disallowed tools should be removed
|
|
expect(result).not.toContain("WebSearch");
|
|
expect(result).not.toContain("WebFetch");
|
|
|
|
// Result should be empty since no custom disallowed tools provided
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
test("should handle custom disallowed tools when all hardcoded tools are overridden", async () => {
|
|
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
|
const allowedTools = ["WebSearch", "WebFetch"];
|
|
const result = buildDisallowedToolsString(
|
|
customDisallowedTools,
|
|
allowedTools,
|
|
);
|
|
|
|
// Hardcoded tools should be removed
|
|
expect(result).not.toContain("WebSearch");
|
|
expect(result).not.toContain("WebFetch");
|
|
|
|
// Only custom disallowed tools should remain
|
|
expect(result).toBe("BadTool1,BadTool2");
|
|
});
|
|
});
|