diff --git a/README.md b/README.md index 0dceb8c..4a56839 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ on: pull_request_review_comment: types: [created] issues: - types: [opened, assigned] + types: [opened, assigned, labeled] pull_request_review: types: [submitted] @@ -65,6 +65,8 @@ jobs: # trigger_phrase: "/claude" # Optional: add assignee trigger for issues # assignee_trigger: "claude" + # Optional: add label trigger for issues + # label_trigger: "claude" # Optional: add custom environment variables (YAML format) # claude_env: | # NODE_ENV: test @@ -92,6 +94,7 @@ jobs: | `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | | `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" | | `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | +| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | | `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | | `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" | diff --git a/action.yml b/action.yml index 697ea8b..8f489f7 100644 --- a/action.yml +++ b/action.yml @@ -12,6 +12,10 @@ inputs: assignee_trigger: description: "The assignee username that triggers the action (e.g. @claude)" required: false + label_trigger: + description: "The label that triggers the action (e.g. claude)" + required: false + default: "claude" base_branch: description: "The branch to use as the base/source when creating new branches (defaults to repository default branch)" required: false diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index d498cf9..876c5d6 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -81,6 +81,7 @@ export function prepareContext( const eventAction = context.eventAction; const triggerPhrase = context.inputs.triggerPhrase || "@claude"; const assigneeTrigger = context.inputs.assigneeTrigger; + const labelTrigger = context.inputs.labelTrigger; const customInstructions = context.inputs.customInstructions; const allowedTools = context.inputs.allowedTools; const disallowedTools = context.inputs.disallowedTools; @@ -256,6 +257,19 @@ export function prepareContext( claudeBranch, assigneeTrigger, }; + } else if (eventAction === "labeled") { + if (!labelTrigger) { + throw new Error("LABEL_TRIGGER is required for issue labeled event"); + } + eventData = { + eventName: "issues", + eventAction: "labeled", + isPR: false, + issueNumber, + baseBranch, + claudeBranch, + labelTrigger, + }; } else if (eventAction === "opened") { eventData = { eventName: "issues", @@ -328,6 +342,11 @@ export function getEventTypeAndContext(envVars: PreparedContext): { eventType: "ISSUE_CREATED", triggerContext: `new issue with '${envVars.triggerPhrase}' in body`, }; + } else if (eventData.eventAction === "labeled") { + return { + eventType: "ISSUE_LABELED", + triggerContext: `issue labeled with '${eventData.labelTrigger}'`, + }; } return { eventType: "ISSUE_ASSIGNED", @@ -465,6 +484,7 @@ Follow these steps: - Analyze the pre-fetched data provided above. - For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase. - For ISSUE_ASSIGNED: Read the entire issue body to understand the task. + - For ISSUE_LABELED: Read the entire issue body to understand the task. ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the tag above.` : ""} ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""} - IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions. diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index 00bba5e..618b33d 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -68,6 +68,16 @@ type IssueAssignedEvent = { assigneeTrigger: string; }; +type IssueLabeledEvent = { + eventName: "issues"; + eventAction: "labeled"; + isPR: false; + issueNumber: string; + baseBranch: string; + claudeBranch: string; + labelTrigger: string; +}; + type PullRequestEvent = { eventName: "pull_request"; eventAction?: string; // opened, synchronize, etc. @@ -85,6 +95,7 @@ export type EventData = | IssueCommentEvent | IssueOpenedEvent | IssueAssignedEvent + | IssueLabeledEvent | PullRequestEvent; // Combined type with separate eventData field diff --git a/src/github/context.ts b/src/github/context.ts index d8b1581..214f2ca 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -28,6 +28,7 @@ export type ParsedGitHubContext = { inputs: { triggerPhrase: string; assigneeTrigger: string; + labelTrigger: string; allowedTools: string[]; disallowedTools: string[]; customInstructions: string; @@ -52,6 +53,7 @@ export function parseGitHubContext(): ParsedGitHubContext { inputs: { triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude", assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "", + labelTrigger: process.env.LABEL_TRIGGER ?? "", allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""), disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""), customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "", diff --git a/src/github/validation/trigger.ts b/src/github/validation/trigger.ts index 6a06153..333f617 100644 --- a/src/github/validation/trigger.ts +++ b/src/github/validation/trigger.ts @@ -12,7 +12,7 @@ import type { ParsedGitHubContext } from "../context"; export function checkContainsTrigger(context: ParsedGitHubContext): boolean { const { - inputs: { assigneeTrigger, triggerPhrase, directPrompt }, + inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt }, } = context; // If direct prompt is provided, always trigger @@ -33,6 +33,16 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean { } } + // Check for label trigger + if (isIssuesEvent(context) && context.eventAction === "labeled") { + const labelName = (context.payload as any).label?.name || ""; + + if (labelTrigger && labelName === labelTrigger) { + console.log(`Issue labeled with trigger label '${labelTrigger}'`); + return true; + } + } + // Check for issue body and title trigger on issue creation if (isIssuesEvent(context) && context.eventAction === "opened") { const issueBody = context.payload.issue.body || ""; diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 472ff65..7ae1ff7 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -226,6 +226,33 @@ describe("generatePrompt", () => { ); }); + test("should generate prompt for issue labeled event", () => { + 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_120000", + labelTrigger: "claude-task", + }, + }; + + const prompt = generatePrompt(envVars, mockGitHubData); + + expect(prompt).toContain("ISSUE_LABELED"); + expect(prompt).toContain( + "issue labeled with 'claude-task'", + ); + expect(prompt).toContain( + "[Create a PR](https://github.com/owner/repo/compare/main", + ); + }); + test("should include direct prompt when provided", () => { const envVars: PreparedContext = { repository: "owner/repo", @@ -614,6 +641,28 @@ describe("getEventTypeAndContext", () => { 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", () => { + 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_120000", + labelTrigger: "claude-task", + }, + }; + + const result = getEventTypeAndContext(envVars); + + expect(result.eventType).toBe("ISSUE_LABELED"); + expect(result.triggerContext).toBe("issue labeled with 'claude-task'"); + }); }); describe("buildAllowedToolsString", () => { diff --git a/test/mockContext.ts b/test/mockContext.ts index 692137c..a843ee9 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -10,6 +10,7 @@ import type { const defaultInputs = { triggerPhrase: "/claude", assigneeTrigger: "", + labelTrigger: "", anthropicModel: "claude-3-7-sonnet-20250219", allowedTools: [] as string[], disallowedTools: [] as string[], @@ -122,6 +123,46 @@ export const mockIssueAssignedContext: ParsedGitHubContext = { inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" }, }; +export const mockIssueLabeledContext: ParsedGitHubContext = { + runId: "1234567890", + eventName: "issues", + eventAction: "labeled", + repository: defaultRepository, + actor: "admin-user", + payload: { + action: "labeled", + issue: { + number: 1234, + title: "Enhancement: Improve search functionality", + body: "The current search is too slow and needs optimization", + user: { + login: "alice-wonder", + id: 54321, + avatar_url: "https://avatars.githubusercontent.com/u/54321", + html_url: "https://github.com/alice-wonder", + }, + assignee: null, + }, + label: { + id: 987654321, + name: "claude-task", + color: "f29513", + description: "Label for Claude AI interactions", + }, + repository: { + name: "test-repo", + full_name: "test-owner/test-repo", + private: false, + owner: { + login: "test-owner", + }, + }, + } as IssuesEvent, + entityNumber: 1234, + isPR: false, + inputs: { ...defaultInputs, labelTrigger: "claude-task" }, +}; + // Issue comment on issue event export const mockIssueCommentContext: ParsedGitHubContext = { runId: "1234567890", diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 61e2ca9..a44cfb1 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -62,6 +62,7 @@ describe("checkWritePermissions", () => { inputs: { triggerPhrase: "@claude", assigneeTrigger: "", + labelTrigger: "", allowedTools: [], disallowedTools: [], customInstructions: "", diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index bbe40bd..8d8f23c 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from "bun:test"; import { createMockContext, mockIssueAssignedContext, + mockIssueLabeledContext, mockIssueCommentContext, mockIssueOpenedContext, mockPullRequestReviewContext, @@ -29,6 +30,7 @@ describe("checkContainsTrigger", () => { inputs: { triggerPhrase: "/claude", assigneeTrigger: "", + labelTrigger: "", directPrompt: "Fix the bug in the login form", allowedTools: [], disallowedTools: [], @@ -55,6 +57,7 @@ describe("checkContainsTrigger", () => { inputs: { triggerPhrase: "/claude", assigneeTrigger: "", + labelTrigger: "", directPrompt: "", allowedTools: [], disallowedTools: [], @@ -102,6 +105,39 @@ describe("checkContainsTrigger", () => { }); }); + describe("label trigger", () => { + it("should return true when issue is labeled with the trigger label", () => { + const context = mockIssueLabeledContext; + expect(checkContainsTrigger(context)).toBe(true); + }); + + it("should return false when issue is labeled with a different label", () => { + const context = { + ...mockIssueLabeledContext, + payload: { + ...mockIssueLabeledContext.payload, + label: { + ...(mockIssueLabeledContext.payload as any).label, + name: "bug", + }, + }, + } as ParsedGitHubContext; + expect(checkContainsTrigger(context)).toBe(false); + }); + + it("should return false for non-labeled events", () => { + const context = { + ...mockIssueLabeledContext, + eventAction: "opened", + payload: { + ...mockIssueLabeledContext.payload, + action: "opened", + }, + } as ParsedGitHubContext; + expect(checkContainsTrigger(context)).toBe(false); + }); + }); + describe("issue body and title trigger", () => { it("should return true when issue body contains trigger phrase", () => { const context = mockIssueOpenedContext; @@ -227,6 +263,7 @@ describe("checkContainsTrigger", () => { inputs: { triggerPhrase: "@claude", assigneeTrigger: "", + labelTrigger: "", directPrompt: "", allowedTools: [], disallowedTools: [], @@ -254,6 +291,7 @@ describe("checkContainsTrigger", () => { inputs: { triggerPhrase: "@claude", assigneeTrigger: "", + labelTrigger: "", directPrompt: "", allowedTools: [], disallowedTools: [], @@ -281,6 +319,7 @@ describe("checkContainsTrigger", () => { inputs: { triggerPhrase: "@claude", assigneeTrigger: "", + labelTrigger: "", directPrompt: "", allowedTools: [], disallowedTools: [],