diff --git a/action.yml b/action.yml index 31e3fca..fa852dc 100644 --- a/action.yml +++ b/action.yml @@ -73,6 +73,10 @@ inputs: description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands" required: false default: "false" + track_progress: + description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events." + required: false + default: "false" experimental_allowed_domains: description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected." required: false @@ -140,6 +144,7 @@ runs: USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} + TRACK_PROGRESS: ${{ inputs.track_progress }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} CLAUDE_ARGS: ${{ inputs.claude_args }} ALL_INPUTS: ${{ toJson(inputs) }} @@ -247,6 +252,7 @@ runs: PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} + TRACK_PROGRESS: ${{ inputs.track_progress }} - name: Display Claude Code Report if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index 6f60b85..4854b77 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -99,4 +99,5 @@ export type EventData = // Combined type with separate eventData field export type PreparedContext = CommonFields & { eventData: EventData; + githubContext?: import("../github/context").GitHubContext; }; diff --git a/src/github/context.ts b/src/github/context.ts index 30936ce..4a7e339 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -75,6 +75,7 @@ type BaseContext = { useStickyComment: boolean; useCommitSigning: boolean; allowedBots: string; + trackProgress: boolean; }; }; @@ -122,6 +123,7 @@ export function parseGitHubContext(): GitHubContext { useStickyComment: process.env.USE_STICKY_COMMENT === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", allowedBots: process.env.ALLOWED_BOTS ?? "", + trackProgress: process.env.TRACK_PROGRESS === "true", }, }; diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 25191f1..6a4dad6 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -5,6 +5,37 @@ import type { PreparedContext } from "../../create-prompt/types"; import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { parseAllowedTools } from "./parse-tools"; import { configureGitAuth } from "../../github/operations/git-config"; +import type { GitHubContext } from "../../github/context"; +import { isEntityContext } from "../../github/context"; + +/** + * Extract GitHub context as environment variables for agent mode + */ +function extractGitHubContext(context: GitHubContext): Record { + const envVars: Record = {}; + + // Basic repository info + envVars.GITHUB_REPOSITORY = context.repository.full_name; + envVars.GITHUB_TRIGGER_ACTOR = context.actor; + envVars.GITHUB_EVENT_NAME = context.eventName; + + // Entity-specific context (PR/issue numbers, branches, etc.) + if (isEntityContext(context)) { + if (context.isPR) { + envVars.GITHUB_PR_NUMBER = String(context.entityNumber); + + // Extract branch info from payload if available + if (context.payload && 'pull_request' in context.payload && context.payload.pull_request) { + envVars.GITHUB_BASE_REF = context.payload.pull_request.base?.ref || ''; + envVars.GITHUB_HEAD_REF = context.payload.pull_request.head?.ref || ''; + } + } else { + envVars.GITHUB_ISSUE_NUMBER = String(context.entityNumber); + } + } + + return envVars; +} /** * Agent mode implementation. @@ -136,6 +167,14 @@ export const agentMode: Mode = { }, generatePrompt(context: PreparedContext): string { + // Inject GitHub context as environment variables + if (context.githubContext) { + const envVars = extractGitHubContext(context.githubContext); + for (const [key, value] of Object.entries(envVars)) { + core.exportVariable(key, value); + } + } + // Agent mode uses prompt field if (context.prompt) { return context.prompt; diff --git a/src/modes/detector.ts b/src/modes/detector.ts index 0d88b28..1113296 100644 --- a/src/modes/detector.ts +++ b/src/modes/detector.ts @@ -3,31 +3,60 @@ import { isEntityContext, isIssueCommentEvent, isPullRequestReviewCommentEvent, + isPullRequestEvent, + isIssuesEvent, + isPullRequestReviewEvent, } from "../github/context"; import { checkContainsTrigger } from "../github/validation/trigger"; export type AutoDetectedMode = "tag" | "agent"; export function detectMode(context: GitHubContext): AutoDetectedMode { - // If prompt is provided, use agent mode for direct execution - if (context.inputs?.prompt) { - return "agent"; + // Validate track_progress usage + if (context.inputs.trackProgress) { + validateTrackProgressEvent(context); } - // Check for @claude mentions (tag mode) + // If track_progress is set for PR/issue events, force tag mode + if (context.inputs.trackProgress && isEntityContext(context)) { + if (isPullRequestEvent(context) || isIssuesEvent(context)) { + return "tag"; + } + } + + // Comment events (current behavior - unchanged) if (isEntityContext(context)) { if ( isIssueCommentEvent(context) || - isPullRequestReviewCommentEvent(context) + isPullRequestReviewCommentEvent(context) || + isPullRequestReviewEvent(context) ) { + // If prompt is provided on comment events, use agent mode + if (context.inputs.prompt) { + return "agent"; + } + // Default to tag mode if @claude mention found if (checkContainsTrigger(context)) { return "tag"; } } + } - if (context.eventName === "issues") { - if (checkContainsTrigger(context)) { - return "tag"; + // Issue events + if (isEntityContext(context) && isIssuesEvent(context)) { + // Check for @claude mentions or labels/assignees + if (checkContainsTrigger(context)) { + return "tag"; + } + } + + // PR events (opened, synchronize, etc.) + if (isEntityContext(context) && isPullRequestEvent(context)) { + const supportedActions = ["opened", "synchronize", "ready_for_review", "reopened"]; + if (context.eventAction && supportedActions.includes(context.eventAction)) { + // If prompt is provided, use agent mode (default for automation) + if (context.inputs.prompt) { + return "agent"; } } } @@ -47,6 +76,28 @@ export function getModeDescription(mode: AutoDetectedMode): string { } } +function validateTrackProgressEvent(context: GitHubContext): void { + // track_progress is only valid for pull_request and issue events + const validEvents = ["pull_request", "issues"]; + if (!validEvents.includes(context.eventName)) { + throw new Error( + `track_progress is only supported for pull_request and issue events. ` + + `Current event: ${context.eventName}` + ); + } + + // Additionally validate PR actions + if (context.eventName === "pull_request" && context.eventAction) { + const validActions = ["opened", "synchronize", "ready_for_review", "reopened"]; + if (!validActions.includes(context.eventAction)) { + throw new Error( + `track_progress for pull_request events is only supported for actions: ` + + `${validActions.join(", ")}. Current action: ${context.eventAction}` + ); + } + } +} + export function shouldUseTrackingComment(mode: AutoDetectedMode): boolean { return mode === "tag"; } diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 5fe917b..cac4f25 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -177,7 +177,18 @@ export const tagMode: Mode = { githubData: FetchDataResult, useCommitSigning: boolean, ): string { - return generateDefaultPrompt(context, githubData, useCommitSigning); + const defaultPrompt = generateDefaultPrompt(context, githubData, useCommitSigning); + + // If a custom prompt is provided, inject it into the tag mode prompt + if (context.githubContext?.inputs?.prompt) { + return defaultPrompt + ` + + +${context.githubContext.inputs.prompt} +`; + } + + return defaultPrompt; }, getSystemPrompt() { diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 20a2ed6..690b9a8 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -32,6 +32,7 @@ describe("prepareMcpConfig", () => { useStickyComment: false, useCommitSigning: false, allowedBots: "", + trackProgress: false, }, }; diff --git a/test/mockContext.ts b/test/mockContext.ts index 6d6e7e2..9d681b4 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -19,6 +19,7 @@ const defaultInputs = { useStickyComment: false, useCommitSigning: false, allowedBots: "", + trackProgress: false, }; const defaultRepository = { diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 67c53d3..3e15966 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -68,6 +68,7 @@ describe("checkWritePermissions", () => { useStickyComment: false, useCommitSigning: false, allowedBots: "", + trackProgress: false, }, }); diff --git a/tests/modes/detector.test.ts b/tests/modes/detector.test.ts new file mode 100644 index 0000000..0034173 --- /dev/null +++ b/tests/modes/detector.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "bun:test"; +import { detectMode } from "../../src/modes/detector"; +import type { GitHubContext } from "../../src/github/context"; + +describe("detectMode with enhanced routing", () => { + const baseContext = { + runId: "test-run", + eventAction: "opened", + repository: { + owner: "test-owner", + repo: "test-repo", + full_name: "test-owner/test-repo", + }, + actor: "test-user", + inputs: { + prompt: "", + triggerPhrase: "@claude", + assigneeTrigger: "", + labelTrigger: "", + branchPrefix: "claude/", + useStickyComment: false, + useCommitSigning: false, + allowedBots: "", + trackProgress: false, + }, + }; + + describe("PR Events with track_progress", () => { + it("should use tag mode when track_progress is true for pull_request.opened", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "opened", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use tag mode when track_progress is true for pull_request.synchronize", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "synchronize", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use agent mode when track_progress is false for pull_request.opened", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "opened", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: false }, + }; + + expect(detectMode(context)).toBe("agent"); + }); + + it("should throw error when track_progress is used with unsupported PR action", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "closed", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(() => detectMode(context)).toThrow(/track_progress for pull_request events is only supported for actions/); + }); + }); + + describe("Issue Events with track_progress", () => { + it("should use tag mode when track_progress is true for issues.opened", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "issues", + eventAction: "opened", + payload: { issue: { number: 1, body: "Test" } } as any, + entityNumber: 1, + isPR: false, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use agent mode when track_progress is false for issues", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "issues", + eventAction: "opened", + payload: { issue: { number: 1, body: "Test" } } as any, + entityNumber: 1, + isPR: false, + inputs: { ...baseContext.inputs, trackProgress: false }, + }; + + expect(detectMode(context)).toBe("agent"); + }); + }); + + describe("Comment Events (unchanged behavior)", () => { + it("should use tag mode for issue_comment with @claude mention", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "issue_comment", + payload: { + issue: { number: 1, body: "Test" }, + comment: { body: "@claude help" } + } as any, + entityNumber: 1, + isPR: false, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use agent mode for issue_comment with prompt provided", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "issue_comment", + payload: { + issue: { number: 1, body: "Test" }, + comment: { body: "@claude help" } + } as any, + entityNumber: 1, + isPR: false, + inputs: { ...baseContext.inputs, prompt: "Review this PR" }, + }; + + expect(detectMode(context)).toBe("agent"); + }); + + it("should use tag mode for PR review comments with @claude mention", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request_review_comment", + payload: { + pull_request: { number: 1, body: "Test" }, + comment: { body: "@claude check this" } + } as any, + entityNumber: 1, + isPR: true, + }; + + expect(detectMode(context)).toBe("tag"); + }); + }); + + describe("Automation Events (should error with track_progress)", () => { + it("should throw error when track_progress is used with workflow_dispatch", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "workflow_dispatch", + payload: {} as any, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(() => detectMode(context)).toThrow(/track_progress is only supported for pull_request and issue events/); + }); + + it("should use agent mode for workflow_dispatch without track_progress", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "workflow_dispatch", + payload: {} as any, + inputs: { ...baseContext.inputs, prompt: "Run workflow" }, + }; + + expect(detectMode(context)).toBe("agent"); + }); + }); + + describe("Custom prompt injection in tag mode", () => { + it("should use tag mode for PR events when both track_progress and prompt are provided", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "opened", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { + ...baseContext.inputs, + trackProgress: true, + prompt: "Review for security issues" + }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use tag mode for issue events when both track_progress and prompt are provided", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "issues", + eventAction: "opened", + payload: { issue: { number: 1, body: "Test" } } as any, + entityNumber: 1, + isPR: false, + inputs: { + ...baseContext.inputs, + trackProgress: true, + prompt: "Analyze this issue" + }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + }); +}); \ No newline at end of file