From e80b27ff72d13bfca972d79b7e40243003e69a34 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 12:48:49 +0100 Subject: [PATCH 1/7] add default permissions for create-pull-request --- .../test-claude-create-pull-request.lock.yml | 12 ++- docs/safe-outputs.md | 21 +++++ pkg/workflow/compiler.go | 88 ++++++++++++++++++- pkg/workflow/compiler_test.go | 6 +- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index de0a102742e..bc248180a84 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -168,10 +168,20 @@ jobs: uses: anthropics/claude-code-base-action@v0.0.56 with: # Allowed tools (sorted): + # - Bash(git add:*) + # - Bash(git branch:*) + # - Bash(git checkout:*) + # - Bash(git commit:*) + # - Bash(git merge:*) + # - Bash(git rm:*) + # - Bash(git switch:*) + # - Edit # - ExitPlanMode # - Glob # - Grep # - LS + # - MultiEdit + # - NotebookEdit # - NotebookRead # - Read # - Task @@ -221,7 +231,7 @@ jobs: # - mcp__github__search_pull_requests # - mcp__github__search_repositories # - mcp__github__search_users - allowed_tools: "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" + allowed_tools: "Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git merge:*),Bash(git rm:*),Bash(git switch:*),Edit,ExitPlanMode,Glob,Grep,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_env: | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 30d99409be7..01ff79ae102 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -249,6 +249,27 @@ Analyze the pull request and make necessary code improvements. - Label count is limited by `max` setting (default: 3) - exceeding this limit causes job failure - Only GitHub's `issues.addLabels` API endpoint is used (no removal endpoints) +When `create-pull-request` or `push-to-branch` are enabled in the `safe-outputs` configuration, the system automatically adds the following additional Claude tools to enable file editing and pull request creation: + +## Automatically Added Tools + +When `create-pull-request` or `push-to-branch` are configured, these Claude tools are automatically added: + +- **Edit**: Allows editing existing files +- **MultiEdit**: Allows making multiple edits to files in a single operation +- **Write**: Allows creating new files or overwriting existing files +- **NotebookEdit**: Allows editing Jupyter notebook files + +Along with the file editing tools, these Git commands are also automatically whitelisted: + +- `git checkout:*` +- `git branch:*` +- `git switch:*` +- `git add:*` +- `git rm:*` +- `git commit:*` +- `git merge:*` + ## Security and Sanitization All coding agent output is automatically sanitized for security before being processed: diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 7027550e13d..b5c0edbd805 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -501,6 +501,9 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) fmt.Println(console.FormatInfoMessage("Processing tools and includes...")) } + // Extract SafeOutputs configuration early so we can use it when applying default tools + safeOutputs := c.extractSafeOutputsConfig(result.Frontmatter) + var tools map[string]any if !agenticEngine.SupportsToolsWhitelist() { @@ -543,7 +546,7 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) // Apply default GitHub MCP tools (only for engines that support MCP) if agenticEngine.SupportsToolsWhitelist() { - tools = c.applyDefaultGitHubMCPAndClaudeTools(tools) + tools = c.applyDefaultGitHubMCPAndClaudeTools(tools, safeOutputs) } if c.verbose && len(tools) > 0 { @@ -629,8 +632,8 @@ func (c *Compiler) parseWorkflowFile(markdownPath string) (*WorkflowData, error) workflowData.Command = c.extractCommandName(result.Frontmatter) workflowData.Jobs = c.extractJobsFromFrontmatter(result.Frontmatter) - // Parse output configuration - workflowData.SafeOutputs = c.extractSafeOutputsConfig(result.Frontmatter) + // Use the already extracted output configuration + workflowData.SafeOutputs = safeOutputs // Check if "command" is used as a trigger in the "on" section // Also extract "reaction" from the "on" section @@ -1131,7 +1134,7 @@ func (c *Compiler) mergeTools(topTools map[string]any, includedToolsJSON string) } // applyDefaultGitHubMCPAndClaudeTools adds default read-only GitHub MCP tools, creating github tool if not present -func (c *Compiler) applyDefaultGitHubMCPAndClaudeTools(tools map[string]any) map[string]any { +func (c *Compiler) applyDefaultGitHubMCPAndClaudeTools(tools map[string]any, safeOutputs *SafeOutputsConfig) map[string]any { // Always apply default GitHub tools (create github section if it doesn't exist) // Define the default read-only GitHub MCP tools @@ -1279,6 +1282,75 @@ func (c *Compiler) applyDefaultGitHubMCPAndClaudeTools(tools map[string]any) map } } + // Add Git commands and file editing tools when safe-outputs includes create-pull-request or push-to-branch + if safeOutputs != nil && needsGitCommands(safeOutputs) { + gitCommands := []any{ + "git checkout:*", + "git branch:*", + "git switch:*", + "git add:*", + "git rm:*", + "git commit:*", + "git merge:*", + } + + // Add additional Claude tools needed for file editing and pull request creation + additionalTools := []string{ + "Edit", + "MultiEdit", + "Write", + "NotebookEdit", + } + + // Add file editing tools that aren't already present + for _, tool := range additionalTools { + if _, exists := claudeExistingAllowed[tool]; !exists { + claudeExistingAllowed[tool] = nil // Add tool with null value + } + } + + // Add Bash tool with Git commands if not already present + if _, exists := claudeExistingAllowed["Bash"]; !exists { + // Bash tool doesn't exist, add it with Git commands + claudeExistingAllowed["Bash"] = gitCommands + } else { + // Bash tool exists, merge Git commands with existing commands + existingBash := claudeExistingAllowed["Bash"] + if existingCommands, ok := existingBash.([]any); ok { + // Convert existing commands to strings for comparison + existingSet := make(map[string]bool) + for _, cmd := range existingCommands { + if cmdStr, ok := cmd.(string); ok { + existingSet[cmdStr] = true + // If we see :* or *, all bash commands are already allowed + if cmdStr == ":*" || cmdStr == "*" { + // Don't add specific Git commands since all are already allowed + goto bashComplete + } + } + } + + // Add Git commands that aren't already present + newCommands := make([]any, len(existingCommands)) + copy(newCommands, existingCommands) + for _, gitCmd := range gitCommands { + if gitCmdStr, ok := gitCmd.(string); ok { + if !existingSet[gitCmdStr] { + newCommands = append(newCommands, gitCmd) + } + } + } + claudeExistingAllowed["Bash"] = newCommands + } else if existingBash == nil { + // Bash tool exists but with nil value (allows all commands) + // Keep it as nil since that's more permissive than specific commands + // No action needed - nil value already permits all commands + _ = existingBash // Keep the nil value as-is + } + } + bashComplete: + } + // Update the claude section with the new format claudeSection["allowed"] = claudeExistingAllowed tools["claude"] = claudeSection @@ -1286,6 +1358,14 @@ func (c *Compiler) applyDefaultGitHubMCPAndClaudeTools(tools map[string]any) map return tools } +// needsGitCommands checks if safe outputs configuration requires Git commands +func needsGitCommands(safeOutputs *SafeOutputsConfig) bool { + if safeOutputs == nil { + return false + } + return safeOutputs.CreatePullRequests != nil || safeOutputs.PushToBranch != nil +} + // detectTextOutputUsage checks if the markdown content uses ${{ needs.task.outputs.text }} func (c *Compiler) detectTextOutputUsage(markdownContent string) bool { // Check for the specific GitHub Actions expression diff --git a/pkg/workflow/compiler_test.go b/pkg/workflow/compiler_test.go index 8dc2bd5cbf4..2ea683599ab 100644 --- a/pkg/workflow/compiler_test.go +++ b/pkg/workflow/compiler_test.go @@ -1089,7 +1089,7 @@ func TestApplyDefaultGitHubMCPTools_DefaultClaudeTools(t *testing.T) { tools[k] = v } - result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools) + result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools, nil) // Check that all expected top-level tools are present for _, expectedTool := range tt.expectedTopLevelTools { @@ -1207,7 +1207,7 @@ func TestDefaultClaudeToolsList(t *testing.T) { }, } - result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools) + result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools, nil) // Verify the claude section was created claudeSection, hasClaudeSection := result["claude"] @@ -1267,7 +1267,7 @@ func TestDefaultClaudeToolsIntegrationWithComputeAllowedTools(t *testing.T) { } // Apply default tools first - toolsWithDefaults := compiler.applyDefaultGitHubMCPAndClaudeTools(tools) + toolsWithDefaults := compiler.applyDefaultGitHubMCPAndClaudeTools(tools, nil) // Verify that the claude section was created with default tools (new format) claudeSection, hasClaudeSection := toolsWithDefaults["claude"] From deaa3948c909bdb0a4391119675a246945444364 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 12:49:52 +0100 Subject: [PATCH 2/7] add default permissions for create-pull-request --- .../test-claude-push-to-branch.lock.yml | 1285 +++++++++++++++++ .../workflows/test-claude-push-to-branch.md | 55 + .../test-codex-push-to-branch.lock.yml | 1160 +++++++++++++++ .../workflows/test-codex-push-to-branch.md | 57 + pkg/workflow/git_commands_integration_test.go | 300 ++++ pkg/workflow/git_commands_test.go | 344 +++++ 6 files changed, 3201 insertions(+) create mode 100644 .github/workflows/test-claude-push-to-branch.lock.yml create mode 100644 .github/workflows/test-claude-push-to-branch.md create mode 100644 .github/workflows/test-codex-push-to-branch.lock.yml create mode 100644 .github/workflows/test-codex-push-to-branch.md create mode 100644 pkg/workflow/git_commands_integration_test.go create mode 100644 pkg/workflow/git_commands_test.go diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml new file mode 100644 index 00000000000..d2be7f592e5 --- /dev/null +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -0,0 +1,1285 @@ +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile + +name: "Claude Test File" +on: + issues: + types: [opened, edited, reopened] + issue_comment: + types: [created, edited] + pull_request: + types: [opened, edited, reopened] + pull_request_review_comment: + types: [created, edited] + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}" + +run-name: "Claude Test File" + +jobs: + task: + if: ((contains(github.event.issue.body, '/test-claude-push-to-branch')) || (contains(github.event.comment.body, '/test-claude-push-to-branch'))) || (contains(github.event.pull_request.body, '/test-claude-push-to-branch')) + runs-on: ubuntu-latest + steps: + - name: Check team membership for command workflow + id: check-team-member + if: contains(github.event.issue.body, '/test-claude-push-to-branch') || contains(github.event.comment.body, '/test-claude-push-to-branch') || contains(github.event.pull_request.body, '/test-claude-push-to-branch') + uses: actions/github-script@v7 + with: + script: | + async function main() { + const actor = context.actor; + const { owner, repo } = context.repo; + // Check if the actor has repository access (admin, maintain permissions) + try { + console.log(`Checking if user '${actor}' is admin or maintainer of ${owner}/${repo}`); + const repoPermission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor + }); + const permission = repoPermission.data.permission; + console.log(`Repository permission level: ${permission}`); + if (permission === 'admin' || permission === 'maintain') { + console.log(`User has ${permission} access to repository`); + core.setOutput('is_team_member', 'true'); + return; + } + } catch (repoError) { + const errorMessage = repoError instanceof Error ? repoError.message : String(repoError); + console.log(`Repository permission check failed: ${errorMessage}`); + } + core.setOutput('is_team_member', 'false'); + } + await main(); + - name: Validate team membership + if: steps.check-team-member.outputs.is_team_member == 'false' + run: | + echo "❌ Access denied: Only team members can trigger command workflows" + echo "User ${{ github.actor }} is not a team member" + exit 1 + + claude-test-file: + needs: task + runs-on: ubuntu-latest + permissions: read-all + outputs: + output: ${{ steps.collect_output.outputs.output }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Setup agent output + id: setup_agent_output + uses: actions/github-script@v7 + with: + script: | + function main() { + const fs = require('fs'); + const crypto = require('crypto'); + // Generate a random filename for the output file + const randomId = crypto.randomBytes(8).toString('hex'); + const outputFile = `/tmp/aw_output_${randomId}.txt`; + // Ensure the /tmp directory exists and create empty output file + fs.mkdirSync('/tmp', { recursive: true }); + fs.writeFileSync(outputFile, '', { mode: 0o644 }); + // Verify the file was created and is writable + if (!fs.existsSync(outputFile)) { + throw new Error(`Failed to create output file: ${outputFile}`); + } + // Set the environment variable for subsequent steps + core.exportVariable('GITHUB_AW_SAFE_OUTPUTS', outputFile); + console.log('Created agentic output file:', outputFile); + // Also set as step output for reference + core.setOutput('output_file', outputFile); + } + main(); + - name: Setup MCPs + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + { + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:sha-45e90ae" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" + } + } + } + } + EOF + - name: Create prompt + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/aw-prompts + cat > /tmp/aw-prompts/prompt.txt << 'EOF' + Create a new file called "claude-test-file.md" with the following content: + + ```markdown + # Claude Test File + + This file was created by the Claude agentic workflow to test the push-to-branch functionality. + + Created at: {{ current timestamp }} + + ## Test Content + + This is a test file created by Claude to demonstrate: + - File creation + - Branch pushing + - Automated commit generation + + The workflow should push this file to the specified branch. + ``` + + Also create a simple Python script called "claude-script.py" with: + + ```python + #!/usr/bin/env python3 + """ + Test script created by Claude agentic workflow + """ + + import datetime + + def main(): + print("Hello from Claude agentic workflow!") + print(f"Current time: {datetime.datetime.now()}") + print("This script was created to test push-to-branch functionality.") + + if __name__ == "__main__": + main() + ``` + + Create a commit message: "Add test files created by Claude agentic workflow" + + Push these changes to the current branch + + + --- + + ## Pushing Changes to Branch + + **IMPORTANT**: To do the actions mentioned in the header of this section, do NOT attempt to use MCP tools, do NOT attempt to use `gh`, do NOT attempt to use the GitHub API. You don't have write access to the GitHub repo. Instead write JSON objects to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}". Each line should contain a single JSON object (JSONL format). You can write them one by one as you do them. + + **Format**: Write one JSON object per line. Each object must have a `type` field specifying the action type. + + ### Available Output Types: + + **Pushing Changes to Branch** + + To push changes to a branch: + 1. Make any file changes directly in the working directory + 2. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to. + 3. Indicate your intention to push to the branch by writing to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": + ```json + {"type": "push-to-branch", "message": "Commit message describing the changes"} + ``` + + **Example JSONL file content:** + ``` + {"type": "push-to-branch", "message": "Update documentation with latest changes"} + ``` + + **Important Notes:** + - Do NOT attempt to use MCP tools, `gh`, or the GitHub API for these actions + - Each JSON object must be on its own line + - Only include output types that are configured for this workflow + - The content of this file will be automatically processed and executed + + EOF + - name: Print prompt to step summary + run: | + echo "## Generated Prompt" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````markdown' >> $GITHUB_STEP_SUMMARY + cat /tmp/aw-prompts/prompt.txt >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Generate agentic run info + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "", + version: "", + workflow_name: "Claude Test File", + experimental: false, + supports_tools_whitelist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + created_at: new Date().toISOString() + }; + + // Write to /tmp directory to avoid inclusion in PR + const tmpPath = '/tmp/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + - name: Upload agentic run info + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw_info.json + path: /tmp/aw_info.json + if-no-files-found: warn + - name: Execute Claude Code Action + id: agentic_execution + uses: anthropics/claude-code-base-action@v0.0.56 + with: + # Allowed tools (sorted): + # - Bash(git add:*) + # - Bash(git branch:*) + # - Bash(git checkout:*) + # - Bash(git commit:*) + # - Bash(git merge:*) + # - Bash(git rm:*) + # - Bash(git switch:*) + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_issue + # - mcp__github__get_issue_comments + # - mcp__github__get_job_logs + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issues + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + allowed_tools: "Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git merge:*),Bash(git rm:*),Bash(git switch:*),Edit,ExitPlanMode,Glob,Grep,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_env: | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + mcp_config: /tmp/mcp-config/mcp-servers.json + prompt_file: /tmp/aw-prompts/prompt.txt + timeout_minutes: 5 + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + - name: Capture Agentic Action logs + if: always() + run: | + # Copy the detailed execution file from Agentic Action if available + if [ -n "${{ steps.agentic_execution.outputs.execution_file }}" ] && [ -f "${{ steps.agentic_execution.outputs.execution_file }}" ]; then + cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/claude-test-file.log + else + echo "No execution file output found from Agentic Action" >> /tmp/claude-test-file.log + fi + + # Ensure log file exists + touch /tmp/claude-test-file.log + - name: Check if workflow-complete.txt exists, if so upload it + id: check_file + run: | + if [ -f workflow-complete.txt ]; then + echo "File exists" + echo "upload=true" >> $GITHUB_OUTPUT + else + echo "File does not exist" + echo "upload=false" >> $GITHUB_OUTPUT + fi + - name: Upload workflow-complete.txt + if: steps.check_file.outputs.upload == 'true' + uses: actions/upload-artifact@v4 + with: + name: workflow-complete + path: workflow-complete.txt + - name: Collect agent output + id: collect_output + uses: actions/github-script@v7 + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"push-to-branch\":{\"branch\":\"claude-test-branch\",\"enabled\":true,\"target\":\"*\"}}" + with: + script: | + async function main() { + const fs = require("fs"); + /** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content + */ + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; + }); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } + } + /** + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count + */ + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 1; // Only one issue allowed + case 'add-issue-comment': + return 1; // Only one comment allowed + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-label': + return 5; // Only one labels operation allowed + case 'update-issue': + return 1; // Only one issue update allowed + case 'push-to-branch': + return 1; // Only one push to branch allowed + default: + return 1; // Default to single item for unknown types + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + console.log('GITHUB_AW_SAFE_OUTPUTS not set, no output to collect'); + core.setOutput('output', ''); + return; + } + if (!fs.existsSync(outputFile)) { + console.log('Output file does not exist:', outputFile); + core.setOutput('output', ''); + return; + } + const outputContent = fs.readFileSync(outputFile, 'utf8'); + if (outputContent.trim() === '') { + console.log('Output file is empty'); + core.setOutput('output', ''); + return; + } + console.log('Raw output content length:', outputContent.length); + // Parse the safe-outputs configuration + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + console.log('Expected output types:', Object.keys(expectedOutputTypes)); + } catch (error) { + console.log('Warning: Could not parse safe-outputs config:', error.message); + } + } + // Parse JSONL content + const lines = outputContent.trim().split('\n'); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '') continue; // Skip empty lines + try { + const item = JSON.parse(line); + // Validate that the item has a 'type' field + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + // Validate against expected output types + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); + continue; + } + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + // Basic validation based on type + switch (itemType) { + case 'create-issue': + if (!item.title || typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: create-issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: create-issue requires a 'body' string field`); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => typeof label === 'string' ? sanitizeContent(label) : label); + } + break; + case 'add-issue-comment': + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: add-issue-comment requires a 'body' string field`); + continue; + } + // Sanitize text content + item.body = sanitizeContent(item.body); + break; + case 'create-pull-request': + if (!item.title || typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: create-pull-request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: create-pull-request requires a 'body' string field`); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize branch name if present + if (item.branch && typeof item.branch === 'string') { + item.branch = sanitizeContent(item.branch); + } + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => typeof label === 'string' ? sanitizeContent(label) : label); + } + break; + case 'add-issue-label': + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add-issue-label requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== 'string')) { + errors.push(`Line ${i + 1}: add-issue-label labels array must contain only strings`); + continue; + } + // Sanitize label strings + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case 'update-issue': + // Check that at least one updateable field is provided + const hasValidField = (item.status !== undefined) || + (item.title !== undefined) || + (item.body !== undefined); + if (!hasValidField) { + errors.push(`Line ${i + 1}: update-issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + // Validate status if provided + if (item.status !== undefined) { + if (typeof item.status !== 'string' || (item.status !== 'open' && item.status !== 'closed')) { + errors.push(`Line ${i + 1}: update-issue 'status' must be 'open' or 'closed'`); + continue; + } + } + // Validate title if provided + if (item.title !== undefined) { + if (typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + // Validate body if provided + if (item.body !== undefined) { + if (typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + // Validate issue_number if provided (for target "*") + if (item.issue_number !== undefined) { + if (typeof item.issue_number !== 'number' && typeof item.issue_number !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'issue_number' must be a number or string`); + continue; + } + } + break; + case 'push-to-branch': + // Validate message if provided (optional) + if (item.message !== undefined) { + if (typeof item.message !== 'string') { + errors.push(`Line ${i + 1}: push-to-branch 'message' must be a string`); + continue; + } + item.message = sanitizeContent(item.message); + } + // Validate pull_request_number if provided (for target "*") + if (item.pull_request_number !== undefined) { + if (typeof item.pull_request_number !== 'number' && typeof item.pull_request_number !== 'string') { + errors.push(`Line ${i + 1}: push-to-branch 'pull_request_number' must be a number or string`); + continue; + } + } + break; + default: + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + console.log(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + errors.push(`Line ${i + 1}: Invalid JSON - ${error.message}`); + } + } + // Report validation results + if (errors.length > 0) { + console.log('Validation errors found:'); + errors.forEach(error => console.log(` - ${error}`)); + // For now, we'll continue with valid items but log the errors + // In the future, we might want to fail the workflow for invalid items + } + console.log(`Successfully parsed ${parsedItems.length} valid output items`); + // Set the parsed and validated items as output + const validatedOutput = { + items: parsedItems, + errors: errors + }; + core.setOutput('output', JSON.stringify(validatedOutput)); + core.setOutput('raw_output', outputContent); + } + // Call the main function + await main(); + - name: Print agent output to step summary + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + echo "## Agent Output (JSONL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````json' >> $GITHUB_STEP_SUMMARY + cat ${{ env.GITHUB_AW_SAFE_OUTPUTS }} >> $GITHUB_STEP_SUMMARY + # Ensure there's a newline after the file content if it doesn't end with one + if [ -s ${{ env.GITHUB_AW_SAFE_OUTPUTS }} ] && [ "$(tail -c1 ${{ env.GITHUB_AW_SAFE_OUTPUTS }})" != "" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + fi + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Upload agentic output file + if: always() && steps.collect_output.outputs.output != '' + uses: actions/upload-artifact@v4 + with: + name: aw_output.txt + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@v4 + with: + name: agent_outputs + path: | + output.txt + if-no-files-found: ignore + - name: Clean up engine output files + run: | + rm -f output.txt + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@v7 + env: + AGENT_LOG_FILE: /tmp/claude-test-file.log + with: + script: | + function main() { + const fs = require('fs'); + try { + // Get the log file path from environment + const logFile = process.env.AGENT_LOG_FILE; + if (!logFile) { + console.log('No agent log file specified'); + return; + } + if (!fs.existsSync(logFile)) { + console.log(`Log file not found: ${logFile}`); + return; + } + const logContent = fs.readFileSync(logFile, 'utf8'); + const markdown = parseClaudeLog(logContent); + // Append to GitHub step summary + core.summary.addRaw(markdown).write(); + } catch (error) { + console.error('Error parsing Claude log:', error.message); + core.setFailed(error.message); + } + } + function parseClaudeLog(logContent) { + try { + const logEntries = JSON.parse(logContent); + if (!Array.isArray(logEntries)) { + return '## Agent Log Summary\n\nLog format not recognized as Claude JSON array.\n'; + } + let markdown = '## 🤖 Commands and Tools\n\n'; + const toolUsePairs = new Map(); // Map tool_use_id to tool_result + const commandSummary = []; // For the succinct summary + // First pass: collect tool results by tool_use_id + for (const entry of logEntries) { + if (entry.type === 'user' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'tool_result' && content.tool_use_id) { + toolUsePairs.set(content.tool_use_id, content); + } + } + } + } + // Collect all tool uses for summary + for (const entry of logEntries) { + if (entry.type === 'assistant' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'tool_use') { + const toolName = content.name; + const input = content.input || {}; + // Skip internal tools - only show external commands and API calls + if (['Read', 'Write', 'Edit', 'MultiEdit', 'LS', 'Grep', 'Glob', 'TodoWrite'].includes(toolName)) { + continue; // Skip internal file operations and searches + } + // Find the corresponding tool result to get status + const toolResult = toolUsePairs.get(content.id); + let statusIcon = '❓'; + if (toolResult) { + statusIcon = toolResult.is_error === true ? '❌' : '✅'; + } + // Add to command summary (only external tools) + if (toolName === 'Bash') { + const formattedCommand = formatBashCommand(input.command || ''); + commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``); + } else if (toolName.startsWith('mcp__')) { + const mcpName = formatMcpName(toolName); + commandSummary.push(`* ${statusIcon} \`${mcpName}(...)\``); + } else { + // Handle other external tools (if any) + commandSummary.push(`* ${statusIcon} ${toolName}`); + } + } + } + } + } + // Add command summary + if (commandSummary.length > 0) { + for (const cmd of commandSummary) { + markdown += `${cmd}\n`; + } + } else { + markdown += 'No commands or tools used.\n'; + } + // Add Information section from the last entry with result metadata + markdown += '\n## 📊 Information\n\n'; + // Find the last entry with metadata + const lastEntry = logEntries[logEntries.length - 1]; + if (lastEntry && (lastEntry.num_turns || lastEntry.duration_ms || lastEntry.total_cost_usd || lastEntry.usage)) { + if (lastEntry.num_turns) { + markdown += `**Turns:** ${lastEntry.num_turns}\n\n`; + } + if (lastEntry.duration_ms) { + const durationSec = Math.round(lastEntry.duration_ms / 1000); + const minutes = Math.floor(durationSec / 60); + const seconds = durationSec % 60; + markdown += `**Duration:** ${minutes}m ${seconds}s\n\n`; + } + if (lastEntry.total_cost_usd) { + markdown += `**Total Cost:** $${lastEntry.total_cost_usd.toFixed(4)}\n\n`; + } + if (lastEntry.usage) { + const usage = lastEntry.usage; + if (usage.input_tokens || usage.output_tokens) { + markdown += `**Token Usage:**\n`; + if (usage.input_tokens) markdown += `- Input: ${usage.input_tokens.toLocaleString()}\n`; + if (usage.cache_creation_input_tokens) markdown += `- Cache Creation: ${usage.cache_creation_input_tokens.toLocaleString()}\n`; + if (usage.cache_read_input_tokens) markdown += `- Cache Read: ${usage.cache_read_input_tokens.toLocaleString()}\n`; + if (usage.output_tokens) markdown += `- Output: ${usage.output_tokens.toLocaleString()}\n`; + markdown += '\n'; + } + } + if (lastEntry.permission_denials && lastEntry.permission_denials.length > 0) { + markdown += `**Permission Denials:** ${lastEntry.permission_denials.length}\n\n`; + } + } + markdown += '\n## 🤖 Reasoning\n\n'; + // Second pass: process assistant messages in sequence + for (const entry of logEntries) { + if (entry.type === 'assistant' && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === 'text' && content.text) { + // Add reasoning text directly (no header) + const text = content.text.trim(); + if (text && text.length > 0) { + markdown += text + '\n\n'; + } + } else if (content.type === 'tool_use') { + // Process tool use with its result + const toolResult = toolUsePairs.get(content.id); + const toolMarkdown = formatToolUse(content, toolResult); + if (toolMarkdown) { + markdown += toolMarkdown; + } + } + } + } + } + return markdown; + } catch (error) { + return `## Agent Log Summary\n\nError parsing Claude log: ${error.message}\n`; + } + } + function formatToolUse(toolUse, toolResult) { + const toolName = toolUse.name; + const input = toolUse.input || {}; + // Skip TodoWrite except the very last one (we'll handle this separately) + if (toolName === 'TodoWrite') { + return ''; // Skip for now, would need global context to find the last one + } + // Helper function to determine status icon + function getStatusIcon() { + if (toolResult) { + return toolResult.is_error === true ? '❌' : '✅'; + } + return '❓'; // Unknown by default + } + let markdown = ''; + const statusIcon = getStatusIcon(); + switch (toolName) { + case 'Bash': + const command = input.command || ''; + const description = input.description || ''; + // Format the command to be single line + const formattedCommand = formatBashCommand(command); + if (description) { + markdown += `${description}:\n\n`; + } + markdown += `${statusIcon} \`${formattedCommand}\`\n\n`; + break; + case 'Read': + const filePath = input.file_path || input.path || ''; + const relativePath = filePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ''); // Remove /home/runner/work/repo/repo/ prefix + markdown += `${statusIcon} Read \`${relativePath}\`\n\n`; + break; + case 'Write': + case 'Edit': + case 'MultiEdit': + const writeFilePath = input.file_path || input.path || ''; + const writeRelativePath = writeFilePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ''); + markdown += `${statusIcon} Write \`${writeRelativePath}\`\n\n`; + break; + case 'Grep': + case 'Glob': + const query = input.query || input.pattern || ''; + markdown += `${statusIcon} Search for \`${truncateString(query, 80)}\`\n\n`; + break; + case 'LS': + const lsPath = input.path || ''; + const lsRelativePath = lsPath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ''); + markdown += `${statusIcon} LS: ${lsRelativePath || lsPath}\n\n`; + break; + default: + // Handle MCP calls and other tools + if (toolName.startsWith('mcp__')) { + const mcpName = formatMcpName(toolName); + const params = formatMcpParameters(input); + markdown += `${statusIcon} ${mcpName}(${params})\n\n`; + } else { + // Generic tool formatting - show the tool name and main parameters + const keys = Object.keys(input); + if (keys.length > 0) { + // Try to find the most important parameter + const mainParam = keys.find(k => ['query', 'command', 'path', 'file_path', 'content'].includes(k)) || keys[0]; + const value = String(input[mainParam] || ''); + if (value) { + markdown += `${statusIcon} ${toolName}: ${truncateString(value, 100)}\n\n`; + } else { + markdown += `${statusIcon} ${toolName}\n\n`; + } + } else { + markdown += `${statusIcon} ${toolName}\n\n`; + } + } + } + return markdown; + } + function formatMcpName(toolName) { + // Convert mcp__github__search_issues to github::search_issues + if (toolName.startsWith('mcp__')) { + const parts = toolName.split('__'); + if (parts.length >= 3) { + const provider = parts[1]; // github, etc. + const method = parts.slice(2).join('_'); // search_issues, etc. + return `${provider}::${method}`; + } + } + return toolName; + } + function formatMcpParameters(input) { + const keys = Object.keys(input); + if (keys.length === 0) return ''; + const paramStrs = []; + for (const key of keys.slice(0, 4)) { // Show up to 4 parameters + const value = String(input[key] || ''); + paramStrs.push(`${key}: ${truncateString(value, 40)}`); + } + if (keys.length > 4) { + paramStrs.push('...'); + } + return paramStrs.join(', '); + } + function formatBashCommand(command) { + if (!command) return ''; + // Convert multi-line commands to single line by replacing newlines with spaces + // and collapsing multiple spaces + let formatted = command + .replace(/\n/g, ' ') // Replace newlines with spaces + .replace(/\r/g, ' ') // Replace carriage returns with spaces + .replace(/\t/g, ' ') // Replace tabs with spaces + .replace(/\s+/g, ' ') // Collapse multiple spaces into one + .trim(); // Remove leading/trailing whitespace + // Escape backticks to prevent markdown issues + formatted = formatted.replace(/`/g, '\\`'); + // Truncate if too long (keep reasonable length for summary) + const maxLength = 80; + if (formatted.length > maxLength) { + formatted = formatted.substring(0, maxLength) + '...'; + } + return formatted; + } + function truncateString(str, maxLength) { + if (!str) return ''; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength) + '...'; + } + // Export for testing + if (typeof module !== 'undefined' && module.exports) { + module.exports = { parseClaudeLog, formatToolUse, formatBashCommand, truncateString }; + } + main(); + - name: Upload agent logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: claude-test-file.log + path: /tmp/claude-test-file.log + if-no-files-found: warn + - name: Generate git patch + if: always() + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_PUSH_BRANCH: "claude-test-branch" + run: | + # Check current git status + echo "Current git status:" + git status + + # Extract branch name from JSONL output + BRANCH_NAME="" + if [ -f "$GITHUB_AW_SAFE_OUTPUTS" ]; then + echo "Checking for branch name in JSONL output..." + while IFS= read -r line; do + if [ -n "$line" ]; then + # Extract branch from create-pull-request line using simple grep and sed + if echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"create-pull-request"'; then + echo "Found create-pull-request line: $line" + # Extract branch value using sed + BRANCH_NAME=$(echo "$line" | sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$BRANCH_NAME" ]; then + echo "Extracted branch name from create-pull-request: $BRANCH_NAME" + break + fi + # Extract branch from push-to-branch line using simple grep and sed + elif echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"push-to-branch"'; then + echo "Found push-to-branch line: $line" + # For push-to-branch, we don't extract branch from JSONL since it's configured in the workflow + # The branch name should come from the environment variable GITHUB_AW_PUSH_BRANCH + if [ -n "$GITHUB_AW_PUSH_BRANCH" ]; then + BRANCH_NAME="$GITHUB_AW_PUSH_BRANCH" + echo "Using configured push-to-branch target: $BRANCH_NAME" + break + fi + fi + fi + done < "$GITHUB_AW_SAFE_OUTPUTS" + fi + + # Get the initial commit SHA from the base branch of the pull request + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ]; then + INITIAL_SHA="$GITHUB_BASE_REF" + else + INITIAL_SHA="$GITHUB_SHA" + fi + echo "Base commit SHA: $INITIAL_SHA" + # Configure git user for GitHub Actions + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + + # If we have a branch name, check if that branch exists and get its diff + if [ -n "$BRANCH_NAME" ]; then + echo "Looking for branch: $BRANCH_NAME" + # Check if the branch exists + if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then + echo "Branch $BRANCH_NAME exists, generating patch from branch changes" + # Generate patch from the base to the branch + git format-patch "$INITIAL_SHA".."$BRANCH_NAME" --stdout > /tmp/aw.patch || echo "Failed to generate patch from branch" > /tmp/aw.patch + echo "Patch file created from branch: $BRANCH_NAME" + else + echo "Branch $BRANCH_NAME does not exist, falling back to current HEAD" + BRANCH_NAME="" + fi + fi + + # If no branch or branch doesn't exist, use the existing logic + if [ -z "$BRANCH_NAME" ]; then + echo "Using current HEAD for patch generation" + # Stage any unstaged files + git add -A || true + # Check if there are staged files to commit + if ! git diff --cached --quiet; then + echo "Staged files found, committing them..." + git commit -m "[agent] staged files" || true + echo "Staged files committed" + else + echo "No staged files to commit" + fi + # Check updated git status + echo "Updated git status after committing staged files:" + git status + # Show compact diff information between initial commit and HEAD (committed changes only) + echo '## Git diff' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + # Check if there are any committed changes since the initial commit + if git diff --quiet "$INITIAL_SHA" HEAD; then + echo "No committed changes detected since initial commit" + echo "Skipping patch generation - no committed changes to create patch from" + else + echo "Committed changes detected, generating patch..." + # Generate patch from initial commit to HEAD (committed changes only) + git format-patch "$INITIAL_SHA"..HEAD --stdout > /tmp/aw.patch || echo "Failed to generate patch" > /tmp/aw.patch + echo "Patch file created at /tmp/aw.patch" + fi + fi + + # Show patch info if it exists + if [ -f /tmp/aw.patch ]; then + ls -la /tmp/aw.patch + # Show the first 50 lines of the patch for review + echo '## Git Patch' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + head -50 /tmp/aw.patch >> $GITHUB_STEP_SUMMARY || echo "Could not display patch contents" >> $GITHUB_STEP_SUMMARY + echo '...' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + fi + - name: Upload git patch + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw.patch + path: /tmp/aw.patch + if-no-files-found: ignore + + push_to_branch: + needs: claude-test-file + if: always() + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.push_to_branch.outputs.branch_name }} + commit_sha: ${{ steps.push_to_branch.outputs.commit_sha }} + push_url: ${{ steps.push_to_branch.outputs.push_url }} + steps: + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: aw.patch + path: /tmp/ + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Push to Branch + id: push_to_branch + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.claude-test-file.outputs.output }} + GITHUB_AW_PUSH_BRANCH: "claude-test-branch" + GITHUB_AW_PUSH_TARGET: "*" + with: + script: | + async function main() { + /** @type {typeof import("fs")} */ + const fs = require("fs"); + const { execSync } = require("child_process"); + // Environment validation - fail early if required variables are missing + const branchName = process.env.GITHUB_AW_PUSH_BRANCH; + if (!branchName) { + core.setFailed('GITHUB_AW_PUSH_BRANCH environment variable is required'); + return; + } + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT || ""; + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + // Check if patch file exists and has valid content + if (!fs.existsSync('/tmp/aw.patch')) { + core.setFailed('No patch file found - cannot push without changes'); + return; + } + const patchContent = fs.readFileSync('/tmp/aw.patch', 'utf8'); + if (!patchContent || !patchContent.trim() || patchContent.includes('Failed to generate patch')) { + core.setFailed('Patch file is empty or contains error message - cannot push without changes'); + return; + } + console.log('Agent output content length:', outputContent.length); + console.log('Patch content validation passed'); + console.log('Target branch:', branchName); + console.log('Target configuration:', target); + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + console.log('Error parsing agent output JSON:', error instanceof Error ? error.message : String(error)); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + console.log('No valid items found in agent output'); + return; + } + // Find the push-to-branch item + const pushItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'push-to-branch'); + if (!pushItem) { + console.log('No push-to-branch item found in agent output'); + return; + } + console.log('Found push-to-branch item'); + // Validate target configuration for pull request context + if (target !== "*" && target !== "triggering") { + // If target is a specific number, validate it's a valid pull request number + const targetNumber = parseInt(target, 10); + if (isNaN(targetNumber)) { + core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); + return; + } + } + // Check if we're in a pull request context when required + if (target === "triggering" && !context.payload.pull_request) { + core.setFailed('push-to-branch with target "triggering" requires pull request context'); + return; + } + // Configure git (required for commits) + execSync('git config --global user.email "action@github.com"', { stdio: 'inherit' }); + execSync('git config --global user.name "GitHub Action"', { stdio: 'inherit' }); + // Switch to or create the target branch + console.log('Switching to branch:', branchName); + try { + // Try to checkout existing branch first + execSync('git fetch origin', { stdio: 'inherit' }); + execSync(`git checkout ${branchName}`, { stdio: 'inherit' }); + console.log('Checked out existing branch:', branchName); + } catch (error) { + // Branch doesn't exist, create it + console.log('Branch does not exist, creating new branch:', branchName); + execSync(`git checkout -b ${branchName}`, { stdio: 'inherit' }); + } + // Apply the patch using git CLI + console.log('Applying patch...'); + try { + execSync('git apply /tmp/aw.patch', { stdio: 'inherit' }); + console.log('Patch applied successfully'); + } catch (error) { + console.error('Failed to apply patch:', error instanceof Error ? error.message : String(error)); + core.setFailed('Failed to apply patch'); + return; + } + // Commit and push the changes + execSync('git add .', { stdio: 'inherit' }); + // Check if there are changes to commit + try { + execSync('git diff --cached --exit-code', { stdio: 'ignore' }); + console.log('No changes to commit'); + return; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + } + const commitMessage = pushItem.message || 'Apply agent changes'; + execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); + execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); + console.log('Changes committed and pushed to branch:', branchName); + // Get commit SHA + const commitSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); + const pushUrl = context.payload.repository + ? `${context.payload.repository.html_url}/tree/${branchName}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; + // Set outputs + core.setOutput('branch_name', branchName); + core.setOutput('commit_sha', commitSha); + core.setOutput('push_url', pushUrl); + // Write summary to GitHub Actions summary + await core.summary + .addRaw(` + ## Push to Branch + - **Branch**: \`${branchName}\` + - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) + - **URL**: [${pushUrl}](${pushUrl}) + `).write(); + } + await main(); + diff --git a/.github/workflows/test-claude-push-to-branch.md b/.github/workflows/test-claude-push-to-branch.md new file mode 100644 index 00000000000..2c0f3ac20f3 --- /dev/null +++ b/.github/workflows/test-claude-push-to-branch.md @@ -0,0 +1,55 @@ +--- +on: + command: + name: test-claude-push-to-branch + +engine: + id: claude + +safe-outputs: + push-to-branch: + branch: claude-test-branch + target: "*" +--- + +Create a new file called "claude-test-file.md" with the following content: + +```markdown +# Claude Test File + +This file was created by the Claude agentic workflow to test the push-to-branch functionality. + +Created at: {{ current timestamp }} + +## Test Content + +This is a test file created by Claude to demonstrate: +- File creation +- Branch pushing +- Automated commit generation + +The workflow should push this file to the specified branch. +``` + +Also create a simple Python script called "claude-script.py" with: + +```python +#!/usr/bin/env python3 +""" +Test script created by Claude agentic workflow +""" + +import datetime + +def main(): + print("Hello from Claude agentic workflow!") + print(f"Current time: {datetime.datetime.now()}") + print("This script was created to test push-to-branch functionality.") + +if __name__ == "__main__": + main() +``` + +Create a commit message: "Add test files created by Claude agentic workflow" + +Push these changes to the branch for the pull request diff --git a/.github/workflows/test-codex-push-to-branch.lock.yml b/.github/workflows/test-codex-push-to-branch.lock.yml new file mode 100644 index 00000000000..b76506b8bef --- /dev/null +++ b/.github/workflows/test-codex-push-to-branch.lock.yml @@ -0,0 +1,1160 @@ +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile + +name: "Codex Test File" +on: + issues: + types: [opened, edited, reopened] + issue_comment: + types: [created, edited] + pull_request: + types: [opened, edited, reopened] + pull_request_review_comment: + types: [created, edited] + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}" + +run-name: "Codex Test File" + +jobs: + task: + if: ((contains(github.event.issue.body, '/test-codex-push-to-branch')) || (contains(github.event.comment.body, '/test-codex-push-to-branch'))) || (contains(github.event.pull_request.body, '/test-codex-push-to-branch')) + runs-on: ubuntu-latest + steps: + - name: Check team membership for command workflow + id: check-team-member + if: contains(github.event.issue.body, '/test-codex-push-to-branch') || contains(github.event.comment.body, '/test-codex-push-to-branch') || contains(github.event.pull_request.body, '/test-codex-push-to-branch') + uses: actions/github-script@v7 + with: + script: | + async function main() { + const actor = context.actor; + const { owner, repo } = context.repo; + // Check if the actor has repository access (admin, maintain permissions) + try { + console.log(`Checking if user '${actor}' is admin or maintainer of ${owner}/${repo}`); + const repoPermission = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor + }); + const permission = repoPermission.data.permission; + console.log(`Repository permission level: ${permission}`); + if (permission === 'admin' || permission === 'maintain') { + console.log(`User has ${permission} access to repository`); + core.setOutput('is_team_member', 'true'); + return; + } + } catch (repoError) { + const errorMessage = repoError instanceof Error ? repoError.message : String(repoError); + console.log(`Repository permission check failed: ${errorMessage}`); + } + core.setOutput('is_team_member', 'false'); + } + await main(); + - name: Validate team membership + if: steps.check-team-member.outputs.is_team_member == 'false' + run: | + echo "❌ Access denied: Only team members can trigger command workflows" + echo "User ${{ github.actor }} is not a team member" + exit 1 + + codex-test-file: + needs: task + runs-on: ubuntu-latest + permissions: read-all + outputs: + output: ${{ steps.collect_output.outputs.output }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Install Codex + run: npm install -g @openai/codex + - name: Setup agent output + id: setup_agent_output + uses: actions/github-script@v7 + with: + script: | + function main() { + const fs = require('fs'); + const crypto = require('crypto'); + // Generate a random filename for the output file + const randomId = crypto.randomBytes(8).toString('hex'); + const outputFile = `/tmp/aw_output_${randomId}.txt`; + // Ensure the /tmp directory exists and create empty output file + fs.mkdirSync('/tmp', { recursive: true }); + fs.writeFileSync(outputFile, '', { mode: 0o644 }); + // Verify the file was created and is writable + if (!fs.existsSync(outputFile)) { + throw new Error(`Failed to create output file: ${outputFile}`); + } + // Set the environment variable for subsequent steps + core.exportVariable('GITHUB_AW_SAFE_OUTPUTS', outputFile); + console.log('Created agentic output file:', outputFile); + // Also set as step output for reference + core.setOutput('output_file', outputFile); + } + main(); + - name: Setup MCPs + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/config.toml << EOF + [history] + persistence = "none" + + [mcp_servers.github] + command = "docker" + args = [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:sha-45e90ae" + ] + env = { "GITHUB_PERSONAL_ACCESS_TOKEN" = "${{ secrets.GITHUB_TOKEN }}" } + EOF + - name: Create prompt + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/aw-prompts + cat > /tmp/aw-prompts/prompt.txt << 'EOF' + Create a new file called "codex-test-file.md" with the following content: + + ```markdown + # Codex Test File + + This file was created by the Codex agentic workflow to test the push-to-branch functionality. + + Created at: {{ current timestamp }} + + ## Test Content + + This is a test file created by Codex to demonstrate: + - File creation + - Branch pushing + - Automated commit generation + + The workflow should push this file to the specified branch. + ``` + + Also create a simple JavaScript script called "codex-script.js" with: + + ```javascript + #!/usr/bin/env node + /** + * Test script created by Codex agentic workflow + */ + + function main() { + console.log("Hello from Codex agentic workflow!"); + console.log(`Current time: ${new Date().toISOString()}`); + console.log("This script was created to test push-to-branch functionality."); + } + + if (require.main === module) { + main(); + } + + module.exports = { main }; + ``` + + Create a commit message: "Add test files created by Codex agentic workflow" + + Push these changes to the branch for the pull request + + + --- + + ## Pushing Changes to Branch + + **IMPORTANT**: To do the actions mentioned in the header of this section, do NOT attempt to use MCP tools, do NOT attempt to use `gh`, do NOT attempt to use the GitHub API. You don't have write access to the GitHub repo. Instead write JSON objects to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}". Each line should contain a single JSON object (JSONL format). You can write them one by one as you do them. + + **Format**: Write one JSON object per line. Each object must have a `type` field specifying the action type. + + ### Available Output Types: + + **Pushing Changes to Branch** + + To push changes to a branch: + 1. Make any file changes directly in the working directory + 2. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to. + 3. Indicate your intention to push to the branch by writing to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": + ```json + {"type": "push-to-branch", "message": "Commit message describing the changes"} + ``` + + **Example JSONL file content:** + ``` + {"type": "push-to-branch", "message": "Update documentation with latest changes"} + ``` + + **Important Notes:** + - Do NOT attempt to use MCP tools, `gh`, or the GitHub API for these actions + - Each JSON object must be on its own line + - Only include output types that are configured for this workflow + - The content of this file will be automatically processed and executed + + EOF + - name: Print prompt to step summary + run: | + echo "## Generated Prompt" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````markdown' >> $GITHUB_STEP_SUMMARY + cat /tmp/aw-prompts/prompt.txt >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Generate agentic run info + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "codex", + engine_name: "Codex", + model: "", + version: "", + workflow_name: "Codex Test File", + experimental: true, + supports_tools_whitelist: true, + supports_http_transport: false, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + created_at: new Date().toISOString() + }; + + // Write to /tmp directory to avoid inclusion in PR + const tmpPath = '/tmp/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + - name: Upload agentic run info + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw_info.json + path: /tmp/aw_info.json + if-no-files-found: warn + - name: Run Codex + run: | + INSTRUCTION=$(cat /tmp/aw-prompts/prompt.txt) + export CODEX_HOME=/tmp/mcp-config + + # Create log directory outside git repo + mkdir -p /tmp/aw-logs + + # Run codex with log capture + codex exec \ + -c model=o4-mini \ + --full-auto "$INSTRUCTION" 2>&1 | tee /tmp/codex-test-file.log + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Check if workflow-complete.txt exists, if so upload it + id: check_file + run: | + if [ -f workflow-complete.txt ]; then + echo "File exists" + echo "upload=true" >> $GITHUB_OUTPUT + else + echo "File does not exist" + echo "upload=false" >> $GITHUB_OUTPUT + fi + - name: Upload workflow-complete.txt + if: steps.check_file.outputs.upload == 'true' + uses: actions/upload-artifact@v4 + with: + name: workflow-complete + path: workflow-complete.txt + - name: Collect agent output + id: collect_output + uses: actions/github-script@v7 + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"push-to-branch\":{\"branch\":\"codex-test-branch\",\"enabled\":true,\"target\":\"*\"}}" + with: + script: | + async function main() { + const fs = require("fs"); + /** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content + */ + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; + }); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } + } + /** + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count + */ + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 1; // Only one issue allowed + case 'add-issue-comment': + return 1; // Only one comment allowed + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-label': + return 5; // Only one labels operation allowed + case 'update-issue': + return 1; // Only one issue update allowed + case 'push-to-branch': + return 1; // Only one push to branch allowed + default: + return 1; // Default to single item for unknown types + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + console.log('GITHUB_AW_SAFE_OUTPUTS not set, no output to collect'); + core.setOutput('output', ''); + return; + } + if (!fs.existsSync(outputFile)) { + console.log('Output file does not exist:', outputFile); + core.setOutput('output', ''); + return; + } + const outputContent = fs.readFileSync(outputFile, 'utf8'); + if (outputContent.trim() === '') { + console.log('Output file is empty'); + core.setOutput('output', ''); + return; + } + console.log('Raw output content length:', outputContent.length); + // Parse the safe-outputs configuration + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + console.log('Expected output types:', Object.keys(expectedOutputTypes)); + } catch (error) { + console.log('Warning: Could not parse safe-outputs config:', error.message); + } + } + // Parse JSONL content + const lines = outputContent.trim().split('\n'); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '') continue; // Skip empty lines + try { + const item = JSON.parse(line); + // Validate that the item has a 'type' field + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + // Validate against expected output types + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); + continue; + } + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + // Basic validation based on type + switch (itemType) { + case 'create-issue': + if (!item.title || typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: create-issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: create-issue requires a 'body' string field`); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => typeof label === 'string' ? sanitizeContent(label) : label); + } + break; + case 'add-issue-comment': + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: add-issue-comment requires a 'body' string field`); + continue; + } + // Sanitize text content + item.body = sanitizeContent(item.body); + break; + case 'create-pull-request': + if (!item.title || typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: create-pull-request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: create-pull-request requires a 'body' string field`); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize branch name if present + if (item.branch && typeof item.branch === 'string') { + item.branch = sanitizeContent(item.branch); + } + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => typeof label === 'string' ? sanitizeContent(label) : label); + } + break; + case 'add-issue-label': + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add-issue-label requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== 'string')) { + errors.push(`Line ${i + 1}: add-issue-label labels array must contain only strings`); + continue; + } + // Sanitize label strings + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case 'update-issue': + // Check that at least one updateable field is provided + const hasValidField = (item.status !== undefined) || + (item.title !== undefined) || + (item.body !== undefined); + if (!hasValidField) { + errors.push(`Line ${i + 1}: update-issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + // Validate status if provided + if (item.status !== undefined) { + if (typeof item.status !== 'string' || (item.status !== 'open' && item.status !== 'closed')) { + errors.push(`Line ${i + 1}: update-issue 'status' must be 'open' or 'closed'`); + continue; + } + } + // Validate title if provided + if (item.title !== undefined) { + if (typeof item.title !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + // Validate body if provided + if (item.body !== undefined) { + if (typeof item.body !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + // Validate issue_number if provided (for target "*") + if (item.issue_number !== undefined) { + if (typeof item.issue_number !== 'number' && typeof item.issue_number !== 'string') { + errors.push(`Line ${i + 1}: update-issue 'issue_number' must be a number or string`); + continue; + } + } + break; + case 'push-to-branch': + // Validate message if provided (optional) + if (item.message !== undefined) { + if (typeof item.message !== 'string') { + errors.push(`Line ${i + 1}: push-to-branch 'message' must be a string`); + continue; + } + item.message = sanitizeContent(item.message); + } + // Validate pull_request_number if provided (for target "*") + if (item.pull_request_number !== undefined) { + if (typeof item.pull_request_number !== 'number' && typeof item.pull_request_number !== 'string') { + errors.push(`Line ${i + 1}: push-to-branch 'pull_request_number' must be a number or string`); + continue; + } + } + break; + default: + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + console.log(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + errors.push(`Line ${i + 1}: Invalid JSON - ${error.message}`); + } + } + // Report validation results + if (errors.length > 0) { + console.log('Validation errors found:'); + errors.forEach(error => console.log(` - ${error}`)); + // For now, we'll continue with valid items but log the errors + // In the future, we might want to fail the workflow for invalid items + } + console.log(`Successfully parsed ${parsedItems.length} valid output items`); + // Set the parsed and validated items as output + const validatedOutput = { + items: parsedItems, + errors: errors + }; + core.setOutput('output', JSON.stringify(validatedOutput)); + core.setOutput('raw_output', outputContent); + } + // Call the main function + await main(); + - name: Print agent output to step summary + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + echo "## Agent Output (JSONL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````json' >> $GITHUB_STEP_SUMMARY + cat ${{ env.GITHUB_AW_SAFE_OUTPUTS }} >> $GITHUB_STEP_SUMMARY + # Ensure there's a newline after the file content if it doesn't end with one + if [ -s ${{ env.GITHUB_AW_SAFE_OUTPUTS }} ] && [ "$(tail -c1 ${{ env.GITHUB_AW_SAFE_OUTPUTS }})" != "" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + fi + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Upload agentic output file + if: always() && steps.collect_output.outputs.output != '' + uses: actions/upload-artifact@v4 + with: + name: aw_output.txt + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@v7 + env: + AGENT_LOG_FILE: /tmp/codex-test-file.log + with: + script: | + function main() { + const fs = require('fs'); + try { + const logFile = process.env.AGENT_LOG_FILE; + if (!logFile) { + console.log('No agent log file specified'); + return; + } + if (!fs.existsSync(logFile)) { + console.log(`Log file not found: ${logFile}`); + return; + } + const content = fs.readFileSync(logFile, 'utf8'); + const parsedLog = parseCodexLog(content); + if (parsedLog) { + core.summary.addRaw(parsedLog).write(); + console.log('Codex log parsed successfully'); + } else { + console.log('Failed to parse Codex log'); + } + } catch (error) { + core.setFailed(error.message); + } + } + function parseCodexLog(logContent) { + try { + const lines = logContent.split('\n'); + let markdown = '## 🤖 Commands and Tools\n\n'; + const commandSummary = []; + // First pass: collect commands for summary + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Detect tool usage and exec commands + if (line.includes('] tool ') && line.includes('(')) { + // Extract tool name + const toolMatch = line.match(/\] tool ([^(]+)\(/); + if (toolMatch) { + const toolName = toolMatch[1]; + // Look ahead to find the result status + let statusIcon = '❓'; // Unknown by default + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes('success in')) { + statusIcon = '✅'; + break; + } else if (nextLine.includes('failure in') || nextLine.includes('error in') || nextLine.includes('failed in')) { + statusIcon = '❌'; + break; + } + } + if (toolName.includes('.')) { + // Format as provider::method + const parts = toolName.split('.'); + const provider = parts[0]; + const method = parts.slice(1).join('_'); + commandSummary.push(`* ${statusIcon} \`${provider}::${method}(...)\``); + } else { + commandSummary.push(`* ${statusIcon} \`${toolName}(...)\``); + } + } + } else if (line.includes('] exec ')) { + // Extract exec command + const execMatch = line.match(/exec (.+?) in/); + if (execMatch) { + const formattedCommand = formatBashCommand(execMatch[1]); + // Look ahead to find the result status + let statusIcon = '❓'; // Unknown by default + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes('succeeded in')) { + statusIcon = '✅'; + break; + } else if (nextLine.includes('failed in') || nextLine.includes('error')) { + statusIcon = '❌'; + break; + } + } + commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``); + } + } + } + // Add command summary + if (commandSummary.length > 0) { + for (const cmd of commandSummary) { + markdown += `${cmd}\n`; + } + } else { + markdown += 'No commands or tools used.\n'; + } + // Add Information section + markdown += '\n## 📊 Information\n\n'; + // Extract metadata from Codex logs + let totalTokens = 0; + const tokenMatches = logContent.match(/tokens used: (\d+)/g); + if (tokenMatches) { + for (const match of tokenMatches) { + const tokens = parseInt(match.match(/(\d+)/)[1]); + totalTokens += tokens; + } + } + if (totalTokens > 0) { + markdown += `**Total Tokens Used:** ${totalTokens.toLocaleString()}\n\n`; + } + // Count tool calls and exec commands + const toolCalls = (logContent.match(/\] tool /g) || []).length; + const execCommands = (logContent.match(/\] exec /g) || []).length; + if (toolCalls > 0) { + markdown += `**Tool Calls:** ${toolCalls}\n\n`; + } + if (execCommands > 0) { + markdown += `**Commands Executed:** ${execCommands}\n\n`; + } + markdown += '\n## 🤖 Reasoning\n\n'; + // Second pass: process full conversation flow with interleaved reasoning, tools, and commands + let inThinkingSection = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Skip metadata lines + if (line.includes('OpenAI Codex') || line.startsWith('--------') || + line.includes('workdir:') || line.includes('model:') || + line.includes('provider:') || line.includes('approval:') || + line.includes('sandbox:') || line.includes('reasoning effort:') || + line.includes('reasoning summaries:') || line.includes('tokens used:')) { + continue; + } + // Process thinking sections + if (line.includes('] thinking')) { + inThinkingSection = true; + continue; + } + // Process tool calls + if (line.includes('] tool ') && line.includes('(')) { + inThinkingSection = false; + const toolMatch = line.match(/\] tool ([^(]+)\(/); + if (toolMatch) { + const toolName = toolMatch[1]; + // Look ahead to find the result status + let statusIcon = '❓'; // Unknown by default + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes('success in')) { + statusIcon = '✅'; + break; + } else if (nextLine.includes('failure in') || nextLine.includes('error in') || nextLine.includes('failed in')) { + statusIcon = '❌'; + break; + } + } + if (toolName.includes('.')) { + const parts = toolName.split('.'); + const provider = parts[0]; + const method = parts.slice(1).join('_'); + markdown += `${statusIcon} ${provider}::${method}(...)\n\n`; + } else { + markdown += `${statusIcon} ${toolName}(...)\n\n`; + } + } + continue; + } + // Process exec commands + if (line.includes('] exec ')) { + inThinkingSection = false; + const execMatch = line.match(/exec (.+?) in/); + if (execMatch) { + const formattedCommand = formatBashCommand(execMatch[1]); + // Look ahead to find the result status + let statusIcon = '❓'; // Unknown by default + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes('succeeded in')) { + statusIcon = '✅'; + break; + } else if (nextLine.includes('failed in') || nextLine.includes('error')) { + statusIcon = '❌'; + break; + } + } + markdown += `${statusIcon} \`${formattedCommand}\`\n\n`; + } + continue; + } + // Process thinking content + if (inThinkingSection && line.trim().length > 20 && !line.startsWith('[2025-')) { + const trimmed = line.trim(); + // Add thinking content directly + markdown += `${trimmed}\n\n`; + } + } + return markdown; + } catch (error) { + console.error('Error parsing Codex log:', error); + return '## 🤖 Commands and Tools\n\nError parsing log content.\n\n## 🤖 Reasoning\n\nUnable to parse reasoning from log.\n\n'; + } + } + function formatBashCommand(command) { + if (!command) return ''; + // Convert multi-line commands to single line by replacing newlines with spaces + // and collapsing multiple spaces + let formatted = command + .replace(/\n/g, ' ') // Replace newlines with spaces + .replace(/\r/g, ' ') // Replace carriage returns with spaces + .replace(/\t/g, ' ') // Replace tabs with spaces + .replace(/\s+/g, ' ') // Collapse multiple spaces into one + .trim(); // Remove leading/trailing whitespace + // Escape backticks to prevent markdown issues + formatted = formatted.replace(/`/g, '\\`'); + // Truncate if too long (keep reasonable length for summary) + const maxLength = 80; + if (formatted.length > maxLength) { + formatted = formatted.substring(0, maxLength) + '...'; + } + return formatted; + } + function truncateString(str, maxLength) { + if (!str) return ''; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength) + '...'; + } + // Export for testing + if (typeof module !== 'undefined' && module.exports) { + module.exports = { parseCodexLog, formatBashCommand, truncateString }; + } + main(); + - name: Upload agent logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: codex-test-file.log + path: /tmp/codex-test-file.log + if-no-files-found: warn + - name: Generate git patch + if: always() + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_PUSH_BRANCH: "codex-test-branch" + run: | + # Check current git status + echo "Current git status:" + git status + + # Extract branch name from JSONL output + BRANCH_NAME="" + if [ -f "$GITHUB_AW_SAFE_OUTPUTS" ]; then + echo "Checking for branch name in JSONL output..." + while IFS= read -r line; do + if [ -n "$line" ]; then + # Extract branch from create-pull-request line using simple grep and sed + if echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"create-pull-request"'; then + echo "Found create-pull-request line: $line" + # Extract branch value using sed + BRANCH_NAME=$(echo "$line" | sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$BRANCH_NAME" ]; then + echo "Extracted branch name from create-pull-request: $BRANCH_NAME" + break + fi + # Extract branch from push-to-branch line using simple grep and sed + elif echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"push-to-branch"'; then + echo "Found push-to-branch line: $line" + # For push-to-branch, we don't extract branch from JSONL since it's configured in the workflow + # The branch name should come from the environment variable GITHUB_AW_PUSH_BRANCH + if [ -n "$GITHUB_AW_PUSH_BRANCH" ]; then + BRANCH_NAME="$GITHUB_AW_PUSH_BRANCH" + echo "Using configured push-to-branch target: $BRANCH_NAME" + break + fi + fi + fi + done < "$GITHUB_AW_SAFE_OUTPUTS" + fi + + # Get the initial commit SHA from the base branch of the pull request + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ]; then + INITIAL_SHA="$GITHUB_BASE_REF" + else + INITIAL_SHA="$GITHUB_SHA" + fi + echo "Base commit SHA: $INITIAL_SHA" + # Configure git user for GitHub Actions + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + + # If we have a branch name, check if that branch exists and get its diff + if [ -n "$BRANCH_NAME" ]; then + echo "Looking for branch: $BRANCH_NAME" + # Check if the branch exists + if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then + echo "Branch $BRANCH_NAME exists, generating patch from branch changes" + # Generate patch from the base to the branch + git format-patch "$INITIAL_SHA".."$BRANCH_NAME" --stdout > /tmp/aw.patch || echo "Failed to generate patch from branch" > /tmp/aw.patch + echo "Patch file created from branch: $BRANCH_NAME" + else + echo "Branch $BRANCH_NAME does not exist, falling back to current HEAD" + BRANCH_NAME="" + fi + fi + + # If no branch or branch doesn't exist, use the existing logic + if [ -z "$BRANCH_NAME" ]; then + echo "Using current HEAD for patch generation" + # Stage any unstaged files + git add -A || true + # Check if there are staged files to commit + if ! git diff --cached --quiet; then + echo "Staged files found, committing them..." + git commit -m "[agent] staged files" || true + echo "Staged files committed" + else + echo "No staged files to commit" + fi + # Check updated git status + echo "Updated git status after committing staged files:" + git status + # Show compact diff information between initial commit and HEAD (committed changes only) + echo '## Git diff' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + # Check if there are any committed changes since the initial commit + if git diff --quiet "$INITIAL_SHA" HEAD; then + echo "No committed changes detected since initial commit" + echo "Skipping patch generation - no committed changes to create patch from" + else + echo "Committed changes detected, generating patch..." + # Generate patch from initial commit to HEAD (committed changes only) + git format-patch "$INITIAL_SHA"..HEAD --stdout > /tmp/aw.patch || echo "Failed to generate patch" > /tmp/aw.patch + echo "Patch file created at /tmp/aw.patch" + fi + fi + + # Show patch info if it exists + if [ -f /tmp/aw.patch ]; then + ls -la /tmp/aw.patch + # Show the first 50 lines of the patch for review + echo '## Git Patch' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + head -50 /tmp/aw.patch >> $GITHUB_STEP_SUMMARY || echo "Could not display patch contents" >> $GITHUB_STEP_SUMMARY + echo '...' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + fi + - name: Upload git patch + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw.patch + path: /tmp/aw.patch + if-no-files-found: ignore + + push_to_branch: + needs: codex-test-file + if: always() + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.push_to_branch.outputs.branch_name }} + commit_sha: ${{ steps.push_to_branch.outputs.commit_sha }} + push_url: ${{ steps.push_to_branch.outputs.push_url }} + steps: + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: aw.patch + path: /tmp/ + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Push to Branch + id: push_to_branch + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.codex-test-file.outputs.output }} + GITHUB_AW_PUSH_BRANCH: "codex-test-branch" + GITHUB_AW_PUSH_TARGET: "*" + with: + script: | + async function main() { + /** @type {typeof import("fs")} */ + const fs = require("fs"); + const { execSync } = require("child_process"); + // Environment validation - fail early if required variables are missing + const branchName = process.env.GITHUB_AW_PUSH_BRANCH; + if (!branchName) { + core.setFailed('GITHUB_AW_PUSH_BRANCH environment variable is required'); + return; + } + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT || ""; + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + // Check if patch file exists and has valid content + if (!fs.existsSync('/tmp/aw.patch')) { + core.setFailed('No patch file found - cannot push without changes'); + return; + } + const patchContent = fs.readFileSync('/tmp/aw.patch', 'utf8'); + if (!patchContent || !patchContent.trim() || patchContent.includes('Failed to generate patch')) { + core.setFailed('Patch file is empty or contains error message - cannot push without changes'); + return; + } + console.log('Agent output content length:', outputContent.length); + console.log('Patch content validation passed'); + console.log('Target branch:', branchName); + console.log('Target configuration:', target); + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + console.log('Error parsing agent output JSON:', error instanceof Error ? error.message : String(error)); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + console.log('No valid items found in agent output'); + return; + } + // Find the push-to-branch item + const pushItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'push-to-branch'); + if (!pushItem) { + console.log('No push-to-branch item found in agent output'); + return; + } + console.log('Found push-to-branch item'); + // Validate target configuration for pull request context + if (target !== "*" && target !== "triggering") { + // If target is a specific number, validate it's a valid pull request number + const targetNumber = parseInt(target, 10); + if (isNaN(targetNumber)) { + core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); + return; + } + } + // Check if we're in a pull request context when required + if (target === "triggering" && !context.payload.pull_request) { + core.setFailed('push-to-branch with target "triggering" requires pull request context'); + return; + } + // Configure git (required for commits) + execSync('git config --global user.email "action@github.com"', { stdio: 'inherit' }); + execSync('git config --global user.name "GitHub Action"', { stdio: 'inherit' }); + // Switch to or create the target branch + console.log('Switching to branch:', branchName); + try { + // Try to checkout existing branch first + execSync('git fetch origin', { stdio: 'inherit' }); + execSync(`git checkout ${branchName}`, { stdio: 'inherit' }); + console.log('Checked out existing branch:', branchName); + } catch (error) { + // Branch doesn't exist, create it + console.log('Branch does not exist, creating new branch:', branchName); + execSync(`git checkout -b ${branchName}`, { stdio: 'inherit' }); + } + // Apply the patch using git CLI + console.log('Applying patch...'); + try { + execSync('git apply /tmp/aw.patch', { stdio: 'inherit' }); + console.log('Patch applied successfully'); + } catch (error) { + console.error('Failed to apply patch:', error instanceof Error ? error.message : String(error)); + core.setFailed('Failed to apply patch'); + return; + } + // Commit and push the changes + execSync('git add .', { stdio: 'inherit' }); + // Check if there are changes to commit + try { + execSync('git diff --cached --exit-code', { stdio: 'ignore' }); + console.log('No changes to commit'); + return; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + } + const commitMessage = pushItem.message || 'Apply agent changes'; + execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); + execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); + console.log('Changes committed and pushed to branch:', branchName); + // Get commit SHA + const commitSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); + const pushUrl = context.payload.repository + ? `${context.payload.repository.html_url}/tree/${branchName}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; + // Set outputs + core.setOutput('branch_name', branchName); + core.setOutput('commit_sha', commitSha); + core.setOutput('push_url', pushUrl); + // Write summary to GitHub Actions summary + await core.summary + .addRaw(` + ## Push to Branch + - **Branch**: \`${branchName}\` + - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) + - **URL**: [${pushUrl}](${pushUrl}) + `).write(); + } + await main(); + diff --git a/.github/workflows/test-codex-push-to-branch.md b/.github/workflows/test-codex-push-to-branch.md new file mode 100644 index 00000000000..9929aa6e4d3 --- /dev/null +++ b/.github/workflows/test-codex-push-to-branch.md @@ -0,0 +1,57 @@ +--- +on: + command: + name: test-codex-push-to-branch + +engine: + id: codex + +safe-outputs: + push-to-branch: + branch: codex-test-branch + target: "*" +--- + +Create a new file called "codex-test-file.md" with the following content: + +```markdown +# Codex Test File + +This file was created by the Codex agentic workflow to test the push-to-branch functionality. + +Created at: {{ current timestamp }} + +## Test Content + +This is a test file created by Codex to demonstrate: +- File creation +- Branch pushing +- Automated commit generation + +The workflow should push this file to the specified branch. +``` + +Also create a simple JavaScript script called "codex-script.js" with: + +```javascript +#!/usr/bin/env node +/** + * Test script created by Codex agentic workflow + */ + +function main() { + console.log("Hello from Codex agentic workflow!"); + console.log(`Current time: ${new Date().toISOString()}`); + console.log("This script was created to test push-to-branch functionality."); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; +``` + +Create a commit message: "Add test files created by Codex agentic workflow" + +Push these changes to the branch for the pull request #${github.event.pull_request.number} diff --git a/pkg/workflow/git_commands_integration_test.go b/pkg/workflow/git_commands_integration_test.go new file mode 100644 index 00000000000..f41fa4cfe58 --- /dev/null +++ b/pkg/workflow/git_commands_integration_test.go @@ -0,0 +1,300 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/parser" +) + +func TestGitCommandsIntegrationWithCreatePullRequest(t *testing.T) { + // Create a simple workflow with create-pull-request enabled + workflowContent := `--- +name: Test Git Commands Integration +tools: + claude: + allowed: + Read: null + Write: null +safe-outputs: + create-pull-request: + max: 1 +--- + +This is a test workflow that should automatically get Git commands when create-pull-request is enabled. +` + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow content and compile it + result, err := compiler.parseWorkflowMarkdownContent(workflowContent) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Check that Git commands were automatically added to the tools + claudeSection, hasClaudeSection := result.Tools["claude"] + if !hasClaudeSection { + t.Fatal("Expected claude section to be present") + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Fatal("Expected claude section to be a map") + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + t.Fatal("Expected claude section to have allowed tools") + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Fatal("Expected allowed to be a map") + } + + bashTool, hasBash := allowedMap["Bash"] + if !hasBash { + t.Fatal("Expected Bash tool to be present when create-pull-request is enabled") + } + + // Verify that Git commands are present + bashCommands, ok := bashTool.([]any) + if !ok { + t.Fatal("Expected Bash tool to have command list") + } + + gitCommandsFound := 0 + expectedGitCommands := []string{"git checkout:*", "git add:*", "git commit:*", "git branch:*", "git switch:*", "git rm:*", "git merge:*"} + + for _, cmd := range bashCommands { + if cmdStr, ok := cmd.(string); ok { + for _, expectedCmd := range expectedGitCommands { + if cmdStr == expectedCmd { + gitCommandsFound++ + break + } + } + } + } + + if gitCommandsFound != len(expectedGitCommands) { + t.Errorf("Expected %d Git commands, found %d. Commands: %v", len(expectedGitCommands), gitCommandsFound, bashCommands) + } + + // Verify allowed tools include the Git commands + allowedToolsStr := compiler.computeAllowedTools(result.Tools, result.SafeOutputs) + if !strings.Contains(allowedToolsStr, "Bash(git checkout:*)") { + t.Errorf("Expected allowed tools to contain Git commands, got: %s", allowedToolsStr) + } +} + +func TestGitCommandsNotAddedWithoutPullRequestOutput(t *testing.T) { + // Create a workflow with only create-issue (no PR-related outputs) + workflowContent := `--- +name: Test No Git Commands +tools: + claude: + allowed: + Read: null + Write: null +safe-outputs: + create-issue: + max: 1 +--- + +This workflow should NOT get Git commands since it doesn't use create-pull-request or push-to-branch. +` + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow content + result, err := compiler.parseWorkflowMarkdownContent(workflowContent) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Check that Git commands were NOT automatically added + claudeSection, hasClaudeSection := result.Tools["claude"] + if !hasClaudeSection { + t.Fatal("Expected claude section to be present") + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Fatal("Expected claude section to be a map") + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + t.Fatal("Expected claude section to have allowed tools") + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Fatal("Expected allowed to be a map") + } + + // Bash tool should NOT be present since no Git commands were needed + _, hasBash := allowedMap["Bash"] + if hasBash { + t.Error("Did not expect Bash tool to be present when only create-issue is enabled") + } + + // Verify allowed tools do not include Git commands + allowedToolsStr := compiler.computeAllowedTools(result.Tools, result.SafeOutputs) + if strings.Contains(allowedToolsStr, "Bash(git") { + t.Errorf("Did not expect allowed tools to contain Git commands, got: %s", allowedToolsStr) + } +} + +func TestAdditionalClaudeToolsIntegrationWithCreatePullRequest(t *testing.T) { + // Create a simple workflow with create-pull-request enabled + workflowContent := `--- +name: Test Additional Claude Tools Integration +tools: + claude: + allowed: + Read: null + Task: null +safe-outputs: + create-pull-request: + max: 1 +--- + +This is a test workflow that should automatically get additional Claude tools when create-pull-request is enabled. +` + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow content and compile it + result, err := compiler.parseWorkflowMarkdownContent(workflowContent) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Check that additional Claude tools were automatically added + claudeSection, hasClaudeSection := result.Tools["claude"] + if !hasClaudeSection { + t.Fatal("Expected claude section to be present") + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Fatal("Expected claude section to be a map") + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + t.Fatal("Expected claude section to have allowed tools") + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Fatal("Expected allowed to be a map") + } + + // Verify that additional Claude tools are present + expectedAdditionalTools := []string{"Edit", "MultiEdit", "Write", "NotebookEdit"} + for _, expectedTool := range expectedAdditionalTools { + if _, exists := allowedMap[expectedTool]; !exists { + t.Errorf("Expected additional Claude tool %s to be present", expectedTool) + } + } + + // Verify that pre-existing tools are still there + if _, exists := allowedMap["Read"]; !exists { + t.Error("Expected pre-existing Read tool to be preserved") + } + if _, exists := allowedMap["Task"]; !exists { + t.Error("Expected pre-existing Task tool to be preserved") + } + + // Verify allowed tools include the additional Claude tools + allowedToolsStr := compiler.computeAllowedTools(result.Tools, result.SafeOutputs) + for _, expectedTool := range expectedAdditionalTools { + if !strings.Contains(allowedToolsStr, expectedTool) { + t.Errorf("Expected allowed tools to contain %s, got: %s", expectedTool, allowedToolsStr) + } + } +} + +func TestAdditionalClaudeToolsIntegrationWithPushToBranch(t *testing.T) { + // Create a simple workflow with push-to-branch enabled + workflowContent := `--- +name: Test Additional Claude Tools Integration with Push to Branch +tools: + claude: + allowed: + Read: null +safe-outputs: + push-to-branch: + branch: "feature-branch" +--- + +This is a test workflow that should automatically get additional Claude tools when push-to-branch is enabled. +` + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow content and compile it + result, err := compiler.parseWorkflowMarkdownContent(workflowContent) + if err != nil { + t.Fatalf("Failed to parse workflow: %v", err) + } + + // Check that additional Claude tools were automatically added + claudeSection, hasClaudeSection := result.Tools["claude"] + if !hasClaudeSection { + t.Fatal("Expected claude section to be present") + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Fatal("Expected claude section to be a map") + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + t.Fatal("Expected claude section to have allowed tools") + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Fatal("Expected allowed to be a map") + } + + // Verify that additional Claude tools are present + expectedAdditionalTools := []string{"Edit", "MultiEdit", "Write", "NotebookEdit"} + for _, expectedTool := range expectedAdditionalTools { + if _, exists := allowedMap[expectedTool]; !exists { + t.Errorf("Expected additional Claude tool %s to be present", expectedTool) + } + } +} + +// Helper function to parse workflow content like parseWorkflowFile but from string +func (c *Compiler) parseWorkflowMarkdownContent(content string) (*WorkflowData, error) { + // This would normally be in parseWorkflowFile, but we'll extract the core logic for testing + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + return nil, err + } + + // Extract SafeOutputs early + safeOutputs := c.extractSafeOutputsConfig(result.Frontmatter) + + // Extract and process tools + topTools := extractToolsFromFrontmatter(result.Frontmatter) + tools := c.applyDefaultGitHubMCPAndClaudeTools(topTools, safeOutputs) + + // Build basic workflow data for testing + workflowData := &WorkflowData{ + Name: "Test Workflow", + Tools: tools, + SafeOutputs: safeOutputs, + AI: "claude", + } + + return workflowData, nil +} diff --git a/pkg/workflow/git_commands_test.go b/pkg/workflow/git_commands_test.go new file mode 100644 index 00000000000..c75a9414a61 --- /dev/null +++ b/pkg/workflow/git_commands_test.go @@ -0,0 +1,344 @@ +package workflow + +import ( + "testing" +) + +func TestApplyDefaultGitCommandsForSafeOutputs(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + tools map[string]any + safeOutputs *SafeOutputsConfig + expectGit bool + }{ + { + name: "no safe outputs - no git commands", + tools: map[string]any{}, + safeOutputs: nil, + expectGit: false, + }, + { + name: "create-pull-request enabled - should add git commands", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectGit: true, + }, + { + name: "push-to-branch enabled - should add git commands", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + PushToBranch: &PushToBranchConfig{Branch: "main"}, + }, + expectGit: true, + }, + { + name: "only create-issue enabled - no git commands", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + }, + expectGit: false, + }, + { + name: "existing bash commands should be preserved", + tools: map[string]any{ + "claude": map[string]any{ + "allowed": map[string]any{ + "Bash": []any{"echo", "ls"}, + }, + }, + }, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectGit: true, + }, + { + name: "bash with wildcard should remain wildcard", + tools: map[string]any{ + "claude": map[string]any{ + "allowed": map[string]any{ + "Bash": []any{":*"}, + }, + }, + }, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectGit: true, + }, + { + name: "bash with nil value should remain nil", + tools: map[string]any{ + "claude": map[string]any{ + "allowed": map[string]any{ + "Bash": nil, + }, + }, + }, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectGit: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a copy of input tools to avoid modifying test data + tools := make(map[string]any) + for k, v := range tt.tools { + tools[k] = v + } + + result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools, tt.safeOutputs) + + // Check if claude section exists and has bash tool + claudeSection, hasClaudeSection := result["claude"] + if !hasClaudeSection { + if tt.expectGit { + t.Error("Expected claude section to be created with Git commands") + } + return + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Error("Expected claude section to be a map") + return + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + if tt.expectGit { + t.Error("Expected claude section to have allowed tools") + } + return + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Error("Expected allowed to be a map") + return + } + + bashTool, hasBash := allowedMap["Bash"] + if !hasBash { + if tt.expectGit { + t.Error("Expected Bash tool to be present when Git commands are needed") + } + return + } + + // If we don't expect Git commands, just verify no error occurred + if !tt.expectGit { + return + } + + // Check the specific cases for bash tool value + if bashCommands, ok := bashTool.([]any); ok { + // Should contain Git commands + foundGitCommands := false + for _, cmd := range bashCommands { + if cmdStr, ok := cmd.(string); ok { + if cmdStr == "git checkout:*" || cmdStr == "git add:*" || cmdStr == ":*" || cmdStr == "*" { + foundGitCommands = true + break + } + } + } + if !foundGitCommands { + t.Error("Expected to find Git commands in Bash tool commands") + } + } else if bashTool == nil { + // nil value means all bash commands are allowed, which includes Git commands + // This is acceptable - nil value already permits all commands + _ = bashTool // Keep the nil value as-is + } else { + t.Errorf("Unexpected Bash tool value type: %T", bashTool) + } + }) + } +} + +func TestAdditionalClaudeToolsForSafeOutputs(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + tools map[string]any + safeOutputs *SafeOutputsConfig + expectEditingTools bool + }{ + { + name: "no safe outputs - no editing tools", + tools: map[string]any{}, + safeOutputs: nil, + expectEditingTools: false, + }, + { + name: "create-pull-request enabled - should add editing tools", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectEditingTools: true, + }, + { + name: "push-to-branch enabled - should add editing tools", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + PushToBranch: &PushToBranchConfig{Branch: "main"}, + }, + expectEditingTools: true, + }, + { + name: "only create-issue enabled - no editing tools", + tools: map[string]any{}, + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + }, + expectEditingTools: false, + }, + { + name: "existing editing tools should be preserved", + tools: map[string]any{ + "claude": map[string]any{ + "allowed": map[string]any{ + "Edit": nil, + "Task": nil, + }, + }, + }, + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectEditingTools: true, + }, + } + + expectedEditingTools := []string{"Edit", "MultiEdit", "Write", "NotebookEdit"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a copy of input tools to avoid modifying test data + tools := make(map[string]any) + for k, v := range tt.tools { + tools[k] = v + } + + result := compiler.applyDefaultGitHubMCPAndClaudeTools(tools, tt.safeOutputs) + + // Check if claude section exists + claudeSection, hasClaudeSection := result["claude"] + if !hasClaudeSection { + if tt.expectEditingTools { + t.Error("Expected claude section to be created with editing tools") + } + return + } + + claudeConfig, ok := claudeSection.(map[string]any) + if !ok { + t.Error("Expected claude section to be a map") + return + } + + allowed, hasAllowed := claudeConfig["allowed"] + if !hasAllowed { + if tt.expectEditingTools { + t.Error("Expected claude section to have allowed tools") + } + return + } + + allowedMap, ok := allowed.(map[string]any) + if !ok { + t.Error("Expected allowed to be a map") + return + } + + // If we don't expect editing tools, verify they aren't there due to this feature + if !tt.expectEditingTools { + // Only check if we started with empty tools - if there were pre-existing tools, they should remain + if len(tt.tools) == 0 { + for _, tool := range expectedEditingTools { + if _, exists := allowedMap[tool]; exists { + t.Errorf("Unexpected editing tool %s found when not expected", tool) + } + } + } + return + } + + // Check that all expected editing tools are present + for _, expectedTool := range expectedEditingTools { + if _, exists := allowedMap[expectedTool]; !exists { + t.Errorf("Expected editing tool %s to be present", expectedTool) + } + } + }) + } +} + +func TestNeedsGitCommands(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expected bool + }{ + { + name: "nil safe outputs", + safeOutputs: nil, + expected: false, + }, + { + name: "empty safe outputs", + safeOutputs: &SafeOutputsConfig{}, + expected: false, + }, + { + name: "create-pull-request enabled", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expected: true, + }, + { + name: "push-to-branch enabled", + safeOutputs: &SafeOutputsConfig{ + PushToBranch: &PushToBranchConfig{Branch: "main"}, + }, + expected: true, + }, + { + name: "both enabled", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + PushToBranch: &PushToBranchConfig{Branch: "main"}, + }, + expected: true, + }, + { + name: "only other outputs enabled", + safeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{}, + AddIssueComments: &AddIssueCommentsConfig{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := needsGitCommands(tt.safeOutputs) + if result != tt.expected { + t.Errorf("needsGitCommands() = %v, expected %v", result, tt.expected) + } + }) + } +} From 39c1598ddb026914a11371039d5b48c79b2e3d4e Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 12:50:42 +0100 Subject: [PATCH 3/7] add default permissions for create-pull-request --- .github/workflows/test-claude-push-to-branch.lock.yml | 2 +- .github/workflows/test-codex-push-to-branch.lock.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml index d2be7f592e5..0a4feab9528 100644 --- a/.github/workflows/test-claude-push-to-branch.lock.yml +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -166,7 +166,7 @@ jobs: Create a commit message: "Add test files created by Claude agentic workflow" - Push these changes to the current branch + Push these changes to the branch for the pull request --- diff --git a/.github/workflows/test-codex-push-to-branch.lock.yml b/.github/workflows/test-codex-push-to-branch.lock.yml index b76506b8bef..024df33bb1b 100644 --- a/.github/workflows/test-codex-push-to-branch.lock.yml +++ b/.github/workflows/test-codex-push-to-branch.lock.yml @@ -170,7 +170,7 @@ jobs: Create a commit message: "Add test files created by Codex agentic workflow" - Push these changes to the branch for the pull request + Push these changes to the branch for the pull request #${github.event.pull_request.number} --- From f755aaa9d8526614955b167c3e183058e12d0f12 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 13:13:40 +0100 Subject: [PATCH 4/7] check the JSONL file after it is witten --- .../test-claude-add-issue-comment.lock.yml | 4 ++++ .../test-claude-add-issue-labels.lock.yml | 4 ++++ .../workflows/test-claude-command.lock.yml | 4 ++++ .../test-claude-create-issue.lock.yml | 4 ++++ .../test-claude-create-pull-request.lock.yml | 1 + .github/workflows/test-claude-mcp.lock.yml | 4 ++++ .../test-claude-push-to-branch.lock.yml | 5 +++-- .../workflows/test-claude-push-to-branch.md | 2 +- .../test-claude-update-issue.lock.yml | 3 +++ .../test-codex-add-issue-comment.lock.yml | 4 ++++ .../test-codex-add-issue-labels.lock.yml | 4 ++++ .github/workflows/test-codex-command.lock.yml | 4 ++++ .../test-codex-create-issue.lock.yml | 4 ++++ .../test-codex-create-pull-request.lock.yml | 1 + .github/workflows/test-codex-mcp.lock.yml | 4 ++++ .../test-codex-push-to-branch.lock.yml | 3 ++- .../test-codex-update-issue.lock.yml | 3 +++ .github/workflows/test-proxy.lock.yml | 4 ++++ pkg/workflow/compiler.go | 19 ++++++++++++++++++- 19 files changed, 76 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-claude-add-issue-comment.lock.yml b/.github/workflows/test-claude-add-issue-comment.lock.yml index 88d1eca7517..00895d7c714 100644 --- a/.github/workflows/test-claude-add-issue-comment.lock.yml +++ b/.github/workflows/test-claude-add-issue-comment.lock.yml @@ -263,9 +263,13 @@ jobs: ### Available Output Types: **Adding a Comment to an Issue or Pull Request** + + To add a comment to an issue or pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-comment", "body": "Your comment content in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-add-issue-labels.lock.yml b/.github/workflows/test-claude-add-issue-labels.lock.yml index b99dfc186b7..23451b32279 100644 --- a/.github/workflows/test-claude-add-issue-labels.lock.yml +++ b/.github/workflows/test-claude-add-issue-labels.lock.yml @@ -263,9 +263,13 @@ jobs: ### Available Output Types: **Adding Labels to Issues or Pull Requests** + + To add labels to a pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-label", "labels": ["label1", "label2", "label3"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-command.lock.yml b/.github/workflows/test-claude-command.lock.yml index c06a3c68e34..28edd04fd59 100644 --- a/.github/workflows/test-claude-command.lock.yml +++ b/.github/workflows/test-claude-command.lock.yml @@ -501,9 +501,13 @@ jobs: ### Available Output Types: **Adding a Comment to an Issue or Pull Request** + + To add a comment to an issue or pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-comment", "body": "Your comment content in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-create-issue.lock.yml b/.github/workflows/test-claude-create-issue.lock.yml index da5be232e8c..bd775ffd5db 100644 --- a/.github/workflows/test-claude-create-issue.lock.yml +++ b/.github/workflows/test-claude-create-issue.lock.yml @@ -92,9 +92,13 @@ jobs: ### Available Output Types: **Creating an Issue** + + To create an issue: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index bc248180a84..d3dc1d6d688 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -105,6 +105,7 @@ jobs: ```json {"type": "create-pull-request", "branch": "branch-name", "title": "PR title", "body": "PR body in markdown", "labels": ["optional", "labels"]} ``` + 5. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-mcp.lock.yml b/.github/workflows/test-claude-mcp.lock.yml index 4bb49b78d5c..fdc53158926 100644 --- a/.github/workflows/test-claude-mcp.lock.yml +++ b/.github/workflows/test-claude-mcp.lock.yml @@ -284,9 +284,13 @@ jobs: ### Available Output Types: **Creating an Issue** + + To create an issue: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml index 0a4feab9528..1be521ac3f6 100644 --- a/.github/workflows/test-claude-push-to-branch.lock.yml +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -166,7 +166,7 @@ jobs: Create a commit message: "Add test files created by Claude agentic workflow" - Push these changes to the branch for the pull request + Push these changes to the branch for the pull request #${github.event.pull_request.number} --- @@ -181,13 +181,14 @@ jobs: **Pushing Changes to Branch** - To push changes to a branch: + To push changes to a branch, for example to add code to a pull request: 1. Make any file changes directly in the working directory 2. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to. 3. Indicate your intention to push to the branch by writing to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "push-to-branch", "message": "Commit message describing the changes"} ``` + 4. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-claude-push-to-branch.md b/.github/workflows/test-claude-push-to-branch.md index 2c0f3ac20f3..aa6cda11087 100644 --- a/.github/workflows/test-claude-push-to-branch.md +++ b/.github/workflows/test-claude-push-to-branch.md @@ -52,4 +52,4 @@ if __name__ == "__main__": Create a commit message: "Add test files created by Claude agentic workflow" -Push these changes to the branch for the pull request +Push these changes to the branch for the pull request #${github.event.pull_request.number} diff --git a/.github/workflows/test-claude-update-issue.lock.yml b/.github/workflows/test-claude-update-issue.lock.yml index c6e2aa194e1..1157c275078 100644 --- a/.github/workflows/test-claude-update-issue.lock.yml +++ b/.github/workflows/test-claude-update-issue.lock.yml @@ -267,9 +267,12 @@ jobs: ### Available Output Types: **Updating an Issue** + + To udpate an issue: ```json {"type": "update-issue", "status": "open" // or "closed", "title": "New issue title", "body": "Updated issue body in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-add-issue-comment.lock.yml b/.github/workflows/test-codex-add-issue-comment.lock.yml index 1b187d7ebc2..da8a1482544 100644 --- a/.github/workflows/test-codex-add-issue-comment.lock.yml +++ b/.github/workflows/test-codex-add-issue-comment.lock.yml @@ -265,9 +265,13 @@ jobs: ### Available Output Types: **Adding a Comment to an Issue or Pull Request** + + To add a comment to an issue or pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-comment", "body": "Your comment content in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-add-issue-labels.lock.yml b/.github/workflows/test-codex-add-issue-labels.lock.yml index 2d7655645cb..a4618034aeb 100644 --- a/.github/workflows/test-codex-add-issue-labels.lock.yml +++ b/.github/workflows/test-codex-add-issue-labels.lock.yml @@ -265,9 +265,13 @@ jobs: ### Available Output Types: **Adding Labels to Issues or Pull Requests** + + To add labels to a pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-label", "labels": ["label1", "label2", "label3"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-command.lock.yml b/.github/workflows/test-codex-command.lock.yml index 29d978be318..c6f6fc348b8 100644 --- a/.github/workflows/test-codex-command.lock.yml +++ b/.github/workflows/test-codex-command.lock.yml @@ -501,9 +501,13 @@ jobs: ### Available Output Types: **Adding a Comment to an Issue or Pull Request** + + To add a comment to an issue or pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-comment", "body": "Your comment content in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-create-issue.lock.yml b/.github/workflows/test-codex-create-issue.lock.yml index 8cf0b2d00eb..2570f47854b 100644 --- a/.github/workflows/test-codex-create-issue.lock.yml +++ b/.github/workflows/test-codex-create-issue.lock.yml @@ -94,9 +94,13 @@ jobs: ### Available Output Types: **Creating an Issue** + + To create an issue: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-create-pull-request.lock.yml b/.github/workflows/test-codex-create-pull-request.lock.yml index 1bc241ef433..502ae2845e9 100644 --- a/.github/workflows/test-codex-create-pull-request.lock.yml +++ b/.github/workflows/test-codex-create-pull-request.lock.yml @@ -107,6 +107,7 @@ jobs: ```json {"type": "create-pull-request", "branch": "branch-name", "title": "PR title", "body": "PR body in markdown", "labels": ["optional", "labels"]} ``` + 5. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-mcp.lock.yml b/.github/workflows/test-codex-mcp.lock.yml index 197e5f92ff6..f5d971756df 100644 --- a/.github/workflows/test-codex-mcp.lock.yml +++ b/.github/workflows/test-codex-mcp.lock.yml @@ -284,9 +284,13 @@ jobs: ### Available Output Types: **Creating an Issue** + + To create an issue: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-push-to-branch.lock.yml b/.github/workflows/test-codex-push-to-branch.lock.yml index 024df33bb1b..3985470a6da 100644 --- a/.github/workflows/test-codex-push-to-branch.lock.yml +++ b/.github/workflows/test-codex-push-to-branch.lock.yml @@ -185,13 +185,14 @@ jobs: **Pushing Changes to Branch** - To push changes to a branch: + To push changes to a branch, for example to add code to a pull request: 1. Make any file changes directly in the working directory 2. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to. 3. Indicate your intention to push to the branch by writing to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "push-to-branch", "message": "Commit message describing the changes"} ``` + 4. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-codex-update-issue.lock.yml b/.github/workflows/test-codex-update-issue.lock.yml index c41013ef32e..9e6c76fcbeb 100644 --- a/.github/workflows/test-codex-update-issue.lock.yml +++ b/.github/workflows/test-codex-update-issue.lock.yml @@ -269,9 +269,12 @@ jobs: ### Available Output Types: **Updating an Issue** + + To udpate an issue: ```json {"type": "update-issue", "status": "open" // or "closed", "title": "New issue title", "body": "Updated issue body in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml index de585f626a9..7862e2149da 100644 --- a/.github/workflows/test-proxy.lock.yml +++ b/.github/workflows/test-proxy.lock.yml @@ -260,9 +260,13 @@ jobs: ### Available Output Types: **Adding a Comment to an Issue or Pull Request** + + To add a comment to an issue or pull request: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": ```json {"type": "add-issue-comment", "body": "Your comment content in markdown"} ``` + 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up **Example JSONL file content:** ``` diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index b5c0edbd805..30f068fb248 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -2456,17 +2456,25 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng if data.SafeOutputs.AddIssueComments != nil { yaml.WriteString(" **Adding a Comment to an Issue or Pull Request**\n") + yaml.WriteString(" \n") + yaml.WriteString(" To add a comment to an issue or pull request:\n") + yaml.WriteString(" 1. Write an entry to \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\":\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"add-issue-comment\", \"body\": \"Your comment content in markdown\"}\n") yaml.WriteString(" ```\n") + yaml.WriteString(" 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } if data.SafeOutputs.CreateIssues != nil { yaml.WriteString(" **Creating an Issue**\n") + yaml.WriteString(" \n") + yaml.WriteString(" To create an issue:\n") + yaml.WriteString(" 1. Write an entry to \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\":\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"create-issue\", \"title\": \"Issue title\", \"body\": \"Issue body in markdown\", \"labels\": [\"optional\", \"labels\"]}\n") yaml.WriteString(" ```\n") + yaml.WriteString(" 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } @@ -2481,19 +2489,26 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"create-pull-request\", \"branch\": \"branch-name\", \"title\": \"PR title\", \"body\": \"PR body in markdown\", \"labels\": [\"optional\", \"labels\"]}\n") yaml.WriteString(" ```\n") + yaml.WriteString(" 5. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } if data.SafeOutputs.AddIssueLabels != nil { yaml.WriteString(" **Adding Labels to Issues or Pull Requests**\n") + yaml.WriteString(" \n") + yaml.WriteString(" To add labels to a pull request:\n") + yaml.WriteString(" 1. Write an entry to \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\":\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"add-issue-label\", \"labels\": [\"label1\", \"label2\", \"label3\"]}\n") yaml.WriteString(" ```\n") + yaml.WriteString(" 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } if data.SafeOutputs.UpdateIssues != nil { yaml.WriteString(" **Updating an Issue**\n") + yaml.WriteString(" \n") + yaml.WriteString(" To udpate an issue:\n") yaml.WriteString(" ```json\n") // Build example based on allowed fields @@ -2519,19 +2534,21 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng } yaml.WriteString(" ```\n") + yaml.WriteString(" 2. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } if data.SafeOutputs.PushToBranch != nil { yaml.WriteString(" **Pushing Changes to Branch**\n") yaml.WriteString(" \n") - yaml.WriteString(" To push changes to a branch:\n") + yaml.WriteString(" To push changes to a branch, for example to add code to a pull request:\n") yaml.WriteString(" 1. Make any file changes directly in the working directory\n") yaml.WriteString(" 2. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to.\n") yaml.WriteString(" 3. Indicate your intention to push to the branch by writing to the file \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\":\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"push-to-branch\", \"message\": \"Commit message describing the changes\"}\n") yaml.WriteString(" ```\n") + yaml.WriteString(" 4. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up\n") yaml.WriteString(" \n") } From be7915df0fdcb3fa020d7e776208fb4e4e4b59e4 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 13:28:10 +0100 Subject: [PATCH 5/7] add conditionals for output jobs --- .../test-claude-push-to-branch.lock.yml | 28 +++++----- .../workflows/test-claude-push-to-branch.md | 2 +- .../workflows/test-codex-push-to-branch.md | 2 +- pkg/workflow/compiler.go | 52 +++++++++++++++++-- pkg/workflow/output_labels.go | 16 +++++- pkg/workflow/output_push_to_branch.go | 26 ++++++++-- pkg/workflow/output_update_issue.go | 28 ++++++++-- 7 files changed, 125 insertions(+), 29 deletions(-) diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml index 1be521ac3f6..3ea2fd300bc 100644 --- a/.github/workflows/test-claude-push-to-branch.lock.yml +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -2,7 +2,7 @@ # To update this file, edit the corresponding .md file and run: # gh aw compile -name: "Claude Test File" +name: "Test Claude Push To Branch" on: issues: types: [opened, edited, reopened] @@ -18,7 +18,7 @@ permissions: {} concurrency: group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}" -run-name: "Claude Test File" +run-name: "Test Claude Push To Branch" jobs: task: @@ -63,7 +63,7 @@ jobs: echo "User ${{ github.actor }} is not a team member" exit 1 - claude-test-file: + test-claude-push-to-branch: needs: task runs-on: ubuntu-latest permissions: read-all @@ -129,7 +129,7 @@ jobs: Create a new file called "claude-test-file.md" with the following content: ```markdown - # Claude Test File + # Test Claude Push To Branch This file was created by the Claude agentic workflow to test the push-to-branch functionality. @@ -220,7 +220,7 @@ jobs: engine_name: "Claude Code", model: "", version: "", - workflow_name: "Claude Test File", + workflow_name: "Test Claude Push To Branch", experimental: false, supports_tools_whitelist: true, supports_http_transport: true, @@ -330,13 +330,13 @@ jobs: run: | # Copy the detailed execution file from Agentic Action if available if [ -n "${{ steps.agentic_execution.outputs.execution_file }}" ] && [ -f "${{ steps.agentic_execution.outputs.execution_file }}" ]; then - cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/claude-test-file.log + cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/test-claude-push-to-branch.log else - echo "No execution file output found from Agentic Action" >> /tmp/claude-test-file.log + echo "No execution file output found from Agentic Action" >> /tmp/test-claude-push-to-branch.log fi # Ensure log file exists - touch /tmp/claude-test-file.log + touch /tmp/test-claude-push-to-branch.log - name: Check if workflow-complete.txt exists, if so upload it id: check_file run: | @@ -732,7 +732,7 @@ jobs: if: always() uses: actions/github-script@v7 env: - AGENT_LOG_FILE: /tmp/claude-test-file.log + AGENT_LOG_FILE: /tmp/test-claude-push-to-branch.log with: script: | function main() { @@ -1007,8 +1007,8 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: claude-test-file.log - path: /tmp/claude-test-file.log + name: test-claude-push-to-branch.log + path: /tmp/test-claude-push-to-branch.log if-no-files-found: warn - name: Generate git patch if: always() @@ -1132,8 +1132,8 @@ jobs: if-no-files-found: ignore push_to_branch: - needs: claude-test-file - if: always() + needs: test-claude-push-to-branch + if: contains(github.event.issue.body, '/test-claude-push-to-branch') || contains(github.event.comment.body, '/test-claude-push-to-branch') || contains(github.event.pull_request.body, '/test-claude-push-to-branch') runs-on: ubuntu-latest permissions: contents: write @@ -1157,7 +1157,7 @@ jobs: id: push_to_branch uses: actions/github-script@v7 env: - GITHUB_AW_AGENT_OUTPUT: ${{ needs.claude-test-file.outputs.output }} + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude-push-to-branch.outputs.output }} GITHUB_AW_PUSH_BRANCH: "claude-test-branch" GITHUB_AW_PUSH_TARGET: "*" with: diff --git a/.github/workflows/test-claude-push-to-branch.md b/.github/workflows/test-claude-push-to-branch.md index aa6cda11087..0b4efea732c 100644 --- a/.github/workflows/test-claude-push-to-branch.md +++ b/.github/workflows/test-claude-push-to-branch.md @@ -15,7 +15,7 @@ safe-outputs: Create a new file called "claude-test-file.md" with the following content: ```markdown -# Claude Test File +# Test Claude Push To Branch This file was created by the Claude agentic workflow to test the push-to-branch functionality. diff --git a/.github/workflows/test-codex-push-to-branch.md b/.github/workflows/test-codex-push-to-branch.md index 9929aa6e4d3..f18d9bfe164 100644 --- a/.github/workflows/test-codex-push-to-branch.md +++ b/.github/workflows/test-codex-push-to-branch.md @@ -15,7 +15,7 @@ safe-outputs: Create a new file called "codex-test-file.md" with the following content: ```markdown -# Codex Test File +# Test Codex Push To Branch This file was created by the Codex agentic workflow to test the push-to-branch functionality. diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 30f068fb248..0ea064c1455 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1877,9 +1877,20 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str "issue_url": "${{ steps.create_issue.outputs.issue_url }}", } + // Determine the job condition for command workflows + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + jobCondition = fmt.Sprintf("if: %s", commandConditionStr) + } else { + jobCondition = "" // No conditional execution + } + job := &Job{ Name: "create_issue", - If: "", // No conditional execution + If: jobCondition, RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write", TimeoutMinutes: 10, // 10-minute timeout as required @@ -1925,13 +1936,33 @@ func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJ } // Determine the job condition based on target configuration - var jobCondition string + var baseCondition string if data.SafeOutputs.AddIssueComments.Target == "*" { // Allow the job to run in any context when target is "*" - jobCondition = "if: always()" // This allows the job to run even without triggering issue/PR + baseCondition = "always()" // This allows the job to run even without triggering issue/PR } else { // Default behavior: only run in issue or PR context - jobCondition = "if: github.event.issue.number || github.event.pull_request.number" + baseCondition = "github.event.issue.number || github.event.pull_request.number" + } + + // If this is a command workflow, combine the command trigger condition with the base condition + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + + // Combine command condition with base condition using AND + if baseCondition == "always()" { + // If base condition is always(), just use the command condition + jobCondition = fmt.Sprintf("if: %s", commandConditionStr) + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("if: (%s) && (%s)", commandConditionStr, baseCondition) + } + } else { + // No command trigger, just use the base condition + jobCondition = fmt.Sprintf("if: %s", baseCondition) } job := &Job{ @@ -2010,9 +2041,20 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa "branch_name": "${{ steps.create_pull_request.outputs.branch_name }}", } + // Determine the job condition for command workflows + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + jobCondition = fmt.Sprintf("if: %s", commandConditionStr) + } else { + jobCondition = "" // No conditional execution + } + job := &Job{ Name: "create_pull_request", - If: "", // No conditional execution + If: jobCondition, RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: write\n issues: write\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/output_labels.go b/pkg/workflow/output_labels.go index ecfdb034a1e..5598665ae65 100644 --- a/pkg/workflow/output_labels.go +++ b/pkg/workflow/output_labels.go @@ -49,9 +49,23 @@ func (c *Compiler) buildCreateOutputLabelJob(data *WorkflowData, mainJobName str "labels_added": "${{ steps.add_labels.outputs.labels_added }}", } + // Determine the job condition for command workflows + var baseCondition = "github.event.issue.number || github.event.pull_request.number" // Only run in issue or PR context + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + // Combine command condition with base condition using AND + jobCondition = fmt.Sprintf("if: (%s) && (%s)", commandConditionStr, baseCondition) + } else { + // No command trigger, just use the base condition + jobCondition = fmt.Sprintf("if: %s", baseCondition) + } + job := &Job{ Name: "add_labels", - If: "if: github.event.issue.number || github.event.pull_request.number", // Only run in issue or PR context + If: jobCondition, RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write\n pull-requests: write", TimeoutMinutes: 10, // 10-minute timeout as required diff --git a/pkg/workflow/output_push_to_branch.go b/pkg/workflow/output_push_to_branch.go index 9775ee7de49..3a5ff01441a 100644 --- a/pkg/workflow/output_push_to_branch.go +++ b/pkg/workflow/output_push_to_branch.go @@ -61,13 +61,33 @@ func (c *Compiler) buildCreateOutputPushToBranchJob(data *WorkflowData, mainJobN } // Determine the job condition based on target configuration - var jobCondition string + var baseCondition string if data.SafeOutputs.PushToBranch.Target == "*" { // Allow pushing to any pull request - no specific context required - jobCondition = "if: always()" + baseCondition = "always()" } else { // Default behavior: only run in pull request context - jobCondition = "if: github.event.pull_request.number" + baseCondition = "github.event.pull_request.number" + } + + // If this is a command workflow, combine the command trigger condition with the base condition + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + + // Combine command condition with base condition using AND + if baseCondition == "always()" { + // If base condition is always(), just use the command condition + jobCondition = fmt.Sprintf("if: %s", commandConditionStr) + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("if: (%s) && (%s)", commandConditionStr, baseCondition) + } + } else { + // No command trigger, just use the base condition + jobCondition = fmt.Sprintf("if: %s", baseCondition) } job := &Job{ diff --git a/pkg/workflow/output_update_issue.go b/pkg/workflow/output_update_issue.go index 8f356a38daf..bb92726c965 100644 --- a/pkg/workflow/output_update_issue.go +++ b/pkg/workflow/output_update_issue.go @@ -44,16 +44,36 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa } // Determine the job condition based on target configuration - var jobCondition string + var baseCondition string if data.SafeOutputs.UpdateIssues.Target == "*" { // Allow updates to any issue - no specific context required - jobCondition = "if: always()" + baseCondition = "always()" } else if data.SafeOutputs.UpdateIssues.Target != "" { // Explicit issue number specified - no specific context required - jobCondition = "if: always()" + baseCondition = "always()" } else { // Default behavior: only update triggering issue - jobCondition = "if: github.event.issue.number" + baseCondition = "github.event.issue.number" + } + + // If this is a command workflow, combine the command trigger condition with the base condition + var jobCondition string + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + + // Combine command condition with base condition using AND + if baseCondition == "always()" { + // If base condition is always(), just use the command condition + jobCondition = fmt.Sprintf("if: %s", commandConditionStr) + } else { + // Combine both conditions with AND + jobCondition = fmt.Sprintf("if: (%s) && (%s)", commandConditionStr, baseCondition) + } + } else { + // No command trigger, just use the base condition + jobCondition = fmt.Sprintf("if: %s", baseCondition) } job := &Job{ From fe16cd8aa995416506839bd2efe6f40e280c26ef Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 13:29:38 +0100 Subject: [PATCH 6/7] add conditionals for output jobs --- .../workflows/test-claude-command.lock.yml | 2 +- .github/workflows/test-codex-command.lock.yml | 2 +- .../test-codex-push-to-branch.lock.yml | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-claude-command.lock.yml b/.github/workflows/test-claude-command.lock.yml index 28edd04fd59..61cb4cf19ad 100644 --- a/.github/workflows/test-claude-command.lock.yml +++ b/.github/workflows/test-claude-command.lock.yml @@ -1322,7 +1322,7 @@ jobs: create_issue_comment: needs: test-claude-command - if: github.event.issue.number || github.event.pull_request.number + if: (contains(github.event.issue.body, '/test-claude-command') || contains(github.event.comment.body, '/test-claude-command') || contains(github.event.pull_request.body, '/test-claude-command')) && (github.event.issue.number || github.event.pull_request.number) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test-codex-command.lock.yml b/.github/workflows/test-codex-command.lock.yml index c6f6fc348b8..6ff932c72f6 100644 --- a/.github/workflows/test-codex-command.lock.yml +++ b/.github/workflows/test-codex-command.lock.yml @@ -1322,7 +1322,7 @@ jobs: create_issue_comment: needs: test-codex-command - if: github.event.issue.number || github.event.pull_request.number + if: (contains(github.event.issue.body, '/test-codex-command') || contains(github.event.comment.body, '/test-codex-command') || contains(github.event.pull_request.body, '/test-codex-command')) && (github.event.issue.number || github.event.pull_request.number) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/test-codex-push-to-branch.lock.yml b/.github/workflows/test-codex-push-to-branch.lock.yml index 3985470a6da..2d8440db84e 100644 --- a/.github/workflows/test-codex-push-to-branch.lock.yml +++ b/.github/workflows/test-codex-push-to-branch.lock.yml @@ -2,7 +2,7 @@ # To update this file, edit the corresponding .md file and run: # gh aw compile -name: "Codex Test File" +name: "Test Codex Push To Branch" on: issues: types: [opened, edited, reopened] @@ -18,7 +18,7 @@ permissions: {} concurrency: group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }}" -run-name: "Codex Test File" +run-name: "Test Codex Push To Branch" jobs: task: @@ -63,7 +63,7 @@ jobs: echo "User ${{ github.actor }} is not a team member" exit 1 - codex-test-file: + test-codex-push-to-branch: needs: task runs-on: ubuntu-latest permissions: read-all @@ -131,7 +131,7 @@ jobs: Create a new file called "codex-test-file.md" with the following content: ```markdown - # Codex Test File + # Test Codex Push To Branch This file was created by the Codex agentic workflow to test the push-to-branch functionality. @@ -224,7 +224,7 @@ jobs: engine_name: "Codex", model: "", version: "", - workflow_name: "Codex Test File", + workflow_name: "Test Codex Push To Branch", experimental: true, supports_tools_whitelist: true, supports_http_transport: false, @@ -262,7 +262,7 @@ jobs: # Run codex with log capture codex exec \ -c model=o4-mini \ - --full-auto "$INSTRUCTION" 2>&1 | tee /tmp/codex-test-file.log + --full-auto "$INSTRUCTION" 2>&1 | tee /tmp/test-codex-push-to-branch.log env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} @@ -652,7 +652,7 @@ jobs: if: always() uses: actions/github-script@v7 env: - AGENT_LOG_FILE: /tmp/codex-test-file.log + AGENT_LOG_FILE: /tmp/test-codex-push-to-branch.log with: script: | function main() { @@ -882,8 +882,8 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: codex-test-file.log - path: /tmp/codex-test-file.log + name: test-codex-push-to-branch.log + path: /tmp/test-codex-push-to-branch.log if-no-files-found: warn - name: Generate git patch if: always() @@ -1007,8 +1007,8 @@ jobs: if-no-files-found: ignore push_to_branch: - needs: codex-test-file - if: always() + needs: test-codex-push-to-branch + if: contains(github.event.issue.body, '/test-codex-push-to-branch') || contains(github.event.comment.body, '/test-codex-push-to-branch') || contains(github.event.pull_request.body, '/test-codex-push-to-branch') runs-on: ubuntu-latest permissions: contents: write @@ -1032,7 +1032,7 @@ jobs: id: push_to_branch uses: actions/github-script@v7 env: - GITHUB_AW_AGENT_OUTPUT: ${{ needs.codex-test-file.outputs.output }} + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-codex-push-to-branch.outputs.output }} GITHUB_AW_PUSH_BRANCH: "codex-test-branch" GITHUB_AW_PUSH_TARGET: "*" with: From 22db5de06f7fe01b9d1d19db71d61886e24a4678 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Wed, 3 Sep 2025 13:44:17 +0100 Subject: [PATCH 7/7] add conditionals for output jobs --- pkg/workflow/compiler.go | 2 +- pkg/workflow/output_push_to_branch.go | 2 +- pkg/workflow/output_update_issue.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 0ea064c1455..f618b2235f7 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1951,7 +1951,7 @@ func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJ // Build the command trigger condition commandCondition := buildCommandOnlyCondition(data.Command) commandConditionStr := commandCondition.Render() - + // Combine command condition with base condition using AND if baseCondition == "always()" { // If base condition is always(), just use the command condition diff --git a/pkg/workflow/output_push_to_branch.go b/pkg/workflow/output_push_to_branch.go index 3a5ff01441a..b4dc937264f 100644 --- a/pkg/workflow/output_push_to_branch.go +++ b/pkg/workflow/output_push_to_branch.go @@ -76,7 +76,7 @@ func (c *Compiler) buildCreateOutputPushToBranchJob(data *WorkflowData, mainJobN // Build the command trigger condition commandCondition := buildCommandOnlyCondition(data.Command) commandConditionStr := commandCondition.Render() - + // Combine command condition with base condition using AND if baseCondition == "always()" { // If base condition is always(), just use the command condition diff --git a/pkg/workflow/output_update_issue.go b/pkg/workflow/output_update_issue.go index bb92726c965..2e4ab6cb35b 100644 --- a/pkg/workflow/output_update_issue.go +++ b/pkg/workflow/output_update_issue.go @@ -62,7 +62,7 @@ func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobNa // Build the command trigger condition commandCondition := buildCommandOnlyCondition(data.Command) commandConditionStr := commandCondition.Render() - + // Combine command condition with base condition using AND if baseCondition == "always()" { // If base condition is always(), just use the command condition