diff --git a/.github/workflows/test-claude-add-issue-comment.lock.yml b/.github/workflows/test-claude-add-issue-comment.lock.yml index 64fb8c79b39..c931dc7cb23 100644 --- a/.github/workflows/test-claude-add-issue-comment.lock.yml +++ b/.github/workflows/test-claude-add-issue-comment.lock.yml @@ -556,6 +556,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -672,6 +674,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-add-issue-labels.lock.yml b/.github/workflows/test-claude-add-issue-labels.lock.yml index c7a1ef59330..4b036f36cb3 100644 --- a/.github/workflows/test-claude-add-issue-labels.lock.yml +++ b/.github/workflows/test-claude-add-issue-labels.lock.yml @@ -556,6 +556,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -672,6 +674,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-command.lock.yml b/.github/workflows/test-claude-command.lock.yml index 46859b1a504..60a1a3afc94 100644 --- a/.github/workflows/test-claude-command.lock.yml +++ b/.github/workflows/test-claude-command.lock.yml @@ -794,6 +794,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -910,6 +912,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-create-issue.lock.yml b/.github/workflows/test-claude-create-issue.lock.yml index 976cb1eaf16..1929b8ef21d 100644 --- a/.github/workflows/test-claude-create-issue.lock.yml +++ b/.github/workflows/test-claude-create-issue.lock.yml @@ -385,6 +385,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -501,6 +503,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index 25c8a6bb9cb..d21efec20e2 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -394,6 +394,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -510,6 +512,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-mcp.lock.yml b/.github/workflows/test-claude-mcp.lock.yml index 2ad2219a23e..9b315a8e24c 100644 --- a/.github/workflows/test-claude-mcp.lock.yml +++ b/.github/workflows/test-claude-mcp.lock.yml @@ -578,6 +578,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -694,6 +696,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-update-issue.lock.yml b/.github/workflows/test-claude-update-issue.lock.yml new file mode 100644 index 00000000000..25181ba6351 --- /dev/null +++ b/.github/workflows/test-claude-update-issue.lock.yml @@ -0,0 +1,1303 @@ +# 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: "Test Claude Update Issue" +"on": + issues: + types: + - opened + - reopened + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "Test Claude Update Issue" + +jobs: + add_reaction: + if: github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_comment' || github.event_name == 'pull_request_review_comment' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + outputs: + reaction_id: ${{ steps.react.outputs.reaction-id }} + steps: + - name: Add eyes reaction to the triggering item + id: react + uses: actions/github-script@v7 + env: + GITHUB_AW_REACTION: eyes + with: + script: | + async function main() { + // Read inputs from environment variables + const reaction = process.env.GITHUB_AW_REACTION || 'eyes'; + const alias = process.env.GITHUB_AW_ALIAS; // Only present for alias workflows + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + console.log('Reaction type:', reaction); + console.log('Alias name:', alias || 'none'); + console.log('Run ID:', runId); + console.log('Run URL:', runUrl); + // Validate reaction type + const validReactions = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']; + if (!validReactions.includes(reaction)) { + core.setFailed(`Invalid reaction type: ${reaction}. Valid reactions are: ${validReactions.join(', ')}`); + return; + } + // Determine the API endpoint based on the event type + let reactionEndpoint; + let commentUpdateEndpoint; + let shouldEditComment = false; + const eventName = context.eventName; + const owner = context.repo.owner; + const repo = context.repo.repo; + try { + switch (eventName) { + case 'issues': + const issueNumber = context.payload?.issue?.number; + if (!issueNumber) { + core.setFailed('Issue number not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`; + // Don't edit issue bodies for now - this might be more complex + shouldEditComment = false; + break; + case 'issue_comment': + const commentId = context.payload?.comment?.id; + if (!commentId) { + core.setFailed('Comment ID not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`; + commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}`; + // Only edit comments for alias workflows + shouldEditComment = alias ? true : false; + break; + case 'pull_request': + const prNumber = context.payload?.pull_request?.number; + if (!prNumber) { + core.setFailed('Pull request number not found in event payload'); + return; + } + // PRs are "issues" for the reactions endpoint + reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`; + // Don't edit PR bodies for now - this might be more complex + shouldEditComment = false; + break; + case 'pull_request_review_comment': + const reviewCommentId = context.payload?.comment?.id; + if (!reviewCommentId) { + core.setFailed('Review comment ID not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`; + commentUpdateEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}`; + // Only edit comments for alias workflows + shouldEditComment = alias ? true : false; + break; + default: + core.setFailed(`Unsupported event type: ${eventName}`); + return; + } + console.log('Reaction API endpoint:', reactionEndpoint); + // Add reaction first + await addReaction(reactionEndpoint, reaction); + // Then edit comment if applicable and if it's a comment event + if (shouldEditComment && commentUpdateEndpoint) { + console.log('Comment update endpoint:', commentUpdateEndpoint); + await editCommentWithWorkflowLink(commentUpdateEndpoint, runUrl); + } else { + if (!alias && commentUpdateEndpoint) { + console.log('Skipping comment edit - only available for alias workflows'); + } else { + console.log('Skipping comment edit for event type:', eventName); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to process reaction and comment edit:', errorMessage); + core.setFailed(`Failed to process reaction and comment edit: ${errorMessage}`); + } + } + /** + * Add a reaction to a GitHub issue, PR, or comment + * @param {string} endpoint - The GitHub API endpoint to add the reaction to + * @param {string} reaction - The reaction type to add + */ + async function addReaction(endpoint, reaction) { + const response = await github.request('POST ' + endpoint, { + content: reaction, + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + const reactionId = response.data?.id; + if (reactionId) { + console.log(`Successfully added reaction: ${reaction} (id: ${reactionId})`); + core.setOutput('reaction-id', reactionId.toString()); + } else { + console.log(`Successfully added reaction: ${reaction}`); + core.setOutput('reaction-id', ''); + } + } + /** + * Edit a comment to add a workflow run link + * @param {string} endpoint - The GitHub API endpoint to update the comment + * @param {string} runUrl - The URL of the workflow run + */ + async function editCommentWithWorkflowLink(endpoint, runUrl) { + try { + // First, get the current comment content + const getResponse = await github.request('GET ' + endpoint, { + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + const originalBody = getResponse.data.body || ''; + const workflowLinkText = `\n\n---\n*🤖 [Workflow run](${runUrl}) triggered by this comment*`; + // Check if we've already added a workflow link to avoid duplicates + if (originalBody.includes('*🤖 [Workflow run](')) { + console.log('Comment already contains a workflow run link, skipping edit'); + return; + } + const updatedBody = originalBody + workflowLinkText; + // Update the comment + const updateResponse = await github.request('PATCH ' + endpoint, { + body: updatedBody, + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + console.log(`Successfully updated comment with workflow link`); + console.log(`Comment ID: ${updateResponse.data.id}`); + } catch (error) { + // Don't fail the entire job if comment editing fails - just log it + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('Failed to edit comment with workflow link:', errorMessage); + console.warn('This is not critical - the reaction was still added successfully'); + } + } + await main(); + + test-claude-update-issue: + 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' + If the title of the issue #${{ github.event.issue.number }} is exactly "[claude-test] Update Issue Test" then: + + 1. Change the status to "closed" + 2. Update the title to "[UPDATED] Update Issue Test - Processed by Claude" + 3. Add a line to the end of the body saying "This issue was automatically updated by the Claude agentic workflow." + + + --- + + ## Updating Issues + + **IMPORTANT**: To do the actions mentioned in the header of this section, do NOT attempt to use MCP tools and do NOT attempt to use `gh` or the GitHub API. 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: + + **Updating an Issue** + ```json + {"type": "update-issue", "status": "open" // or "closed", "title": "New issue title", "body": "Updated issue body in markdown"} + ``` + + **Example JSONL file content:** + ``` + # No safe outputs configured for this workflow + ``` + + **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: "Test Claude Update Issue", + 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): + # - 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: "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" + 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/test-claude-update-issue.log + else + echo "No execution file output found from Agentic Action" >> /tmp/test-claude-update-issue.log + fi + + # Ensure log file exists + touch /tmp/test-claude-update-issue.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: "{\"update-issue\":true}" + 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 + 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 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; + 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/test-claude-update-issue.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: test-claude-update-issue.log + path: /tmp/test-claude-update-issue.log + if-no-files-found: warn + - name: Generate git patch + if: always() + run: | + # Check current git status + echo "Current git status:" + git status + # 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" + # 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" + 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 + + update_issue: + needs: test-claude-update-issue + if: github.event.issue.number + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.update_issue.outputs.issue_number }} + issue_url: ${{ steps.update_issue.outputs.issue_url }} + steps: + - name: Update Issue + id: update_issue + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude-update-issue.outputs.output }} + GITHUB_AW_UPDATE_STATUS: true + GITHUB_AW_UPDATE_TITLE: true + GITHUB_AW_UPDATE_BODY: true + with: + script: | + async function main() { + // Read the validated output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + console.log('Agent output content length:', outputContent.length); + // 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 all update-issue items + const updateItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'update-issue'); + if (updateItems.length === 0) { + console.log('No update-issue items found in agent output'); + return; + } + console.log(`Found ${updateItems.length} update-issue item(s)`); + // Get the configuration from environment variables + const updateTarget = process.env.GITHUB_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GITHUB_AW_UPDATE_STATUS === 'true'; + const canUpdateTitle = process.env.GITHUB_AW_UPDATE_TITLE === 'true'; + const canUpdateBody = process.env.GITHUB_AW_UPDATE_BODY === 'true'; + console.log(`Update target configuration: ${updateTarget}`); + console.log(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + // Check if we're in an issue context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + // Validate context based on target configuration + if (updateTarget === "triggering" && !isIssueContext) { + console.log('Target is "triggering" but not running in issue context, skipping issue update'); + return; + } + const updatedIssues = []; + // Process each update item + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + console.log(`Processing update-issue item ${i + 1}/${updateItems.length}`); + // Determine the issue number for this update + let issueNumber; + if (updateTarget === "*") { + // For target "*", we need an explicit issue number from the update item + if (updateItem.issue_number) { + issueNumber = parseInt(updateItem.issue_number, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number specified: ${updateItem.issue_number}`); + continue; + } + } else { + console.log('Target is "*" but no issue_number specified in update item'); + continue; + } + } else if (updateTarget && updateTarget !== "triggering") { + // Explicit issue number specified in target + issueNumber = parseInt(updateTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number in target configuration: ${updateTarget}`); + continue; + } + } else { + // Default behavior: use triggering issue + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else { + console.log('Could not determine issue number'); + continue; + } + } + if (!issueNumber) { + console.log('Could not determine issue number'); + continue; + } + console.log(`Updating issue #${issueNumber}`); + // Build the update object based on allowed fields and provided values + const updateData = {}; + let hasUpdates = false; + if (canUpdateStatus && updateItem.status !== undefined) { + // Validate status value + if (updateItem.status === 'open' || updateItem.status === 'closed') { + updateData.state = updateItem.status; + hasUpdates = true; + console.log(`Will update status to: ${updateItem.status}`); + } else { + console.log(`Invalid status value: ${updateItem.status}. Must be 'open' or 'closed'`); + } + } + if (canUpdateTitle && updateItem.title !== undefined) { + if (typeof updateItem.title === 'string' && updateItem.title.trim().length > 0) { + updateData.title = updateItem.title.trim(); + hasUpdates = true; + console.log(`Will update title to: ${updateItem.title.trim()}`); + } else { + console.log('Invalid title value: must be a non-empty string'); + } + } + if (canUpdateBody && updateItem.body !== undefined) { + if (typeof updateItem.body === 'string') { + updateData.body = updateItem.body; + hasUpdates = true; + console.log(`Will update body (length: ${updateItem.body.length})`); + } else { + console.log('Invalid body value: must be a string'); + } + } + if (!hasUpdates) { + console.log('No valid updates to apply for this item'); + continue; + } + try { + // Update the issue using GitHub API + const { data: issue } = await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + ...updateData + }); + console.log('Updated issue #' + issue.number + ': ' + issue.html_url); + updatedIssues.push(issue); + // Set output for the last updated issue (for backward compatibility) + if (i === updateItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } + } catch (error) { + console.error(`✗ Failed to update issue #${issueNumber}:`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all updated issues + if (updatedIssues.length > 0) { + let summaryContent = '\n\n## Updated Issues\n'; + for (const issue of updatedIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + console.log(`Successfully updated ${updatedIssues.length} issue(s)`); + return updatedIssues; + } + await main(); + diff --git a/.github/workflows/test-claude-update-issue.md b/.github/workflows/test-claude-update-issue.md new file mode 100644 index 00000000000..5d998fba67e --- /dev/null +++ b/.github/workflows/test-claude-update-issue.md @@ -0,0 +1,21 @@ +--- +on: + issues: + types: [opened, reopened] + reaction: eyes + +engine: + id: claude + +safe-outputs: + update-issue: + status: + title: + body: +--- + +If the title of the issue #${{ github.event.issue.number }} is exactly "[claude-test] Update Issue Test" then: + +1. Change the status to "closed" +2. Update the title to "[UPDATED] Update Issue Test - Processed by Claude" +3. Add a line to the end of the body saying "This issue was automatically updated by the Claude agentic workflow." diff --git a/.github/workflows/test-codex-add-issue-comment.lock.yml b/.github/workflows/test-codex-add-issue-comment.lock.yml index 3bbbf2efec1..ab84276dcba 100644 --- a/.github/workflows/test-codex-add-issue-comment.lock.yml +++ b/.github/workflows/test-codex-add-issue-comment.lock.yml @@ -494,6 +494,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -610,6 +612,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-add-issue-labels.lock.yml b/.github/workflows/test-codex-add-issue-labels.lock.yml index cf8727ac0c7..89bfc354179 100644 --- a/.github/workflows/test-codex-add-issue-labels.lock.yml +++ b/.github/workflows/test-codex-add-issue-labels.lock.yml @@ -494,6 +494,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -610,6 +612,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-command.lock.yml b/.github/workflows/test-codex-command.lock.yml index f2d504645db..cbd87fd0a63 100644 --- a/.github/workflows/test-codex-command.lock.yml +++ b/.github/workflows/test-codex-command.lock.yml @@ -794,6 +794,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -910,6 +912,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-create-issue.lock.yml b/.github/workflows/test-codex-create-issue.lock.yml index 9f490568f8c..d0cecd8859d 100644 --- a/.github/workflows/test-codex-create-issue.lock.yml +++ b/.github/workflows/test-codex-create-issue.lock.yml @@ -323,6 +323,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -439,6 +441,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-create-pull-request.lock.yml b/.github/workflows/test-codex-create-pull-request.lock.yml index c1d7ff11743..c8c43de2992 100644 --- a/.github/workflows/test-codex-create-pull-request.lock.yml +++ b/.github/workflows/test-codex-create-pull-request.lock.yml @@ -332,6 +332,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -448,6 +450,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-mcp.lock.yml b/.github/workflows/test-codex-mcp.lock.yml index 917fa40cab1..abfbaebbabe 100644 --- a/.github/workflows/test-codex-mcp.lock.yml +++ b/.github/workflows/test-codex-mcp.lock.yml @@ -513,6 +513,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -629,6 +631,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-update-issue.lock.yml b/.github/workflows/test-codex-update-issue.lock.yml new file mode 100644 index 00000000000..2222bcc1205 --- /dev/null +++ b/.github/workflows/test-codex-update-issue.lock.yml @@ -0,0 +1,1186 @@ +# 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: "Test Codex Update Issue" +"on": + issues: + types: + - opened + - reopened + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "Test Codex Update Issue" + +jobs: + add_reaction: + if: github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_comment' || github.event_name == 'pull_request_review_comment' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + outputs: + reaction_id: ${{ steps.react.outputs.reaction-id }} + steps: + - name: Add eyes reaction to the triggering item + id: react + uses: actions/github-script@v7 + env: + GITHUB_AW_REACTION: eyes + with: + script: | + async function main() { + // Read inputs from environment variables + const reaction = process.env.GITHUB_AW_REACTION || 'eyes'; + const alias = process.env.GITHUB_AW_ALIAS; // Only present for alias workflows + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + console.log('Reaction type:', reaction); + console.log('Alias name:', alias || 'none'); + console.log('Run ID:', runId); + console.log('Run URL:', runUrl); + // Validate reaction type + const validReactions = ['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']; + if (!validReactions.includes(reaction)) { + core.setFailed(`Invalid reaction type: ${reaction}. Valid reactions are: ${validReactions.join(', ')}`); + return; + } + // Determine the API endpoint based on the event type + let reactionEndpoint; + let commentUpdateEndpoint; + let shouldEditComment = false; + const eventName = context.eventName; + const owner = context.repo.owner; + const repo = context.repo.repo; + try { + switch (eventName) { + case 'issues': + const issueNumber = context.payload?.issue?.number; + if (!issueNumber) { + core.setFailed('Issue number not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`; + // Don't edit issue bodies for now - this might be more complex + shouldEditComment = false; + break; + case 'issue_comment': + const commentId = context.payload?.comment?.id; + if (!commentId) { + core.setFailed('Comment ID not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`; + commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}`; + // Only edit comments for alias workflows + shouldEditComment = alias ? true : false; + break; + case 'pull_request': + const prNumber = context.payload?.pull_request?.number; + if (!prNumber) { + core.setFailed('Pull request number not found in event payload'); + return; + } + // PRs are "issues" for the reactions endpoint + reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`; + // Don't edit PR bodies for now - this might be more complex + shouldEditComment = false; + break; + case 'pull_request_review_comment': + const reviewCommentId = context.payload?.comment?.id; + if (!reviewCommentId) { + core.setFailed('Review comment ID not found in event payload'); + return; + } + reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`; + commentUpdateEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}`; + // Only edit comments for alias workflows + shouldEditComment = alias ? true : false; + break; + default: + core.setFailed(`Unsupported event type: ${eventName}`); + return; + } + console.log('Reaction API endpoint:', reactionEndpoint); + // Add reaction first + await addReaction(reactionEndpoint, reaction); + // Then edit comment if applicable and if it's a comment event + if (shouldEditComment && commentUpdateEndpoint) { + console.log('Comment update endpoint:', commentUpdateEndpoint); + await editCommentWithWorkflowLink(commentUpdateEndpoint, runUrl); + } else { + if (!alias && commentUpdateEndpoint) { + console.log('Skipping comment edit - only available for alias workflows'); + } else { + console.log('Skipping comment edit for event type:', eventName); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to process reaction and comment edit:', errorMessage); + core.setFailed(`Failed to process reaction and comment edit: ${errorMessage}`); + } + } + /** + * Add a reaction to a GitHub issue, PR, or comment + * @param {string} endpoint - The GitHub API endpoint to add the reaction to + * @param {string} reaction - The reaction type to add + */ + async function addReaction(endpoint, reaction) { + const response = await github.request('POST ' + endpoint, { + content: reaction, + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + const reactionId = response.data?.id; + if (reactionId) { + console.log(`Successfully added reaction: ${reaction} (id: ${reactionId})`); + core.setOutput('reaction-id', reactionId.toString()); + } else { + console.log(`Successfully added reaction: ${reaction}`); + core.setOutput('reaction-id', ''); + } + } + /** + * Edit a comment to add a workflow run link + * @param {string} endpoint - The GitHub API endpoint to update the comment + * @param {string} runUrl - The URL of the workflow run + */ + async function editCommentWithWorkflowLink(endpoint, runUrl) { + try { + // First, get the current comment content + const getResponse = await github.request('GET ' + endpoint, { + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + const originalBody = getResponse.data.body || ''; + const workflowLinkText = `\n\n---\n*🤖 [Workflow run](${runUrl}) triggered by this comment*`; + // Check if we've already added a workflow link to avoid duplicates + if (originalBody.includes('*🤖 [Workflow run](')) { + console.log('Comment already contains a workflow run link, skipping edit'); + return; + } + const updatedBody = originalBody + workflowLinkText; + // Update the comment + const updateResponse = await github.request('PATCH ' + endpoint, { + body: updatedBody, + headers: { + 'Accept': 'application/vnd.github+json' + } + }); + console.log(`Successfully updated comment with workflow link`); + console.log(`Comment ID: ${updateResponse.data.id}`); + } catch (error) { + // Don't fail the entire job if comment editing fails - just log it + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('Failed to edit comment with workflow link:', errorMessage); + console.warn('This is not critical - the reaction was still added successfully'); + } + } + await main(); + + test-codex-update-issue: + 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' + If the title of the issue #${{ github.event.issue.number }} is exactly "[codex-test] Update Issue Test" then: + + 1. Change the status to "closed" + 2. Update the title to "[UPDATED] Update Issue Test - Processed by Codex" + 3. Add a line to the end of the body saying "This issue was automatically updated by the Codex agentic workflow." + + + --- + + ## Updating Issues + + **IMPORTANT**: To do the actions mentioned in the header of this section, do NOT attempt to use MCP tools and do NOT attempt to use `gh` or the GitHub API. 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: + + **Updating an Issue** + ```json + {"type": "update-issue", "status": "open" // or "closed", "title": "New issue title", "body": "Updated issue body in markdown"} + ``` + + **Example JSONL file content:** + ``` + # No safe outputs configured for this workflow + ``` + + **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: "Test Codex Update Issue", + 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/test-codex-update-issue.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: "{\"update-issue\":true}" + 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 + 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 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; + 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/test-codex-update-issue.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: test-codex-update-issue.log + path: /tmp/test-codex-update-issue.log + if-no-files-found: warn + - name: Generate git patch + if: always() + run: | + # Check current git status + echo "Current git status:" + git status + # 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" + # 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" + 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 + + update_issue: + needs: test-codex-update-issue + if: github.event.issue.number + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.update_issue.outputs.issue_number }} + issue_url: ${{ steps.update_issue.outputs.issue_url }} + steps: + - name: Update Issue + id: update_issue + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-codex-update-issue.outputs.output }} + GITHUB_AW_UPDATE_STATUS: true + GITHUB_AW_UPDATE_TITLE: true + GITHUB_AW_UPDATE_BODY: true + with: + script: | + async function main() { + // Read the validated output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + console.log('Agent output content length:', outputContent.length); + // 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 all update-issue items + const updateItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'update-issue'); + if (updateItems.length === 0) { + console.log('No update-issue items found in agent output'); + return; + } + console.log(`Found ${updateItems.length} update-issue item(s)`); + // Get the configuration from environment variables + const updateTarget = process.env.GITHUB_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GITHUB_AW_UPDATE_STATUS === 'true'; + const canUpdateTitle = process.env.GITHUB_AW_UPDATE_TITLE === 'true'; + const canUpdateBody = process.env.GITHUB_AW_UPDATE_BODY === 'true'; + console.log(`Update target configuration: ${updateTarget}`); + console.log(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + // Check if we're in an issue context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + // Validate context based on target configuration + if (updateTarget === "triggering" && !isIssueContext) { + console.log('Target is "triggering" but not running in issue context, skipping issue update'); + return; + } + const updatedIssues = []; + // Process each update item + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + console.log(`Processing update-issue item ${i + 1}/${updateItems.length}`); + // Determine the issue number for this update + let issueNumber; + if (updateTarget === "*") { + // For target "*", we need an explicit issue number from the update item + if (updateItem.issue_number) { + issueNumber = parseInt(updateItem.issue_number, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number specified: ${updateItem.issue_number}`); + continue; + } + } else { + console.log('Target is "*" but no issue_number specified in update item'); + continue; + } + } else if (updateTarget && updateTarget !== "triggering") { + // Explicit issue number specified in target + issueNumber = parseInt(updateTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number in target configuration: ${updateTarget}`); + continue; + } + } else { + // Default behavior: use triggering issue + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else { + console.log('Could not determine issue number'); + continue; + } + } + if (!issueNumber) { + console.log('Could not determine issue number'); + continue; + } + console.log(`Updating issue #${issueNumber}`); + // Build the update object based on allowed fields and provided values + const updateData = {}; + let hasUpdates = false; + if (canUpdateStatus && updateItem.status !== undefined) { + // Validate status value + if (updateItem.status === 'open' || updateItem.status === 'closed') { + updateData.state = updateItem.status; + hasUpdates = true; + console.log(`Will update status to: ${updateItem.status}`); + } else { + console.log(`Invalid status value: ${updateItem.status}. Must be 'open' or 'closed'`); + } + } + if (canUpdateTitle && updateItem.title !== undefined) { + if (typeof updateItem.title === 'string' && updateItem.title.trim().length > 0) { + updateData.title = updateItem.title.trim(); + hasUpdates = true; + console.log(`Will update title to: ${updateItem.title.trim()}`); + } else { + console.log('Invalid title value: must be a non-empty string'); + } + } + if (canUpdateBody && updateItem.body !== undefined) { + if (typeof updateItem.body === 'string') { + updateData.body = updateItem.body; + hasUpdates = true; + console.log(`Will update body (length: ${updateItem.body.length})`); + } else { + console.log('Invalid body value: must be a string'); + } + } + if (!hasUpdates) { + console.log('No valid updates to apply for this item'); + continue; + } + try { + // Update the issue using GitHub API + const { data: issue } = await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + ...updateData + }); + console.log('Updated issue #' + issue.number + ': ' + issue.html_url); + updatedIssues.push(issue); + // Set output for the last updated issue (for backward compatibility) + if (i === updateItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } + } catch (error) { + console.error(`✗ Failed to update issue #${issueNumber}:`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all updated issues + if (updatedIssues.length > 0) { + let summaryContent = '\n\n## Updated Issues\n'; + for (const issue of updatedIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + console.log(`Successfully updated ${updatedIssues.length} issue(s)`); + return updatedIssues; + } + await main(); + diff --git a/.github/workflows/test-codex-update-issue.md b/.github/workflows/test-codex-update-issue.md new file mode 100644 index 00000000000..081be508cf5 --- /dev/null +++ b/.github/workflows/test-codex-update-issue.md @@ -0,0 +1,21 @@ +--- +on: + issues: + types: [opened, reopened] + reaction: eyes + +engine: + id: codex + +safe-outputs: + update-issue: + status: + title: + body: +--- + +If the title of the issue #${{ github.event.issue.number }} is exactly "[codex-test] Update Issue Test" then: + +1. Change the status to "closed" +2. Update the title to "[UPDATED] Update Issue Test - Processed by Codex" +3. Add a line to the end of the body saying "This issue was automatically updated by the Codex agentic workflow." diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml index 1b6a238f944..6c811724749 100644 --- a/.github/workflows/test-proxy.lock.yml +++ b/.github/workflows/test-proxy.lock.yml @@ -556,6 +556,8 @@ jobs: 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 default: return 1; // Default to single item for unknown types } @@ -672,6 +674,46 @@ jobs: // 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; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/docs/commands.md b/docs/commands.md index 8871d722cb7..ac1c5734bfe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -301,6 +301,7 @@ gh aw uninstall org/repo --local - [Workflow Structure](workflow-structure.md) - Directory layout and file organization - [Frontmatter Options](frontmatter.md) - Configuration options for workflows +- [Safe Outputs](safe-outputs.md) - Secure output processing including issue updates - [Tools Configuration](tools.md) - GitHub and MCP server configuration - [Include Directives](include-directives.md) - Modularizing workflows with includes - [Secrets Management](secrets.md) - Managing secrets and environment variables diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index a4e72a3b4d2..f81358ee740 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -154,6 +154,50 @@ Analyze the issue content and add appropriate labels to the issue. The agentic part of your workflow will have implicit additional prompting saying that, to add labels to a GitHub issue, you must write labels to a special file, one label per line. +### Issue Updates (`update-issue:`) + +Adding `update-issue:` to the `safe-outputs:` section declares that the workflow should conclude with updating GitHub issues based on the coding agent's analysis. You can configure which fields are allowed to be updated. + +**Basic Configuration:** +```yaml +safe-outputs: + update-issue: +``` + +**With Configuration:** +```yaml +safe-outputs: + update-issue: + status: # Optional: presence indicates status can be updated (open/closed) + target: "*" # Optional: target for updates + # "triggering" (default) - only update triggering issue + # "*" - allow updates to any issue (requires issue_number in agent output) + # explicit number - update specific issue number + title: # Optional: presence indicates title can be updated + body: # Optional: presence indicates body can be updated + max: 3 # Optional: maximum number of issues to update (default: 1) +``` + +The agentic part of your workflow should analyze the issue and determine what updates to make. + +**Example natural language to generate the output:** + +```markdown +# Issue Update Agent + +Analyze the issue and update its status, title, or body as needed. +Update the issue based on your analysis. You can change the title, body content, or status (open/closed). +``` + +**Safety Features:** + +- Only explicitly enabled fields (`status`, `title`, `body`) can be updated +- Status values are validated (must be "open" or "closed") +- Empty or invalid field values are rejected +- Target configuration controls which issues can be updated for security +- Update count is limited by `max` setting (default: 1) +- Only GitHub's `issues.update` API endpoint is used + **Safety Features:** - Empty lines in coding agent output are ignored diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 6cf72ed271f..1d8cdb6e884 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -107,7 +107,18 @@ The YAML frontmatter supports these fields: labels: [automation, ai-agent] # Optional: labels to attach to PRs draft: true # Optional: create as draft PR (defaults to true) ``` - When using `output.create-pull-request`, the main job does **not** need `contents: write` or `pull-requests: write` permissions since PR creation is handled by a separate job with appropriate permissions. + When using `output.create-pull-request`, the main job does **not** need `contents: write` or `pull-requests: write` permissions since PR creation is handled by a separate job with appropriate permissions. + - `update-issue:` - Safe issue updates + ```yaml + safe-outputs: + update-issue: + status: true # Optional: allow updating issue status (open/closed) + target: "*" # Optional: target for updates (default: "triggering") + title: true # Optional: allow updating issue title + body: true # Optional: allow updating issue body + max: 3 # Optional: maximum number of issues to update (default: 1) + ``` + When using `safe-outputs.update-issue`, the main job does **not** need `issues: write` permission since issue updates are handled by a separate job with appropriate permissions. - **`alias:`** - Alternative workflow name (string) - **`cache:`** - Cache configuration for workflow dependencies (object or array) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 30a5aa47b40..4de43ebfe0d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1097,6 +1097,43 @@ "additionalProperties": false } ] + }, + "update-issue": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for updating GitHub issues from agentic workflow output", + "properties": { + "status": { + "type": "null", + "description": "Allow updating issue status (open/closed) - presence of key indicates field can be updated" + }, + "target": { + "type": "string", + "description": "Target for updates: 'triggering' (default), '*' (any issue), or explicit issue number" + }, + "title": { + "type": "null", + "description": "Allow updating issue title - presence of key indicates field can be updated" + }, + "body": { + "type": "null", + "description": "Allow updating issue body - presence of key indicates field can be updated" + }, + "max": { + "type": "integer", + "description": "Maximum number of issues to update (default: 1)", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable issue updating with default configuration" + } + ] } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 7e5cd1e79fe..c696768ae5d 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -146,6 +146,7 @@ type SafeOutputsConfig struct { AddIssueComments *AddIssueCommentsConfig `yaml:"add-issue-comment,omitempty"` CreatePullRequests *CreatePullRequestsConfig `yaml:"create-pull-request,omitempty"` AddIssueLabels *AddIssueLabelsConfig `yaml:"add-issue-label,omitempty"` + UpdateIssues *UpdateIssuesConfig `yaml:"update-issue,omitempty"` AllowedDomains []string `yaml:"allowed-domains,omitempty"` } @@ -181,6 +182,15 @@ type AddIssueLabelsConfig struct { MaxCount *int `yaml:"max,omitempty"` // Optional maximum number of labels to add (default: 3) } +// UpdateIssuesConfig holds configuration for updating GitHub issues from agent output +type UpdateIssuesConfig struct { + Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated + Target string `yaml:"target,omitempty"` // Target for updates: "triggering" (default), "*" (any issue), or explicit issue number + Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated + Body *bool `yaml:"body,omitempty"` // Allow updating issue body - presence indicates field can be updated + Max int `yaml:"max,omitempty"` // Maximum number of issues to update (default: 1) +} + // CompileWorkflow converts a markdown workflow to GitHub Actions YAML func (c *Compiler) CompileWorkflow(markdownPath string) error { @@ -1592,6 +1602,17 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { return fmt.Errorf("failed to add add_labels job: %w", err) } } + + // Build update_issue job if output.update-issue is configured + if data.SafeOutputs.UpdateIssues != nil { + updateIssueJob, err := c.buildCreateOutputUpdateIssueJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build update_issue job: %w", err) + } + if err := c.jobManager.AddJob(updateIssueJob); err != nil { + return fmt.Errorf("failed to add update_issue job: %w", err) + } + } } // Build additional custom jobs from frontmatter jobs section if err := c.buildCustomJobs(data); err != nil { @@ -2309,6 +2330,14 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(", ") } yaml.WriteString("Adding Labels to Issues or Pull Requests") + written = true + } + + if data.SafeOutputs.UpdateIssues != nil { + if written { + yaml.WriteString(", ") + } + yaml.WriteString("Updating Issues") } yaml.WriteString("\n") yaml.WriteString(" \n") @@ -2356,6 +2385,36 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" \n") } + if data.SafeOutputs.UpdateIssues != nil { + yaml.WriteString(" **Updating an Issue**\n") + yaml.WriteString(" ```json\n") + + // Build example based on allowed fields + var fields []string + if data.SafeOutputs.UpdateIssues.Status != nil { + fields = append(fields, "\"status\": \"open\" // or \"closed\"") + } + if data.SafeOutputs.UpdateIssues.Title != nil { + fields = append(fields, "\"title\": \"New issue title\"") + } + if data.SafeOutputs.UpdateIssues.Body != nil { + fields = append(fields, "\"body\": \"Updated issue body in markdown\"") + } + + if len(fields) > 0 { + yaml.WriteString(" {\"type\": \"update-issue\"") + for _, field := range fields { + yaml.WriteString(", " + field) + } + yaml.WriteString("}\n") + } else { + yaml.WriteString(" {\"type\": \"update-issue\", \"title\": \"New issue title\", \"body\": \"Updated issue body\", \"status\": \"open\"}\n") + } + + yaml.WriteString(" ```\n") + yaml.WriteString(" \n") + } + yaml.WriteString(" **Example JSONL file content:**\n") yaml.WriteString(" ```\n") @@ -2519,6 +2578,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Handle update-issue + updateIssuesConfig := c.parseUpdateIssuesConfig(outputMap) + if updateIssuesConfig != nil { + config.UpdateIssues = updateIssuesConfig + } + return config } } @@ -2653,6 +2718,50 @@ func (c *Compiler) parseIntValue(value any) (int, bool) { } } +// parseUpdateIssuesConfig handles update-issue configuration +func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssuesConfig { + if configData, exists := outputMap["update-issue"]; exists { + updateIssuesConfig := &UpdateIssuesConfig{Max: 1} // Default max is 1 + + if configMap, ok := configData.(map[string]any); ok { + // Parse max + if max, exists := configMap["max"]; exists { + if maxInt, ok := c.parseIntValue(max); ok { + updateIssuesConfig.Max = maxInt + } + } + + // Parse target + if target, exists := configMap["target"]; exists { + if targetStr, ok := target.(string); ok { + updateIssuesConfig.Target = targetStr + } + } + + // Parse status - presence of the key (even if nil/empty) indicates field can be updated + if _, exists := configMap["status"]; exists { + // If the key exists, it means we can update the status + // We don't care about the value - just that the key is present + updateIssuesConfig.Status = new(bool) // Allocate a new bool pointer (defaults to false) + } + + // Parse title - presence of the key (even if nil/empty) indicates field can be updated + if _, exists := configMap["title"]; exists { + updateIssuesConfig.Title = new(bool) + } + + // Parse body - presence of the key (even if nil/empty) indicates field can be updated + if _, exists := configMap["body"]; exists { + updateIssuesConfig.Body = new(bool) + } + } + + return updateIssuesConfig + } + + return nil +} + // buildCustomJobs creates custom jobs defined in the frontmatter jobs section func (c *Compiler) buildCustomJobs(data *WorkflowData) error { for jobName, jobConfig := range data.Jobs { @@ -2949,6 +3058,9 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor if data.SafeOutputs.AddIssueLabels != nil { safeOutputsConfig["add-issue-label"] = true } + if data.SafeOutputs.UpdateIssues != nil { + safeOutputsConfig["update-issue"] = true + } // Convert to JSON string for environment variable configJSON, _ := json.Marshal(safeOutputsConfig) diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index ee878666cd7..bcc3ba6637a 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -24,6 +24,9 @@ var collectJSONLOutputScript string //go:embed js/add_labels.cjs var addLabelsScript string +//go:embed js/update_issue.cjs +var updateIssueScript string + //go:embed js/setup_agent_output.cjs var setupAgentOutputScript string diff --git a/pkg/workflow/js/collect_ndjson_output.cjs b/pkg/workflow/js/collect_ndjson_output.cjs index 0e7fd7b8a03..42911e8fb5c 100644 --- a/pkg/workflow/js/collect_ndjson_output.cjs +++ b/pkg/workflow/js/collect_ndjson_output.cjs @@ -148,6 +148,8 @@ async function main() { 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 default: return 1; // Default to single item for unknown types } @@ -280,6 +282,47 @@ async function main() { 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; + default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/pkg/workflow/js/update_issue.cjs b/pkg/workflow/js/update_issue.cjs new file mode 100644 index 00000000000..6b52a491c3d --- /dev/null +++ b/pkg/workflow/js/update_issue.cjs @@ -0,0 +1,183 @@ +async function main() { + // Read the validated output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // 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 all update-issue items + const updateItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'update-issue'); + if (updateItems.length === 0) { + console.log('No update-issue items found in agent output'); + return; + } + + console.log(`Found ${updateItems.length} update-issue item(s)`); + + // Get the configuration from environment variables + const updateTarget = process.env.GITHUB_AW_UPDATE_TARGET || "triggering"; + const canUpdateStatus = process.env.GITHUB_AW_UPDATE_STATUS === 'true'; + const canUpdateTitle = process.env.GITHUB_AW_UPDATE_TITLE === 'true'; + const canUpdateBody = process.env.GITHUB_AW_UPDATE_BODY === 'true'; + + console.log(`Update target configuration: ${updateTarget}`); + console.log(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); + + // Check if we're in an issue context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + + // Validate context based on target configuration + if (updateTarget === "triggering" && !isIssueContext) { + console.log('Target is "triggering" but not running in issue context, skipping issue update'); + return; + } + + const updatedIssues = []; + + // Process each update item + for (let i = 0; i < updateItems.length; i++) { + const updateItem = updateItems[i]; + console.log(`Processing update-issue item ${i + 1}/${updateItems.length}`); + + // Determine the issue number for this update + let issueNumber; + + if (updateTarget === "*") { + // For target "*", we need an explicit issue number from the update item + if (updateItem.issue_number) { + issueNumber = parseInt(updateItem.issue_number, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number specified: ${updateItem.issue_number}`); + continue; + } + } else { + console.log('Target is "*" but no issue_number specified in update item'); + continue; + } + } else if (updateTarget && updateTarget !== "triggering") { + // Explicit issue number specified in target + issueNumber = parseInt(updateTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + console.log(`Invalid issue number in target configuration: ${updateTarget}`); + continue; + } + } else { + // Default behavior: use triggering issue + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else { + console.log('Could not determine issue number'); + continue; + } + } + + if (!issueNumber) { + console.log('Could not determine issue number'); + continue; + } + + console.log(`Updating issue #${issueNumber}`); + + // Build the update object based on allowed fields and provided values + const updateData = {}; + let hasUpdates = false; + + if (canUpdateStatus && updateItem.status !== undefined) { + // Validate status value + if (updateItem.status === 'open' || updateItem.status === 'closed') { + updateData.state = updateItem.status; + hasUpdates = true; + console.log(`Will update status to: ${updateItem.status}`); + } else { + console.log(`Invalid status value: ${updateItem.status}. Must be 'open' or 'closed'`); + } + } + + if (canUpdateTitle && updateItem.title !== undefined) { + if (typeof updateItem.title === 'string' && updateItem.title.trim().length > 0) { + updateData.title = updateItem.title.trim(); + hasUpdates = true; + console.log(`Will update title to: ${updateItem.title.trim()}`); + } else { + console.log('Invalid title value: must be a non-empty string'); + } + } + + if (canUpdateBody && updateItem.body !== undefined) { + if (typeof updateItem.body === 'string') { + updateData.body = updateItem.body; + hasUpdates = true; + console.log(`Will update body (length: ${updateItem.body.length})`); + } else { + console.log('Invalid body value: must be a string'); + } + } + + if (!hasUpdates) { + console.log('No valid updates to apply for this item'); + continue; + } + + try { + // Update the issue using GitHub API + const { data: issue } = await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + ...updateData + }); + + console.log('Updated issue #' + issue.number + ': ' + issue.html_url); + updatedIssues.push(issue); + + // Set output for the last updated issue (for backward compatibility) + if (i === updateItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } + } catch (error) { + console.error(`✗ Failed to update issue #${issueNumber}:`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + + // Write summary for all updated issues + if (updatedIssues.length > 0) { + let summaryContent = '\n\n## Updated Issues\n'; + for (const issue of updatedIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + + console.log(`Successfully updated ${updatedIssues.length} issue(s)`); + return updatedIssues; +} +await main(); diff --git a/pkg/workflow/js/update_issue.test.cjs b/pkg/workflow/js/update_issue.test.cjs new file mode 100644 index 00000000000..3f5351d91ef --- /dev/null +++ b/pkg/workflow/js/update_issue.test.cjs @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn() + } +}; + +const mockGithub = { + rest: { + issues: { + update: vi.fn() + } + } +}; + +const mockContext = { + eventName: 'issues', + repo: { + owner: 'testowner', + repo: 'testrepo' + }, + payload: { + issue: { + number: 123 + } + } +}; + +// Set up global variables +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe('update_issue.cjs', () => { + let updateIssueScript; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Reset environment variables + delete process.env.GITHUB_AW_AGENT_OUTPUT; + delete process.env.GITHUB_AW_UPDATE_STATUS; + delete process.env.GITHUB_AW_UPDATE_TITLE; + delete process.env.GITHUB_AW_UPDATE_BODY; + delete process.env.GITHUB_AW_UPDATE_TARGET; + + // Set default values + process.env.GITHUB_AW_UPDATE_STATUS = 'false'; + process.env.GITHUB_AW_UPDATE_TITLE = 'false'; + process.env.GITHUB_AW_UPDATE_BODY = 'false'; + + // Read the script + const scriptPath = path.join(__dirname, 'update_issue.cjs'); + updateIssueScript = fs.readFileSync(scriptPath, 'utf8'); + }); + + it('should skip when no agent output is provided', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should skip when agent output is empty', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = ' '; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Agent output content is empty'); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should skip when not in issue context for triggering target', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + title: 'Updated title' + }] + }); + process.env.GITHUB_AW_UPDATE_TITLE = 'true'; + global.context.eventName = 'push'; // Not an issue event + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Target is "triggering" but not running in issue context, skipping issue update'); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should update issue title successfully', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + title: 'Updated issue title' + }] + }); + process.env.GITHUB_AW_UPDATE_TITLE = 'true'; + global.context.eventName = 'issues'; + + const mockIssue = { + number: 123, + title: 'Updated issue title', + html_url: 'https://github.com/testowner/testrepo/issues/123' + }; + + mockGithub.rest.issues.update.mockResolvedValue({ data: mockIssue }); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + title: 'Updated issue title' + }); + + expect(mockCore.setOutput).toHaveBeenCalledWith('issue_number', 123); + expect(mockCore.setOutput).toHaveBeenCalledWith('issue_url', mockIssue.html_url); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should update issue status successfully', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + status: 'closed' + }] + }); + process.env.GITHUB_AW_UPDATE_STATUS = 'true'; + global.context.eventName = 'issues'; + + const mockIssue = { + number: 123, + html_url: 'https://github.com/testowner/testrepo/issues/123' + }; + + mockGithub.rest.issues.update.mockResolvedValue({ data: mockIssue }); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + state: 'closed' + }); + + consoleSpy.mockRestore(); + }); + + it('should update multiple fields successfully', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + title: 'New title', + body: 'New body content', + status: 'open' + }] + }); + process.env.GITHUB_AW_UPDATE_TITLE = 'true'; + process.env.GITHUB_AW_UPDATE_BODY = 'true'; + process.env.GITHUB_AW_UPDATE_STATUS = 'true'; + global.context.eventName = 'issues'; + + const mockIssue = { + number: 123, + html_url: 'https://github.com/testowner/testrepo/issues/123' + }; + + mockGithub.rest.issues.update.mockResolvedValue({ data: mockIssue }); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 123, + title: 'New title', + body: 'New body content', + state: 'open' + }); + + consoleSpy.mockRestore(); + }); + + it('should handle explicit issue number with target "*"', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + issue_number: 456, + title: 'Updated title' + }] + }); + process.env.GITHUB_AW_UPDATE_TITLE = 'true'; + process.env.GITHUB_AW_UPDATE_TARGET = '*'; + global.context.eventName = 'push'; // Not an issue event, but should work with explicit target + + const mockIssue = { + number: 456, + html_url: 'https://github.com/testowner/testrepo/issues/456' + }; + + mockGithub.rest.issues.update.mockResolvedValue({ data: mockIssue }); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: 'testowner', + repo: 'testrepo', + issue_number: 456, + title: 'Updated title' + }); + + consoleSpy.mockRestore(); + }); + + it('should skip when no valid updates are provided', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + title: 'New title' + }] + }); + // All update flags are false + process.env.GITHUB_AW_UPDATE_STATUS = 'false'; + process.env.GITHUB_AW_UPDATE_TITLE = 'false'; + process.env.GITHUB_AW_UPDATE_BODY = 'false'; + global.context.eventName = 'issues'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('No valid updates to apply for this item'); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should validate status values', async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [{ + type: 'update-issue', + status: 'invalid' + }] + }); + process.env.GITHUB_AW_UPDATE_STATUS = 'true'; + global.context.eventName = 'issues'; + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${updateIssueScript} })()`); + + expect(consoleSpy).toHaveBeenCalledWith('Invalid status value: invalid. Must be \'open\' or \'closed\''); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/pkg/workflow/js_test.go b/pkg/workflow/js_test.go index 1843ca828c6..8dc69883105 100644 --- a/pkg/workflow/js_test.go +++ b/pkg/workflow/js_test.go @@ -171,6 +171,7 @@ func TestEmbeddedScriptsNotEmpty(t *testing.T) { {"createCommentScript", createCommentScript}, {"collectJSONLOutputScript", collectJSONLOutputScript}, {"addLabelsScript", addLabelsScript}, + {"updateIssueScript", updateIssueScript}, {"setupAgentOutputScript", setupAgentOutputScript}, {"addReactionScript", addReactionScript}, {"addReactionAndEditCommentScript", addReactionAndEditCommentScript}, diff --git a/pkg/workflow/output_update_issue.go b/pkg/workflow/output_update_issue.go new file mode 100644 index 00000000000..8f356a38daf --- /dev/null +++ b/pkg/workflow/output_update_issue.go @@ -0,0 +1,71 @@ +package workflow + +import ( + "fmt" +) + +// buildCreateOutputUpdateIssueJob creates the update_issue job +func (c *Compiler) buildCreateOutputUpdateIssueJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.UpdateIssues == nil { + return nil, fmt.Errorf("safe-outputs.update-issue configuration is required") + } + + var steps []string + steps = append(steps, " - name: Update Issue\n") + steps = append(steps, " id: update_issue\n") + steps = append(steps, " uses: actions/github-script@v7\n") + + // Add environment variables + steps = append(steps, " env:\n") + // Pass the agent output content from the main job + steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + + // Pass the configuration flags + steps = append(steps, fmt.Sprintf(" GITHUB_AW_UPDATE_STATUS: %t\n", data.SafeOutputs.UpdateIssues.Status != nil)) + steps = append(steps, fmt.Sprintf(" GITHUB_AW_UPDATE_TITLE: %t\n", data.SafeOutputs.UpdateIssues.Title != nil)) + steps = append(steps, fmt.Sprintf(" GITHUB_AW_UPDATE_BODY: %t\n", data.SafeOutputs.UpdateIssues.Body != nil)) + + // Pass the target configuration + if data.SafeOutputs.UpdateIssues.Target != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_UPDATE_TARGET: %q\n", data.SafeOutputs.UpdateIssues.Target)) + } + + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add each line of the script with proper indentation + formattedScript := FormatJavaScriptForYAML(updateIssueScript) + steps = append(steps, formattedScript...) + + // Create outputs for the job + outputs := map[string]string{ + "issue_number": "${{ steps.update_issue.outputs.issue_number }}", + "issue_url": "${{ steps.update_issue.outputs.issue_url }}", + } + + // Determine the job condition based on target configuration + var jobCondition string + if data.SafeOutputs.UpdateIssues.Target == "*" { + // Allow updates to any issue - no specific context required + jobCondition = "if: always()" + } else if data.SafeOutputs.UpdateIssues.Target != "" { + // Explicit issue number specified - no specific context required + jobCondition = "if: always()" + } else { + // Default behavior: only update triggering issue + jobCondition = "if: github.event.issue.number" + } + + job := &Job{ + Name: "update_issue", + If: jobCondition, + RunsOn: "runs-on: ubuntu-latest", + Permissions: "permissions:\n contents: read\n issues: write", + TimeoutMinutes: 10, // 10-minute timeout as required + Steps: steps, + Outputs: outputs, + Depends: []string{mainJobName}, // Depend on the main workflow job + } + + return job, nil +} diff --git a/pkg/workflow/output_update_issue_test.go b/pkg/workflow/output_update_issue_test.go new file mode 100644 index 00000000000..f9c1ff77d31 --- /dev/null +++ b/pkg/workflow/output_update_issue_test.go @@ -0,0 +1,211 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUpdateIssueConfigParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-update-issue-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with basic update-issue configuration + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write +engine: claude +safe-outputs: + update-issue: +--- + +# Test Update Issue Configuration + +This workflow tests the update-issue configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-update-issue.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with update-issue config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateIssues == nil { + t.Fatal("Expected update-issue configuration to be parsed") + } + + // Check defaults + if workflowData.SafeOutputs.UpdateIssues.Max != 1 { + t.Fatalf("Expected max to be 1, got %d", workflowData.SafeOutputs.UpdateIssues.Max) + } + + if workflowData.SafeOutputs.UpdateIssues.Target != "" { + t.Fatalf("Expected target to be empty (default), got '%s'", workflowData.SafeOutputs.UpdateIssues.Target) + } + + if workflowData.SafeOutputs.UpdateIssues.Status != nil { + t.Fatal("Expected status to be nil by default (not updatable)") + } + + if workflowData.SafeOutputs.UpdateIssues.Title != nil { + t.Fatal("Expected title to be nil by default (not updatable)") + } + + if workflowData.SafeOutputs.UpdateIssues.Body != nil { + t.Fatal("Expected body to be nil by default (not updatable)") + } +} + +func TestUpdateIssueConfigWithAllOptions(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-update-issue-all-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with all options configured + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write +engine: claude +safe-outputs: + update-issue: + max: 3 + target: "*" + status: + title: + body: +--- + +# Test Update Issue Full Configuration + +This workflow tests the update-issue configuration with all options. +` + + testFile := filepath.Join(tmpDir, "test-update-issue-full.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with full update-issue config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateIssues == nil { + t.Fatal("Expected update-issue configuration to be parsed") + } + + // Check all options + if workflowData.SafeOutputs.UpdateIssues.Max != 3 { + t.Fatalf("Expected max to be 3, got %d", workflowData.SafeOutputs.UpdateIssues.Max) + } + + if workflowData.SafeOutputs.UpdateIssues.Target != "*" { + t.Fatalf("Expected target to be '*', got '%s'", workflowData.SafeOutputs.UpdateIssues.Target) + } + + if workflowData.SafeOutputs.UpdateIssues.Status == nil { + t.Fatal("Expected status to be non-nil (updatable)") + } + + if workflowData.SafeOutputs.UpdateIssues.Title == nil { + t.Fatal("Expected title to be non-nil (updatable)") + } + + if workflowData.SafeOutputs.UpdateIssues.Body == nil { + t.Fatal("Expected body to be non-nil (updatable)") + } +} + +func TestUpdateIssueConfigTargetParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-update-issue-target-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with specific target number + testContent := `--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: write +engine: claude +safe-outputs: + update-issue: + target: "123" + title: +--- + +# Test Update Issue Target Configuration + +This workflow tests the update-issue target configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-update-issue-target.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with target update-issue config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateIssues == nil { + t.Fatal("Expected update-issue configuration to be parsed") + } + + if workflowData.SafeOutputs.UpdateIssues.Target != "123" { + t.Fatalf("Expected target to be '123', got '%s'", workflowData.SafeOutputs.UpdateIssues.Target) + } + + if workflowData.SafeOutputs.UpdateIssues.Title == nil { + t.Fatal("Expected title to be non-nil (updatable)") + } +} diff --git a/pkg/workflow/safe_outputs_max_test.go b/pkg/workflow/safe_outputs_max_test.go index a9f7453b8df..168b63d1e3b 100644 --- a/pkg/workflow/safe_outputs_max_test.go +++ b/pkg/workflow/safe_outputs_max_test.go @@ -13,6 +13,7 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { "create-issue": nil, "add-issue-comment": nil, "create-pull-request": nil, + "update-issue": nil, }, } @@ -41,6 +42,13 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { if config.CreatePullRequests.Max != 1 { t.Errorf("Expected CreatePullRequests.Max to be 1 by default, got %d", config.CreatePullRequests.Max) } + + if config.UpdateIssues == nil { + t.Fatal("Expected UpdateIssues to be parsed") + } + if config.UpdateIssues.Max != 1 { + t.Errorf("Expected UpdateIssues.Max to be 1 by default, got %d", config.UpdateIssues.Max) + } }) t.Run("Explicit max values should be used", func(t *testing.T) { @@ -56,6 +64,9 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { // max parameter is ignored for pull requests "max": 2, }, + "update-issue": map[string]any{ + "max": 4, + }, }, } @@ -84,6 +95,13 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { if config.CreatePullRequests.Max != 1 { t.Errorf("Expected CreatePullRequests.Max to be 1 (max ignored for pull requests), got %d", config.CreatePullRequests.Max) } + + if config.UpdateIssues == nil { + t.Fatal("Expected UpdateIssues to be parsed") + } + if config.UpdateIssues.Max != 4 { + t.Errorf("Expected UpdateIssues.Max to be 4, got %d", config.UpdateIssues.Max) + } }) t.Run("Complete configuration with all options", func(t *testing.T) { @@ -103,6 +121,13 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { "labels": []any{"fix"}, "draft": true, }, + "update-issue": map[string]any{ + "max": 2, + "target": "456", + "status": nil, + "title": nil, + "body": nil, + }, }, } @@ -152,5 +177,25 @@ func TestSafeOutputsMaxConfiguration(t *testing.T) { if config.CreatePullRequests.Draft == nil || *config.CreatePullRequests.Draft != true { t.Errorf("Expected CreatePullRequests.Draft to be true, got %v", config.CreatePullRequests.Draft) } + + // Check update-issue + if config.UpdateIssues == nil { + t.Fatal("Expected UpdateIssues to be parsed") + } + if config.UpdateIssues.Max != 2 { + t.Errorf("Expected UpdateIssues.Max to be 2, got %d", config.UpdateIssues.Max) + } + if config.UpdateIssues.Target != "456" { + t.Errorf("Expected UpdateIssues.Target to be '456', got '%s'", config.UpdateIssues.Target) + } + if config.UpdateIssues.Status == nil { + t.Error("Expected UpdateIssues.Status to be non-nil (updatable)") + } + if config.UpdateIssues.Title == nil { + t.Error("Expected UpdateIssues.Title to be non-nil (updatable)") + } + if config.UpdateIssues.Body == nil { + t.Error("Expected UpdateIssues.Body to be non-nil (updatable)") + } }) }