diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 842ca0abcbc..4ac26ad43bb 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -27,10 +27,13 @@ # Imports: # - shared/activation-app.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"429c9553c1b8706ab97bd48fe931129d4fdfe815c78402277377062529800d8a","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"80035cf878cbaf37da0e8584c32106eafd5e488f78bea3e758d86a4d3e025864","strict":true} name: "Issue Monster" "on": + # permissions: # Permissions applied to pre-activation job + # issues: read + # pull-requests: read schedule: - cron: "*/30 * * * *" # Friendly format: every 30m @@ -38,29 +41,352 @@ name: "Issue Monster" # max: 5 # query: is:pr is:open is:draft author:app/copilot-swe-agent # skip-if-no-match: is:issue is:open # Skip-if-no-match processed as search check in pre-activation job + # steps: # Steps injected into pre-activation job + # - id: search + # name: Search for candidate issues + # uses: actions/github-script@v8 + # with: + # script: | + # const { owner, repo } = context.repo; + # + # try { + # // Check for recent rate-limited PRs to avoid scheduling more work during rate limiting + # core.info('Checking for recent rate-limited PRs...'); + # const rateLimitCheckDate = new Date(); + # rateLimitCheckDate.setHours(rateLimitCheckDate.getHours() - 1); // Check last hour + # // Format as YYYY-MM-DDTHH:MM:SS for GitHub search API + # const rateLimitCheckISO = rateLimitCheckDate.toISOString().split('.')[0] + 'Z'; + # + # const recentPRsQuery = `is:pr author:app/copilot-swe-agent created:>${rateLimitCheckISO} repo:${owner}/${repo}`; + # const recentPRsResponse = await github.rest.search.issuesAndPullRequests({ + # q: recentPRsQuery, + # per_page: 10, + # sort: 'created', + # order: 'desc' + # }); + # + # core.info(`Found ${recentPRsResponse.data.total_count} recent Copilot PRs to check for rate limiting`); + # + # // Check if any recent PRs have rate limit indicators + # let rateLimitDetected = false; + # for (const pr of recentPRsResponse.data.items) { + # try { + # const prTimelineQuery = ` + # query($owner: String!, $repo: String!, $number: Int!) { + # repository(owner: $owner, name: $repo) { + # pullRequest(number: $number) { + # timelineItems(first: 50, itemTypes: [ISSUE_COMMENT]) { + # nodes { + # __typename + # ... on IssueComment { + # body + # createdAt + # } + # } + # } + # } + # } + # } + # `; + # + # const prTimelineResult = await github.graphql(prTimelineQuery, { + # owner, + # repo, + # number: pr.number + # }); + # + # const comments = prTimelineResult?.repository?.pullRequest?.timelineItems?.nodes || []; + # const rateLimitPattern = /rate limit|API rate limit|secondary rate limit|abuse detection|429|too many requests/i; + # + # for (const comment of comments) { + # if (comment.body && rateLimitPattern.test(comment.body)) { + # core.warning(`Rate limiting detected in PR #${pr.number}: ${comment.body.substring(0, 200)}`); + # rateLimitDetected = true; + # break; + # } + # } + # + # if (rateLimitDetected) break; + # } catch (error) { + # core.warning(`Could not check PR #${pr.number} for rate limiting: ${error.message}`); + # } + # } + # + # if (rateLimitDetected) { + # core.warning('🛑 Rate limiting detected in recent PRs. Skipping issue assignment to prevent further rate limit issues.'); + # core.setOutput('issue_count', 0); + # core.setOutput('issue_numbers', ''); + # core.setOutput('issue_list', ''); + # core.setOutput('has_issues', 'false'); + # return; + # } + # + # core.info('✓ No rate limiting detected. Proceeding with issue search.'); + # + # // Labels that indicate an issue should NOT be auto-assigned + # const excludeLabels = [ + # 'wontfix', + # 'duplicate', + # 'invalid', + # 'question', + # 'discussion', + # 'needs-discussion', + # 'blocked', + # 'on-hold', + # 'waiting-for-feedback', + # 'needs-more-info', + # 'no-bot', + # 'no-campaign' + # ]; + # + # // Labels that indicate an issue is a GOOD candidate for auto-assignment + # const priorityLabels = [ + # 'good first issue', + # 'good-first-issue', + # 'bug', + # 'enhancement', + # 'feature', + # 'documentation', + # 'tech-debt', + # 'refactoring', + # 'performance', + # 'security' + # ]; + # + # // Search for open issues with "cookie" label and without excluded labels + # // The "cookie" label indicates issues that are approved work queue items from automated workflows + # const query = `is:issue is:open repo:${owner}/${repo} label:cookie -label:"${excludeLabels.join('" -label:"')}"`; + # core.info(`Searching: ${query}`); + # const response = await github.rest.search.issuesAndPullRequests({ + # q: query, + # per_page: 100, + # sort: 'created', + # order: 'desc' + # }); + # core.info(`Found ${response.data.total_count} total issues matching basic criteria`); + # + # // Fetch full details for each issue to get labels, assignees, sub-issues, and linked PRs + # const issuesWithDetails = await Promise.all( + # response.data.items.map(async (issue) => { + # const fullIssue = await github.rest.issues.get({ + # owner, + # repo, + # issue_number: issue.number + # }); + # + # // Check if this issue has sub-issues and linked PRs using GraphQL + # let subIssuesCount = 0; + # let linkedPRs = []; + # try { + # const issueDetailsQuery = ` + # query($owner: String!, $repo: String!, $number: Int!) { + # repository(owner: $owner, name: $repo) { + # issue(number: $number) { + # subIssues { + # totalCount + # } + # timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) { + # nodes { + # ... on CrossReferencedEvent { + # source { + # __typename + # ... on PullRequest { + # number + # state + # isDraft + # author { + # login + # } + # } + # } + # } + # } + # } + # } + # } + # } + # `; + # const issueDetailsResult = await github.graphql(issueDetailsQuery, { + # owner, + # repo, + # number: issue.number + # }); + # + # subIssuesCount = issueDetailsResult?.repository?.issue?.subIssues?.totalCount || 0; + # + # // Extract linked PRs from timeline + # const timelineItems = issueDetailsResult?.repository?.issue?.timelineItems?.nodes || []; + # linkedPRs = timelineItems + # .filter(item => item?.source?.__typename === 'PullRequest') + # .map(item => ({ + # number: item.source.number, + # state: item.source.state, + # isDraft: item.source.isDraft, + # author: item.source.author?.login + # })); + # + # core.info(`Issue #${issue.number} has ${linkedPRs.length} linked PR(s)`); + # } catch (error) { + # // If GraphQL query fails, continue with defaults + # core.warning(`Could not check details for #${issue.number}: ${error.message}`); + # } + # + # return { + # ...fullIssue.data, + # subIssuesCount, + # linkedPRs + # }; + # }) + # ); + # + # // Filter and score issues + # const scoredIssues = issuesWithDetails + # .filter(issue => { + # // Exclude issues that already have assignees + # if (issue.assignees && issue.assignees.length > 0) { + # core.info(`Skipping #${issue.number}: already has assignees`); + # return false; + # } + # + # // Exclude issues with excluded labels (double check) + # const issueLabels = issue.labels.map(l => l.name.toLowerCase()); + # if (issueLabels.some(label => excludeLabels.map(l => l.toLowerCase()).includes(label))) { + # core.info(`Skipping #${issue.number}: has excluded label`); + # return false; + # } + # + # // Exclude issues with campaign labels (campaign:*) + # // Campaign items are managed by campaign orchestrators + # if (issueLabels.some(label => label.startsWith('campaign:'))) { + # core.info(`Skipping #${issue.number}: has campaign label (managed by campaign orchestrator)`); + # return false; + # } + # + # // Exclude issues that have sub-issues (parent/organizing issues) + # if (issue.subIssuesCount > 0) { + # core.info(`Skipping #${issue.number}: has ${issue.subIssuesCount} sub-issue(s) - parent issues are used for organizing, not tasks`); + # return false; + # } + # + # // Exclude issues with closed PRs (treat as complete) + # const closedPRs = issue.linkedPRs?.filter(pr => pr.state === 'CLOSED' || pr.state === 'MERGED') || []; + # if (closedPRs.length > 0) { + # core.info(`Skipping #${issue.number}: has ${closedPRs.length} closed/merged PR(s) - treating as complete`); + # return false; + # } + # + # // Exclude issues with open PRs from Copilot coding agent + # const openCopilotPRs = issue.linkedPRs?.filter(pr => + # pr.state === 'OPEN' && + # (pr.author === 'copilot-swe-agent' || pr.author?.includes('copilot')) + # ) || []; + # if (openCopilotPRs.length > 0) { + # core.info(`Skipping #${issue.number}: has ${openCopilotPRs.length} open PR(s) from Copilot - already being worked on`); + # return false; + # } + # + # return true; + # }) + # .map(issue => { + # const issueLabels = issue.labels.map(l => l.name.toLowerCase()); + # let score = 0; + # + # // Score based on priority labels (higher score = higher priority) + # if (issueLabels.includes('good first issue') || issueLabels.includes('good-first-issue')) { + # score += 50; + # } + # if (issueLabels.includes('bug')) { + # score += 40; + # } + # if (issueLabels.includes('security')) { + # score += 45; + # } + # if (issueLabels.includes('documentation')) { + # score += 35; + # } + # if (issueLabels.includes('enhancement') || issueLabels.includes('feature')) { + # score += 30; + # } + # if (issueLabels.includes('performance')) { + # score += 25; + # } + # if (issueLabels.includes('tech-debt') || issueLabels.includes('refactoring')) { + # score += 20; + # } + # + # // Bonus for issues with clear labels (any priority label) + # if (issueLabels.some(label => priorityLabels.map(l => l.toLowerCase()).includes(label))) { + # score += 10; + # } + # + # // Age bonus: older issues get slight priority (days old / 10) + # const ageInDays = Math.floor((Date.now() - new Date(issue.created_at)) / (1000 * 60 * 60 * 24)); + # score += Math.min(ageInDays / 10, 20); // Cap age bonus at 20 points + # + # return { + # number: issue.number, + # title: issue.title, + # labels: issue.labels.map(l => l.name), + # created_at: issue.created_at, + # score + # }; + # }) + # .sort((a, b) => b.score - a.score); // Sort by score descending + # + # // Format output + # const issueList = scoredIssues.map(i => { + # const labelStr = i.labels.length > 0 ? ` [${i.labels.join(', ')}]` : ''; + # return `#${i.number}: ${i.title}${labelStr} (score: ${i.score.toFixed(1)})`; + # }).join('\n'); + # + # const issueNumbers = scoredIssues.map(i => i.number).join(','); + # + # core.info(`Total candidate issues after filtering: ${scoredIssues.length}`); + # if (scoredIssues.length > 0) { + # core.info(`Top candidates:\n${issueList.split('\n').slice(0, 10).join('\n')}`); + # } + # + # core.setOutput('issue_count', scoredIssues.length); + # core.setOutput('issue_numbers', issueNumbers); + # core.setOutput('issue_list', issueList); + # + # if (scoredIssues.length === 0) { + # core.info('🍽️ No suitable candidate issues - the plate is empty!'); + # core.setOutput('has_issues', 'false'); + # } else { + # core.setOutput('has_issues', 'true'); + # } + # } catch (error) { + # core.error(`Error searching for issues: ${error.message}`); + # core.setOutput('issue_count', 0); + # core.setOutput('issue_numbers', ''); + # core.setOutput('issue_list', ''); + # core.setOutput('has_issues', 'false'); + # } workflow_dispatch: permissions: {} concurrency: - group: "gh-aw-${{ github.workflow }}" + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.discussion.number || github.run_id }}" run-name: "Issue Monster" jobs: activation: - needs: - - pre_activation - - search_issues - if: (needs.pre_activation.outputs.activated == 'true') && (needs.search_issues.outputs.has_issues == 'true') + needs: pre_activation + if: (needs.pre_activation.outputs.activated == 'true') && (needs.pre_activation.outputs.has_issues == 'true') runs-on: ubuntu-slim permissions: contents: read outputs: + body: ${{ steps.sanitized.outputs.body }} comment_id: "" comment_repo: "" model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -123,6 +449,15 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/compute_text.cjs'); + await main(); - name: Create prompt with built-in context env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -135,9 +470,9 @@ jobs: GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_COUNT: ${{ needs.search_issues.outputs.issue_count }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_LIST: ${{ needs.search_issues.outputs.issue_list }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_NUMBERS: ${{ needs.search_issues.outputs.issue_numbers }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_COUNT: ${{ needs.pre_activation.outputs.issue_count }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_LIST: ${{ needs.pre_activation.outputs.issue_list }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_NUMBERS: ${{ needs.pre_activation.outputs.issue_numbers }} run: | bash /opt/gh-aw/actions/create_prompt_first.sh { @@ -196,9 +531,9 @@ jobs: env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_COUNT: ${{ needs.search_issues.outputs.issue_count }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_LIST: ${{ needs.search_issues.outputs.issue_list }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_NUMBERS: ${{ needs.search_issues.outputs.issue_numbers }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_COUNT: ${{ needs.pre_activation.outputs.issue_count }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_LIST: ${{ needs.pre_activation.outputs.issue_list }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_NUMBERS: ${{ needs.pre_activation.outputs.issue_numbers }} with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -218,9 +553,9 @@ jobs: GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_COUNT: ${{ needs.search_issues.outputs.issue_count }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_LIST: ${{ needs.search_issues.outputs.issue_list }} - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_NUMBERS: ${{ needs.search_issues.outputs.issue_numbers }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_COUNT: ${{ needs.pre_activation.outputs.issue_count }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_LIST: ${{ needs.pre_activation.outputs.issue_list }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_NUMBERS: ${{ needs.pre_activation.outputs.issue_numbers }} with: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); @@ -241,9 +576,9 @@ jobs: GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED, - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_COUNT: process.env.GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_COUNT, - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_LIST: process.env.GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_LIST, - GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_NUMBERS: process.env.GH_AW_NEEDS_SEARCH_ISSUES_OUTPUTS_ISSUE_NUMBERS + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_COUNT: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_COUNT, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_LIST: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_LIST, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_NUMBERS: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ISSUE_NUMBERS } }); - name: Validate prompt placeholders @@ -265,16 +600,12 @@ jobs: retention-days: 1 agent: - needs: - - activation - - search_issues + needs: activation runs-on: ubuntu-latest permissions: contents: read issues: read pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" @@ -1143,9 +1474,16 @@ jobs: runs-on: ubuntu-slim permissions: contents: read + issues: read + pull-requests: read outputs: activated: ${{ ((steps.check_membership.outputs.is_team_member == 'true') && (steps.check_skip_if_match.outputs.skip_check_ok == 'true')) && (steps.check_skip_if_no_match.outputs.skip_no_match_check_ok == 'true') }} + has_issues: ${{ steps.search.outputs.has_issues }} + issue_count: ${{ steps.search.outputs.issue_count }} + issue_list: ${{ steps.search.outputs.issue_list }} + issue_numbers: ${{ steps.search.outputs.issue_numbers }} matched_command: '' + search_result: ${{ steps.search.outcome }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -1196,117 +1534,9 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/check_skip_if_no_match.cjs'); await main(); - - safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - discussions: write - issues: write - pull-requests: write - timeout-minutes: 15 - env: - GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/issue-monster" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5.1-codex-mini" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🍪 *Om nom nom by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🍪 ISSUE! ISSUE! [{workflow_name}]({run_url}) hungry for issues on this {event_type}! Om nom nom...\",\"runSuccess\":\"🍪 YUMMY! [{workflow_name}]({run_url}) ate the issues! That was DELICIOUS! Me want MORE! 😋\",\"runFailure\":\"🍪 Aww... [{workflow_name}]({run_url}) {status}. No cookie for monster today... 😢\"}" - GH_AW_WORKFLOW_ID: "issue-monster" - GH_AW_WORKFLOW_NAME: "Issue Monster" - outputs: - assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }} - assign_to_agent_assignment_error_count: ${{ steps.assign_to_agent.outputs.assignment_error_count }} - assign_to_agent_assignment_errors: ${{ steps.assign_to_agent.outputs.assignment_errors }} - code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} - code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} - comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Checkout actions folder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: github/gh-aw - sparse-checkout: | - actions - persist-credentials: false - - name: Setup Scripts - uses: ./actions/setup - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: agent - path: /tmp/gh-aw/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/ - find "/tmp/gh-aw/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3,\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - name: Assign to agent - id: assign_to_agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_AGENT_MAX_COUNT: 3 - GH_AW_AGENT_TARGET: "*" - GH_AW_AGENT_ALLOWED: "copilot" - with: - github-token: ${{ secrets.GH_AW_AGENT_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/assign_to_agent.cjs'); - await main(); - - name: Upload Safe Output Items Manifest - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn - - search_issues: - needs: pre_activation - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-latest - permissions: - issues: read - - outputs: - has_issues: ${{ steps.search.outputs.has_issues }} - issue_count: ${{ steps.search.outputs.issue_count }} - issue_list: ${{ steps.search.outputs.issue_list }} - issue_numbers: ${{ steps.search.outputs.issue_numbers }} - steps: - name: Search for candidate issues id: search - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@v8 with: script: | const { owner, repo } = context.repo; @@ -1626,3 +1856,97 @@ jobs: core.setOutput('has_issues', 'false'); } + safe_outputs: + needs: agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/issue-monster" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: "gpt-5.1-codex-mini" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🍪 *Om nom nom by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🍪 ISSUE! ISSUE! [{workflow_name}]({run_url}) hungry for issues on this {event_type}! Om nom nom...\",\"runSuccess\":\"🍪 YUMMY! [{workflow_name}]({run_url}) ate the issues! That was DELICIOUS! Me want MORE! 😋\",\"runFailure\":\"🍪 Aww... [{workflow_name}]({run_url}) {status}. No cookie for monster today... 😢\"}" + GH_AW_WORKFLOW_ID: "issue-monster" + GH_AW_WORKFLOW_NAME: "Issue Monster" + outputs: + assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }} + assign_to_agent_assignment_error_count: ${{ steps.assign_to_agent.outputs.assignment_error_count }} + assign_to_agent_assignment_errors: ${{ steps.assign_to_agent.outputs.assignment_errors }} + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3,\"target\":\"*\"},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Assign to agent + id: assign_to_agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_AGENT_MAX_COUNT: 3 + GH_AW_AGENT_TARGET: "*" + GH_AW_AGENT_ALLOWED: "copilot" + with: + github-token: ${{ secrets.GH_AW_AGENT_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/assign_to_agent.cjs'); + await main(); + - name: Upload Safe Output Items Manifest + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn + diff --git a/.github/workflows/issue-monster.md b/.github/workflows/issue-monster.md index da0c5212fcb..cbeaf8b8c94 100644 --- a/.github/workflows/issue-monster.md +++ b/.github/workflows/issue-monster.md @@ -8,6 +8,332 @@ on: query: "is:pr is:open is:draft author:app/copilot-swe-agent" max: 5 skip-if-no-match: "is:issue is:open" + permissions: + issues: read + pull-requests: read + steps: + - name: Search for candidate issues + id: search + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + + try { + // Check for recent rate-limited PRs to avoid scheduling more work during rate limiting + core.info('Checking for recent rate-limited PRs...'); + const rateLimitCheckDate = new Date(); + rateLimitCheckDate.setHours(rateLimitCheckDate.getHours() - 1); // Check last hour + // Format as YYYY-MM-DDTHH:MM:SS for GitHub search API + const rateLimitCheckISO = rateLimitCheckDate.toISOString().split('.')[0] + 'Z'; + + const recentPRsQuery = `is:pr author:app/copilot-swe-agent created:>${rateLimitCheckISO} repo:${owner}/${repo}`; + const recentPRsResponse = await github.rest.search.issuesAndPullRequests({ + q: recentPRsQuery, + per_page: 10, + sort: 'created', + order: 'desc' + }); + + core.info(`Found ${recentPRsResponse.data.total_count} recent Copilot PRs to check for rate limiting`); + + // Check if any recent PRs have rate limit indicators + let rateLimitDetected = false; + for (const pr of recentPRsResponse.data.items) { + try { + const prTimelineQuery = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + timelineItems(first: 50, itemTypes: [ISSUE_COMMENT]) { + nodes { + __typename + ... on IssueComment { + body + createdAt + } + } + } + } + } + } + `; + + const prTimelineResult = await github.graphql(prTimelineQuery, { + owner, + repo, + number: pr.number + }); + + const comments = prTimelineResult?.repository?.pullRequest?.timelineItems?.nodes || []; + const rateLimitPattern = /rate limit|API rate limit|secondary rate limit|abuse detection|429|too many requests/i; + + for (const comment of comments) { + if (comment.body && rateLimitPattern.test(comment.body)) { + core.warning(`Rate limiting detected in PR #${pr.number}: ${comment.body.substring(0, 200)}`); + rateLimitDetected = true; + break; + } + } + + if (rateLimitDetected) break; + } catch (error) { + core.warning(`Could not check PR #${pr.number} for rate limiting: ${error.message}`); + } + } + + if (rateLimitDetected) { + core.warning('🛑 Rate limiting detected in recent PRs. Skipping issue assignment to prevent further rate limit issues.'); + core.setOutput('issue_count', 0); + core.setOutput('issue_numbers', ''); + core.setOutput('issue_list', ''); + core.setOutput('has_issues', 'false'); + return; + } + + core.info('✓ No rate limiting detected. Proceeding with issue search.'); + + // Labels that indicate an issue should NOT be auto-assigned + const excludeLabels = [ + 'wontfix', + 'duplicate', + 'invalid', + 'question', + 'discussion', + 'needs-discussion', + 'blocked', + 'on-hold', + 'waiting-for-feedback', + 'needs-more-info', + 'no-bot', + 'no-campaign' + ]; + + // Labels that indicate an issue is a GOOD candidate for auto-assignment + const priorityLabels = [ + 'good first issue', + 'good-first-issue', + 'bug', + 'enhancement', + 'feature', + 'documentation', + 'tech-debt', + 'refactoring', + 'performance', + 'security' + ]; + + // Search for open issues with "cookie" label and without excluded labels + // The "cookie" label indicates issues that are approved work queue items from automated workflows + const query = `is:issue is:open repo:${owner}/${repo} label:cookie -label:"${excludeLabels.join('" -label:"')}"`; + core.info(`Searching: ${query}`); + const response = await github.rest.search.issuesAndPullRequests({ + q: query, + per_page: 100, + sort: 'created', + order: 'desc' + }); + core.info(`Found ${response.data.total_count} total issues matching basic criteria`); + + // Fetch full details for each issue to get labels, assignees, sub-issues, and linked PRs + const issuesWithDetails = await Promise.all( + response.data.items.map(async (issue) => { + const fullIssue = await github.rest.issues.get({ + owner, + repo, + issue_number: issue.number + }); + + // Check if this issue has sub-issues and linked PRs using GraphQL + let subIssuesCount = 0; + let linkedPRs = []; + try { + const issueDetailsQuery = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + subIssues { + totalCount + } + timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + source { + __typename + ... on PullRequest { + number + state + isDraft + author { + login + } + } + } + } + } + } + } + } + } + `; + const issueDetailsResult = await github.graphql(issueDetailsQuery, { + owner, + repo, + number: issue.number + }); + + subIssuesCount = issueDetailsResult?.repository?.issue?.subIssues?.totalCount || 0; + + // Extract linked PRs from timeline + const timelineItems = issueDetailsResult?.repository?.issue?.timelineItems?.nodes || []; + linkedPRs = timelineItems + .filter(item => item?.source?.__typename === 'PullRequest') + .map(item => ({ + number: item.source.number, + state: item.source.state, + isDraft: item.source.isDraft, + author: item.source.author?.login + })); + + core.info(`Issue #${issue.number} has ${linkedPRs.length} linked PR(s)`); + } catch (error) { + // If GraphQL query fails, continue with defaults + core.warning(`Could not check details for #${issue.number}: ${error.message}`); + } + + return { + ...fullIssue.data, + subIssuesCount, + linkedPRs + }; + }) + ); + + // Filter and score issues + const scoredIssues = issuesWithDetails + .filter(issue => { + // Exclude issues that already have assignees + if (issue.assignees && issue.assignees.length > 0) { + core.info(`Skipping #${issue.number}: already has assignees`); + return false; + } + + // Exclude issues with excluded labels (double check) + const issueLabels = issue.labels.map(l => l.name.toLowerCase()); + if (issueLabels.some(label => excludeLabels.map(l => l.toLowerCase()).includes(label))) { + core.info(`Skipping #${issue.number}: has excluded label`); + return false; + } + + // Exclude issues with campaign labels (campaign:*) + // Campaign items are managed by campaign orchestrators + if (issueLabels.some(label => label.startsWith('campaign:'))) { + core.info(`Skipping #${issue.number}: has campaign label (managed by campaign orchestrator)`); + return false; + } + + // Exclude issues that have sub-issues (parent/organizing issues) + if (issue.subIssuesCount > 0) { + core.info(`Skipping #${issue.number}: has ${issue.subIssuesCount} sub-issue(s) - parent issues are used for organizing, not tasks`); + return false; + } + + // Exclude issues with closed PRs (treat as complete) + const closedPRs = issue.linkedPRs?.filter(pr => pr.state === 'CLOSED' || pr.state === 'MERGED') || []; + if (closedPRs.length > 0) { + core.info(`Skipping #${issue.number}: has ${closedPRs.length} closed/merged PR(s) - treating as complete`); + return false; + } + + // Exclude issues with open PRs from Copilot coding agent + const openCopilotPRs = issue.linkedPRs?.filter(pr => + pr.state === 'OPEN' && + (pr.author === 'copilot-swe-agent' || pr.author?.includes('copilot')) + ) || []; + if (openCopilotPRs.length > 0) { + core.info(`Skipping #${issue.number}: has ${openCopilotPRs.length} open PR(s) from Copilot - already being worked on`); + return false; + } + + return true; + }) + .map(issue => { + const issueLabels = issue.labels.map(l => l.name.toLowerCase()); + let score = 0; + + // Score based on priority labels (higher score = higher priority) + if (issueLabels.includes('good first issue') || issueLabels.includes('good-first-issue')) { + score += 50; + } + if (issueLabels.includes('bug')) { + score += 40; + } + if (issueLabels.includes('security')) { + score += 45; + } + if (issueLabels.includes('documentation')) { + score += 35; + } + if (issueLabels.includes('enhancement') || issueLabels.includes('feature')) { + score += 30; + } + if (issueLabels.includes('performance')) { + score += 25; + } + if (issueLabels.includes('tech-debt') || issueLabels.includes('refactoring')) { + score += 20; + } + + // Bonus for issues with clear labels (any priority label) + if (issueLabels.some(label => priorityLabels.map(l => l.toLowerCase()).includes(label))) { + score += 10; + } + + // Age bonus: older issues get slight priority (days old / 10) + const ageInDays = Math.floor((Date.now() - new Date(issue.created_at)) / (1000 * 60 * 60 * 24)); + score += Math.min(ageInDays / 10, 20); // Cap age bonus at 20 points + + return { + number: issue.number, + title: issue.title, + labels: issue.labels.map(l => l.name), + created_at: issue.created_at, + score + }; + }) + .sort((a, b) => b.score - a.score); // Sort by score descending + + // Format output + const issueList = scoredIssues.map(i => { + const labelStr = i.labels.length > 0 ? ` [${i.labels.join(', ')}]` : ''; + return `#${i.number}: ${i.title}${labelStr} (score: ${i.score.toFixed(1)})`; + }).join('\n'); + + const issueNumbers = scoredIssues.map(i => i.number).join(','); + + core.info(`Total candidate issues after filtering: ${scoredIssues.length}`); + if (scoredIssues.length > 0) { + core.info(`Top candidates:\n${issueList.split('\n').slice(0, 10).join('\n')}`); + } + + core.setOutput('issue_count', scoredIssues.length); + core.setOutput('issue_numbers', issueNumbers); + core.setOutput('issue_list', issueList); + + if (scoredIssues.length === 0) { + core.info('🍽️ No suitable candidate issues - the plate is empty!'); + core.setOutput('has_issues', 'false'); + } else { + core.setOutput('has_issues', 'true'); + } + } catch (error) { + core.error(`Error searching for issues: ${error.message}`); + core.setOutput('issue_count', 0); + core.setOutput('issue_numbers', ''); + core.setOutput('issue_list', ''); + core.setOutput('has_issues', 'false'); + } + permissions: contents: read @@ -28,342 +354,15 @@ tools: lockdown: true toolsets: [default, pull_requests] -if: needs.search_issues.outputs.has_issues == 'true' +if: needs.pre_activation.outputs.has_issues == 'true' jobs: - search_issues: - needs: ["pre_activation"] - if: needs.pre_activation.outputs.activated == 'true' - runs-on: ubuntu-latest - permissions: - issues: read + pre-activation: outputs: issue_count: ${{ steps.search.outputs.issue_count }} issue_numbers: ${{ steps.search.outputs.issue_numbers }} issue_list: ${{ steps.search.outputs.issue_list }} has_issues: ${{ steps.search.outputs.has_issues }} - steps: - - name: Search for candidate issues - id: search - uses: actions/github-script@v8 - with: - script: | - const { owner, repo } = context.repo; - - try { - // Check for recent rate-limited PRs to avoid scheduling more work during rate limiting - core.info('Checking for recent rate-limited PRs...'); - const rateLimitCheckDate = new Date(); - rateLimitCheckDate.setHours(rateLimitCheckDate.getHours() - 1); // Check last hour - // Format as YYYY-MM-DDTHH:MM:SS for GitHub search API - const rateLimitCheckISO = rateLimitCheckDate.toISOString().split('.')[0] + 'Z'; - - const recentPRsQuery = `is:pr author:app/copilot-swe-agent created:>${rateLimitCheckISO} repo:${owner}/${repo}`; - const recentPRsResponse = await github.rest.search.issuesAndPullRequests({ - q: recentPRsQuery, - per_page: 10, - sort: 'created', - order: 'desc' - }); - - core.info(`Found ${recentPRsResponse.data.total_count} recent Copilot PRs to check for rate limiting`); - - // Check if any recent PRs have rate limit indicators - let rateLimitDetected = false; - for (const pr of recentPRsResponse.data.items) { - try { - const prTimelineQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - timelineItems(first: 50, itemTypes: [ISSUE_COMMENT]) { - nodes { - __typename - ... on IssueComment { - body - createdAt - } - } - } - } - } - } - `; - - const prTimelineResult = await github.graphql(prTimelineQuery, { - owner, - repo, - number: pr.number - }); - - const comments = prTimelineResult?.repository?.pullRequest?.timelineItems?.nodes || []; - const rateLimitPattern = /rate limit|API rate limit|secondary rate limit|abuse detection|429|too many requests/i; - - for (const comment of comments) { - if (comment.body && rateLimitPattern.test(comment.body)) { - core.warning(`Rate limiting detected in PR #${pr.number}: ${comment.body.substring(0, 200)}`); - rateLimitDetected = true; - break; - } - } - - if (rateLimitDetected) break; - } catch (error) { - core.warning(`Could not check PR #${pr.number} for rate limiting: ${error.message}`); - } - } - - if (rateLimitDetected) { - core.warning('🛑 Rate limiting detected in recent PRs. Skipping issue assignment to prevent further rate limit issues.'); - core.setOutput('issue_count', 0); - core.setOutput('issue_numbers', ''); - core.setOutput('issue_list', ''); - core.setOutput('has_issues', 'false'); - return; - } - - core.info('✓ No rate limiting detected. Proceeding with issue search.'); - - // Labels that indicate an issue should NOT be auto-assigned - const excludeLabels = [ - 'wontfix', - 'duplicate', - 'invalid', - 'question', - 'discussion', - 'needs-discussion', - 'blocked', - 'on-hold', - 'waiting-for-feedback', - 'needs-more-info', - 'no-bot', - 'no-campaign' - ]; - - // Labels that indicate an issue is a GOOD candidate for auto-assignment - const priorityLabels = [ - 'good first issue', - 'good-first-issue', - 'bug', - 'enhancement', - 'feature', - 'documentation', - 'tech-debt', - 'refactoring', - 'performance', - 'security' - ]; - - // Search for open issues with "cookie" label and without excluded labels - // The "cookie" label indicates issues that are approved work queue items from automated workflows - const query = `is:issue is:open repo:${owner}/${repo} label:cookie -label:"${excludeLabels.join('" -label:"')}"`; - core.info(`Searching: ${query}`); - const response = await github.rest.search.issuesAndPullRequests({ - q: query, - per_page: 100, - sort: 'created', - order: 'desc' - }); - core.info(`Found ${response.data.total_count} total issues matching basic criteria`); - - // Fetch full details for each issue to get labels, assignees, sub-issues, and linked PRs - const issuesWithDetails = await Promise.all( - response.data.items.map(async (issue) => { - const fullIssue = await github.rest.issues.get({ - owner, - repo, - issue_number: issue.number - }); - - // Check if this issue has sub-issues and linked PRs using GraphQL - let subIssuesCount = 0; - let linkedPRs = []; - try { - const issueDetailsQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - subIssues { - totalCount - } - timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT]) { - nodes { - ... on CrossReferencedEvent { - source { - __typename - ... on PullRequest { - number - state - isDraft - author { - login - } - } - } - } - } - } - } - } - } - `; - const issueDetailsResult = await github.graphql(issueDetailsQuery, { - owner, - repo, - number: issue.number - }); - - subIssuesCount = issueDetailsResult?.repository?.issue?.subIssues?.totalCount || 0; - - // Extract linked PRs from timeline - const timelineItems = issueDetailsResult?.repository?.issue?.timelineItems?.nodes || []; - linkedPRs = timelineItems - .filter(item => item?.source?.__typename === 'PullRequest') - .map(item => ({ - number: item.source.number, - state: item.source.state, - isDraft: item.source.isDraft, - author: item.source.author?.login - })); - - core.info(`Issue #${issue.number} has ${linkedPRs.length} linked PR(s)`); - } catch (error) { - // If GraphQL query fails, continue with defaults - core.warning(`Could not check details for #${issue.number}: ${error.message}`); - } - - return { - ...fullIssue.data, - subIssuesCount, - linkedPRs - }; - }) - ); - - // Filter and score issues - const scoredIssues = issuesWithDetails - .filter(issue => { - // Exclude issues that already have assignees - if (issue.assignees && issue.assignees.length > 0) { - core.info(`Skipping #${issue.number}: already has assignees`); - return false; - } - - // Exclude issues with excluded labels (double check) - const issueLabels = issue.labels.map(l => l.name.toLowerCase()); - if (issueLabels.some(label => excludeLabels.map(l => l.toLowerCase()).includes(label))) { - core.info(`Skipping #${issue.number}: has excluded label`); - return false; - } - - // Exclude issues with campaign labels (campaign:*) - // Campaign items are managed by campaign orchestrators - if (issueLabels.some(label => label.startsWith('campaign:'))) { - core.info(`Skipping #${issue.number}: has campaign label (managed by campaign orchestrator)`); - return false; - } - - // Exclude issues that have sub-issues (parent/organizing issues) - if (issue.subIssuesCount > 0) { - core.info(`Skipping #${issue.number}: has ${issue.subIssuesCount} sub-issue(s) - parent issues are used for organizing, not tasks`); - return false; - } - - // Exclude issues with closed PRs (treat as complete) - const closedPRs = issue.linkedPRs?.filter(pr => pr.state === 'CLOSED' || pr.state === 'MERGED') || []; - if (closedPRs.length > 0) { - core.info(`Skipping #${issue.number}: has ${closedPRs.length} closed/merged PR(s) - treating as complete`); - return false; - } - - // Exclude issues with open PRs from Copilot coding agent - const openCopilotPRs = issue.linkedPRs?.filter(pr => - pr.state === 'OPEN' && - (pr.author === 'copilot-swe-agent' || pr.author?.includes('copilot')) - ) || []; - if (openCopilotPRs.length > 0) { - core.info(`Skipping #${issue.number}: has ${openCopilotPRs.length} open PR(s) from Copilot - already being worked on`); - return false; - } - - return true; - }) - .map(issue => { - const issueLabels = issue.labels.map(l => l.name.toLowerCase()); - let score = 0; - - // Score based on priority labels (higher score = higher priority) - if (issueLabels.includes('good first issue') || issueLabels.includes('good-first-issue')) { - score += 50; - } - if (issueLabels.includes('bug')) { - score += 40; - } - if (issueLabels.includes('security')) { - score += 45; - } - if (issueLabels.includes('documentation')) { - score += 35; - } - if (issueLabels.includes('enhancement') || issueLabels.includes('feature')) { - score += 30; - } - if (issueLabels.includes('performance')) { - score += 25; - } - if (issueLabels.includes('tech-debt') || issueLabels.includes('refactoring')) { - score += 20; - } - - // Bonus for issues with clear labels (any priority label) - if (issueLabels.some(label => priorityLabels.map(l => l.toLowerCase()).includes(label))) { - score += 10; - } - - // Age bonus: older issues get slight priority (days old / 10) - const ageInDays = Math.floor((Date.now() - new Date(issue.created_at)) / (1000 * 60 * 60 * 24)); - score += Math.min(ageInDays / 10, 20); // Cap age bonus at 20 points - - return { - number: issue.number, - title: issue.title, - labels: issue.labels.map(l => l.name), - created_at: issue.created_at, - score - }; - }) - .sort((a, b) => b.score - a.score); // Sort by score descending - - // Format output - const issueList = scoredIssues.map(i => { - const labelStr = i.labels.length > 0 ? ` [${i.labels.join(', ')}]` : ''; - return `#${i.number}: ${i.title}${labelStr} (score: ${i.score.toFixed(1)})`; - }).join('\n'); - - const issueNumbers = scoredIssues.map(i => i.number).join(','); - - core.info(`Total candidate issues after filtering: ${scoredIssues.length}`); - if (scoredIssues.length > 0) { - core.info(`Top candidates:\n${issueList.split('\n').slice(0, 10).join('\n')}`); - } - - core.setOutput('issue_count', scoredIssues.length); - core.setOutput('issue_numbers', issueNumbers); - core.setOutput('issue_list', issueList); - - if (scoredIssues.length === 0) { - core.info('🍽️ No suitable candidate issues - the plate is empty!'); - core.setOutput('has_issues', 'false'); - } else { - core.setOutput('has_issues', 'true'); - } - } catch (error) { - core.error(`Error searching for issues: ${error.message}`); - core.setOutput('issue_count', 0); - core.setOutput('issue_numbers', ''); - core.setOutput('issue_list', ''); - core.setOutput('has_issues', 'false'); - } safe-outputs: assign-to-agent: @@ -399,7 +398,7 @@ Find up to three issues that need work and assign them to the Copilot coding age ### 1. Review Pre-Searched and Prioritized Issue List -The issue search has already been performed in a previous job with smart filtering and prioritization: +The issue search has already been performed in the pre-activation job with smart filtering and prioritization: **Rate Limiting Protection:** - 🛡️ **Checks for rate-limited PRs in the last hour** before scheduling new work @@ -428,12 +427,12 @@ Issues are scored and sorted by priority: - Has any priority label: +10 points - Age bonus: +0-20 points (older issues get slight priority) -**Issue Count**: ${{ needs.search_issues.outputs.issue_count }} -**Issue Numbers**: ${{ needs.search_issues.outputs.issue_numbers }} +**Issue Count**: ${{ needs.pre_activation.outputs.issue_count }} +**Issue Numbers**: ${{ needs.pre_activation.outputs.issue_numbers }} **Available Issues (sorted by priority score):** ``` -${{ needs.search_issues.outputs.issue_list }} +${{ needs.pre_activation.outputs.issue_list }} ``` Work with this pre-fetched, filtered, and prioritized list of issues. Do not perform additional searches - the issue numbers are already identified above, sorted from highest to lowest priority. @@ -459,7 +458,7 @@ For issues with the "task" or "plan" label, check if they are sub-issues linked ### 2. Review the Pre-Filtered Issue List -The search job has already performed comprehensive filtering, including: +The pre-activation job has already performed comprehensive filtering, including: - Issues already assigned to Copilot - Issues with open PRs linked to them (from any author) - Issues with closed/merged PRs (treated as complete) @@ -568,7 +567,7 @@ A successful run means: ## Error Handling If anything goes wrong or no work can be assigned: -- **Rate limiting detected**: The workflow automatically skips (no action needed - the search job handles this) +- **Rate limiting detected**: The workflow automatically skips (no action needed - the pre-activation job handles this) - **No issues found**: Use the `noop` tool with message: "🍽️ No suitable candidate issues - the plate is empty!" - **All issues assigned**: Use the `noop` tool with message: "🍽️ All issues are already being worked on!" - **No suitable separate issues**: Use the `noop` tool explaining which issues were considered and why they couldn't be assigned (e.g., overlapping topics, sibling PRs, etc.) diff --git a/docs/src/content/docs/guides/deterministic-agentic-patterns.md b/docs/src/content/docs/guides/deterministic-agentic-patterns.md index d08733e48dd..4ab31dc8333 100644 --- a/docs/src/content/docs/guides/deterministic-agentic-patterns.md +++ b/docs/src/content/docs/guides/deterministic-agentic-patterns.md @@ -94,7 +94,74 @@ Pass data between jobs via artifacts, job outputs, or environment variables. ## Custom Trigger Filtering -Use a deterministic job to compute whether the agent should run, expose the result as a job output, and reference it with `if:`. The compiler automatically adds the filter job as a dependency of the activation job, so when the condition is false the workflow run is **skipped** (not failed), keeping the Actions tab clean. +### Inline Steps (`on.steps:`) — Preferred + +Inject deterministic steps directly into the pre-activation job using `on.steps:`. This saves **one workflow job** compared to the multi-job pattern and is the recommended approach for lightweight filtering: + +```yaml wrap title=".github/workflows/smart-responder.md" +--- +on: + issues: + types: [opened] + steps: + - id: check + env: + LABELS: ${{ toJSON(github.event.issue.labels.*.name) }} + run: echo "$LABELS" | grep -q '"bug"' + # exits 0 (outcome: success) if the label is found, 1 (outcome: failure) if not +engine: copilot +safe-outputs: + add-comment: + +if: needs.pre_activation.outputs.check_result == 'success' +--- + +# Bug Issue Responder + +Triage bug report: "${{ github.event.issue.title }}" and add-comment with a summary of the next steps. +``` + +Each step with an `id` gets an auto-wired output `_result` set to `${{ steps..outcome }}` — `success` when the step's exit code is 0, `failure` when non-zero. Gate the workflow by checking `needs.pre_activation.outputs._result == 'success'`. + +To pass an explicit value rather than relying on exit codes, set a step output and re-expose it via `jobs.pre-activation.outputs`: + +```yaml wrap +jobs: + pre-activation: + outputs: + has_bug_label: ${{ steps.check.outputs.has_bug_label }} + +if: needs.pre_activation.outputs.has_bug_label == 'true' +``` + +When `on.steps:` need GitHub API access, use `on.permissions:` to grant the required scopes to the pre-activation job: + +```yaml wrap +on: + schedule: every 30m + permissions: + issues: read + steps: + - id: search + uses: actions/github-script@v8 + with: + script: | + const open = await github.rest.issues.listForRepo({ ...context.repo, state: 'open' }); + core.setOutput('has_work', open.data.length > 0 ? 'true' : 'false'); + +jobs: + pre-activation: + outputs: + has_work: ${{ steps.search.outputs.has_work }} + +if: needs.pre_activation.outputs.has_work == 'true' +``` + +See [Pre-Activation Steps](/gh-aw/reference/triggers/#pre-activation-steps-onsteps) and [Pre-Activation Permissions](/gh-aw/reference/triggers/#pre-activation-permissions-onpermissions) for full documentation. + +### Multi-Job Pattern — For Complex Cases + +Use a separate `jobs:` entry when filtering requires heavy tooling (checkout, compiled tools, multiple runners): ```yaml wrap title=".github/workflows/smart-responder.md" --- @@ -129,7 +196,7 @@ if: needs.filter.outputs.should-run == 'true' Triage bug report: "${{ github.event.issue.title }}" and add-comment with a summary of the next steps. ``` -When `should-run` is `false`, GitHub marks the dependent jobs as **skipped** rather than failed, so no red X appears in the Actions tab and the Workflow Failure issue mechanism is not triggered. +The compiler automatically adds the filter job as a dependency of the activation job, so when the condition is false the workflow run is **skipped** (not failed), keeping the Actions tab clean. ### Simple Context Conditions @@ -220,6 +287,8 @@ Reference in prompts: "Analyze issues in `/tmp/gh-aw/agent/issues.json` and PRs ## Related Documentation +- [Pre-Activation Steps](/gh-aw/reference/triggers/#pre-activation-steps-onsteps) - Inline step injection into the pre-activation job +- [Pre-Activation Permissions](/gh-aw/reference/triggers/#pre-activation-permissions-onpermissions) - Grant additional scopes for `on.steps:` API calls - [Custom Safe Outputs](/gh-aw/reference/custom-safe-outputs/) - Custom post-processing jobs - [Frontmatter Reference](/gh-aw/reference/frontmatter/) - Configuration options - [Compilation Process](/gh-aw/reference/compilation-process/) - How jobs are orchestrated diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index e51d938a77b..8d70139abf7 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -37,6 +37,8 @@ The `on:` section uses standard GitHub Actions syntax to define workflow trigger - `skip-bots:` - Skip workflow execution for specific GitHub actors - `skip-if-match:` - Skip execution when a search query has matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth) - `skip-if-no-match:` - Skip execution when a search query has no matches (supports `scope: none`; use top-level `on.github-token` / `on.github-app` for custom auth) +- `steps:` - Inject custom deterministic steps into the pre-activation job (saves one workflow job vs. multi-job pattern) +- `permissions:` - Grant additional GitHub token scopes to the pre-activation job (for use with `on.steps:` API calls) - `github-token:` - Custom token for activation job reactions, status comments, and skip-if search queries - `github-app:` - GitHub App for minting a short-lived token used by the activation job and all skip-if search steps diff --git a/docs/src/content/docs/reference/triggers.md b/docs/src/content/docs/reference/triggers.md index f218491feed..da20e621d21 100644 --- a/docs/src/content/docs/reference/triggers.md +++ b/docs/src/content/docs/reference/triggers.md @@ -496,6 +496,86 @@ on: owner: myorg ``` +### Pre-Activation Steps (`on.steps:`) + +Inject custom deterministic steps directly into the pre-activation job. Steps run after all built-in checks (membership, stop-time, skip-if, etc.) and **before** agent execution. This saves one workflow job compared to the multi-job pattern and keeps filtering logic co-located with the trigger configuration. + +```yaml wrap +on: + issues: + types: [opened] + steps: + - name: Check issue label + id: label_check + env: + LABELS: ${{ toJSON(github.event.issue.labels.*.name) }} + run: echo "$LABELS" | grep -q '"bug"' + # exits 0 (outcome: success) if the label is found, 1 (outcome: failure) if not + +if: needs.pre_activation.outputs.label_check_result == 'success' +``` + +Each step with an `id` automatically gets an output `_result` wired to `${{ steps..outcome }}` (values: `success`, `failure`, `cancelled`, `skipped`). This lets you gate the workflow on whether the step **succeeded or failed** via its exit code. + +To pass an explicit value rather than relying on exit codes, set a step output and re-expose it via `jobs.pre-activation.outputs`: + +```yaml wrap +on: + issues: + types: [opened] + steps: + - name: Check issue label + id: label_check + env: + LABELS: ${{ toJSON(github.event.issue.labels.*.name) }} + run: | + if echo "$LABELS" | grep -q '"bug"'; then + echo "has_bug_label=true" >> "$GITHUB_OUTPUT" + else + echo "has_bug_label=false" >> "$GITHUB_OUTPUT" + fi + +jobs: + pre-activation: + outputs: + has_bug_label: ${{ steps.label_check.outputs.has_bug_label }} + +if: needs.pre_activation.outputs.has_bug_label == 'true' +``` + +Explicit outputs defined in `jobs.pre-activation.outputs` take precedence over auto-wired `_result` outputs on key collision. + +### Pre-Activation Permissions (`on.permissions:`) + +Grant additional GitHub token permission scopes to the pre-activation job. Use when `on.steps:` make GitHub API calls that require permissions beyond the defaults. + +```yaml wrap +on: + schedule: every 30m + permissions: + issues: read + pull-requests: read + steps: + - name: Search for candidate issues + id: search + uses: actions/github-script@v8 + with: + script: | + const issues = await github.rest.issues.listForRepo(context.repo); + core.setOutput('has_issues', issues.data.length > 0 ? 'true' : 'false'); + +jobs: + pre-activation: + outputs: + has_issues: ${{ steps.search.outputs.has_issues }} + +if: needs.pre_activation.outputs.has_issues == 'true' +``` + +Supported permission scopes: `actions`, `checks`, `contents`, `deployments`, `discussions`, `issues`, `packages`, `pages`, `pull-requests`, `repository-projects`, `security-events`, `statuses`. + +`on.permissions` is merged on top of any permissions already required by the pre-activation job (e.g., `contents: read` for dev-mode checkout, `actions: read` for rate limiting). + ## Trigger Shorthands Instead of writing full YAML trigger configurations, you can use natural-language shorthand strings with `on:`. The compiler expands these into standard GitHub Actions trigger syntax and automatically includes `workflow_dispatch` so the workflow can also be run manually. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 21014dfdaed..9bc4eeebfac 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -322,7 +322,7 @@ { "type": "array", "minItems": 1, - "description": "Array of label names — any of these labels will trigger the workflow.", + "description": "Array of label names \u2014 any of these labels will trigger the workflow.", "items": { "type": "string", "minLength": 1, @@ -343,7 +343,7 @@ { "type": "array", "minItems": 1, - "description": "Array of label names — any of these labels will trigger the workflow.", + "description": "Array of label names \u2014 any of these labels will trigger the workflow.", "items": { "type": "string", "minLength": 1, @@ -1589,6 +1589,131 @@ "private-key": "${{ secrets.APP_PRIVATE_KEY }}" } ] + }, + "steps": { + "type": "array", + "description": "Steps to inject into the pre-activation job. These steps run after all built-in checks (membership, stop-time, skip-if, etc.) and their results are exposed as pre-activation outputs. Use 'id' on steps to reference their results via needs.pre_activation.outputs._result.", + "items": { + "type": "object", + "description": "A GitHub Actions step to inject into the pre-activation job", + "properties": { + "name": { + "type": "string", + "description": "Optional name for the step" + }, + "id": { + "type": "string", + "description": "Optional step ID. When set, the step result is exposed as needs.pre_activation.outputs._result" + }, + "run": { + "type": "string", + "description": "Shell command to run" + }, + "uses": { + "type": "string", + "description": "Action to use (e.g., 'actions/checkout@v4')" + }, + "with": { + "type": "object", + "description": "Input parameters for the action", + "additionalProperties": true + }, + "env": { + "type": "object", + "description": "Environment variables for the step", + "additionalProperties": { + "type": "string" + } + }, + "if": { + "type": "string", + "description": "Conditional expression for the step" + }, + "continue-on-error": { + "type": "boolean", + "description": "Whether to continue if the step fails" + } + }, + "additionalProperties": true + }, + "examples": [ + [ + { + "name": "Gate check", + "id": "gate", + "run": "echo 'Checking gate...' && exit 0" + } + ] + ] + }, + "permissions": { + "description": "Additional permissions for the pre-activation job. Use to declare extra scopes required by on.steps (e.g., issues: read for GitHub API calls in steps).", + "oneOf": [ + { + "type": "object", + "description": "Map of permission scope to level", + "properties": { + "actions": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "checks": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "contents": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "deployments": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "discussions": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "issues": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "packages": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "pages": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "pull-requests": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "repository-projects": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "security-events": { + "type": "string", + "enum": ["read", "write", "none"] + }, + "statuses": { + "type": "string", + "enum": ["read", "write", "none"] + } + }, + "additionalProperties": false + } + ], + "examples": [ + { + "issues": "read" + }, + { + "issues": "read", + "pull-requests": "read" + } + ] } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 9756e09c833..a2c7e95f119 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -243,10 +243,11 @@ func (c *Compiler) buildPreActivationAndActivationJobs(data *WorkflowData, front hasSkipBots := len(data.SkipBots) > 0 hasCommandTrigger := len(data.Command) > 0 hasRateLimit := data.RateLimit != nil - compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasCommand=%v, hasRateLimit=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasCommandTrigger, hasRateLimit) + hasOnSteps := len(data.OnSteps) > 0 + compilerJobsLog.Printf("Job configuration: needsPermissionCheck=%v, hasStopTime=%v, hasSkipIfMatch=%v, hasSkipIfNoMatch=%v, hasSkipRoles=%v, hasSkipBots=%v, hasCommand=%v, hasRateLimit=%v, hasOnSteps=%v", needsPermissionCheck, hasStopTime, hasSkipIfMatch, hasSkipIfNoMatch, hasSkipRoles, hasSkipBots, hasCommandTrigger, hasRateLimit, hasOnSteps) - // Build pre-activation job if needed (combines membership checks, stop-time validation, skip-if-match check, skip-if-no-match check, skip-roles check, skip-bots check, rate limit check, and command position check) - if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit { + // Build pre-activation job if needed (combines membership checks, stop-time validation, skip-if-match check, skip-if-no-match check, skip-roles check, skip-bots check, rate limit check, command position check, and on.steps injection) + if needsPermissionCheck || hasStopTime || hasSkipIfMatch || hasSkipIfNoMatch || hasSkipRoles || hasSkipBots || hasCommandTrigger || hasRateLimit || hasOnSteps { compilerJobsLog.Print("Building pre-activation job") preActivationJob, err := c.buildPreActivationJob(data, needsPermissionCheck) if err != nil { diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 8799258d911..90fd4d35641 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -728,5 +728,15 @@ func (c *Compiler) processOnSectionAndFilters( // Apply label filter if specified c.applyLabelFilter(workflowData, frontmatter) + // Extract on.steps for pre-activation step injection + onSteps, err := extractOnSteps(frontmatter) + if err != nil { + return err + } + workflowData.OnSteps = onSteps + + // Extract on.permissions for pre-activation job permissions + workflowData.OnPermissions = extractOnPermissions(frontmatter) + return nil } diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 8ebdbddcb6b..a049bd25927 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -55,6 +55,15 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec perms.Set(PermissionActions, PermissionRead) } + // Merge on.permissions into the pre-activation job permissions. + // on.permissions lets users declare extra scopes required by their on.steps steps. + if data.OnPermissions != nil { + if perms == nil { + perms = NewPermissions() + } + perms.Merge(data.OnPermissions) + } + // Set permissions if any were configured if perms != nil { permissions = perms.RenderToYAML() @@ -195,6 +204,23 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, customSteps...) } + // Append on.steps if present (injected after other checks) + var onStepIDs []string + if len(data.OnSteps) > 0 { + compilerActivationJobsLog.Printf("Adding %d on.steps to pre-activation job", len(data.OnSteps)) + for i, stepMap := range data.OnSteps { + stepYAML, err := c.convertStepToYAML(stepMap) + if err != nil { + return nil, fmt.Errorf("failed to convert on.steps[%d] to YAML: %w", i, err) + } + steps = append(steps, stepYAML) + // Collect step IDs for output wiring + if id, ok := stepMap["id"].(string); ok && id != "" { + onStepIDs = append(onStepIDs, id) + } + } + } + // Generate the activated output expression using expression builders var activatedNode ConditionNode @@ -283,9 +309,18 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Build the final expression if len(conditions) == 0 { - // This should never happen - it means pre-activation job was created without any checks - // If we reach this point, it's a developer error in the compiler logic - return nil, errors.New("developer error: pre-activation job created without permission check or stop-time configuration") + // Pre-activation was created solely for on.steps injection. + // The activated output is unconditionally true; the user controls + // agent execution through their own if: condition referencing the + // on.steps outputs (e.g., needs.pre_activation.outputs.gate_result). + if len(data.OnSteps) > 0 { + compilerActivationJobsLog.Printf("Pre-activation created with on.steps only (%d steps); activated output is unconditionally true", len(data.OnSteps)) + activatedNode = BuildStringLiteral("true") + } else { + // This should never happen - it means pre-activation job was created without any checks + // If we reach this point, it's a developer error in the compiler logic + return nil, errors.New("developer error: pre-activation job created without permission check or stop-time configuration") + } } else if len(conditions) == 1 { // Single condition activatedNode = conditions[0] @@ -313,7 +348,21 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec outputs[constants.MatchedCommandOutput] = "''" } - // Merge custom outputs from jobs.pre-activation if present + // Wire on.steps step outcomes as pre-activation outputs. + // For each step with an id, emit output "_result: ${{ steps..outcome }}" + // so users can reference them with: needs.pre_activation.outputs._result + // This is done BEFORE merging custom outputs so that explicit user-defined outputs + // in jobs.pre-activation.outputs take precedence over the auto-wired values. + if len(onStepIDs) > 0 { + compilerActivationJobsLog.Printf("Wiring %d on.steps step outcomes as pre-activation outputs", len(onStepIDs)) + for _, id := range onStepIDs { + outputKey := id + "_result" + outputs[outputKey] = fmt.Sprintf("${{ steps.%s.outcome }}", id) + } + } + + // Merge custom outputs from jobs.pre-activation if present. + // Custom outputs are applied last so they take precedence over auto-wired on.steps outputs. if len(customOutputs) > 0 { compilerActivationJobsLog.Printf("Adding %d custom outputs to pre-activation job", len(customOutputs)) maps.Copy(outputs, customOutputs) @@ -322,8 +371,10 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Pre-activation job uses the user's original if condition (data.If) // The workflow_run safety check is NOT applied here - it's only on the activation job // Don't include conditions that reference custom job outputs (those belong on the agent job) + // Also don't include conditions that reference pre_activation outputs - those are outputs of this + // very job and can only be evaluated by downstream jobs (activation, agent). var jobIfCondition string - if !c.referencesCustomJobOutputs(data.If, data.Jobs) { + if !c.referencesCustomJobOutputs(data.If, data.Jobs) && !referencesPreActivationOutputs(data.If) { jobIfCondition = data.If } @@ -478,3 +529,80 @@ func (c *Compiler) resolvePreActivationSkipIfToken(data *WorkflowData) string { } return "" } + +// extractOnSteps extracts the 'steps' field from the 'on:' section of frontmatter. +// These steps are injected into the pre-activation job and their step outcome is wired +// as pre-activation outputs so users can reference them with: +// +// needs.pre_activation.outputs._result (contains outcome: success/failure/cancelled/skipped) +// +// Returns nil if on.steps is not configured. +// Returns an error if on.steps is not an array or contains non-object items. +func extractOnSteps(frontmatter map[string]any) ([]map[string]any, error) { + onValue, exists := frontmatter["on"] + if !exists || onValue == nil { + return nil, nil + } + + onMap, ok := onValue.(map[string]any) + if !ok { + return nil, nil + } + + stepsValue, exists := onMap["steps"] + if !exists || stepsValue == nil { + return nil, nil + } + + stepsList, ok := stepsValue.([]any) + if !ok { + return nil, fmt.Errorf("on.steps must be an array, got %T", stepsValue) + } + + result := make([]map[string]any, 0, len(stepsList)) + for i, step := range stepsList { + stepMap, ok := step.(map[string]any) + if !ok { + return nil, fmt.Errorf("on.steps[%d] must be an object, got %T", i, step) + } + result = append(result, stepMap) + } + + return result, nil +} + +// extractOnPermissions extracts the 'permissions' field from the 'on:' section of frontmatter. +// These permissions are merged into the pre-activation job permissions, allowing users to declare +// extra scopes required by their on.steps (e.g., issues: read for GitHub API calls). +// +// Returns nil if on.permissions is not configured. +func extractOnPermissions(frontmatter map[string]any) *Permissions { + onValue, exists := frontmatter["on"] + if !exists || onValue == nil { + return nil + } + + onMap, ok := onValue.(map[string]any) + if !ok { + return nil + } + + permsValue, exists := onMap["permissions"] + if !exists || permsValue == nil { + return nil + } + + parser := NewPermissionsParserFromValue(permsValue) + return parser.ToPermissions() +} + +// referencesPreActivationOutputs returns true if the condition references the pre_activation job's +// own outputs (e.g., "needs.pre_activation.outputs.foo"). Such conditions cannot be applied to the +// pre_activation job itself (a job cannot reference its own outputs), so they are deferred to +// downstream jobs (activation, agent). +func referencesPreActivationOutputs(condition string) bool { + if condition == "" { + return false + } + return strings.Contains(condition, "needs."+string(constants.PreActivationJobName)+".outputs.") +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index e267e910542..c1e81d5e6a7 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -381,6 +381,8 @@ type WorkflowData struct { SkipIfNoMatch *SkipIfNoMatchConfig // skip-if-no-match configuration with query and min threshold SkipRoles []string // roles to skip workflow for (e.g., [admin, maintainer, write]) SkipBots []string // users to skip workflow for (e.g., [user1, user2]) + OnSteps []map[string]any // steps to inject into the pre-activation job from on.steps + OnPermissions *Permissions // additional permissions for the pre-activation job from on.permissions ManualApproval string // environment name for manual approval from on: section Command []string // for /command trigger support - multiple command names CommandEvents []string // events where command should be active (nil = all events) diff --git a/pkg/workflow/frontmatter_extraction_yaml.go b/pkg/workflow/frontmatter_extraction_yaml.go index 7b4c2fce285..4c774f827df 100644 --- a/pkg/workflow/frontmatter_extraction_yaml.go +++ b/pkg/workflow/frontmatter_extraction_yaml.go @@ -100,7 +100,7 @@ func (c *Compiler) extractTopLevelYAMLSection(frontmatter map[string]any, key st return yamlStr } -// commentOutProcessedFieldsInOnSection comments out draft, fork, forks, names, manual-approval, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, and lock-for-agent fields in the on section +// commentOutProcessedFieldsInOnSection comments out draft, fork, forks, names, manual-approval, stop-after, skip-if-match, skip-if-no-match, skip-roles, reaction, lock-for-agent, steps, and permissions fields in the on section // These fields are processed separately and should be commented for documentation // Exception: names fields in sections with __gh_aw_native_label_filter__ marker in frontmatter are NOT commented out func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmatter map[string]any) string { @@ -139,45 +139,53 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat inRolesArray := false inBotsArray := false inGitHubApp := false + inOnSteps := false + inOnPermissions := false currentSection := "" // Track which section we're in ("issues", "pull_request", "discussion", or "issue_comment") for _, line := range lines { - // Check if we're entering a pull_request, issues, discussion, or issue_comment section - if strings.Contains(line, "pull_request:") { - inPullRequest = true - inIssues = false - inDiscussion = false - inIssueComment = false - currentSection = "pull_request" - result = append(result, line) - continue - } - if strings.Contains(line, "issues:") { - inIssues = true - inPullRequest = false - inDiscussion = false - inIssueComment = false - currentSection = "issues" - result = append(result, line) - continue - } - if strings.Contains(line, "discussion:") { - inDiscussion = true - inPullRequest = false - inIssues = false - inIssueComment = false - currentSection = "discussion" - result = append(result, line) - continue - } - if strings.Contains(line, "issue_comment:") { - inIssueComment = true - inPullRequest = false - inIssues = false - inDiscussion = false - currentSection = "issue_comment" - result = append(result, line) - continue + // Check if we're entering a pull_request, issues, discussion, or issue_comment section. + // Skip these checks when inside on.permissions or on.steps to avoid false matches. + // Example: ` issues: read` inside on.permissions was previously matched as the + // `issues:` event trigger, incorrectly entering the inIssues state and suppressing + // the permission comment-out logic. + if !inOnPermissions && !inOnSteps { + if strings.Contains(line, "pull_request:") { + inPullRequest = true + inIssues = false + inDiscussion = false + inIssueComment = false + currentSection = "pull_request" + result = append(result, line) + continue + } + if strings.Contains(line, "issues:") { + inIssues = true + inPullRequest = false + inDiscussion = false + inIssueComment = false + currentSection = "issues" + result = append(result, line) + continue + } + if strings.Contains(line, "discussion:") { + inDiscussion = true + inPullRequest = false + inIssues = false + inIssueComment = false + currentSection = "discussion" + result = append(result, line) + continue + } + if strings.Contains(line, "issue_comment:") { + inIssueComment = true + inPullRequest = false + inIssues = false + inDiscussion = false + currentSection = "issue_comment" + result = append(result, line) + continue + } } // Check if we're leaving the pull_request, issues, discussion, or issue_comment section (new top-level key or end of indent) @@ -232,6 +240,17 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat inBotsArray = true } + // Check if we're entering on.steps array + if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "steps:") { + inOnSteps = true + } + + // Check if we're entering on.permissions object + if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inOnPermissions && + strings.HasPrefix(trimmedLine, "permissions:") { + inOnPermissions = true + } + // Check if we're entering skip-if-match object if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfMatch { // Check both uncommented and commented forms @@ -353,6 +372,25 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat } } + // Check if we're leaving the on.steps array by encountering another top-level field + if inOnSteps && strings.TrimSpace(line) != "" { + lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) + // If this is a line at the same level as steps (2 spaces) and not a dash or comment, we're out + if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "steps:") && !strings.HasPrefix(trimmedLine, "#") { + inOnSteps = false + } + } + + // Check if we're leaving the on.permissions object by encountering another top-level field + if inOnPermissions && strings.TrimSpace(line) != "" && + !strings.HasPrefix(trimmedLine, "permissions:") && + !strings.HasPrefix(trimmedLine, "# permissions:") { + lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) + if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { + inOnPermissions = false + } + } + // Determine if we should comment out this line shouldComment := false var commentReason string @@ -407,6 +445,20 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat // Comment out array items in bots shouldComment = true commentReason = " # Bots processed as bot check in pre-activation job" + } else if strings.HasPrefix(trimmedLine, "steps:") { + shouldComment = true + commentReason = " # Steps injected into pre-activation job" + } else if inOnSteps { + // Comment out all content of on.steps (both array items and their nested fields) + shouldComment = true + commentReason = "" + } else if strings.HasPrefix(trimmedLine, "permissions:") { + shouldComment = true + commentReason = " # Permissions applied to pre-activation job" + } else if inOnPermissions { + // Comment out all nested permission scope lines + shouldComment = true + commentReason = "" } else if strings.HasPrefix(trimmedLine, "reaction:") { shouldComment = true commentReason = " # Reaction processed as activation job step" diff --git a/pkg/workflow/on_steps_test.go b/pkg/workflow/on_steps_test.go new file mode 100644 index 00000000000..ea00e75d0c3 --- /dev/null +++ b/pkg/workflow/on_steps_test.go @@ -0,0 +1,509 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/stringutil" + "github.com/github/gh-aw/pkg/testutil" +) + +// TestOnSteps tests that on.steps are injected into the pre-activation job and their +// results are wired as pre-activation outputs. +func TestOnSteps(t *testing.T) { + tmpDir := testutil.TempDir(t, "on-steps-test") + compiler := NewCompiler() + + t.Run("on_steps_creates_pre_activation_job", func(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: null + roles: all + steps: + - name: Gate check + id: gate + run: echo "checking..." +engine: copilot +--- + +Test workflow with on.steps creating pre-activation job +` + workflowFile := filepath.Join(tmpDir, "test-on-steps-only.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("CompileWorkflow() returned error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Verify pre_activation job is created + if !strings.Contains(lockContentStr, "pre_activation:") { + t.Error("Expected pre_activation job to be created when on.steps is used") + } + + // Verify the step is included + if !strings.Contains(lockContentStr, "Gate check") { + t.Error("Expected Gate check step to be in pre_activation job") + } + if !strings.Contains(lockContentStr, "id: gate") { + t.Error("Expected gate step ID to be in pre_activation job") + } + + // Verify the output is wired + if !strings.Contains(lockContentStr, "gate_result: ${{ steps.gate.outcome }}") { + t.Errorf("Expected gate_result output to be wired. Lock file:\n%s", lockContentStr) + } + + // Verify activated output is 'true' when on.steps is the only condition + if !strings.Contains(lockContentStr, "activated: ${{ 'true' }}") { + t.Errorf("Expected activated output to be 'true'. Lock file:\n%s", lockContentStr) + } + }) + + t.Run("on_steps_with_other_checks", func(t *testing.T) { + workflowContent := `--- +on: + issues: + types: [opened] + roles: [admin, maintainer] + steps: + - name: Gate check + id: gate + run: echo "checking..." +engine: copilot +--- + +Test workflow with on.steps combined with role checks +` + workflowFile := filepath.Join(tmpDir, "test-on-steps-with-roles.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("CompileWorkflow() returned error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // The on.steps step should be present + if !strings.Contains(lockContentStr, "Gate check") { + t.Error("Expected Gate check step to be in pre_activation job") + } + + // The gate_result output should be wired + if !strings.Contains(lockContentStr, "gate_result: ${{ steps.gate.outcome }}") { + t.Errorf("Expected gate_result output. Lock file:\n%s", lockContentStr) + } + + // The activated output should use the membership check (not 'true') + if strings.Contains(lockContentStr, "activated: ${{ 'true' }}") { + t.Error("Expected activated output to use membership check, not 'true'") + } + if !strings.Contains(lockContentStr, "check_membership") { + t.Error("Expected membership check step to be present") + } + }) + + t.Run("on_steps_multiple_steps_with_ids", func(t *testing.T) { + workflowContent := `--- +on: + workflow_dispatch: null + roles: all + steps: + - name: First check + id: first + run: echo "first..." + - name: Second check + id: second + run: echo "second..." + - name: No ID step + run: echo "no id" +engine: copilot +--- + +Test workflow with multiple on.steps +` + workflowFile := filepath.Join(tmpDir, "test-on-steps-multi.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("CompileWorkflow() returned error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Both step IDs should have outputs + if !strings.Contains(lockContentStr, "first_result: ${{ steps.first.outcome }}") { + t.Errorf("Expected first_result output. Lock file:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, "second_result: ${{ steps.second.outcome }}") { + t.Errorf("Expected second_result output. Lock file:\n%s", lockContentStr) + } + + // All three steps should be present + if !strings.Contains(lockContentStr, "First check") { + t.Error("Expected First check step to be in pre_activation job") + } + if !strings.Contains(lockContentStr, "Second check") { + t.Error("Expected Second check step to be in pre_activation job") + } + if !strings.Contains(lockContentStr, "No ID step") { + t.Error("Expected No ID step to be in pre_activation job") + } + }) + + t.Run("on_steps_step_appended_after_builtin_checks", func(t *testing.T) { + workflowContent := `--- +on: + issues: + types: [opened] + roles: [admin, maintainer] + steps: + - name: Gate check + id: gate + run: echo "checking..." +engine: copilot +--- + +Test on.steps are appended after built-in checks +` + workflowFile := filepath.Join(tmpDir, "test-on-steps-order.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("CompileWorkflow() returned error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // The membership check step should appear before the gate step in the pre_activation steps. + // Search for step-level indented IDs (8 spaces = within steps array under pre_activation job) + membershipStepIdx := strings.Index(lockContentStr, " id: check_membership") + gateStepIdx := strings.Index(lockContentStr, " id: gate") + if membershipStepIdx == -1 || gateStepIdx == -1 { + t.Fatalf("Expected both check_membership step and gate step to be present. Lock file:\n%s", lockContentStr) + } + if membershipStepIdx > gateStepIdx { + t.Error("Expected membership check step to appear before on.steps gate step in pre_activation job") + } + }) +} + +// TestExtractOnSteps tests the extractOnSteps function directly +func TestExtractOnSteps(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectSteps int + expectError bool + errorContains string + }{ + { + name: "no_on_section", + frontmatter: map[string]any{}, + expectSteps: 0, + expectError: false, + }, + { + name: "on_section_string", + frontmatter: map[string]any{ + "on": "push", + }, + expectSteps: 0, + expectError: false, + }, + { + name: "on_section_without_steps", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": nil, + }, + }, + expectSteps: 0, + expectError: false, + }, + { + name: "on_steps_with_steps", + frontmatter: map[string]any{ + "on": map[string]any{ + "issues": nil, + "steps": []any{ + map[string]any{"name": "Step 1", "id": "step1", "run": "echo ok"}, + map[string]any{"name": "Step 2", "run": "echo ok2"}, + }, + }, + }, + expectSteps: 2, + expectError: false, + }, + { + name: "on_steps_invalid_type", + frontmatter: map[string]any{ + "on": map[string]any{ + "steps": "not an array", + }, + }, + expectError: true, + errorContains: "on.steps must be an array", + }, + { + name: "on_steps_invalid_step_type", + frontmatter: map[string]any{ + "on": map[string]any{ + "steps": []any{"not a map"}, + }, + }, + expectError: true, + errorContains: "on.steps[0] must be an object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + steps, err := extractOnSteps(tt.frontmatter) + + if tt.expectError { + if err == nil { + t.Fatalf("Expected error containing '%s', but got none", tt.errorContains) + } + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorContains, err) + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(steps) != tt.expectSteps { + t.Errorf("Expected %d steps, got %d", tt.expectSteps, len(steps)) + } + }) + } +} + +// TestExtractOnPermissions tests the extractOnPermissions function directly +func TestExtractOnPermissions(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectNil bool + expectScopes map[string]string // scope -> level + }{ + { + name: "no_on_section", + frontmatter: map[string]any{}, + expectNil: true, + }, + { + name: "on_section_string", + frontmatter: map[string]any{ + "on": "push", + }, + expectNil: true, + }, + { + name: "on_section_without_permissions", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": nil, + }, + }, + expectNil: true, + }, + { + name: "on_permissions_issues_read", + frontmatter: map[string]any{ + "on": map[string]any{ + "issues": nil, + "permissions": map[string]any{ + "issues": "read", + }, + }, + }, + expectNil: false, + expectScopes: map[string]string{"issues": "read"}, + }, + { + name: "on_permissions_multiple_scopes", + frontmatter: map[string]any{ + "on": map[string]any{ + "issues": nil, + "permissions": map[string]any{ + "issues": "read", + "pull-requests": "read", + }, + }, + }, + expectNil: false, + expectScopes: map[string]string{"issues": "read", "pull-requests": "read"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + perms := extractOnPermissions(tt.frontmatter) + if tt.expectNil { + if perms != nil { + t.Errorf("Expected nil permissions, got non-nil") + } + return + } + if perms == nil { + t.Fatalf("Expected non-nil permissions, got nil") + } + for scope, wantLevel := range tt.expectScopes { + gotLevel, ok := perms.Get(convertStringToPermissionScope(scope)) + if !ok { + t.Errorf("Expected scope %s to be set", scope) + continue + } + if string(gotLevel) != wantLevel { + t.Errorf("Expected scope %s = %s, got %s", scope, wantLevel, gotLevel) + } + } + }) + } +} + +// TestOnPermissionsAppliedToPreActivation tests that on.permissions are applied to the pre-activation job +func TestOnPermissionsAppliedToPreActivation(t *testing.T) { + tmpDir := testutil.TempDir(t, "on-permissions-test") + compiler := NewCompiler() + + workflowContent := `--- +on: + workflow_dispatch: null + roles: [admin] + permissions: + issues: read + pull-requests: read + steps: + - name: Check something + id: check + uses: actions/github-script@v8 + with: + script: | + const issues = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo }); + core.setOutput('count', issues.data.length); +engine: copilot +--- + +Workflow with on.permissions +` + workflowFile := filepath.Join(tmpDir, "test-on-permissions.md") + if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + err := compiler.CompileWorkflow(workflowFile) + if err != nil { + t.Fatalf("CompileWorkflow() returned error: %v", err) + } + + lockFile := stringutil.MarkdownToLockFile(workflowFile) + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // The pre_activation job should have issues: read and pull-requests: read permissions + if !strings.Contains(lockContentStr, "issues: read") { + t.Error("Expected issues: read permission in pre_activation job") + } + if !strings.Contains(lockContentStr, "pull-requests: read") { + t.Error("Expected pull-requests: read permission in pre_activation job") + } + + // The on.permissions should be commented out in the compiled on: section + if !strings.Contains(lockContentStr, "# permissions:") { + t.Error("Expected on.permissions to be commented out in the on: section") + } +} + +// TestReferencesPreActivationOutputs tests the referencesPreActivationOutputs function +func TestReferencesPreActivationOutputs(t *testing.T) { + tests := []struct { + name string + condition string + expected bool + }{ + { + name: "empty_condition", + condition: "", + expected: false, + }, + { + name: "references_pre_activation_outputs", + condition: "needs.pre_activation.outputs.has_issues == 'true'", + expected: true, + }, + { + name: "references_custom_job_outputs", + condition: "needs.search_issues.outputs.has_issues == 'true'", + expected: false, + }, + { + name: "references_pre_activation_activated", + condition: "needs.pre_activation.outputs.activated == 'true'", + expected: true, + }, + { + name: "references_pre_activation_result_not_outputs", + condition: "needs.pre_activation.result == 'success'", + expected: false, + }, + { + name: "references_pre_activation_arbitrary_output", + condition: "needs.pre_activation.outputs.custom_gate_check == 'passed'", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := referencesPreActivationOutputs(tt.condition) + if result != tt.expected { + t.Errorf("referencesPreActivationOutputs(%q) = %v, want %v", tt.condition, result, tt.expected) + } + }) + } +}