diff --git a/.github/workflows/test-claude.lock.yml b/.github/workflows/test-claude.lock.yml index 492b2670622..9af3da25db9 100644 --- a/.github/workflows/test-claude.lock.yml +++ b/.github/workflows/test-claude.lock.yml @@ -647,3 +647,90 @@ jobs: core.setOutput('issue_number', issue.number); core.setOutput('issue_url', issue.html_url); + create_issue_comment: + 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: + comment_id: ${{ steps.create_comment.outputs.comment_id }} + comment_url: ${{ steps.create_comment.outputs.comment_url }} + steps: + - name: Create Output Comment + id: create_comment + uses: actions/github-script@v7 + env: + AGENT_OUTPUT_CONTENT: ${{ needs.test-claude.outputs.output }} + with: + script: | + // Read the agent output content from environment variable + const outputContent = process.env.AGENT_OUTPUT_CONTENT; + if (!outputContent) { + console.log('No AGENT_OUTPUT_CONTENT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // 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) { + console.log('Not running in issue or pull request context, skipping comment creation'); + return; + } + + // Determine the issue/PR number and comment endpoint + let issueNumber; + let commentEndpoint; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + return; + } + } + + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + return; + } + + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', outputContent.length); + + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: outputContent + }); + + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + + // Set output for other jobs to use + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + diff --git a/.github/workflows/test-claude.md b/.github/workflows/test-claude.md index fbd02c61d67..c728547067f 100644 --- a/.github/workflows/test-claude.md +++ b/.github/workflows/test-claude.md @@ -19,6 +19,7 @@ output: issue: title-prefix: "[claude-test] " labels: [claude, automation, haiku] + comment: {} tools: claude: allowed: diff --git a/docs/frontmatter.md b/docs/frontmatter.md index 957eead872c..57731739655 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -25,7 +25,7 @@ The YAML frontmatter supports standard GitHub Actions properties plus additional - `alias`: Alias name for the workflow - `ai-reaction`: Emoji reaction to add/remove on triggering GitHub item - `cache`: Cache configuration for workflow dependencies -- `output`: Output processing configuration for automatic issue creation +- `output`: Output processing configuration for automatic issue creation and comment posting ## Trigger Events (`on:`) @@ -255,8 +255,11 @@ output: issue: title-prefix: "[ai] " # Optional: prefix for issue titles labels: [automation, ai-agent] # Optional: labels to attach to issues + comment: {} # Create comments on issues/PRs from agent output ``` +### Issue Creation (`output.issue`) + **Behavior:** - When `output.issue` is configured, the compiler automatically generates a separate `create_output_issue` job - This job runs after the main AI agent job completes @@ -272,7 +275,24 @@ output: - **Environment Variables**: Configuration passed via `GITHUB_AW_ISSUE_TITLE_PREFIX` and `GITHUB_AW_ISSUE_LABELS` - **Outputs**: Returns `issue_number` and `issue_url` for downstream jobs -**Example workflow using output processing:** +### Comment Creation (`output.comment`) + +**Behavior:** +- When `output.comment` is configured, the compiler automatically generates a separate `create_issue_comment` job +- This job runs after the main AI agent job completes and **only** if the workflow is triggered by an issue or pull request event +- The agent's output content flows from the main job to the comment creation job via job output variables +- The comment creation job posts the entire agent output as a comment on the triggering issue or pull request +- **Conditional Execution**: The job automatically skips if not running in an issue or pull request context + +**Generated Job Properties:** +- **Job Name**: `create_issue_comment` +- **Dependencies**: Runs after the main agent job (`needs: [main-job-name]`) +- **Conditional**: Only runs when `github.event.issue.number || github.event.pull_request.number` is present +- **Permissions**: Only the comment creation job has `issues: write` and `pull-requests: write` permissions +- **Timeout**: 10-minute timeout to prevent hanging +- **Outputs**: Returns `comment_id` and `comment_url` for downstream jobs + +**Example workflow using issue creation:** ```yaml --- on: push @@ -292,7 +312,29 @@ Analyze the latest commit and provide insights. Write your analysis to ${{ env.GITHUB_AW_OUTPUT }} at the end. ``` -This automatically creates GitHub issues from the agent's analysis without requiring `issues: write` permission on the main job. +**Example workflow using comment creation:** +```yaml +--- +on: + issues: + types: [opened, labeled] + pull_request: + types: [opened, synchronize] +permissions: + contents: read # Main job only needs minimal permissions + actions: read +engine: claude +output: + comment: {} +--- + +# Issue/PR Analysis Agent + +Analyze the issue or pull request and provide feedback. +Write your analysis to ${{ env.GITHUB_AW_OUTPUT }} at the end. +``` + +This automatically creates GitHub issues or comments from the agent's analysis without requiring write permissions on the main job. ## Cache Configuration (`cache:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 56ca73bcae8..02f3dea895e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -525,6 +525,11 @@ } }, "additionalProperties": false + }, + "comment": { + "type": "object", + "description": "Configuration for creating GitHub issue/PR comments from agent output", + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 410969e388f..768f48cd156 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -102,6 +102,9 @@ var checkTeamMemberTemplate string //go:embed js/create_issue.js var createIssueScript string +//go:embed js/create_comment.js +var createCommentScript string + // Compiler handles converting markdown workflows to GitHub Actions YAML type Compiler struct { verbose bool @@ -216,7 +219,8 @@ type WorkflowData struct { // OutputConfig holds configuration for automatic output routes type OutputConfig struct { - Issue *IssueConfig `yaml:"issue,omitempty"` + Issue *IssueConfig `yaml:"issue,omitempty"` + Comment *CommentConfig `yaml:"comment,omitempty"` } // IssueConfig holds configuration for creating GitHub issues from agent output @@ -225,6 +229,11 @@ type IssueConfig struct { Labels []string `yaml:"labels,omitempty"` } +// CommentConfig holds configuration for creating GitHub issue/PR comments from agent output +type CommentConfig struct { + // Empty struct for now, as per requirements, but structured for future expansion +} + // CompileWorkflow converts a markdown workflow to GitHub Actions YAML func (c *Compiler) CompileWorkflow(markdownPath string) error { @@ -1554,6 +1563,17 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { } } + // Build create_issue_comment job if output.comment is configured + if data.Output != nil && data.Output.Comment != nil { + createCommentJob, err := c.buildCreateOutputCommentJob(data) + if err != nil { + return fmt.Errorf("failed to build create_issue_comment job: %w", err) + } + if err := c.jobManager.AddJob(createCommentJob); err != nil { + return fmt.Errorf("failed to add create_issue_comment 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) @@ -1716,6 +1736,58 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData) (*Job, error) { return job, nil } +// buildCreateOutputCommentJob creates the create_issue_comment job +func (c *Compiler) buildCreateOutputCommentJob(data *WorkflowData) (*Job, error) { + if data.Output == nil || data.Output.Comment == nil { + return nil, fmt.Errorf("output.comment configuration is required") + } + + var steps []string + steps = append(steps, " - name: Create Output Comment\n") + steps = append(steps, " id: create_comment\n") + steps = append(steps, " uses: actions/github-script@v7\n") + + // Determine the main job name to get output from + mainJobName := c.generateJobName(data.Name) + + // Add environment variables + steps = append(steps, " env:\n") + // Pass the agent output content from the main job + steps = append(steps, fmt.Sprintf(" AGENT_OUTPUT_CONTENT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add each line of the script with proper indentation + scriptLines := strings.Split(createCommentScript, "\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{ + "comment_id": "${{ steps.create_comment.outputs.comment_id }}", + "comment_url": "${{ steps.create_comment.outputs.comment_url }}", + } + + job := &Job{ + Name: "create_issue_comment", + 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 +} + // buildMainJob creates the main workflow job func (c *Compiler) buildMainJob(data *WorkflowData, jobName string) (*Job, error) { var steps []string @@ -2123,6 +2195,14 @@ func (c *Compiler) extractOutputConfig(frontmatter map[string]any) *OutputConfig } } + // Parse comment configuration + if comment, exists := outputMap["comment"]; exists { + if _, ok := comment.(map[string]any); ok { + // For now, CommentConfig is an empty struct + config.Comment = &CommentConfig{} + } + } + return config } } diff --git a/pkg/workflow/js/create_comment.js b/pkg/workflow/js/create_comment.js new file mode 100644 index 00000000000..646e74b7d64 --- /dev/null +++ b/pkg/workflow/js/create_comment.js @@ -0,0 +1,66 @@ +// Read the agent output content from environment variable +const outputContent = process.env.AGENT_OUTPUT_CONTENT; +if (!outputContent) { + console.log('No AGENT_OUTPUT_CONTENT environment variable found'); + return; +} + +if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; +} + +console.log('Agent output content length:', outputContent.length); + +// 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) { + console.log('Not running in issue or pull request context, skipping comment creation'); + return; +} + +// Determine the issue/PR number and comment endpoint +let issueNumber; +let commentEndpoint; + +if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + return; + } +} else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + return; + } +} + +if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + return; +} + +console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); +console.log('Comment content length:', outputContent.length); + +// Create the comment using GitHub API +const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: outputContent +}); + +console.log('Created comment #' + comment.id + ': ' + comment.html_url); + +// Set output for other jobs to use +core.setOutput('comment_id', comment.id); +core.setOutput('comment_url', comment.html_url); \ No newline at end of file diff --git a/pkg/workflow/output_test.go b/pkg/workflow/output_test.go index e44019ead03..f6881ac9522 100644 --- a/pkg/workflow/output_test.go +++ b/pkg/workflow/output_test.go @@ -201,3 +201,202 @@ This workflow tests the create_output_issue job generation. t.Logf("Generated workflow content:\n%s", lockContent) } + +func TestOutputCommentConfigParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-comment-config-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.comment configuration + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +engine: claude +output: + comment: {} +--- + +# Test Output Comment Configuration + +This workflow tests the output.comment configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-output-comment.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 comment config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.Output == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.Output.Comment == nil { + t.Fatal("Expected comment configuration to be parsed") + } +} + +func TestOutputCommentJobGeneration(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-comment-job-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.comment configuration + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write + pull-requests: write +tools: + github: + allowed: [get_issue] +engine: claude +output: + comment: {} +--- + +# Test Output Comment Job Generation + +This workflow tests the create_issue_comment job generation. +` + + testFile := filepath.Join(tmpDir, "test-output-comment.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 comment: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-output-comment.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + // Verify create_issue_comment job exists + if !strings.Contains(lockContent, "create_issue_comment:") { + t.Error("Expected 'create_issue_comment' job to be in generated workflow") + } + + // Verify job properties + if !strings.Contains(lockContent, "timeout-minutes: 10") { + t.Error("Expected 10-minute timeout in create_issue_comment job") + } + + if !strings.Contains(lockContent, "permissions:\n contents: read\n issues: write\n pull-requests: write") { + t.Error("Expected correct permissions in create_issue_comment 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 create_issue_comment job") + } + + // Verify job has conditional execution + if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + t.Error("Expected create_issue_comment job to have conditional execution") + } + + // Verify job dependencies + if !strings.Contains(lockContent, "needs: test-output-comment") { + t.Error("Expected create_issue_comment job to depend on main job") + } + + // Verify JavaScript content includes environment variable for agent output + if !strings.Contains(lockContent, "AGENT_OUTPUT_CONTENT:") { + t.Error("Expected agent output content to be passed as environment variable") + } + + t.Logf("Generated workflow content:\n%s", lockContent) +} + +func TestOutputCommentJobSkippedForNonIssueEvents(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-comment-skip-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.comment configuration but push trigger (not issue/PR) + testContent := `--- +on: push +permissions: + contents: read + issues: write + pull-requests: write +engine: claude +output: + comment: {} +--- + +# Test Output Comment Job Skipping + +This workflow tests that comment job is skipped for non-issue/PR events. +` + + testFile := filepath.Join(tmpDir, "test-comment-skip.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 comment: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-comment-skip.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + lockContent := string(content) + + // Verify create_issue_comment job exists (it should be generated regardless of trigger) + if !strings.Contains(lockContent, "create_issue_comment:") { + t.Error("Expected 'create_issue_comment' job to be in generated workflow") + } + + // Verify job has conditional execution to skip when not in issue/PR context + if !strings.Contains(lockContent, "if: github.event.issue.number || github.event.pull_request.number") { + t.Error("Expected create_issue_comment job to have conditional execution for skipping") + } + + t.Logf("Generated workflow content:\n%s", lockContent) +}