diff --git a/.github/workflows/test-claude.lock.yml b/.github/workflows/test-claude.lock.yml index 6419b6389b..09afdd19d7 100644 --- a/.github/workflows/test-claude.lock.yml +++ b/.github/workflows/test-claude.lock.yml @@ -970,3 +970,180 @@ jobs: } await main(); + add_labels: + needs: test-claude + if: github.event.issue.number || github.event.pull_request.number + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + timeout-minutes: 10 + outputs: + labels_added: ${{ steps.add_labels.outputs.labels_added }} + steps: + - name: Add Labels + id: add_labels + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude.outputs.output }} + GITHUB_AW_LABELS_ALLOWED: "bug,feature" + GITHUB_AW_LABELS_MAX_COUNT: 3 + with: + script: | + async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // Read the allowed labels from environment variable (mandatory) + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED; + if (!allowedLabelsEnv) { + core.setFailed('GITHUB_AW_LABELS_ALLOWED environment variable is required but missing'); + return; + } + + const allowedLabels = allowedLabelsEnv.split(',').map(label => label.trim()).filter(label => label); + if (allowedLabels.length === 0) { + core.setFailed('Allowed labels list is empty. At least one allowed label must be specified'); + return; + } + + console.log('Allowed labels:', allowedLabels); + + // Read the max-count limit from environment variable (default: 3) + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max-count value: ${maxCountEnv}. Must be a positive integer`); + return; + } + + console.log('Max count:', maxCount); + + // Check if we're in an issue or pull request context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; + + if (!isIssueContext && !isPRContext) { + core.setFailed('Not running in issue or pull request context, skipping label addition'); + return; + } + + // Determine the issue/PR number + let issueNumber; + let contextType; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = 'issue'; + } else { + core.setFailed('Issue context detected but no issue found in payload'); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = 'pull request'; + } else { + core.setFailed('Pull request context detected but no pull request found in payload'); + return; + } + } + + if (!issueNumber) { + core.setFailed('Could not determine issue or pull request number'); + return; + } + + // Parse labels from agent output (one per line, ignore empty lines) + const lines = outputContent.split('\n'); + const requestedLabels = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines + if (trimmedLine === '') { + continue; + } + + // Reject lines that start with '-' (removal indication) + if (trimmedLine.startsWith('-')) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${trimmedLine}`); + return; + } + + requestedLabels.push(trimmedLine); + } + + console.log('Requested labels:', requestedLabels); + + // Validate that all requested labels are in the allowed list + const validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + + // Remove duplicates from requested labels + let uniqueLabels = [...new Set(validLabels)]; + + // Enforce max-count limit + if (uniqueLabels.length > maxCount) { + console.log(`too many labels, keep ${maxCount}`) + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + + if (uniqueLabels.length === 0) { + console.log('No labels to add'); + core.setOutput('labels_added', ''); + await core.summary.addRaw(` + ## Label Addition + + No labels were added (no valid labels found in agent output). + `).write(); + return; + } + + console.log(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}:`, uniqueLabels); + + try { + // Add labels using GitHub API + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels + }); + + console.log(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + + // Set output for other jobs to use + core.setOutput('labels_added', uniqueLabels.join('\n')); + + // Write summary + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join('\n'); + await core.summary.addRaw(` + ## Label Addition + + Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: + + ${labelsListMarkdown} + `).write(); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to add labels:', errorMessage); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } + } + await main(); + diff --git a/.github/workflows/test-claude.md b/.github/workflows/test-claude.md index ed60688f2b..7eac67ad27 100644 --- a/.github/workflows/test-claude.md +++ b/.github/workflows/test-claude.md @@ -16,6 +16,8 @@ permissions: actions: read contents: read output: + labels: + allowed: ["bug", "feature"] issue: title-prefix: "[claude-test] " labels: [claude, automation, haiku] diff --git a/docs/frontmatter.md b/docs/frontmatter.md index b82fd5677a..686b98e771 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -260,6 +260,9 @@ output: title-prefix: "[ai] " # Optional: prefix for PR titles labels: [automation, ai-agent] # Optional: labels to attach to PRs draft: true # Optional: create as draft PR (defaults to true) + labels: + allowed: [triage, bug, enhancement] # Mandatory: allowed labels for addition + max-count: 3 # Optional: maximum number of labels to add (default: 3) ``` ### Issue Creation (`output.issue`) @@ -391,6 +394,71 @@ Write a summary to ${{ env.GITHUB_AW_OUTPUT }} with title and description. **Required Patch Format:** The agent must create git patches in `/tmp/aw.patch` for the changes to be applied. The pull request creation job validates patch existence and content before proceeding. +### Label Addition (`output.labels`) + +**Behavior:** +- When `output.labels` is configured, the compiler automatically generates a separate `add_labels` job +- This job runs after the main AI agent job completes +- The agent's output content flows from the main job to the label addition job via job output variables +- The job parses labels from the agent output (one per line), validates them against the allowed list, and adds them to the current issue or pull request +- **Important**: Only **label addition** is supported; label removal is strictly prohibited and will cause the job to fail +- **Security**: The `allowed` list is mandatory and enforced at runtime - only labels from this list can be added + +**Generated Job Properties:** +- **Job Name**: `add_labels` +- **Dependencies**: Runs after the main agent job (`needs: [main-job-name]`) +- **Permissions**: Only the label addition job has `issues: write` and `pull-requests: write` permissions +- **Timeout**: 10-minute timeout to prevent hanging +- **Conditional Execution**: Only runs when `github.event.issue.number` or `github.event.pull_request.number` is available +- **Environment Variables**: Configuration passed via `GITHUB_AW_LABELS_ALLOWED` +- **Outputs**: Returns `labels_added` as a newline-separated list of labels that were successfully added + +**Configuration:** +```yaml +output: + labels: + allowed: [triage, bug, enhancement] # Mandatory: list of allowed labels (must be non-empty) + max-count: 3 # Optional: maximum number of labels to add (default: 3) +``` + +**Agent Output Format:** +The agent should write labels to add, one per line, to the `${{ env.GITHUB_AW_OUTPUT }}` file: +``` +triage +bug +needs-review +``` + +**Safety Features:** +- Empty lines in agent output are ignored +- Lines starting with `-` are rejected (no removal operations allowed) +- Duplicate labels are automatically removed +- All requested labels must be in the `allowed` list or the job fails with a clear error message +- Label count is limited by `max-count` setting (default: 3) - exceeding this limit causes job failure +- Only GitHub's `issues.addLabels` API endpoint is used (no removal endpoints) + +**Example workflow using label addition:** +```yaml +--- +on: + issues: + types: [opened] +permissions: + contents: read + actions: read # Main job only needs minimal permissions +engine: claude +output: + labels: + allowed: [triage, bug, enhancement, documentation, needs-review] +--- + +# Issue Labeling Agent + +Analyze the issue content and add appropriate labels. +Write the labels you want to add (one per line) to ${{ env.GITHUB_AW_OUTPUT }}. +Only use labels from the allowed list: triage, bug, enhancement, documentation, needs-review. +``` + ## Cache Configuration (`cache:`) Cache configuration using GitHub Actions `actions/cache` syntax: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index dcebb5b290..56daa6b36c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -997,6 +997,27 @@ } }, "additionalProperties": false + }, + "labels": { + "type": "object", + "description": "Configuration for adding labels to issues/PRs from agent output", + "properties": { + "allowed": { + "type": "array", + "description": "Mandatory list of allowed labels that can be added", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "max-count": { + "type": "integer", + "description": "Optional maximum number of labels to add (default: 3)", + "minimum": 1 + } + }, + "required": ["allowed"], + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index bc0cddcefa..31714bda98 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -150,6 +150,7 @@ type OutputConfig struct { Issue *IssueConfig `yaml:"issue,omitempty"` Comment *CommentConfig `yaml:"comment,omitempty"` PullRequest *PullRequestConfig `yaml:"pull-request,omitempty"` + Labels *LabelConfig `yaml:"labels,omitempty"` } // IssueConfig holds configuration for creating GitHub issues from agent output @@ -170,6 +171,12 @@ type PullRequestConfig struct { Draft *bool `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil) and explicitly false } +// LabelConfig holds configuration for adding labels to issues/PRs from agent output +type LabelConfig struct { + Allowed []string `yaml:"allowed"` // Mandatory list of allowed labels + MaxCount *int `yaml:"max-count,omitempty"` // Optional maximum number of labels to add (default: 3) +} + // CompileWorkflow converts a markdown workflow to GitHub Actions YAML func (c *Compiler) CompileWorkflow(markdownPath string) error { @@ -1534,6 +1541,17 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { } } + // Build add_labels job if output.labels is configured + if data.Output != nil && data.Output.Labels != nil { + addLabelsJob, err := c.buildCreateOutputLabelJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build add_labels job: %w", err) + } + if err := c.jobManager.AddJob(addLabelsJob); err != nil { + return fmt.Errorf("failed to add add_labels job: %w", err) + } + } + // Build additional custom jobs from frontmatter jobs section if err := c.buildCustomJobs(data); err != nil { return fmt.Errorf("failed to build custom jobs: %w", err) @@ -2284,6 +2302,52 @@ func (c *Compiler) extractOutputConfig(frontmatter map[string]any) *OutputConfig } } + // Parse labels configuration + if labels, exists := outputMap["labels"]; exists { + if labelsMap, ok := labels.(map[string]any); ok { + labelConfig := &LabelConfig{} + + // Parse allowed labels (mandatory) + if allowed, exists := labelsMap["allowed"]; exists { + if allowedArray, ok := allowed.([]any); ok { + var allowedStrings []string + for _, label := range allowedArray { + if labelStr, ok := label.(string); ok { + allowedStrings = append(allowedStrings, labelStr) + } + } + labelConfig.Allowed = allowedStrings + } + } + + // Parse max-count (optional) + if maxCount, exists := labelsMap["max-count"]; exists { + // Handle different numeric types that YAML parsers might return + var maxCountInt int + var validMaxCount bool + switch v := maxCount.(type) { + case int: + maxCountInt = v + validMaxCount = true + case int64: + maxCountInt = int(v) + validMaxCount = true + case uint64: + maxCountInt = int(v) + validMaxCount = true + case float64: + maxCountInt = int(v) + validMaxCount = true + } + if validMaxCount { + labelConfig.MaxCount = &maxCountInt + } + } + + config.Labels = labelConfig + } + } + return config } } diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 11a4e4c813..43d7614b2a 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -12,3 +12,6 @@ var createIssueScript string //go:embed js/create_comment.cjs var createCommentScript string + +//go:embed js/add_labels.cjs +var addLabelsScript string diff --git a/pkg/workflow/js/add_labels.cjs b/pkg/workflow/js/add_labels.cjs new file mode 100644 index 0000000000..9e5cf02b41 --- /dev/null +++ b/pkg/workflow/js/add_labels.cjs @@ -0,0 +1,155 @@ +async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // Read the allowed labels from environment variable (mandatory) + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED; + if (!allowedLabelsEnv) { + core.setFailed('GITHUB_AW_LABELS_ALLOWED environment variable is required but missing'); + return; + } + + const allowedLabels = allowedLabelsEnv.split(',').map(label => label.trim()).filter(label => label); + if (allowedLabels.length === 0) { + core.setFailed('Allowed labels list is empty. At least one allowed label must be specified'); + return; + } + + console.log('Allowed labels:', allowedLabels); + + // Read the max-count limit from environment variable (default: 3) + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max-count value: ${maxCountEnv}. Must be a positive integer`); + return; + } + + console.log('Max count:', maxCount); + + // Check if we're in an issue or pull request context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; + + if (!isIssueContext && !isPRContext) { + core.setFailed('Not running in issue or pull request context, skipping label addition'); + return; + } + + // Determine the issue/PR number + let issueNumber; + let contextType; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = 'issue'; + } else { + core.setFailed('Issue context detected but no issue found in payload'); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = 'pull request'; + } else { + core.setFailed('Pull request context detected but no pull request found in payload'); + return; + } + } + + if (!issueNumber) { + core.setFailed('Could not determine issue or pull request number'); + return; + } + + // Parse labels from agent output (one per line, ignore empty lines) + const lines = outputContent.split('\n'); + const requestedLabels = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines + if (trimmedLine === '') { + continue; + } + + // Reject lines that start with '-' (removal indication) + if (trimmedLine.startsWith('-')) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${trimmedLine}`); + return; + } + + requestedLabels.push(trimmedLine); + } + + console.log('Requested labels:', requestedLabels); + + // Validate that all requested labels are in the allowed list + const validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + + // Remove duplicates from requested labels + let uniqueLabels = [...new Set(validLabels)]; + + // Enforce max-count limit + if (uniqueLabels.length > maxCount) { + console.log(`too many labels, keep ${maxCount}`) + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + + if (uniqueLabels.length === 0) { + console.log('No labels to add'); + core.setOutput('labels_added', ''); + await core.summary.addRaw(` +## Label Addition + +No labels were added (no valid labels found in agent output). +`).write(); + return; + } + + console.log(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}:`, uniqueLabels); + + try { + // Add labels using GitHub API + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels + }); + + console.log(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + + // Set output for other jobs to use + core.setOutput('labels_added', uniqueLabels.join('\n')); + + // Write summary + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join('\n'); + await core.summary.addRaw(` +## Label Addition + +Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: + +${labelsListMarkdown} +`).write(); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to add labels:', errorMessage); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } +} +await main(); \ No newline at end of file diff --git a/pkg/workflow/js/add_labels.test.cjs b/pkg/workflow/js/add_labels.test.cjs new file mode 100644 index 0000000000..b81e6dc7c6 --- /dev/null +++ b/pkg/workflow/js/add_labels.test.cjs @@ -0,0 +1,585 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn() + } +}; + +const mockGithub = { + rest: { + issues: { + addLabels: vi.fn() + } + } +}; + +const mockContext = { + eventName: 'issues', + repo: { + owner: 'testowner', + repo: 'testrepo' + }, + payload: { + issue: { + number: 123 + } + } +}; + +// Set up global variables +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe('add_labels.cjs', () => { + let addLabelsScript; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Reset environment variables + delete process.env.GITHUB_AW_AGENT_OUTPUT; + delete process.env.GITHUB_AW_LABELS_ALLOWED; + delete process.env.GITHUB_AW_LABELS_MAX_COUNT; + + // Reset context to default state + global.context.eventName = 'issues'; + global.context.payload.issue = { number: 123 }; + delete global.context.payload.pull_request; + + // Read the script content + const scriptPath = path.join(process.cwd(), 'pkg/workflow/js/add_labels.cjs'); + addLabelsScript = fs.readFileSync(scriptPath, 'utf8'); + }); + + describe('Environment variable validation', () => { + it('should skip when no agent output is provided', async () => { + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + delete process.env.GITHUB_AW_AGENT_OUTPUT; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should skip when agent output is empty', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = ' '; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Agent output content is empty'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should fail when allowed labels are not provided', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + delete process.env.GITHUB_AW_LABELS_ALLOWED; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('GITHUB_AW_LABELS_ALLOWED environment variable is required but missing'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should fail when allowed labels list is empty', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = ' '; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Allowed labels list is empty. At least one allowed label must be specified'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should fail when max count is invalid', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + process.env.GITHUB_AW_LABELS_MAX_COUNT = 'invalid'; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Invalid max-count value: invalid. Must be a positive integer'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should fail when max count is zero', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + process.env.GITHUB_AW_LABELS_MAX_COUNT = '0'; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Invalid max-count value: 0. Must be a positive integer'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should use default max count when not specified', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement\nfeature\ndocumentation'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement,feature,documentation'; + delete process.env.GITHUB_AW_LABELS_MAX_COUNT; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Max count:', 3); + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement', 'feature'] // Only first 3 due to default max count + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('Context validation', () => { + it('should fail when not in issue or PR context', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'push'; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Not running in issue or pull request context, skipping label addition'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should work with issue_comment event', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'issue_comment'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should work with pull_request event', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'pull_request'; + global.context.payload.pull_request = { number: 456 }; + delete global.context.payload.issue; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 456, + labels: ['bug'] + }); + + consoleSpy.mockRestore(); + }); + + it('should work with pull_request_review event', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'pull_request_review'; + global.context.payload.pull_request = { number: 789 }; + delete global.context.payload.issue; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 789, + labels: ['bug'] + }); + + consoleSpy.mockRestore(); + }); + + it('should fail when issue context detected but no issue in payload', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'issues'; + delete global.context.payload.issue; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Issue context detected but no issue found in payload'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should fail when PR context detected but no PR in payload', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'pull_request'; + delete global.context.payload.issue; + delete global.context.payload.pull_request; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Pull request context detected but no pull request found in payload'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + }); + + describe('Label parsing and validation', () => { + it('should parse labels from agent output and add valid ones', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement\ndocumentation'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement,feature'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] // 'documentation' not in allowed list + }); + + expect(mockCore.setOutput).toHaveBeenCalledWith('labels_added', 'bug\nenhancement'); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should skip empty lines in agent output', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\n\n\nenhancement\n\n'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] + }); + + consoleSpy.mockRestore(); + }); + + it('should fail when line starts with dash (removal indication)', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\n-enhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockCore.setFailed).toHaveBeenCalledWith('Label removal is not permitted. Found line starting with \'-\': -enhancement'); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('should remove duplicate labels', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement\nbug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] // Duplicates removed + }); + + consoleSpy.mockRestore(); + }); + + it('should enforce max count limit', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement\nfeature\ndocumentation\nquestion'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement,feature,documentation,question'; + process.env.GITHUB_AW_LABELS_MAX_COUNT = '2'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('too many labels, keep 2'); + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] // Only first 2 + }); + + consoleSpy.mockRestore(); + }); + + it('should skip when no valid labels found', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'invalid\nanother-invalid'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('No labels to add'); + expect(mockCore.setOutput).toHaveBeenCalledWith('labels_added', ''); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining('No labels were added')); + expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('GitHub API integration', () => { + it('should successfully add labels to issue', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement,feature'; + + mockGithub.rest.issues.addLabels.mockResolvedValue({}); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] + }); + + expect(consoleSpy).toHaveBeenCalledWith('Successfully added 2 labels to issue #123'); + expect(mockCore.setOutput).toHaveBeenCalledWith('labels_added', 'bug\nenhancement'); + + const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => + call[0].includes('Successfully added 2 label(s) to issue #123') + ); + expect(summaryCall).toBeDefined(); + expect(summaryCall[0]).toContain('- `bug`'); + expect(summaryCall[0]).toContain('- `enhancement`'); + + consoleSpy.mockRestore(); + }); + + it('should successfully add labels to pull request', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + global.context.eventName = 'pull_request'; + global.context.payload.pull_request = { number: 456 }; + delete global.context.payload.issue; + + mockGithub.rest.issues.addLabels.mockResolvedValue({}); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Successfully added 1 labels to pull request #456'); + + const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => + call[0].includes('Successfully added 1 label(s) to pull request #456') + ); + expect(summaryCall).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it('should handle GitHub API errors', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const apiError = new Error('Label does not exist'); + mockGithub.rest.issues.addLabels.mockRejectedValue(apiError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to add labels:', 'Label does not exist'); + expect(mockCore.setFailed).toHaveBeenCalledWith('Failed to add labels: Label does not exist'); + + consoleSpy.mockRestore(); + }); + + it('should handle non-Error objects in catch block', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const stringError = 'Something went wrong'; + mockGithub.rest.issues.addLabels.mockRejectedValue(stringError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to add labels:', 'Something went wrong'); + expect(mockCore.setFailed).toHaveBeenCalledWith('Failed to add labels: Something went wrong'); + + consoleSpy.mockRestore(); + }); + }); + + describe('Output and logging', () => { + it('should log agent output content length', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Agent output content length:', 15); + + consoleSpy.mockRestore(); + }); + + it('should log allowed labels and max count', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement,feature'; + process.env.GITHUB_AW_LABELS_MAX_COUNT = '5'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Allowed labels:', ['bug', 'enhancement', 'feature']); + expect(consoleSpy).toHaveBeenCalledWith('Max count:', 5); + + consoleSpy.mockRestore(); + }); + + it('should log requested labels', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement\ninvalid'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Requested labels:', ['bug', 'enhancement', 'invalid']); + + consoleSpy.mockRestore(); + }); + + it('should log final labels being added', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Adding 2 labels to issue #123:', ['bug', 'enhancement']); + + consoleSpy.mockRestore(); + }); + }); + + describe('Edge cases', () => { + it('should handle whitespace in allowed labels', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug\nenhancement'; + process.env.GITHUB_AW_LABELS_ALLOWED = ' bug , enhancement , feature '; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Allowed labels:', ['bug', 'enhancement', 'feature']); + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug', 'enhancement'] + }); + + consoleSpy.mockRestore(); + }); + + it('should handle empty entries in allowed labels', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,,enhancement,'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Allowed labels:', ['bug', 'enhancement']); + + consoleSpy.mockRestore(); + }); + + it('should handle single label output', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = 'bug'; + process.env.GITHUB_AW_LABELS_ALLOWED = 'bug,enhancement'; + + mockGithub.rest.issues.addLabels.mockResolvedValue({}); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${addLabelsScript} })()`); + + expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + labels: ['bug'] + }); + + expect(mockCore.setOutput).toHaveBeenCalledWith('labels_added', 'bug'); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/pkg/workflow/output_labels.go b/pkg/workflow/output_labels.go new file mode 100644 index 0000000000..33065e2f6c --- /dev/null +++ b/pkg/workflow/output_labels.go @@ -0,0 +1,70 @@ +package workflow + +import ( + "fmt" + "strings" +) + +// buildCreateOutputLabelJob creates the add_labels job +func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.Output == nil || data.Output.Labels == nil { + return nil, fmt.Errorf("output.labels configuration is required") + } + + // Validate that allowed labels list is not empty + if len(data.Output.Labels.Allowed) == 0 { + return nil, fmt.Errorf("output.labels.allowed must be non-empty") + } + + // Get max-count with default of 3 + maxCount := 3 + if data.Output.Labels.MaxCount != nil { + maxCount = *data.Output.Labels.MaxCount + } + + var steps []string + steps = append(steps, " - name: Add Labels\n") + steps = append(steps, " id: add_labels\n") + steps = append(steps, " uses: actions/github-script@v7\n") + + // Add environment variables + steps = append(steps, " env:\n") + // Pass the agent output content from the main job + steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + // Pass the allowed labels list + allowedLabelsStr := strings.Join(data.Output.Labels.Allowed, ",") + steps = append(steps, fmt.Sprintf(" GITHUB_AW_LABELS_ALLOWED: %q\n", allowedLabelsStr)) + // Pass the max-count limit + steps = append(steps, fmt.Sprintf(" GITHUB_AW_LABELS_MAX_COUNT: %d\n", maxCount)) + + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add each line of the script with proper indentation + scriptLines := strings.Split(addLabelsScript, "\n") + for _, line := range scriptLines { + if strings.TrimSpace(line) == "" { + steps = append(steps, "\n") + } else { + steps = append(steps, fmt.Sprintf(" %s\n", line)) + } + } + + // Create outputs for the job + outputs := map[string]string{ + "labels_added": "${{ steps.add_labels.outputs.labels_added }}", + } + + job := &Job{ + Name: "add_labels", + If: "if: github.event.issue.number || github.event.pull_request.number", // Only run in issue or PR context + RunsOn: "runs-on: ubuntu-latest", + Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", + TimeoutMinutes: 10, // 10-minute timeout as required + Steps: steps, + Outputs: outputs, + Depends: []string{mainJobName}, // Depend on the main workflow job + } + + return job, nil +} diff --git a/pkg/workflow/output_test.go b/pkg/workflow/output_test.go index b445daf8c1..d0e3913bbc 100644 --- a/pkg/workflow/output_test.go +++ b/pkg/workflow/output_test.go @@ -726,3 +726,513 @@ This workflow tests the create_pull_request job generation with draft: true. t.Logf("Generated workflow content:\n%s", lockContentStr) } + +func TestOutputLabelConfigParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-config-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +engine: claude +output: + labels: + allowed: [triage, bug, enhancement, needs-review] +--- + +# Test Output Label Configuration + +This workflow tests the output labels configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-output-labels.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with output labels config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.Output == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.Output.Labels == nil { + t.Fatal("Expected labels configuration to be parsed") + } + + // Verify allowed labels + expectedLabels := []string{"triage", "bug", "enhancement", "needs-review"} + if len(workflowData.Output.Labels.Allowed) != len(expectedLabels) { + t.Errorf("Expected %d allowed labels, got %d", len(expectedLabels), len(workflowData.Output.Labels.Allowed)) + } + + for i, expectedLabel := range expectedLabels { + if i >= len(workflowData.Output.Labels.Allowed) || workflowData.Output.Labels.Allowed[i] != expectedLabel { + t.Errorf("Expected label[%d] to be '%s', got '%s'", i, expectedLabel, workflowData.Output.Labels.Allowed[i]) + } + } +} + +func TestOutputLabelJobGeneration(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-job-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +tools: + github: + allowed: [get_issue] +engine: claude +output: + labels: + allowed: [triage, bug, enhancement] +--- + +# Test Output Label Job Generation + +This workflow tests the add_labels job generation. +` + + testFile := filepath.Join(tmpDir, "test-output-labels.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow with output labels: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-output-labels.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + // Verify add_labels job exists + if !strings.Contains(lockContent, "add_labels:") { + t.Error("Expected 'add_labels' job to be in generated workflow") + } + + // Verify job properties + if !strings.Contains(lockContent, "timeout-minutes: 10") { + t.Error("Expected 10-minute timeout in add_labels job") + } + + if !strings.Contains(lockContent, "permissions:\n contents: read\n issues: write\n pull-requests: write") { + t.Error("Expected correct permissions in add_labels job") + } + + // Verify the job uses github-script + if !strings.Contains(lockContent, "uses: actions/github-script@v7") { + t.Error("Expected github-script action to be used in add_labels job") + } + + // Verify job has conditional execution + if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + t.Error("Expected add_labels job to have conditional execution") + } + + // Verify job dependencies + if !strings.Contains(lockContent, "needs: test-output-label-job-generation") { + t.Error("Expected add_labels job to depend on main job") + } + + // Verify JavaScript content includes environment variables for configuration + if !strings.Contains(lockContent, "GITHUB_AW_AGENT_OUTPUT:") { + t.Error("Expected agent output content to be passed as environment variable") + } + + if !strings.Contains(lockContent, "GITHUB_AW_LABELS_ALLOWED: \"triage,bug,enhancement\"") { + t.Error("Expected allowed labels to be set as environment variable") + } + + // Verify output variables + if !strings.Contains(lockContent, "labels_added: ${{ steps.add_labels.outputs.labels_added }}") { + t.Error("Expected labels_added output to be available") + } + + t.Logf("Generated workflow content:\n%s", lockContent) +} + +func TestOutputLabelConfigMaxCountParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-max-count-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration including max-count + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +engine: claude +output: + labels: + allowed: [triage, bug, enhancement, needs-review] + max-count: 5 +--- + +# Test Output Label Max Count Configuration + +This workflow tests the output labels max-count configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-output-labels-max-count.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with output labels max-count config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.Output == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.Output.Labels == nil { + t.Fatal("Expected labels configuration to be parsed") + } + + // Verify allowed labels + expectedLabels := []string{"triage", "bug", "enhancement", "needs-review"} + if len(workflowData.Output.Labels.Allowed) != len(expectedLabels) { + t.Errorf("Expected %d allowed labels, got %d", len(expectedLabels), len(workflowData.Output.Labels.Allowed)) + } + + for i, expectedLabel := range expectedLabels { + if i >= len(workflowData.Output.Labels.Allowed) || workflowData.Output.Labels.Allowed[i] != expectedLabel { + t.Errorf("Expected label[%d] to be '%s', got '%s'", i, expectedLabel, workflowData.Output.Labels.Allowed[i]) + } + } + + // Verify max-count + if workflowData.Output.Labels.MaxCount == nil { + t.Fatal("Expected max-count to be parsed") + } + + expectedMaxCount := 5 + if *workflowData.Output.Labels.MaxCount != expectedMaxCount { + t.Errorf("Expected max-count to be %d, got %d", expectedMaxCount, *workflowData.Output.Labels.MaxCount) + } +} + +func TestOutputLabelConfigDefaultMaxCount(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-default-max-count-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration without max-count (should use default) + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +engine: claude +output: + labels: + allowed: [triage, bug, enhancement] +--- + +# Test Output Label Default Max Count + +This workflow tests the default max-count behavior. +` + + testFile := filepath.Join(tmpDir, "test-output-labels-default.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow without max-count: %v", err) + } + + // Verify max-count is nil (will use default in job generation) + if workflowData.Output.Labels.MaxCount != nil { + t.Errorf("Expected max-count to be nil (default), got %d", *workflowData.Output.Labels.MaxCount) + } +} + +func TestOutputLabelJobGenerationWithMaxCount(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-job-max-count-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration including max-count + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +tools: + github: + allowed: [get_issue] +engine: claude +output: + labels: + allowed: [triage, bug, enhancement] + max-count: 2 +--- + +# Test Output Label Job Generation with Max Count + +This workflow tests the add_labels job generation with max-count. +` + + testFile := filepath.Join(tmpDir, "test-output-labels-max-count.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow with output labels max-count: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-output-labels-max-count.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + // Verify add_labels job exists + if !strings.Contains(lockContent, "add_labels:") { + t.Error("Expected 'add_labels' job to be in generated workflow") + } + + // Verify JavaScript content includes environment variables for configuration + if !strings.Contains(lockContent, "GITHUB_AW_LABELS_ALLOWED: \"triage,bug,enhancement\"") { + t.Error("Expected allowed labels to be set as environment variable") + } + + // Verify max-count environment variable is set + if !strings.Contains(lockContent, "GITHUB_AW_LABELS_MAX_COUNT: 2") { + t.Error("Expected max-count to be set as environment variable") + } + + t.Logf("Generated workflow content:\n%s", lockContent) +} + +func TestOutputLabelJobGenerationWithDefaultMaxCount(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-job-default-max-count-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.labels configuration without max-count (should use default of 3) + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +tools: + github: + allowed: [get_issue] +engine: claude +output: + labels: + allowed: [triage, bug, enhancement] +--- + +# Test Output Label Job Generation with Default Max Count + +This workflow tests the add_labels job generation with default max-count. +` + + testFile := filepath.Join(tmpDir, "test-output-labels-default-max-count.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow with output labels default max-count: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-output-labels-default-max-count.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + // Verify add_labels job exists + if !strings.Contains(lockContent, "add_labels:") { + t.Error("Expected 'add_labels' job to be in generated workflow") + } + + // Verify max-count environment variable is set to default value of 3 + if !strings.Contains(lockContent, "GITHUB_AW_LABELS_MAX_COUNT: 3") { + t.Error("Expected max-count to be set to default value of 3 as environment variable") + } + + t.Logf("Generated workflow content:\n%s", lockContent) +} + +func TestOutputLabelConfigValidation(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-validation-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with empty allowed labels (should fail) + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write +engine: claude +output: + labels: + allowed: [] +--- + +# Test Output Label Validation + +This workflow tests validation of empty allowed labels. +` + + testFile := filepath.Join(tmpDir, "test-label-validation.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow - should fail with empty allowed labels + err = compiler.CompileWorkflow(testFile) + if err == nil { + t.Fatal("Expected error when compiling workflow with empty allowed labels") + } + + if !strings.Contains(err.Error(), "minItems: got 0, want 1") { + t.Errorf("Expected schema validation error about minItems, got: %v", err) + } +} + +func TestOutputLabelConfigMissingAllowed(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-label-missing-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with missing allowed field (should fail) + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write +engine: claude +output: + labels: {} +--- + +# Test Output Label Missing Allowed + +This workflow tests validation of missing allowed field. +` + + testFile := filepath.Join(tmpDir, "test-label-missing.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow - should fail with missing allowed labels + err = compiler.CompileWorkflow(testFile) + if err == nil { + t.Fatal("Expected error when compiling workflow with missing allowed labels") + } + + if !strings.Contains(err.Error(), "missing property 'allowed'") { + t.Errorf("Expected schema validation error about missing required property, got: %v", err) + } +}