diff --git a/.github/scripts/__tests__/agents-verifier-context.test.js b/.github/scripts/__tests__/agents-verifier-context.test.js index b12e66c85..765a28797 100644 --- a/.github/scripts/__tests__/agents-verifier-context.test.js +++ b/.github/scripts/__tests__/agents-verifier-context.test.js @@ -111,6 +111,13 @@ const buildGithubStub = ({ }, }); +const withEmptyJobs = (result) => ({ + ...result, + jobs_summary: { total: 0, conclusions: {}, samples: [], truncated: false }, + jobs_error_category: '', + jobs_error_message: '', +}); + test('buildVerifierContext skips when pull request is not merged', async () => { const core = buildCore(); const context = { @@ -768,27 +775,27 @@ test('buildVerifierContext selects CI results for the merge commit SHA', async ( assert.ok(headShas.length > 0); assert.ok(headShas.every((sha) => sha === 'merge-sha-333')); assert.deepEqual(result.ciResults, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'https://ci/gate-merge', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'success', run_url: 'https://ci/selftest-merge', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'PR 11 - Minimal invariant CI', conclusion: 'success', run_url: 'https://ci/pr11-merge', error_category: '', error_message: '', - }, + }), ]); const contextPath = result.contextPath || path.join(process.cwd(), 'verifier-context.md'); @@ -896,27 +903,27 @@ test('buildVerifierContext falls back to head SHA when merge runs are missing', assert.equal(result.shouldRun, true); assert.deepEqual(result.ciResults, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'https://ci/pr-00-gate.yml', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'success', run_url: 'https://ci/selftest-ci.yml', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'PR 11 - Minimal invariant CI', conclusion: 'success', run_url: 'https://ci/pr-11-ci-smoke.yml', error_category: '', error_message: '', - }, + }), ]); for (const workflowId of workflowIds) { assert.ok(calls.includes(`${workflowId}:merge-sha-555`)); @@ -983,27 +990,27 @@ test('buildVerifierContext uses merge commit SHA for push events', async () => { assert.ok(headShas.length > 0); assert.ok(headShas.every((sha) => sha === 'merge-sha-444')); assert.deepEqual(result.ciResults, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'https://ci/gate-push', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'success', run_url: 'https://ci/selftest-push', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'PR 11 - Minimal invariant CI', conclusion: 'success', run_url: 'https://ci/pr11-push', error_category: '', error_message: '', - }, + }), ]); const contextPath = result.contextPath || path.join(process.cwd(), 'verifier-context.md'); diff --git a/.github/scripts/__tests__/verifier-ci-query.test.js b/.github/scripts/__tests__/verifier-ci-query.test.js index 1de96c270..0bf8a9e93 100644 --- a/.github/scripts/__tests__/verifier-ci-query.test.js +++ b/.github/scripts/__tests__/verifier-ci-query.test.js @@ -5,6 +5,13 @@ const assert = require('node:assert/strict'); const { queryVerifierCiResults } = require('../verifier_ci_query.js'); +const withEmptyJobs = (result) => ({ + ...result, + jobs_summary: { total: 0, conclusions: {}, samples: [], truncated: false }, + jobs_error_category: '', + jobs_error_message: '', +}); + const buildGithubStub = ({ runsByWorkflow = {}, errorWorkflow = null, @@ -57,27 +64,27 @@ test('queryVerifierCiResults selects runs and reports conclusions', async () => }); assert.equal(results.length, 3); - assert.deepEqual(results[0], { + assert.deepEqual(results[0], withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'gate-url', error_category: '', error_message: '', - }); - assert.deepEqual(results[1], { + })); + assert.deepEqual(results[1], withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'in_progress', run_url: 'selftest-url', error_category: '', error_message: '', - }); - assert.deepEqual(results[2], { + })); + assert.deepEqual(results[2], withEmptyJobs({ workflow_name: 'PR 11', conclusion: 'not_found', run_url: '', error_category: '', error_message: '', - }); + })); }); test('queryVerifierCiResults supports workflowId/workflowName aliases', async () => { @@ -101,13 +108,13 @@ test('queryVerifierCiResults supports workflowId/workflowName aliases', async () }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'success', run_url: 'selftest-alias-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -124,13 +131,13 @@ test('queryVerifierCiResults treats query errors as api_error', async () => { }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'api_error', run_url: '', error_category: 'resource', error_message: 'listWorkflowRuns:pr-00-gate.yml failed after 1 attempt(s): boom', - }, + }), ]); }); @@ -153,13 +160,13 @@ test('queryVerifierCiResults uses latest run when no target SHA is provided', as }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'gate-latest-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -194,13 +201,13 @@ test('queryVerifierCiResults falls back to secondary SHA when primary has no run }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'head-url', error_category: '', error_message: '', - }, + }), ]); assert.deepEqual(headShas, ['merge-sha', 'head-sha']); }); @@ -228,27 +235,27 @@ test('queryVerifierCiResults falls back to default workflows', async () => { }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'gate-default-url', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'Selftest CI', conclusion: 'failure', run_url: 'selftest-default-url', error_category: '', error_message: '', - }, - { + }), + withEmptyJobs({ workflow_name: 'PR 11 - Minimal invariant CI', conclusion: 'success', run_url: 'pr11-default-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -269,13 +276,13 @@ test('queryVerifierCiResults uses API url when html_url is missing', async () => }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'api-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -296,13 +303,13 @@ test('queryVerifierCiResults treats completed runs without conclusion as unknown }); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'unknown', run_url: 'gate-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -341,13 +348,13 @@ test('queryVerifierCiResults retries transient errors and returns success', asyn assert.equal(attempts, 3); assert.equal(warnings.length, 2); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'success', run_url: 'retry-url', error_category: '', error_message: '', - }, + }), ]); }); @@ -381,13 +388,13 @@ test('queryVerifierCiResults returns api_error after max retries', async (t) => assert.equal(attempts, 4); assert.equal(warnings.length, 4); assert.deepEqual(results, [ - { + withEmptyJobs({ workflow_name: 'Gate', conclusion: 'api_error', run_url: '', error_category: 'transient', error_message: `listWorkflowRuns:pr-00-gate.yml failed after 4 attempt(s): status-${status}`, - }, + }), ]); }); } diff --git a/.github/workflows/agents-auto-pilot.yml b/.github/workflows/agents-auto-pilot.yml index 981bd066d..4339eaa01 100644 --- a/.github/workflows/agents-auto-pilot.yml +++ b/.github/workflows/agents-auto-pilot.yml @@ -115,6 +115,16 @@ jobs: if: steps.check_enabled.outputs.enabled == 'true' uses: actions/checkout@v6 + - name: Set up Node + if: steps.check_enabled.outputs.enabled == 'true' + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install GitHub API dependencies + if: steps.check_enabled.outputs.enabled == 'true' + run: npm install --no-save --no-package-lock @octokit/rest @octokit/auth-app + - name: Export load balancer tokens if: steps.check_enabled.outputs.enabled == 'true' uses: ./.github/actions/export-load-balancer-tokens @@ -128,10 +138,17 @@ jobs: uses: actions/github-script@v8 with: script: | - const { data } = await github.rest.repos.get({ + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + task: 'auto-pilot-resolve-workflows-ref', + }); + + const { data } = await withRetry((client) => client.rest.repos.get({ owner: 'stranske', repo: 'Workflows' - }); + })); if (!data?.default_branch) { core.setFailed('Could not determine Workflows default branch'); return; diff --git a/.github/workflows/agents-bot-comment-handler.yml b/.github/workflows/agents-bot-comment-handler.yml index 2d4fb815a..8322e345f 100644 --- a/.github/workflows/agents-bot-comment-handler.yml +++ b/.github/workflows/agents-bot-comment-handler.yml @@ -67,6 +67,7 @@ jobs: sparse-checkout: | .github/actions/export-load-balancer-tokens .github/scripts/github-api-with-retry.js + .github/scripts/token_load_balancer.js sparse-checkout-cone-mode: false - name: Export load balancer tokens uses: ./.github/actions/export-load-balancer-tokens @@ -74,6 +75,12 @@ jobs: github_token: ${{ github.token }} token_rotation_json: ${{ secrets.TOKEN_ROTATION_JSON }} token_rotation_env_keys: ${{ vars.TOKEN_ROTATION_ENV_KEYS }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20' + - name: Install GitHub API dependencies + run: npm install --no-save --no-package-lock @octokit/rest @octokit/auth-app - name: Resolve PR number and check conditions id: resolve uses: actions/github-script@v8 @@ -81,14 +88,17 @@ jobs: script: | const fs = require('fs'); const retryHelperPath = './.github/scripts/github-api-with-retry.js'; - const retryHelpers = fs.existsSync(retryHelperPath) - ? require(retryHelperPath) - : { - withRetry: (fn) => fn(), - paginateWithRetry: (githubInstance, method, params) => - githubInstance.paginate(method, params), - }; - const { withRetry } = retryHelpers; + const retryHelpers = fs.existsSync(retryHelperPath) ? require(retryHelperPath) : null; + let withRetry = (fn) => fn(); + if (retryHelpers?.createTokenAwareRetry) { + ({ withRetry } = await retryHelpers.createTokenAwareRetry({ + github, + core, + task: 'bot-comment-handler', + })); + } else if (retryHelpers?.withRetry) { + ({ withRetry } = retryHelpers); + } const eventName = context.eventName; let prNumber = null; @@ -116,10 +126,10 @@ jobs: let defaultBranch = context.payload.repository?.default_branch; if (!defaultBranch) { - const repoResponse = await github.rest.repos.get({ + const repoResponse = await withRetry((client) => client.rest.repos.get({ owner: context.repo.owner, repo: context.repo.repo - }); + })); defaultBranch = repoResponse.data?.default_branch; } if (!defaultBranch) { @@ -154,7 +164,7 @@ jobs: // Check if PR has agent label let pr; try { - const response = await withRetry(() => github.rest.pulls.get({ + const response = await withRetry((client) => client.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 9c8676e37..633aa0048 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -38,19 +38,41 @@ jobs: - name: Checkout for API helpers uses: actions/checkout@v6 with: - sparse-checkout: .github/scripts + sparse-checkout: | + .github/actions/export-load-balancer-tokens + .github/scripts/github-api-with-retry.js + .github/scripts/token_load_balancer.js sparse-checkout-cone-mode: false + - name: Export load balancer tokens + uses: ./.github/actions/export-load-balancer-tokens + with: + github_token: ${{ github.token }} + token_rotation_json: ${{ secrets.TOKEN_ROTATION_JSON }} + token_rotation_env_keys: ${{ vars.TOKEN_ROTATION_ENV_KEYS }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install GitHub API dependencies + run: npm install --no-save --no-package-lock @octokit/rest @octokit/auth-app + - name: Resolve PR context id: context uses: actions/github-script@v8 with: github-token: ${{ secrets.AGENTS_AUTOMATION_PAT || secrets.ACTIONS_BOT_PAT || github.token }} script: | - const path = require('path'); - const { paginateWithBackoff } = require( - path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/api-helpers.js') + const { createTokenAwareRetry } = require( + './.github/scripts/github-api-with-retry.js' ); + const { paginateWithRetry } = await createTokenAwareRetry({ + github, + core, + task: 'autofix-loop-resolve-context', + }); const pr = context.payload.pull_request; if (!pr) { @@ -103,11 +125,10 @@ jobs: const { owner, repo } = context.repo; let files = []; try { - files = await paginateWithBackoff( - github, + files = await paginateWithRetry( github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }, - { maxRetries: 3, core } + { maxRetries: 3 } ); } catch (error) { const message = String(error?.message || error || ''); diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index 8ed983536..a5313cd42 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -246,10 +246,23 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.event.pull_request.head.sha || github.sha }} sparse-checkout: | + .github/actions/export-load-balancer-tokens .github/scripts .github/config tools sparse-checkout-cone-mode: false + - name: Export load balancer tokens + uses: ./.github/actions/export-load-balancer-tokens + with: + github_token: ${{ github.token }} + token_rotation_json: ${{ secrets.TOKEN_ROTATION_JSON }} + token_rotation_env_keys: ${{ vars.TOKEN_ROTATION_ENV_KEYS }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20' + - name: Install GitHub API dependencies + run: npm install --no-save --no-package-lock @octokit/rest @octokit/auth-app - name: Handle docs-only change if: needs.detect.outputs.doc_only == 'true' id: docs_only @@ -416,6 +429,12 @@ jobs: FAILURE_CHECKS: ${{ steps.summarize.outputs.failure_checks || '' }} with: script: | + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { withRetry, paginateWithRetry } = await createTokenAwareRetry({ + github, + core, + task: 'gate-autofix-label', + }); const { owner, repo } = context.repo; const pr = context.payload.pull_request; core.setOutput('applied', 'false'); @@ -450,12 +469,15 @@ jobs: return; } - const iterator = github.paginate.iterator(github.rest.pulls.listFiles, { - owner, - repo, - pull_number: pr.number, - per_page: 100, - }); + const files = await paginateWithRetry( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }, + ); const allowedExtensions = String(process.env.ALLOWED_EXTENSIONS || '') .split(',') @@ -464,20 +486,15 @@ jobs: .map(ext => (ext.startsWith('.') ? ext : `.${ext}`)); const disallowed = []; - for await (const page of iterator) { - if (!Array.isArray(page.data)) { + for (const file of files || []) { + const filename = file?.filename; + if (typeof filename !== 'string' || filename.length === 0) { continue; } - for (const file of page.data) { - const filename = file?.filename; - if (typeof filename !== 'string' || filename.length === 0) { - continue; - } - const lower = filename.toLowerCase(); - const allowed = allowedExtensions.some(ext => lower.endsWith(ext)); - if (!allowed) { - disallowed.push(filename); - } + const lower = filename.toLowerCase(); + const allowed = allowedExtensions.some(ext => lower.endsWith(ext)); + if (!allowed) { + disallowed.push(filename); } } @@ -487,12 +504,12 @@ jobs: return; } - await github.rest.issues.addLabels({ + await withRetry((client) => client.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['autofix:clean'], - }); + })); core.info('Applied autofix:clean label for cosmetic failure.'); core.setOutput('applied', 'true'); @@ -554,6 +571,12 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const fs = require('fs'); + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + task: 'gate-keepalive-checklist', + }); const prNumber = Number(process.env.PR_NUMBER || '0'); const payloadBody = context.payload.pull_request?.body || ''; @@ -566,7 +589,11 @@ jobs: if (!prBody && prNumber) { const { owner, repo } = context.repo; try { - const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const pr = await withRetry((client) => client.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + })); prBody = pr.data.body || ''; } catch (error) { const hitRateLimit = error?.status === 403 && /rate limit/i.test(error?.message || ''); @@ -716,6 +743,12 @@ jobs: TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: script: | + const { createTokenAwareRetry } = require('./.github/scripts/github-api-with-retry.js'); + const { withRetry } = await createTokenAwareRetry({ + github, + core, + task: 'gate-commit-status', + }); const owner = context.repo.owner; const repo = context.repo.repo; const sha = context.payload.pull_request?.head?.sha ?? context.sha; @@ -727,7 +760,7 @@ jobs: const targetUrl = process.env.TARGET_URL; try { - await github.rest.repos.createCommitStatus({ + await withRetry((client) => client.rest.repos.createCommitStatus({ owner, repo, sha, @@ -735,7 +768,7 @@ jobs: context: 'Gate / gate', description, target_url: targetUrl, - }); + })); } catch (error) { const hitRateLimit = error?.status === 403 && /rate limit/i.test(error?.message || ''); if (hitRateLimit) { diff --git a/scripts/check_issue_consistency.py b/scripts/check_issue_consistency.py index 9b4e37702..6080257e0 100755 --- a/scripts/check_issue_consistency.py +++ b/scripts/check_issue_consistency.py @@ -64,6 +64,15 @@ def extract_title_issue_number(title: str) -> int | None: return None +def extract_head_ref_issue_numbers(head_ref: str) -> set[int]: + return extract_issue_numbers(head_ref or "", include_hash=False) + + +def is_autofix_context(pr_title: str, head_ref: str) -> bool: + combined = f"{pr_title or ''}\n{head_ref or ''}".lower() + return "autofix" in combined or (head_ref or "").lower().startswith("autofix/") + + def _run_git(args: list[str]) -> str: result = subprocess.run( ["git", *args], @@ -104,12 +113,40 @@ def collect_changed_files( def collect_header_issue_numbers(file_path: Path, max_lines: int) -> set[int]: numbers: set[int] = set() + in_docstring = False + docstring_delim = "" + + def is_comment_line(line: str) -> bool: + stripped = line.lstrip() + return stripped.startswith(("#", "//", "/*", "*", "--", ";", "