diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 844a027762b..06439e02ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,20 @@ jobs: echo "Error: Found uncommitted changes in workflow files:" exit 1 fi - + js: + name: Build JavaScript + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + cache: npm + - name: Install npm dependencies + run: npm ci + - name: Run tests + run: npm test lint: name: Lint Code runs-on: ubuntu-latest diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index 95a8e321b24..55eb9601602 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -445,11 +445,6 @@ jobs: # Check current git status echo "Current git status:" git status - # Stage any unstaged files - git add -A || true - # Check updated git status - echo "Updated 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" @@ -457,23 +452,47 @@ jobs: INITIAL_SHA="$GITHUB_SHA" fi echo "Base commit SHA: $INITIAL_SHA" - # Show compact diff information between initial commit and staged files + # 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 --cached --name-only "$INITIAL_SHA" >> $GITHUB_STEP_SUMMARY || true + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true echo '```' >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY - # Check if there are any changes since the initial commit - if git diff --quiet --cached "$INITIAL_SHA" && git diff --quiet "$INITIAL_SHA" HEAD; then - echo "No changes detected since initial commit (staged or committed)" - echo "Skipping patch generation - no changes to create patch from" + # 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 "Changes detected, generating patch..." - # Generate patch from initial commit to current state + 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() diff --git a/.github/workflows/test-claude.lock.yml b/.github/workflows/test-claude.lock.yml index 9af3da25db9..cd6a49aa3a6 100644 --- a/.github/workflows/test-claude.lock.yml +++ b/.github/workflows/test-claude.lock.yml @@ -480,11 +480,6 @@ jobs: # Check current git status echo "Current git status:" git status - # Stage any unstaged files - git add -A || true - # Check updated git status - echo "Updated 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" @@ -492,23 +487,47 @@ jobs: INITIAL_SHA="$GITHUB_SHA" fi echo "Base commit SHA: $INITIAL_SHA" - # Show compact diff information between initial commit and staged files + # 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 --cached --name-only "$INITIAL_SHA" >> $GITHUB_STEP_SUMMARY || true + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true echo '```' >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY - # Check if there are any changes since the initial commit - if git diff --quiet --cached "$INITIAL_SHA" && git diff --quiet "$INITIAL_SHA" HEAD; then - echo "No changes detected since initial commit (staged or committed)" - echo "Skipping patch generation - no changes to create patch from" + # 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 "Changes detected, generating patch..." - # Generate patch from initial commit to current state + 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() @@ -518,7 +537,7 @@ jobs: path: /tmp/aw.patch if-no-files-found: ignore - create_output_issue: + create_issue: needs: test-claude runs-on: ubuntu-latest permissions: @@ -533,119 +552,129 @@ jobs: id: create_issue uses: actions/github-script@v7 env: - AGENT_OUTPUT_CONTENT: ${{ needs.test-claude.outputs.output }} + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude.outputs.output }} GITHUB_AW_ISSUE_TITLE_PREFIX: "[claude-test] " GITHUB_AW_ISSUE_LABELS: "claude,automation,haiku" with: script: | - // Read the agent output content from environment variable - const outputContent = process.env.AGENT_OUTPUT_CONTENT; - if (!outputContent) { - console.log('No AGENT_OUTPUT_CONTENT environment variable found'); - return; - } + async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } - if (outputContent.trim() === '') { - console.log('Agent output content is empty'); - return; - } + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } - console.log('Agent output content length:', outputContent.length); + console.log('Agent output content length:', outputContent.length); - // Parse the output to extract title and body - const lines = outputContent.split('\n'); - let title = ''; - let bodyLines = []; - let foundTitle = false; + // Parse the output to extract title and body + const lines = outputContent.split('\n'); + let title = ''; + let bodyLines = []; + let foundTitle = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); - // Skip empty lines until we find the title - if (!foundTitle && line === '') { - continue; - } + // Skip empty lines until we find the title + if (!foundTitle && line === '') { + continue; + } - // First non-empty line becomes the title - if (!foundTitle && line !== '') { - // Remove markdown heading syntax if present - title = line.replace(/^#+\s*/, '').trim(); - foundTitle = true; - continue; - } + // First non-empty line becomes the title + if (!foundTitle && line !== '') { + // Remove markdown heading syntax if present + title = line.replace(/^#+\s*/, '').trim(); + foundTitle = true; + continue; + } - // Everything else goes into the body - if (foundTitle) { - bodyLines.push(lines[i]); // Keep original formatting + // Everything else goes into the body + if (foundTitle) { + bodyLines.push(lines[i]); // Keep original formatting + } } - } - // If no title was found, use a default - if (!title) { - title = 'Agent Output'; - } + // If no title was found, use a default + if (!title) { + title = 'Agent Output'; + } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } - // Prepare the body content - const body = bodyLines.join('\n').trim(); + // Prepare the body content + const body = bodyLines.join('\n').trim(); - // Parse labels from environment variable (comma-separated string) - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); - // Check if we're in an issue context (triggered by an issue event) - const parentIssueNumber = context.payload?.issue?.number; - let finalBody = body; + // Check if we're in an issue context (triggered by an issue event) + const parentIssueNumber = context.payload?.issue?.number; + let finalBody = body; - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); - // Add reference to parent issue in the child issue body - if (finalBody.trim()) { - finalBody = `Related to #${parentIssueNumber}\n\n${finalBody}`; - } else { - finalBody = `Related to #${parentIssueNumber}`; + // Add reference to parent issue in the child issue body + if (finalBody.trim()) { + finalBody = `Related to #${parentIssueNumber}\n\n${finalBody}`; + } else { + finalBody = `Related to #${parentIssueNumber}`; + } } - } - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: finalBody, - labels: labels - }); - - console.log('Created issue #' + issue.number + ': ' + issue.html_url); - - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` - }); - console.log('Added comment to parent issue #' + parentIssueNumber); - } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error.message); + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: finalBody, + labels: labels + }); + + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error.message); + } } - } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); + // Set output for other jobs to use + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + // write issue to summary + await core.summary.addRaw(` + + ## GitHub Issue + - Issue ID: ${issue.number} + - Issue URL: ${issue.html_url} + `).write(); + } + await main(); create_issue_comment: needs: test-claude @@ -664,73 +693,272 @@ jobs: id: create_comment uses: actions/github-script@v7 env: - AGENT_OUTPUT_CONTENT: ${{ needs.test-claude.outputs.output }} + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude.outputs.output }} with: script: | - // Read the agent output content from environment variable - const outputContent = process.env.AGENT_OUTPUT_CONTENT; - if (!outputContent) { - console.log('No AGENT_OUTPUT_CONTENT environment variable found'); - return; - } - - if (outputContent.trim() === '') { - console.log('Agent output content is empty'); - return; - } + async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } - console.log('Agent output content length:', outputContent.length); + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } - // Check if we're in an issue or pull request context - const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; - const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; + console.log('Agent output content length:', outputContent.length); - if (!isIssueContext && !isPRContext) { - console.log('Not running in issue or pull request context, skipping comment creation'); - return; - } - - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; + // Check if we're in an issue or pull request context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); + if (!isIssueContext && !isPRContext) { + console.log('Not running in issue or pull request context, skipping comment creation'); return; } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); + + // Determine the issue/PR number and comment endpoint + let issueNumber; + let commentEndpoint; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + return; + } + } + + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); return; } - } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', outputContent.length); + + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: outputContent + }); + + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + + // Set output for other jobs to use + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + + // write comment id, url to the github_step_summary + await core.summary.addRaw(` + + ## GitHub Comment + - Comment ID: ${comment.id} + - Comment URL: ${comment.html_url} + `).write(); + } + await main(); - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', outputContent.length); + create_pull_request: + needs: test-claude + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.create_pull_request.outputs.branch_name }} + pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} + pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + steps: + - name: Download patch artifact + uses: actions/download-artifact@v4 + with: + name: aw.patch + path: /tmp/ + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create Pull Request + id: create_pull_request + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude.outputs.output }} + GITHUB_AW_WORKFLOW_ID: "test-claude" + GITHUB_AW_BASE_BRANCH: ${{ github.ref_name }} + GITHUB_AW_PR_TITLE_PREFIX: "[claude-test] " + GITHUB_AW_PR_LABELS: "claude,automation,bot" + with: + script: | + async function main() { + // Required Node.js modules + const fs = require('fs'); + const crypto = require('crypto'); + const { execSync } = require('child_process'); + + // Environment validation - fail early if required variables are missing + const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; + if (!workflowId) { + throw new Error('GITHUB_AW_WORKFLOW_ID environment variable is required'); + } - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: outputContent - }); + const baseBranch = process.env.GITHUB_AW_BASE_BRANCH; + if (!baseBranch) { + throw new Error('GITHUB_AW_BASE_BRANCH environment variable is required'); + } - console.log('Created comment #' + comment.id + ': ' + comment.html_url); + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT || ""; + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + } + + // Check if patch file exists and has valid content + if (!fs.existsSync('/tmp/aw.patch')) { + throw new Error('No patch file found - cannot create pull request without changes'); + } + + const patchContent = fs.readFileSync('/tmp/aw.patch', 'utf8'); + if (!patchContent || !patchContent.trim() || patchContent.includes('Failed to generate patch')) { + throw new Error('Patch file is empty or contains error message - cannot create pull request without changes'); + } + + console.log('Agent output content length:', outputContent.length); + console.log('Patch content validation passed'); + + // Parse the output to extract title and body + const lines = outputContent.split('\n'); + let title = ''; + let bodyLines = []; + let foundTitle = false; - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines until we find the title + if (!foundTitle && line === '') { + continue; + } + + // First non-empty line becomes the title + if (!foundTitle && line !== '') { + // Remove markdown heading syntax if present + title = line.replace(/^#+\s*/, '').trim(); + foundTitle = true; + continue; + } + + // Everything else goes into the body + if (foundTitle) { + bodyLines.push(lines[i]); // Keep original formatting + } + } + + // If no title was found, use a default + if (!title) { + title = 'Agent Output'; + } + + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_PR_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + + // Prepare the body content + const body = bodyLines.join('\n').trim(); + + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_PR_LABELS; + const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; + + console.log('Creating pull request with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); + + // Generate unique branch name using cryptographic random hex + const randomHex = crypto.randomBytes(8).toString('hex'); + const branchName = `${workflowId}/${randomHex}`; + + console.log('Generated branch name:', branchName); + console.log('Base branch:', baseBranch); + + // Create a new branch using git CLI + // Configure git (required for commits) + execSync('git config --global user.email "action@github.com"', { stdio: 'inherit' }); + execSync('git config --global user.name "GitHub Action"', { stdio: 'inherit' }); + + // Create and checkout new branch + execSync(`git checkout -b ${branchName}`, { stdio: 'inherit' }); + console.log('Created and checked out branch:', branchName); + + // Apply the patch using git CLI + console.log('Applying patch...'); + + // Apply the patch using git apply + execSync('git apply /tmp/aw.patch', { stdio: 'inherit' }); + console.log('Patch applied successfully'); + + // Commit and push the changes + execSync('git add .', { stdio: 'inherit' }); + execSync(`git commit -m "Add agent output: ${title}"`, { stdio: 'inherit' }); + execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); + console.log('Changes committed and pushed'); + + // Create the pull request + const { data: pullRequest } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + head: branchName, + base: baseBranch + }); + + console.log('Created pull request #' + pullRequest.number + ': ' + pullRequest.html_url); + + // Add labels if specified + if (labels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: labels + }); + console.log('Added labels to pull request:', labels); + } + + // Set output for other jobs to use + core.setOutput('pull_request_number', pullRequest.number); + core.setOutput('pull_request_url', pullRequest.html_url); + core.setOutput('branch_name', branchName); + + // Write summary to GitHub Actions summary + await core.summary + .addRaw(` + + ## Pull Request + - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) + - **Branch**: \`${branchName}\` + - **Base Branch**: \`${baseBranch}\` + `).write(); + } + await main(); diff --git a/.github/workflows/test-claude.md b/.github/workflows/test-claude.md index c728547067f..ed60688f2b8 100644 --- a/.github/workflows/test-claude.md +++ b/.github/workflows/test-claude.md @@ -12,14 +12,17 @@ engine: model: claude-3-5-sonnet-20241022 timeout_minutes: 10 permissions: - contents: read pull-requests: write actions: read + contents: read output: issue: title-prefix: "[claude-test] " labels: [claude, automation, haiku] comment: {} + pull-request: + title-prefix: "[claude-test] " + labels: [claude, automation, bot] tools: claude: allowed: diff --git a/.github/workflows/test-codex.lock.yml b/.github/workflows/test-codex.lock.yml index 3acf8fc073e..fc28ad87904 100644 --- a/.github/workflows/test-codex.lock.yml +++ b/.github/workflows/test-codex.lock.yml @@ -382,11 +382,6 @@ jobs: # Check current git status echo "Current git status:" git status - # Stage any unstaged files - git add -A || true - # Check updated git status - echo "Updated 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" @@ -394,23 +389,47 @@ jobs: INITIAL_SHA="$GITHUB_SHA" fi echo "Base commit SHA: $INITIAL_SHA" - # Show compact diff information between initial commit and staged files + # 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 --cached --name-only "$INITIAL_SHA" >> $GITHUB_STEP_SUMMARY || true + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true echo '```' >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY - # Check if there are any changes since the initial commit - if git diff --quiet --cached "$INITIAL_SHA" && git diff --quiet "$INITIAL_SHA" HEAD; then - echo "No changes detected since initial commit (staged or committed)" - echo "Skipping patch generation - no changes to create patch from" + # 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 "Changes detected, generating patch..." - # Generate patch from initial commit to current state + 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() diff --git a/.github/workflows/weekly-research.lock.yml b/.github/workflows/weekly-research.lock.yml index 341d9f8816a..c50e121c186 100644 --- a/.github/workflows/weekly-research.lock.yml +++ b/.github/workflows/weekly-research.lock.yml @@ -414,11 +414,6 @@ jobs: # Check current git status echo "Current git status:" git status - # Stage any unstaged files - git add -A || true - # Check updated git status - echo "Updated 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" @@ -426,23 +421,47 @@ jobs: INITIAL_SHA="$GITHUB_SHA" fi echo "Base commit SHA: $INITIAL_SHA" - # Show compact diff information between initial commit and staged files + # 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 --cached --name-only "$INITIAL_SHA" >> $GITHUB_STEP_SUMMARY || true + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true echo '```' >> $GITHUB_STEP_SUMMARY echo '' >> $GITHUB_STEP_SUMMARY - # Check if there are any changes since the initial commit - if git diff --quiet --cached "$INITIAL_SHA" && git diff --quiet "$INITIAL_SHA" HEAD; then - echo "No changes detected since initial commit (staged or committed)" - echo "Skipping patch generation - no changes to create patch from" + # 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 "Changes detected, generating patch..." - # Generate patch from initial commit to current state + 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() diff --git a/.gitignore b/.gitignore index b322ed0c056..45f8fd1ddde 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ coverage.html logs/ .github/instructions/github-agentic-workflows.instructions.md +node_modules/ diff --git a/Makefile b/Makefile index 031737e9ac9..24075c62e6b 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,7 @@ deps: .PHONY: deps-dev deps-dev: deps copy-copilot-to-claude go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + npm install --package-lock-only # Run linter .PHONY: golint @@ -105,15 +106,8 @@ fmt: # Run TypeScript compiler on JavaScript files .PHONY: js js: - @if command -v tsc >/dev/null 2>&1; then \ - echo "Running TypeScript compiler..."; \ - tsc --noEmit; \ - echo "✓ TypeScript check completed"; \ - else \ - echo "TypeScript compiler (tsc) is not installed. Install it with:"; \ - echo " npm install -g typescript"; \ - echo "Skipping TypeScript check."; \ - fi + echo "Running TypeScript compiler..."; \ + npm run typecheck # Check formatting .PHONY: fmt-check @@ -215,7 +209,7 @@ copy-copilot-to-claude: # Agent should run this task before finishing its turns .PHONY: agent-finish -agent-finish: deps-dev fmt lint build test recompile +agent-finish: deps-dev fmt lint js build test recompile @echo "Agent finished tasks successfully." # Help target diff --git a/docs/frontmatter.md b/docs/frontmatter.md index 57731739655..f9637967421 100644 --- a/docs/frontmatter.md +++ b/docs/frontmatter.md @@ -256,19 +256,22 @@ output: title-prefix: "[ai] " # Optional: prefix for issue titles labels: [automation, ai-agent] # Optional: labels to attach to issues comment: {} # Create comments on issues/PRs from agent output + pull-request: + title-prefix: "[ai] " # Optional: prefix for PR titles + labels: [automation, ai-agent] # Optional: labels to attach to PRs ``` ### Issue Creation (`output.issue`) **Behavior:** -- When `output.issue` is configured, the compiler automatically generates a separate `create_output_issue` job +- When `output.issue` is configured, the compiler automatically generates a separate `create_issue` job - This job runs after the main AI agent job completes - The agent's output content flows from the main job to the issue creation job via job output variables - The issue creation job parses the output content, using the first non-empty line as the title and the remainder as the body - **Important**: With output processing, the main job **does not** need `issues: write` permission since the write operation is performed in the separate job **Generated Job Properties:** -- **Job Name**: `create_output_issue` +- **Job Name**: `create_issue` - **Dependencies**: Runs after the main agent job (`needs: [main-job-name]`) - **Permissions**: Only the issue creation job has `issues: write` permission - **Timeout**: 10-minute timeout to prevent hanging @@ -336,6 +339,56 @@ Write your analysis to ${{ env.GITHUB_AW_OUTPUT }} at the end. This automatically creates GitHub issues or comments from the agent's analysis without requiring write permissions on the main job. +### Pull Request Creation (`output.pull-request`) + +**Behavior:** +- When `output.pull-request` is configured, the compiler automatically generates a separate `create_output_pull_request` job +- This job runs after the main AI agent job completes +- The agent's output content flows from the main job to the pull request creation job via job output variables +- The job creates a new branch, applies git patches from the agent's output, and creates a pull request +- **Important**: With output processing, the main job **does not** need `contents: write` permission since the write operation is performed in the separate job + +**Generated Job Properties:** +- **Job Name**: `create_output_pull_request` +- **Dependencies**: Runs after the main agent job (`needs: [main-job-name]`) +- **Permissions**: Only the pull request creation job has `contents: write` and `pull-requests: write` permissions +- **Timeout**: 10-minute timeout to prevent hanging +- **Environment Variables**: Configuration passed via `GITHUB_AW_PR_TITLE_PREFIX`, `GITHUB_AW_PR_LABELS`, `GITHUB_AW_WORKFLOW_ID`, and `GITHUB_AW_BASE_BRANCH` +- **Branch Creation**: Uses cryptographic random hex for secure branch naming (`{workflowId}/{randomHex}`) +- **Git Operations**: Creates branch using git CLI, applies patches, commits changes, and pushes to GitHub +- **Outputs**: Returns `pr_number` and `pr_url` for downstream jobs + +**Configuration:** +```yaml +output: + pull-request: + title-prefix: "[ai] " # Optional: prefix for PR titles + labels: [automation, ai-agent] # Optional: labels to attach to PRs +``` + +**Example workflow using pull request creation:** +```yaml +--- +on: push +permissions: + actions: read # Main job only needs minimal permissions +engine: claude +output: + pull-request: + title-prefix: "[bot] " + labels: [automation, ai-generated] +--- + +# Code Improvement Agent + +Analyze the latest commit and suggest improvements. +Generate patches and write them to /tmp/aw.patch. +Write a summary to ${{ env.GITHUB_AW_OUTPUT }} with title and description. +``` + +**Required Patch Format:** +The agent must create git patches in `/tmp/aw.patch` for the changes to be applied. The pull request creation job validates patch existence and content before proceeding. + ## Cache Configuration (`cache:`) Cache configuration using GitHub Actions `actions/cache` syntax: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000000..8ec282e9ffd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,344 @@ +{ + "name": "gh-aw", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1", + "@types/node": "^24.3.0", + "typescript": "^5.9.2" + } + }, + "node_modules/@actions/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-6.0.1.tgz", + "integrity": "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^2.2.0", + "@octokit/core": "^5.0.1", + "@octokit/plugin-paginate-rest": "^9.2.2", + "@octokit/plugin-rest-endpoint-methods": "^10.4.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "undici": "^5.28.5" + } + }, + "node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", + "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000000..d8435df42ed --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "devDependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.1", + "@types/node": "^24.3.0", + "typescript": "^5.9.2" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "npm run typecheck" + } +} diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 7ac4ffa546f..8b0972d1f72 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -83,6 +83,20 @@ The YAML frontmatter supports these fields: labels: [automation, ai-agent] # Optional: labels to attach to issues ``` **Important**: When using `output.issue`, the main job does **not** need `issues: write` permission since issue creation is handled by a separate job with appropriate permissions. + - `comment:` - Automatic comment creation on issues/PRs from agent output + ```yaml + output: + comment: {} + ``` + **Important**: When using `output.comment`, the main job does **not** need `issues: write` or `pull-requests: write` permissions since comment creation is handled by a separate job with appropriate permissions. + - `pull-request:` - Automatic pull request creation from agent output with git patches + ```yaml + output: + pull-request: + title-prefix: "[ai] " # Optional: prefix for PR titles + labels: [automation, ai-agent] # Optional: labels to attach to PRs + ``` + **Important**: When using `output.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. The agent must create git patches in `/tmp/aw.patch`. - **`max-turns:`** - Maximum chat iterations per run (integer) - **`stop-time:`** - Deadline for workflow. Can be absolute timestamp ("YYYY-MM-DD HH:MM:SS") or relative delta (+25h, +3d, +1d12h30m). Uses precise date calculations that account for varying month lengths. @@ -162,7 +176,7 @@ Write your final analysis to ${{ env.GITHUB_AW_OUTPUT }}. **How It Works:** 1. AI agent writes output to `${{ env.GITHUB_AW_OUTPUT }}` 2. Main job completes and passes output via job output variables -3. Separate `create_output_issue` job runs with `issues: write` permission +3. Separate `create_issue` job runs with `issues: write` permission 4. JavaScript parses the output (first line = title, rest = body) 5. GitHub issue is created with optional title prefix and labels @@ -374,11 +388,17 @@ output: issue: title-prefix: "[ai] " labels: [automation] + # OR for pull requests: + # pull-request: + # title-prefix: "[ai] " + # labels: [automation] + # OR for comments: + # comment: {} ``` -**Note**: With output processing, the main job doesn't need `issues: write` permission. The separate issue creation job automatically gets the required permissions. +**Note**: With output processing, the main job doesn't need `issues: write`, `pull-requests: write`, or `contents: write` permissions. The separate output creation jobs automatically get the required permissions. -## Output Processing and Issue Creation +## Output Processing Examples ### Automatic GitHub Issue Creation @@ -412,19 +432,84 @@ Write your final analysis to ${{ env.GITHUB_AW_OUTPUT }}. **How It Works:** 1. AI agent writes output to `${{ env.GITHUB_AW_OUTPUT }}` 2. Main job completes and passes output via job output variables -3. Separate `create_output_issue` job runs with `issues: write` permission +3. Separate `create_issue` job runs with `issues: write` permission 4. JavaScript parses the output (first line = title, rest = body) 5. GitHub issue is created with optional title prefix and labels - models: read + +### Automatic Pull Request Creation + +Use the `output.pull-request` configuration to automatically create pull requests from AI agent output: + +```yaml +--- +on: push +permissions: + actions: read # Main job only needs minimal permissions +engine: claude +output: + pull-request: + title-prefix: "[bot] " + labels: [automation, ai-generated] +--- + +# Code Improvement Agent + +Analyze the latest code and suggest improvements. +Generate git patches in /tmp/aw.patch and write summary to ${{ env.GITHUB_AW_OUTPUT }}. ``` -### PR Review Pattern +**Key Features:** +- **Secure Branch Naming**: Uses cryptographic random hex instead of user-provided titles +- **Git CLI Integration**: Leverages git CLI commands for branch creation and patch application +- **Environment-based Configuration**: Resolves base branch from GitHub Action context +- **Fail-Fast Error Handling**: Validates required environment variables and patch file existence + +**How It Works:** +1. AI agent creates git patches in `/tmp/aw.patch` and writes title/description to `${{ env.GITHUB_AW_OUTPUT }}` +2. Main job completes and passes output via job output variables +3. Separate `create_output_pull_request` job runs with `contents: write` and `pull-requests: write` permissions +4. Job creates a new branch using `{workflowId}/{randomHex}` pattern +5. Git patches are applied using `git apply` +6. Changes are committed and pushed to the new branch +7. Pull request is created with parsed title/body and optional labels + +### Automatic Comment Creation + +Use the `output.comment` configuration to automatically create comments from AI agent output: + +```yaml +--- +on: + issues: + types: [opened] +permissions: + contents: read # Main job only needs minimal permissions + actions: read +engine: claude +output: + comment: {} +--- + +# Issue Analysis Agent + +Analyze the issue and provide feedback. +Write your analysis to ${{ env.GITHUB_AW_OUTPUT }}. +``` + +**How It Works:** +1. AI agent writes output to `${{ env.GITHUB_AW_OUTPUT }}` +2. Main job completes and passes output via job output variables +3. Separate `create_issue_comment` job runs with `issues: write` and `pull-requests: write` permissions +4. Job posts the entire agent output as a comment on the triggering issue or pull request +5. Automatically skips if not running in an issue or pull request context + +## Permission Patterns + +### Read-Only Pattern ```yaml permissions: contents: read - pull-requests: write - checks: read - statuses: read + metadata: read ``` ### Full Repository Access diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 9c0b717cba3..51d50185223 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -975,6 +975,24 @@ "type": "object", "description": "Configuration for creating GitHub issue/PR comments from agent output", "additionalProperties": false + }, + "pull-request": { + "type": "object", + "description": "Configuration for creating GitHub pull requests from agent output", + "properties": { + "title-prefix": { + "type": "string", + "description": "Optional prefix for the pull request title" + }, + "labels": { + "type": "array", + "description": "Optional list of labels to attach to the pull request", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/alias_test.go b/pkg/workflow/alias_test.go index adf6b8026ae..18f8abfa45b 100644 --- a/pkg/workflow/alias_test.go +++ b/pkg/workflow/alias_test.go @@ -125,7 +125,7 @@ This test validates that alias conditions are applied correctly based on event t } if tt.expectedEventAware { - // Should contain event-aware condition with event_name checks (but not just in add-reaction job) + // Should contain event-aware condition with event_name checks (but not just in add_reaction job) expectedPattern := "github.event_name == 'issues'" if !strings.Contains(lockContentStr, expectedPattern) { t.Errorf("Expected event-aware condition containing '%s' but not found", expectedPattern) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 768f48cd156..c572e8da368 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "sort" "strings" "time" @@ -20,71 +19,6 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -// validateExpressionSafety checks that all GitHub Actions expressions in the markdown content -// are in the allowed list and returns an error if any unauthorized expressions are found -func validateExpressionSafety(markdownContent string) error { - // Regular expression to match GitHub Actions expressions: ${{ ... }} - // Use (?s) flag to enable dotall mode so . matches newlines to capture multiline expressions - // Use non-greedy matching with .*? to handle nested braces properly - expressionRegex := regexp.MustCompile(`(?s)\$\{\{(.*?)\}\}`) - needsStepsRegex := regexp.MustCompile(`^(needs|steps)\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) - inputsRegex := regexp.MustCompile(`^github\.event\.inputs\.[a-zA-Z0-9_-]+$`) - envRegex := regexp.MustCompile(`^env\.[a-zA-Z0-9_-]+$`) - - // Find all expressions in the markdown content - matches := expressionRegex.FindAllStringSubmatch(markdownContent, -1) - - var unauthorizedExpressions []string - - for _, match := range matches { - if len(match) < 2 { - continue - } - - // Extract the expression content (everything between ${{ and }}) - expression := strings.TrimSpace(match[1]) - - // Reject expressions that span multiple lines (contain newlines) - if strings.Contains(match[1], "\n") { - unauthorizedExpressions = append(unauthorizedExpressions, expression) - continue - } - - // Check if this expression is in the allowed list - allowed := false - - // Check if this expression starts with "needs." or "steps." and is a simple property access - if needsStepsRegex.MatchString(expression) { - allowed = true - } else if inputsRegex.MatchString(expression) { - // Check if this expression matches github.event.inputs.* pattern - allowed = true - } else if envRegex.MatchString(expression) { - // check if this expression matches env.* pattern - allowed = true - } else { - for _, allowedExpr := range constants.AllowedExpressions { - if expression == allowedExpr { - allowed = true - break - } - } - } - - if !allowed { - unauthorizedExpressions = append(unauthorizedExpressions, expression) - } - } - - // If we found unauthorized expressions, return an error - if len(unauthorizedExpressions) > 0 { - return fmt.Errorf("unauthorized expressions: %v. allowed: %v", - unauthorizedExpressions, constants.AllowedExpressions) - } - - return nil -} - // FileTracker interface for tracking files created during compilation type FileTracker interface { TrackCreated(filePath string) @@ -99,12 +33,6 @@ var computeTextActionTemplate string //go:embed templates/check_team_member.yaml var checkTeamMemberTemplate string -//go:embed js/create_issue.js -var createIssueScript string - -//go:embed js/create_comment.js -var createCommentScript string - // Compiler handles converting markdown workflows to GitHub Actions YAML type Compiler struct { verbose bool @@ -219,8 +147,9 @@ type WorkflowData struct { // OutputConfig holds configuration for automatic output routes type OutputConfig struct { - Issue *IssueConfig `yaml:"issue,omitempty"` - Comment *CommentConfig `yaml:"comment,omitempty"` + Issue *IssueConfig `yaml:"issue,omitempty"` + Comment *CommentConfig `yaml:"comment,omitempty"` + PullRequest *PullRequestConfig `yaml:"pull-request,omitempty"` } // IssueConfig holds configuration for creating GitHub issues from agent output @@ -234,6 +163,12 @@ type CommentConfig struct { // Empty struct for now, as per requirements, but structured for future expansion } +// PullRequestConfig holds configuration for creating GitHub pull requests from agent output +type PullRequestConfig struct { + TitlePrefix string `yaml:"title-prefix,omitempty"` + Labels []string `yaml:"labels,omitempty"` +} + // CompileWorkflow converts a markdown workflow to GitHub Actions YAML func (c *Compiler) CompileWorkflow(markdownPath string) error { @@ -1532,14 +1467,14 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { return fmt.Errorf("failed to add task job: %w", err) } - // Build add-reaction job only if ai-reaction is configured + // Build add_reaction job only if ai-reaction is configured if data.AIReaction != "" { addReactionJob, err := c.buildAddReactionJob(data) if err != nil { - return fmt.Errorf("failed to build add-reaction job: %w", err) + return fmt.Errorf("failed to build add_reaction job: %w", err) } if err := c.jobManager.AddJob(addReactionJob); err != nil { - return fmt.Errorf("failed to add add-reaction job: %w", err) + return fmt.Errorf("failed to add add_reaction job: %w", err) } } @@ -1552,20 +1487,20 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { return fmt.Errorf("failed to add main job: %w", err) } - // Build create_output_issue job if output.issue is configured + // Build create_issue job if output.issue is configured if data.Output != nil && data.Output.Issue != nil { - createIssueJob, err := c.buildCreateOutputIssueJob(data) + createIssueJob, err := c.buildCreateOutputIssueJob(data, jobName) if err != nil { - return fmt.Errorf("failed to build create_output_issue job: %w", err) + return fmt.Errorf("failed to build create_issue job: %w", err) } if err := c.jobManager.AddJob(createIssueJob); err != nil { - return fmt.Errorf("failed to add create_output_issue job: %w", err) + return fmt.Errorf("failed to add create_issue job: %w", err) } } // Build create_issue_comment job if output.comment is configured if data.Output != nil && data.Output.Comment != nil { - createCommentJob, err := c.buildCreateOutputCommentJob(data) + createCommentJob, err := c.buildCreateOutputCommentJob(data, jobName) if err != nil { return fmt.Errorf("failed to build create_issue_comment job: %w", err) } @@ -1574,6 +1509,17 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { } } + // Build create_pull_request job if output.pull-request is configured + if data.Output != nil && data.Output.PullRequest != nil { + createPullRequestJob, err := c.buildCreateOutputPullRequestJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build create_pull_request job: %w", err) + } + if err := c.jobManager.AddJob(createPullRequestJob); err != nil { + return fmt.Errorf("failed to add create_pull_request job: %w", err) + } + } + // Build additional custom jobs from frontmatter jobs section if err := c.buildCustomJobs(data); err != nil { return fmt.Errorf("failed to build custom jobs: %w", err) @@ -1644,7 +1590,7 @@ func (c *Compiler) buildTaskJob(data *WorkflowData) (*Job, error) { return job, nil } -// buildAddReactionJob creates the add-reaction job +// buildAddReactionJob creates the add_reaction job func (c *Compiler) buildAddReactionJob(data *WorkflowData) (*Job, error) { reactionCondition := buildReactionCondition() @@ -1665,7 +1611,7 @@ func (c *Compiler) buildAddReactionJob(data *WorkflowData) (*Job, error) { } job := &Job{ - Name: "add-reaction", + Name: "add_reaction", If: fmt.Sprintf("if: %s", reactionCondition.Render()), RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: write # Read .github\n issues: write\n pull-requests: write", @@ -1677,8 +1623,8 @@ func (c *Compiler) buildAddReactionJob(data *WorkflowData) (*Job, error) { return job, nil } -// buildCreateOutputIssueJob creates the create_output_issue job -func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData) (*Job, error) { +// buildCreateOutputIssueJob creates the create_issue job +func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName string) (*Job, error) { if data.Output == nil || data.Output.Issue == nil { return nil, fmt.Errorf("output.issue configuration is required") } @@ -1688,13 +1634,10 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData) (*Job, error) { steps = append(steps, " id: create_issue\n") steps = append(steps, " uses: actions/github-script@v7\n") - // Determine the main job name to get output from - mainJobName := c.generateJobName(data.Name) - // Add environment variables steps = append(steps, " env:\n") // Pass the agent output content from the main job - steps = append(steps, fmt.Sprintf(" AGENT_OUTPUT_CONTENT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) if data.Output.Issue.TitlePrefix != "" { steps = append(steps, fmt.Sprintf(" GITHUB_AW_ISSUE_TITLE_PREFIX: %q\n", data.Output.Issue.TitlePrefix)) } @@ -1723,7 +1666,7 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData) (*Job, error) { } job := &Job{ - Name: "create_output_issue", + Name: "create_issue", If: "", // No conditional execution RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions:\n contents: read\n issues: write", @@ -1737,7 +1680,7 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData) (*Job, error) { } // buildCreateOutputCommentJob creates the create_issue_comment job -func (c *Compiler) buildCreateOutputCommentJob(data *WorkflowData) (*Job, error) { +func (c *Compiler) buildCreateOutputCommentJob(data *WorkflowData, mainJobName string) (*Job, error) { if data.Output == nil || data.Output.Comment == nil { return nil, fmt.Errorf("output.comment configuration is required") } @@ -1747,13 +1690,10 @@ func (c *Compiler) buildCreateOutputCommentJob(data *WorkflowData) (*Job, error) steps = append(steps, " id: create_comment\n") steps = append(steps, " uses: actions/github-script@v7\n") - // Determine the main job name to get output from - mainJobName := c.generateJobName(data.Name) - // Add environment variables steps = append(steps, " env:\n") // Pass the agent output content from the main job - steps = append(steps, fmt.Sprintf(" AGENT_OUTPUT_CONTENT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -1788,6 +1728,82 @@ func (c *Compiler) buildCreateOutputCommentJob(data *WorkflowData) (*Job, error) return job, nil } +// buildCreateOutputPullRequestJob creates the create_pull_request job +func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.Output == nil || data.Output.PullRequest == nil { + return nil, fmt.Errorf("output.pull-request configuration is required") + } + + var steps []string + + // Step 1: Download patch artifact + steps = append(steps, " - name: Download patch artifact\n") + steps = append(steps, " uses: actions/download-artifact@v4\n") + steps = append(steps, " with:\n") + steps = append(steps, " name: aw.patch\n") + steps = append(steps, " path: /tmp/\n") + + // Step 2: Checkout repository + steps = append(steps, " - name: Checkout repository\n") + steps = append(steps, " uses: actions/checkout@v4\n") + steps = append(steps, " with:\n") + steps = append(steps, " fetch-depth: 0\n") + + // Step 3: Create pull request + steps = append(steps, " - name: Create Pull Request\n") + steps = append(steps, " id: create_pull_request\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 workflow ID for branch naming + steps = append(steps, fmt.Sprintf(" GITHUB_AW_WORKFLOW_ID: %q\n", mainJobName)) + // Pass the base branch from GitHub context + steps = append(steps, " GITHUB_AW_BASE_BRANCH: ${{ github.ref_name }}\n") + if data.Output.PullRequest.TitlePrefix != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_TITLE_PREFIX: %q\n", data.Output.PullRequest.TitlePrefix)) + } + if len(data.Output.PullRequest.Labels) > 0 { + labelsStr := strings.Join(data.Output.PullRequest.Labels, ",") + steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_LABELS: %q\n", labelsStr)) + } + + steps = append(steps, " with:\n") + steps = append(steps, " script: |\n") + + // Add each line of the script with proper indentation + scriptLines := strings.Split(createPullRequestScript, "\n") + for _, line := range scriptLines { + if strings.TrimSpace(line) == "" { + steps = append(steps, "\n") + } else { + steps = append(steps, fmt.Sprintf(" %s\n", line)) + } + } + + // Create outputs for the job + outputs := map[string]string{ + "pull_request_number": "${{ steps.create_pull_request.outputs.pull_request_number }}", + "pull_request_url": "${{ steps.create_pull_request.outputs.pull_request_url }}", + "branch_name": "${{ steps.create_pull_request.outputs.branch_name }}", + } + + job := &Job{ + Name: "create_pull_request", + If: "", // No conditional execution + RunsOn: "runs-on: ubuntu-latest", + Permissions: "permissions:\n contents: write\n issues: write\n pull-requests: write", + TimeoutMinutes: 10, // 10-minute timeout as required + Steps: steps, + Outputs: outputs, + Depends: []string{mainJobName}, // Depend on the main workflow job + } + + return job, nil +} + // buildMainJob creates the main workflow job func (c *Compiler) buildMainJob(data *WorkflowData, jobName string) (*Job, error) { var steps []string @@ -2203,6 +2219,35 @@ func (c *Compiler) extractOutputConfig(frontmatter map[string]any) *OutputConfig } } + // Parse pull-request configuration + if pullRequest, exists := outputMap["pull-request"]; exists { + if pullRequestMap, ok := pullRequest.(map[string]any); ok { + pullRequestConfig := &PullRequestConfig{} + + // Parse title-prefix + if titlePrefix, exists := pullRequestMap["title-prefix"]; exists { + if titlePrefixStr, ok := titlePrefix.(string); ok { + pullRequestConfig.TitlePrefix = titlePrefixStr + } + } + + // Parse labels + if labels, exists := pullRequestMap["labels"]; exists { + if labelsArray, ok := labels.([]any); ok { + var labelStrings []string + for _, label := range labelsArray { + if labelStr, ok := label.(string); ok { + labelStrings = append(labelStrings, labelStr) + } + } + pullRequestConfig.Labels = labelStrings + } + } + + config.PullRequest = pullRequestConfig + } + } + return config } } diff --git a/pkg/workflow/compiler_test.go b/pkg/workflow/compiler_test.go index 0986d3177ac..4f88e13de6b 100644 --- a/pkg/workflow/compiler_test.go +++ b/pkg/workflow/compiler_test.go @@ -3339,7 +3339,7 @@ Test workflow with ai-reaction. // Check for reaction-specific content in generated YAML expectedStrings := []string{ - "add-reaction:", + "add_reaction:", "mode: add", "reaction: eyes", "uses: ./.github/actions/reaction", @@ -3351,10 +3351,10 @@ Test workflow with ai-reaction. } } - // Verify three jobs are created (task, add-reaction, main) + // Verify three jobs are created (task, add_reaction, main) jobCount := strings.Count(yamlContent, "runs-on: ubuntu-latest") if jobCount != 3 { - t.Errorf("Expected 3 jobs (task, add-reaction, main), found %d", jobCount) + t.Errorf("Expected 3 jobs (task, add_reaction, main), found %d", jobCount) } } @@ -3412,7 +3412,7 @@ Test workflow without explicit ai-reaction (should not create reaction action). // Check that reaction-specific content is NOT in generated YAML unexpectedStrings := []string{ - "add-reaction:", + "add_reaction:", "uses: ./.github/actions/reaction", "mode: add", } @@ -3423,7 +3423,7 @@ Test workflow without explicit ai-reaction (should not create reaction action). } } - // Verify only two jobs are created (task and main, no add-reaction) + // Verify only two jobs are created (task and main, no add_reaction) jobCount := strings.Count(yamlContent, "runs-on: ubuntu-latest") if jobCount != 2 { t.Errorf("Expected 2 jobs (task, main), found %d", jobCount) diff --git a/pkg/workflow/create_issue_subissue_test.go b/pkg/workflow/create_issue_subissue_test.go index 59091b075a5..c2947620e6c 100644 --- a/pkg/workflow/create_issue_subissue_test.go +++ b/pkg/workflow/create_issue_subissue_test.go @@ -91,12 +91,12 @@ Write output to ${{ env.GITHUB_AW_OUTPUT }}.` t.Error("Expected compiled workflow to include parent issue comment") } - // Verify it still has the standard create_output_issue job structure - if !strings.Contains(lockContent, "create_output_issue:") { - t.Error("Expected create_output_issue job to be present") + // Verify it still has the standard create_issue job structure + if !strings.Contains(lockContent, "create_issue:") { + t.Error("Expected create_issue job to be present") } if !strings.Contains(lockContent, "permissions:\n contents: read\n issues: write") { - t.Error("Expected correct permissions in create_output_issue job") + t.Error("Expected correct permissions in create_issue job") } } diff --git a/pkg/workflow/expression_safety.go b/pkg/workflow/expression_safety.go new file mode 100644 index 00000000000..76a6873e73a --- /dev/null +++ b/pkg/workflow/expression_safety.go @@ -0,0 +1,74 @@ +package workflow + +import ( + "fmt" + "regexp" + "strings" + + "github.com/githubnext/gh-aw/pkg/constants" +) + +// validateExpressionSafety checks that all GitHub Actions expressions in the markdown content +// are in the allowed list and returns an error if any unauthorized expressions are found +func validateExpressionSafety(markdownContent string) error { + // Regular expression to match GitHub Actions expressions: ${{ ... }} + // Use (?s) flag to enable dotall mode so . matches newlines to capture multiline expressions + // Use non-greedy matching with .*? to handle nested braces properly + expressionRegex := regexp.MustCompile(`(?s)\$\{\{(.*?)\}\}`) + needsStepsRegex := regexp.MustCompile(`^(needs|steps)\.[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`) + inputsRegex := regexp.MustCompile(`^github\.event\.inputs\.[a-zA-Z0-9_-]+$`) + envRegex := regexp.MustCompile(`^env\.[a-zA-Z0-9_-]+$`) + + // Find all expressions in the markdown content + matches := expressionRegex.FindAllStringSubmatch(markdownContent, -1) + + var unauthorizedExpressions []string + + for _, match := range matches { + if len(match) < 2 { + continue + } + + // Extract the expression content (everything between ${{ and }}) + expression := strings.TrimSpace(match[1]) + + // Reject expressions that span multiple lines (contain newlines) + if strings.Contains(match[1], "\n") { + unauthorizedExpressions = append(unauthorizedExpressions, expression) + continue + } + + // Check if this expression is in the allowed list + allowed := false + + // Check if this expression starts with "needs." or "steps." and is a simple property access + if needsStepsRegex.MatchString(expression) { + allowed = true + } else if inputsRegex.MatchString(expression) { + // Check if this expression matches github.event.inputs.* pattern + allowed = true + } else if envRegex.MatchString(expression) { + // check if this expression matches env.* pattern + allowed = true + } else { + for _, allowedExpr := range constants.AllowedExpressions { + if expression == allowedExpr { + allowed = true + break + } + } + } + + if !allowed { + unauthorizedExpressions = append(unauthorizedExpressions, expression) + } + } + + // If we found unauthorized expressions, return an error + if len(unauthorizedExpressions) > 0 { + return fmt.Errorf("unauthorized expressions: %v. allowed: %v", + unauthorizedExpressions, constants.AllowedExpressions) + } + + return nil +} diff --git a/pkg/workflow/expressions.go b/pkg/workflow/expressions.go index 1e9cdc74966..0572e03ed9c 100644 --- a/pkg/workflow/expressions.go +++ b/pkg/workflow/expressions.go @@ -202,7 +202,7 @@ func buildConditionTree(existingCondition string, draftCondition string) Conditi return &AndNode{Left: existingNode, Right: draftNode} } -// buildReactionCondition creates a condition tree for the add-reaction job +// buildReactionCondition creates a condition tree for the add_reaction job func buildReactionCondition() ConditionNode { // Build a list of event types that should trigger reactions using the new expression nodes var terms []ConditionNode diff --git a/pkg/workflow/git_patch.go b/pkg/workflow/git_patch.go index 41775b9fecb..9dd9e05b700 100644 --- a/pkg/workflow/git_patch.go +++ b/pkg/workflow/git_patch.go @@ -10,11 +10,6 @@ func (c *Compiler) generateGitPatchStep(yaml *strings.Builder) { yaml.WriteString(" # Check current git status\n") yaml.WriteString(" echo \"Current git status:\"\n") yaml.WriteString(" git status\n") - yaml.WriteString(" # Stage any unstaged files\n") - yaml.WriteString(" git add -A || true\n") - yaml.WriteString(" # Check updated git status\n") - yaml.WriteString(" echo \"Updated git status:\"\n") - yaml.WriteString(" git status\n") yaml.WriteString(" # Get the initial commit SHA from the base branch of the pull request\n") yaml.WriteString(" if [ \"$GITHUB_EVENT_NAME\" = \"pull_request\" ] || [ \"$GITHUB_EVENT_NAME\" = \"pull_request_review_comment\" ]; then\n") yaml.WriteString(" INITIAL_SHA=\"$GITHUB_BASE_REF\"\n") @@ -22,23 +17,47 @@ func (c *Compiler) generateGitPatchStep(yaml *strings.Builder) { yaml.WriteString(" INITIAL_SHA=\"$GITHUB_SHA\"\n") yaml.WriteString(" fi\n") yaml.WriteString(" echo \"Base commit SHA: $INITIAL_SHA\"\n") - yaml.WriteString(" # Show compact diff information between initial commit and staged files\n") + yaml.WriteString(" # Configure git user for GitHub Actions\n") + yaml.WriteString(" git config --global user.email \"action@github.com\"\n") + yaml.WriteString(" git config --global user.name \"GitHub Action\"\n") + yaml.WriteString(" # Stage any unstaged files\n") + yaml.WriteString(" git add -A || true\n") + yaml.WriteString(" # Check if there are staged files to commit\n") + yaml.WriteString(" if ! git diff --cached --quiet; then\n") + yaml.WriteString(" echo \"Staged files found, committing them...\"\n") + yaml.WriteString(" git commit -m \"[agent] staged files\" || true\n") + yaml.WriteString(" echo \"Staged files committed\"\n") + yaml.WriteString(" else\n") + yaml.WriteString(" echo \"No staged files to commit\"\n") + yaml.WriteString(" fi\n") + yaml.WriteString(" # Check updated git status\n") + yaml.WriteString(" echo \"Updated git status after committing staged files:\"\n") + yaml.WriteString(" git status\n") + yaml.WriteString(" # Show compact diff information between initial commit and HEAD (committed changes only)\n") yaml.WriteString(" echo '## Git diff' >> $GITHUB_STEP_SUMMARY\n") yaml.WriteString(" echo '' >> $GITHUB_STEP_SUMMARY\n") yaml.WriteString(" echo '```' >> $GITHUB_STEP_SUMMARY\n") - yaml.WriteString(" git diff --cached --name-only \"$INITIAL_SHA\" >> $GITHUB_STEP_SUMMARY || true\n") + yaml.WriteString(" git diff --name-only \"$INITIAL_SHA\"..HEAD >> $GITHUB_STEP_SUMMARY || true\n") yaml.WriteString(" echo '```' >> $GITHUB_STEP_SUMMARY\n") yaml.WriteString(" echo '' >> $GITHUB_STEP_SUMMARY\n") - yaml.WriteString(" # Check if there are any changes since the initial commit\n") - yaml.WriteString(" if git diff --quiet --cached \"$INITIAL_SHA\" && git diff --quiet \"$INITIAL_SHA\" HEAD; then\n") - yaml.WriteString(" echo \"No changes detected since initial commit (staged or committed)\"\n") - yaml.WriteString(" echo \"Skipping patch generation - no changes to create patch from\"\n") + yaml.WriteString(" # Check if there are any committed changes since the initial commit\n") + yaml.WriteString(" if git diff --quiet \"$INITIAL_SHA\" HEAD; then\n") + yaml.WriteString(" echo \"No committed changes detected since initial commit\"\n") + yaml.WriteString(" echo \"Skipping patch generation - no committed changes to create patch from\"\n") yaml.WriteString(" else\n") - yaml.WriteString(" echo \"Changes detected, generating patch...\"\n") - yaml.WriteString(" # Generate patch from initial commit to current state\n") + yaml.WriteString(" echo \"Committed changes detected, generating patch...\"\n") + yaml.WriteString(" # Generate patch from initial commit to HEAD (committed changes only)\n") yaml.WriteString(" git format-patch \"$INITIAL_SHA\"..HEAD --stdout > /tmp/aw.patch || echo \"Failed to generate patch\" > /tmp/aw.patch\n") yaml.WriteString(" echo \"Patch file created at /tmp/aw.patch\"\n") yaml.WriteString(" ls -la /tmp/aw.patch\n") + yaml.WriteString(" # Show the first 50 lines of the patch for review\n") + yaml.WriteString(" echo '## Git Patch' >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" echo '' >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" echo '```diff' >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" head -50 /tmp/aw.patch >> $GITHUB_STEP_SUMMARY || echo \"Could not display patch contents\" >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" echo '...' >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" echo '```' >> $GITHUB_STEP_SUMMARY\n") + yaml.WriteString(" echo '' >> $GITHUB_STEP_SUMMARY\n") yaml.WriteString(" fi\n") yaml.WriteString(" - name: Upload git patch\n") yaml.WriteString(" if: always()\n") diff --git a/pkg/workflow/git_patch_test.go b/pkg/workflow/git_patch_test.go index 3b5046419a5..289f5fc8e05 100644 --- a/pkg/workflow/git_patch_test.go +++ b/pkg/workflow/git_patch_test.go @@ -91,7 +91,7 @@ Please do the following tasks: } // Verify it skips patch generation when no changes - if !strings.Contains(lockContent, "Skipping patch generation - no changes to create patch from") { + if !strings.Contains(lockContent, "Skipping patch generation - no committed changes to create patch from") { t.Error("Expected message about skipping patch generation when no changes") } diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go new file mode 100644 index 00000000000..80d67dc7e59 --- /dev/null +++ b/pkg/workflow/js.go @@ -0,0 +1,14 @@ +package workflow + +import ( + _ "embed" +) + +//go:embed js/create_pull_request.mjs +var createPullRequestScript string + +//go:embed js/create_issue.mjs +var createIssueScript string + +//go:embed js/create_comment.mjs +var createCommentScript string diff --git a/pkg/workflow/js/create_comment.js b/pkg/workflow/js/create_comment.js deleted file mode 100644 index 646e74b7d64..00000000000 --- a/pkg/workflow/js/create_comment.js +++ /dev/null @@ -1,66 +0,0 @@ -// Read the agent output content from environment variable -const outputContent = process.env.AGENT_OUTPUT_CONTENT; -if (!outputContent) { - console.log('No AGENT_OUTPUT_CONTENT environment variable found'); - return; -} - -if (outputContent.trim() === '') { - console.log('Agent output content is empty'); - return; -} - -console.log('Agent output content length:', outputContent.length); - -// Check if we're in an issue or pull request context -const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; -const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; - -if (!isIssueContext && !isPRContext) { - console.log('Not running in issue or pull request context, skipping comment creation'); - return; -} - -// Determine the issue/PR number and comment endpoint -let issueNumber; -let commentEndpoint; - -if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; - } -} else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; - } -} - -if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; -} - -console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); -console.log('Comment content length:', outputContent.length); - -// Create the comment using GitHub API -const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: outputContent -}); - -console.log('Created comment #' + comment.id + ': ' + comment.html_url); - -// Set output for other jobs to use -core.setOutput('comment_id', comment.id); -core.setOutput('comment_url', comment.html_url); \ No newline at end of file diff --git a/pkg/workflow/js/create_comment.mjs b/pkg/workflow/js/create_comment.mjs new file mode 100644 index 00000000000..bc3997fa53b --- /dev/null +++ b/pkg/workflow/js/create_comment.mjs @@ -0,0 +1,78 @@ +async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // Check if we're in an issue or pull request context + const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; + const isPRContext = context.eventName === 'pull_request' || context.eventName === 'pull_request_review' || context.eventName === 'pull_request_review_comment'; + + if (!isIssueContext && !isPRContext) { + console.log('Not running in issue or pull request context, skipping comment creation'); + return; + } + + // Determine the issue/PR number and comment endpoint + let issueNumber; + let commentEndpoint; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + return; + } + } + + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + return; + } + + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', outputContent.length); + + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: outputContent + }); + + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + + // Set output for other jobs to use + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + + // write comment id, url to the github_step_summary + await core.summary.addRaw(` + +## GitHub Comment +- Comment ID: ${comment.id} +- Comment URL: ${comment.html_url} +`).write(); + +} +await main(); \ No newline at end of file diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js deleted file mode 100644 index acb59ab0ecd..00000000000 --- a/pkg/workflow/js/create_issue.js +++ /dev/null @@ -1,108 +0,0 @@ -// Read the agent output content from environment variable -const outputContent = process.env.AGENT_OUTPUT_CONTENT; -if (!outputContent) { - console.log('No AGENT_OUTPUT_CONTENT environment variable found'); - return; -} - -if (outputContent.trim() === '') { - console.log('Agent output content is empty'); - return; -} - -console.log('Agent output content length:', outputContent.length); - -// Parse the output to extract title and body -const lines = outputContent.split('\n'); -let title = ''; -let bodyLines = []; -let foundTitle = false; - -for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - // Skip empty lines until we find the title - if (!foundTitle && line === '') { - continue; - } - - // First non-empty line becomes the title - if (!foundTitle && line !== '') { - // Remove markdown heading syntax if present - title = line.replace(/^#+\s*/, '').trim(); - foundTitle = true; - continue; - } - - // Everything else goes into the body - if (foundTitle) { - bodyLines.push(lines[i]); // Keep original formatting - } -} - -// If no title was found, use a default -if (!title) { - title = 'Agent Output'; -} - -// Apply title prefix if provided via environment variable -const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; -if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; -} - -// Prepare the body content -const body = bodyLines.join('\n').trim(); - -// Parse labels from environment variable (comma-separated string) -const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; -const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; - -console.log('Creating issue with title:', title); -console.log('Labels:', labels); -console.log('Body length:', body.length); - -// Check if we're in an issue context (triggered by an issue event) -const parentIssueNumber = context.payload?.issue?.number; -let finalBody = body; - -if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); - - // Add reference to parent issue in the child issue body - if (finalBody.trim()) { - finalBody = `Related to #${parentIssueNumber}\n\n${finalBody}`; - } else { - finalBody = `Related to #${parentIssueNumber}`; - } -} - -// Create the issue using GitHub API -const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: finalBody, - labels: labels -}); - -console.log('Created issue #' + issue.number + ': ' + issue.html_url); - -// If we have a parent issue, add a comment to it referencing the new child issue -if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` - }); - console.log('Added comment to parent issue #' + parentIssueNumber); - } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error.message); - } -} - -// Set output for other jobs to use -core.setOutput('issue_number', issue.number); -core.setOutput('issue_url', issue.html_url); \ No newline at end of file diff --git a/pkg/workflow/js/create_issue.mjs b/pkg/workflow/js/create_issue.mjs new file mode 100644 index 00000000000..59a9a0b6d5f --- /dev/null +++ b/pkg/workflow/js/create_issue.mjs @@ -0,0 +1,118 @@ +async function main() { + // Read the agent output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log('No GITHUB_AW_AGENT_OUTPUT environment variable found'); + return; + } + + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + return; + } + + console.log('Agent output content length:', outputContent.length); + + // Parse the output to extract title and body + const lines = outputContent.split('\n'); + let title = ''; + let bodyLines = []; + let foundTitle = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines until we find the title + if (!foundTitle && line === '') { + continue; + } + + // First non-empty line becomes the title + if (!foundTitle && line !== '') { + // Remove markdown heading syntax if present + title = line.replace(/^#+\s*/, '').trim(); + foundTitle = true; + continue; + } + + // Everything else goes into the body + if (foundTitle) { + bodyLines.push(lines[i]); // Keep original formatting + } + } + + // If no title was found, use a default + if (!title) { + title = 'Agent Output'; + } + + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + + // Prepare the body content + const body = bodyLines.join('\n').trim(); + + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; + + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); + + // Check if we're in an issue context (triggered by an issue event) + const parentIssueNumber = context.payload?.issue?.number; + let finalBody = body; + + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); + + // Add reference to parent issue in the child issue body + if (finalBody.trim()) { + finalBody = `Related to #${parentIssueNumber}\n\n${finalBody}`; + } else { + finalBody = `Related to #${parentIssueNumber}`; + } + } + + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: finalBody, + labels: labels + }); + + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error.message); + } + } + + // Set output for other jobs to use + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + // write issue to summary + await core.summary.addRaw(` + +## GitHub Issue +- Issue ID: ${issue.number} +- Issue URL: ${issue.html_url} +`).write(); +} +await main(); \ No newline at end of file diff --git a/pkg/workflow/js/create_pull_request.mjs b/pkg/workflow/js/create_pull_request.mjs new file mode 100644 index 00000000000..bd57d9a13af --- /dev/null +++ b/pkg/workflow/js/create_pull_request.mjs @@ -0,0 +1,153 @@ +async function main() { + // Required Node.js modules + const fs = require('fs'); + const crypto = require('crypto'); + const { execSync } = require('child_process'); + + // Environment validation - fail early if required variables are missing + const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; + if (!workflowId) { + throw new Error('GITHUB_AW_WORKFLOW_ID environment variable is required'); + } + + const baseBranch = process.env.GITHUB_AW_BASE_BRANCH; + if (!baseBranch) { + throw new Error('GITHUB_AW_BASE_BRANCH environment variable is required'); + } + + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT || ""; + if (outputContent.trim() === '') { + console.log('Agent output content is empty'); + } + + // Check if patch file exists and has valid content + if (!fs.existsSync('/tmp/aw.patch')) { + throw new Error('No patch file found - cannot create pull request without changes'); + } + + const patchContent = fs.readFileSync('/tmp/aw.patch', 'utf8'); + if (!patchContent || !patchContent.trim() || patchContent.includes('Failed to generate patch')) { + throw new Error('Patch file is empty or contains error message - cannot create pull request without changes'); + } + + console.log('Agent output content length:', outputContent.length); + console.log('Patch content validation passed'); + + // Parse the output to extract title and body + const lines = outputContent.split('\n'); + let title = ''; + let bodyLines = []; + let foundTitle = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip empty lines until we find the title + if (!foundTitle && line === '') { + continue; + } + + // First non-empty line becomes the title + if (!foundTitle && line !== '') { + // Remove markdown heading syntax if present + title = line.replace(/^#+\s*/, '').trim(); + foundTitle = true; + continue; + } + + // Everything else goes into the body + if (foundTitle) { + bodyLines.push(lines[i]); // Keep original formatting + } + } + + // If no title was found, use a default + if (!title) { + title = 'Agent Output'; + } + + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_PR_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + + // Prepare the body content + const body = bodyLines.join('\n').trim(); + + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_PR_LABELS; + const labels = labelsEnv ? labelsEnv.split(',').map(label => label.trim()).filter(label => label) : []; + + console.log('Creating pull request with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); + + // Generate unique branch name using cryptographic random hex + const randomHex = crypto.randomBytes(8).toString('hex'); + const branchName = `${workflowId}/${randomHex}`; + + console.log('Generated branch name:', branchName); + console.log('Base branch:', baseBranch); + + // Create a new branch using git CLI + // Configure git (required for commits) + execSync('git config --global user.email "action@github.com"', { stdio: 'inherit' }); + execSync('git config --global user.name "GitHub Action"', { stdio: 'inherit' }); + + // Create and checkout new branch + execSync(`git checkout -b ${branchName}`, { stdio: 'inherit' }); + console.log('Created and checked out branch:', branchName); + + // Apply the patch using git CLI + console.log('Applying patch...'); + + // Apply the patch using git apply + execSync('git apply /tmp/aw.patch', { stdio: 'inherit' }); + console.log('Patch applied successfully'); + + // Commit and push the changes + execSync('git add .', { stdio: 'inherit' }); + execSync(`git commit -m "Add agent output: ${title}"`, { stdio: 'inherit' }); + execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); + console.log('Changes committed and pushed'); + + // Create the pull request + const { data: pullRequest } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + head: branchName, + base: baseBranch + }); + + console.log('Created pull request #' + pullRequest.number + ': ' + pullRequest.html_url); + + // Add labels if specified + if (labels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: labels + }); + console.log('Added labels to pull request:', labels); + } + + // Set output for other jobs to use + core.setOutput('pull_request_number', pullRequest.number); + core.setOutput('pull_request_url', pullRequest.html_url); + core.setOutput('branch_name', branchName); + + // Write summary to GitHub Actions summary + await core.summary + .addRaw(` + +## Pull Request +- **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) +- **Branch**: \`${branchName}\` +- **Base Branch**: \`${baseBranch}\` +`).write(); +} +await main(); \ No newline at end of file diff --git a/pkg/workflow/js/types/github-script.d.ts b/pkg/workflow/js/types/github-script.d.ts new file mode 100644 index 00000000000..eff35483acb --- /dev/null +++ b/pkg/workflow/js/types/github-script.d.ts @@ -0,0 +1,49 @@ +// Type definitions for GitHub Actions github-script action +// These globals are provided by the github-script action environment + +import * as core from '@actions/core'; +import * as github from '@actions/github'; + +declare global { + /** + * GitHub API client instance provided by github-script action + */ + const github: ReturnType; + + /** + * GitHub Actions context object provided by github-script action + */ + const context: typeof github.context; + + /** + * Actions core utilities provided by github-script action + */ + const core: typeof core; + + /** + * Console object for logging (available in Node.js environment) + */ + const console: Console; + + /** + * Process object for environment variables and utilities + */ + const process: NodeJS.Process; + + /** + * Require function for CommonJS modules + */ + const require: NodeRequire; + + /** + * Global exports object for CommonJS modules + */ + var exports: any; + + /** + * Global module object for CommonJS modules + */ + var module: NodeJS.Module; +} + +export {}; diff --git a/pkg/workflow/output_test.go b/pkg/workflow/output_test.go index f6881ac9522..bcc7d807c58 100644 --- a/pkg/workflow/output_test.go +++ b/pkg/workflow/output_test.go @@ -141,7 +141,7 @@ output: # Test Output Issue Job Generation -This workflow tests the create_output_issue job generation. +This workflow tests the create_issue job generation. ` testFile := filepath.Join(tmpDir, "test-output-issue.md") @@ -166,23 +166,23 @@ This workflow tests the create_output_issue job generation. lockContent := string(content) - // Verify create_output_issue job exists - if !strings.Contains(lockContent, "create_output_issue:") { - t.Error("Expected 'create_output_issue' job to be in generated workflow") + // Verify create_issue job exists + if !strings.Contains(lockContent, "create_issue:") { + t.Error("Expected 'create_issue' job to be in generated workflow") } // Verify job properties if !strings.Contains(lockContent, "timeout-minutes: 10") { - t.Error("Expected 10-minute timeout in create_output_issue job") + t.Error("Expected 10-minute timeout in create_issue job") } if !strings.Contains(lockContent, "permissions:\n contents: read\n issues: write") { - t.Error("Expected correct permissions in create_output_issue job") + t.Error("Expected correct permissions in create_issue job") } // Verify the job uses github-script if !strings.Contains(lockContent, "uses: actions/github-script@v7") { - t.Error("Expected github-script action to be used in create_output_issue job") + t.Error("Expected github-script action to be used in create_issue job") } // Verify JavaScript content includes environment variables for configuration @@ -196,7 +196,7 @@ This workflow tests the create_output_issue job generation. // Verify job dependencies if !strings.Contains(lockContent, "needs: test-output-issue") { - t.Error("Expected create_output_issue job to depend on main job") + t.Error("Expected create_issue job to depend on main job") } t.Logf("Generated workflow content:\n%s", lockContent) @@ -334,7 +334,7 @@ This workflow tests the create_issue_comment job generation. } // Verify JavaScript content includes environment variable for agent output - if !strings.Contains(lockContent, "AGENT_OUTPUT_CONTENT:") { + if !strings.Contains(lockContent, "GITHUB_AW_AGENT_OUTPUT:") { t.Error("Expected agent output content to be passed as environment variable") } @@ -400,3 +400,174 @@ This workflow tests that comment job is skipped for non-issue/PR events. t.Logf("Generated workflow content:\n%s", lockContent) } + +func TestOutputPullRequestConfigParsing(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-pr-config-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.pull-request configuration + testContent := `--- +on: push +permissions: + contents: read + pull-requests: write +engine: claude +output: + pull-request: + title-prefix: "[agent] " + labels: [automation, bot] +--- + +# Test Output Pull Request Configuration + +This workflow tests the output pull request configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-output-pr-config.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with output pull-request config: %v", err) + } + + // Verify output configuration is parsed correctly + if workflowData.Output == nil { + t.Fatal("Expected output configuration to be parsed") + } + + if workflowData.Output.PullRequest == nil { + t.Fatal("Expected pull-request configuration to be parsed") + } + + // Verify title prefix + expectedPrefix := "[agent] " + if workflowData.Output.PullRequest.TitlePrefix != expectedPrefix { + t.Errorf("Expected title prefix '%s', got '%s'", expectedPrefix, workflowData.Output.PullRequest.TitlePrefix) + } + + // Verify labels + expectedLabels := []string{"automation", "bot"} + if len(workflowData.Output.PullRequest.Labels) != len(expectedLabels) { + t.Errorf("Expected %d labels, got %d", len(expectedLabels), len(workflowData.Output.PullRequest.Labels)) + } + + for i, expectedLabel := range expectedLabels { + if i >= len(workflowData.Output.PullRequest.Labels) || workflowData.Output.PullRequest.Labels[i] != expectedLabel { + t.Errorf("Expected label[%d] to be '%s', got '%s'", i, expectedLabel, workflowData.Output.PullRequest.Labels[i]) + } + } +} + +func TestOutputPullRequestJobGeneration(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "output-pr-job-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with output.pull-request configuration + testContent := `--- +on: push +permissions: + contents: read + pull-requests: write +tools: + github: + allowed: [list_issues] +engine: claude +output: + pull-request: + title-prefix: "[agent] " + labels: [automation] +--- + +# Test Output Pull Request Job Generation + +This workflow tests the create_pull_request job generation. +` + + testFile := filepath.Join(tmpDir, "test-output-pr.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow with output pull-request: %v", err) + } + + // Read the generated lock file + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + // Convert to string for easier testing + lockContentStr := string(lockContent) + + // Verify create_pull_request job is present + if !strings.Contains(lockContentStr, "create_pull_request:") { + t.Error("Expected 'create_pull_request' job to be in generated workflow") + } + + // Verify permissions + if !strings.Contains(lockContentStr, "contents: write") { + t.Error("Expected contents: write permission in create_pull_request job") + } + + if !strings.Contains(lockContentStr, "pull-requests: write") { + t.Error("Expected pull-requests: write permission in create_pull_request job") + } + + // Verify steps + if !strings.Contains(lockContentStr, "Download patch artifact") { + t.Error("Expected 'Download patch artifact' step in create_pull_request job") + } + + if !strings.Contains(lockContentStr, "actions/download-artifact@v4") { + t.Error("Expected download-artifact action to be used in create_pull_request job") + } + + if !strings.Contains(lockContentStr, "Checkout repository") { + t.Error("Expected 'Checkout repository' step in create_pull_request job") + } + + if !strings.Contains(lockContentStr, "Create Pull Request") { + t.Error("Expected 'Create Pull Request' step in create_pull_request job") + } + + if !strings.Contains(lockContentStr, "uses: actions/github-script@v7") { + t.Error("Expected github-script action to be used in create_pull_request job") + } + + // Verify JavaScript content includes environment variables for configuration + if !strings.Contains(lockContentStr, "GITHUB_AW_PR_TITLE_PREFIX: \"[agent] \"") { + t.Error("Expected title prefix to be set as environment variable") + } + + if !strings.Contains(lockContentStr, "GITHUB_AW_PR_LABELS: \"automation\"") { + t.Error("Expected automation label to be set as environment variable") + } + + // Verify job dependencies + if !strings.Contains(lockContentStr, "needs: test-output-pull-request-job-generation") { + t.Error("Expected create_pull_request job to depend on main job") + } + + t.Logf("Generated workflow content:\n%s", lockContentStr) +} diff --git a/tsconfig.json b/tsconfig.json index 56d0277c762..a97cfa17118 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,24 +4,31 @@ "module": "esnext", "lib": ["es2022", "dom"], "allowJs": true, - "checkJs": false, + "checkJs": true, "declaration": false, "outDir": "./dist/js", "rootDir": "./pkg/workflow/js", "strict": false, - "noImplicitAny": false, - "strictNullChecks": false, + "noImplicitAny": true, + "strictNullChecks": true, "strictFunctionTypes": false, "noImplicitThis": false, "noImplicitReturns": false, - "noFallthroughCasesInSwitch": false, - "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "noEmit": true + "noEmit": true, + "allowUnreachableCode": true, + "allowUnusedLabels": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "isolatedModules": false, + "allowUmdGlobalAccess": true, + "typeRoots": ["./node_modules/@types", "./pkg/workflow/js/types"] }, "include": [ "pkg/workflow/js/**/*"