diff --git a/.github/actions/setup-api-client/action.yml b/.github/actions/setup-api-client/action.yml index 8cac589a0..b912fe4ad 100644 --- a/.github/actions/setup-api-client/action.yml +++ b/.github/actions/setup-api-client/action.yml @@ -164,6 +164,46 @@ runs: create_vendor_aliases + # Strip unresolvable file: dependencies from package.json so npm install + # doesn't choke when vendored packages are missing (e.g. sparse checkout + # that doesn't include node_modules/). Back up the original first so we + # can restore it after npm install — avoids leaking modified state into + # downstream steps that expect a clean workspace. + STRIPPED_PKG_JSON=false + if [ -f "package.json" ]; then + node -e ' + const fs = require("fs"); + const path = require("path"); + const raw = fs.readFileSync("package.json", "utf8"); + const pkg = JSON.parse(raw); + let changed = false; + for (const section of ["dependencies", "devDependencies"]) { + if (!pkg[section]) continue; + for (const [name, spec] of Object.entries(pkg[section])) { + if (typeof spec === "string" && spec.startsWith("file:")) { + const target = path.resolve(spec.slice(5)); + if (!fs.existsSync(target)) { + delete pkg[section][name]; + changed = true; + console.log("::notice::Stripped unresolvable vendored dep: " + name + " (" + spec + ")"); + } + } + } + if (pkg[section] && Object.keys(pkg[section]).length === 0) { + delete pkg[section]; + } + } + if (changed) { + fs.copyFileSync("package.json", "package.json.bak"); + fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n"); + process.stdout.write("STRIPPED"); + } + ' + if [ -f "package.json.bak" ]; then + STRIPPED_PKG_JSON=true + fi + fi + # Vendored packages often ship lifecycle scripts (tshy builds, custom bundlers, etc.) # that expect repo-specific tooling. Disable lifecycle scripts globally so npm install # never invokes those hooks inside CI. @@ -240,6 +280,12 @@ runs: echo "✅ @octokit dependencies installed" fi + # Restore original package.json if we stripped file: deps earlier, + # so downstream steps see an unmodified workspace. + if [ "$STRIPPED_PKG_JSON" = "true" ] && [ -f "package.json.bak" ]; then + mv "package.json.bak" "package.json" + fi + cleanup_vendor_aliases trap - EXIT - name: Export NODE_PATH for shared deps diff --git a/.github/workflows/agents-auto-pilot.yml b/.github/workflows/agents-auto-pilot.yml index dcfacbbdf..9d4e14a89 100644 --- a/.github/workflows/agents-auto-pilot.yml +++ b/.github/workflows/agents-auto-pilot.yml @@ -342,8 +342,14 @@ jobs: const hasFormat = labels.includes('agents:formatted'); const hasOptimize = labels.includes('agents:optimize'); const hasApplySuggestions = labels.includes('agents:apply-suggestions'); - const nonRoutingAgentLabels = new Set(['agent:rate-limited', 'agent:needs-attention', 'agent:retry']); - const hasAgentLabel = labels.some(l => l.startsWith('agent:') && !nonRoutingAgentLabels.has(l)); + const nonRoutingAgentLabels = new Set([ + 'agent:rate-limited', + 'agent:needs-attention', + 'agent:retry', + ]); + const hasAgentLabel = labels.some( + (label) => label.startsWith('agent:') && !nonRoutingAgentLabels.has(label), + ); const hasAutofix = labels.includes('autofix'); const hasAutomerge = labels.includes('automerge'); const hasVerify = labels.includes('verify:evaluate'); @@ -1707,16 +1713,142 @@ jobs: const issueNumber = parseInt(process.env.ISSUE_NUMBER); const stepCount = parseInt(process.env.STEP_COUNT || '0') + 1; + const toLabelName = (label) => { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.name || ''; + }; + + const loadIssueLabels = async () => { + const payload = context.payload || {}; + if ( + payload.issue && + Number(payload.issue.number) === issueNumber && + Array.isArray(payload.issue.labels) + ) { + return payload.issue.labels; + } + if ( + payload.pull_request && + Number(payload.pull_request.number) === issueNumber && + Array.isArray(payload.pull_request.labels) + ) { + return payload.pull_request.labels; + } + try { + const { data: issue } = await withRetry((client) => + client.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }) + ); + return issue.labels || []; + } catch (fetchError) { + const warnContext = fetchError?.message || fetchError; + core.warning( + 'Failed to load issue labels before capability check: ' + warnContext, + ); + return []; + } + }; + + let issueLabels = await loadIssueLabels(); let agentKey = 'codex'; + let defaultAgent = agentKey; + let registry = null; + let recognizedAgentLabels = []; + const runnerOverride = issueLabels + .map((label) => toLabelName(label).toLowerCase()) + .find((name) => name.startsWith('runner:')); + try { - const { loadAgentRegistry } = require('./.github/scripts/agent_registry.js'); - const registry = loadAgentRegistry(); - const defaultAgent = registry.default_agent || agentKey; - agentKey = String(defaultAgent).trim().toLowerCase() || agentKey; - } catch (error) { - const prefix = `Failed to load agent registry; defaulting to ${agentKey}:`; - core.warning(`${prefix} ${error.message}`); + const registryLib = require('./.github/scripts/agent_registry.js'); + const { loadAgentRegistry, resolveAgentFromLabels } = registryLib; + registry = loadAgentRegistry(); + defaultAgent = + String(registry.default_agent || agentKey).trim().toLowerCase() || agentKey; + + if (Array.isArray(issueLabels) && issueLabels.length) { + const knownAgents = new Set(Object.keys(registry.agents || {})); + recognizedAgentLabels = issueLabels + .map((label) => { + const normalized = toLabelName(label).toLowerCase(); + const display = typeof label === 'string' ? label : (label?.name || normalized); + return { normalized, display }; + }) + .filter(({ normalized }) => normalized.startsWith('agent:')) + .filter(({ normalized }) => { + const suffix = normalized.slice('agent:'.length); + if (suffix === 'auto') { + return true; + } + return knownAgents.has(suffix); + }); + + const routingLabels = recognizedAgentLabels.length + ? recognizedAgentLabels.map(({ normalized }) => normalized) + : issueLabels; + + if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else if (routingLabels.length) { + try { + agentKey = + resolveAgentFromLabels( + routingLabels, + { registryPath: './.github/agents/registry.yml' }, + ) || defaultAgent; + } catch (resolveError) { + const resolveContext = resolveError?.message || resolveError; + core.warning( + 'Failed to resolve agent label; defaulting to ' + + defaultAgent + + ': ' + + resolveContext, + ); + agentKey = defaultAgent; + } + } else { + agentKey = defaultAgent; + } + } else if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else { + agentKey = defaultAgent; } + } catch (error) { + const prefix = `Failed to load agent registry; defaulting to ${agentKey}:`; + core.warning(`${prefix} ${error.message}`); + } + + if (registry && recognizedAgentLabels.length) { + const removalTargets = new Set( + recognizedAgentLabels + .filter(({ normalized }) => normalized !== `agent:${agentKey}`) + .map(({ display, normalized }) => (display || normalized).trim()) + .filter(Boolean) + ); + for (const labelName of removalTargets) { + try { + await withRetry((client) => + client.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName + }) + ); + } catch (removeError) { + const removeContext = removeError?.message || removeError; + core.warning( + `Failed to remove label ${labelName}: ${removeContext}`, + ); + } + } + } await withRetry((client) => client.rest.issues.createComment({ owner: context.repo.owner, @@ -1969,13 +2101,106 @@ jobs: const issueTitle = process.env.ISSUE_TITLE || `Issue #${issueNumber}`; const stepCount = parseInt(process.env.STEP_COUNT || '0') + 1; let agentKey = 'codex'; + let issueLabels = []; let branchPrefix = 'codex/issue-'; + + const toLabelName = (label) => { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.name || ''; + }; + + const loadIssueLabels = async () => { + const payload = context.payload || {}; + if ( + payload.issue && + Number(payload.issue.number) === issueNumber && + Array.isArray(payload.issue.labels) + ) { + return payload.issue.labels; + } + if ( + payload.pull_request && + Number(payload.pull_request.number) === issueNumber && + Array.isArray(payload.pull_request.labels) + ) { + return payload.pull_request.labels; + } + try { + const { data: issue } = await withRetry((client) => + client.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }) + ); + return issue.labels || []; + } catch (fetchError) { + const warnContext = fetchError?.message || fetchError; + core.warning( + 'Failed to load issue labels before create-pr: ' + warnContext, + ); + return []; + } + }; + + issueLabels = await loadIssueLabels(); + try { const registryLib = require('./.github/scripts/agent_registry.js'); - const { loadAgentRegistry, getAgentConfig } = registryLib; + const { loadAgentRegistry, getAgentConfig, resolveAgentFromLabels } = registryLib; const registry = loadAgentRegistry(); - const defaultAgent = registry.default_agent || agentKey; - agentKey = String(defaultAgent).trim().toLowerCase() || agentKey; + const defaultAgent = + String(registry.default_agent || agentKey).trim().toLowerCase() || agentKey; + const runnerOverride = issueLabels + .map((label) => toLabelName(label).toLowerCase()) + .find((name) => name.startsWith('runner:')); + + let recognizedAgentLabels = []; + if (Array.isArray(issueLabels) && issueLabels.length) { + const knownAgents = new Set(Object.keys(registry.agents || {})); + recognizedAgentLabels = issueLabels + .map((label) => { + const normalized = toLabelName(label).toLowerCase(); + return normalized; + }) + .filter((normalized) => normalized.startsWith('agent:')) + .filter((normalized) => { + const suffix = normalized.slice('agent:'.length); + if (suffix === 'auto') { + return true; + } + return knownAgents.has(suffix); + }); + } + + const routingLabels = recognizedAgentLabels.length + ? recognizedAgentLabels + : issueLabels; + + if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else if (routingLabels.length) { + try { + agentKey = + resolveAgentFromLabels( + routingLabels, + { registryPath: './.github/agents/registry.yml' }, + ) || defaultAgent; + } catch (resolveError) { + const resolveContext = resolveError?.message || resolveError; + core.warning( + 'Failed to resolve agent label; defaulting to ' + + defaultAgent + + ': ' + + resolveContext, + ); + agentKey = defaultAgent; + } + } else { + agentKey = defaultAgent; + } const cfg = getAgentConfig(agentKey); branchPrefix = String(cfg.branch_prefix || branchPrefix); } catch (error) { diff --git a/.github/workflows/agents-verify-to-issue-v2.yml b/.github/workflows/agents-verify-to-issue-v2.yml index 7c5834565..046a05960 100644 --- a/.github/workflows/agents-verify-to-issue-v2.yml +++ b/.github/workflows/agents-verify-to-issue-v2.yml @@ -83,6 +83,7 @@ jobs: .github/scripts/token_load_balancer.js scripts/langchain tools + sparse-checkout-cone-mode: false - name: Setup API client uses: ./.github/actions/setup-api-client diff --git a/.github/workflows/agents-verify-to-new-pr.yml b/.github/workflows/agents-verify-to-new-pr.yml index 136a17fca..031812c15 100644 --- a/.github/workflows/agents-verify-to-new-pr.yml +++ b/.github/workflows/agents-verify-to-new-pr.yml @@ -82,6 +82,7 @@ jobs: .github/scripts/token_load_balancer.js scripts/langchain tools + sparse-checkout-cone-mode: false - name: Setup API client uses: ./.github/actions/setup-api-client diff --git a/agents/codex-1638.md b/agents/codex-1638.md new file mode 100644 index 000000000..07c0df2c4 --- /dev/null +++ b/agents/codex-1638.md @@ -0,0 +1,29 @@ + diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 2a8e755f8..59d5cbc61 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -97,7 +97,7 @@ _Inline Gate helper_ - **`agents-72-codex-belt-worker.yml`** — Repository-dispatch consumer that re-validates labels, ensures the branch diverges from the base (empty commit when needed), and opens or refreshes the Codex automation PR with labels, assignees, and activation comment. Like the dispatcher it prefers App tokens, falls back to PAT/`GITHUB_TOKEN`, honours per-agent concurrency, and exposes a dry-run guard for diagnostics. - **`agents-73-codex-belt-conveyor.yml`** — Gate follower that squash-merges successful belt PRs, deletes the branch, closes the originating issue, posts audit breadcrumbs, and re-dispatches the dispatcher so the queue keeps moving. It reuses the shared API client for token load balancing, requires Gate success before merging, blocks bootstrap-only placeholders, and mirrors the same dry-run + concurrency protections as the dispatcher/worker. - **`agents-auto-label.yml`** — Issue/PR label suggester that feeds LangChain embeddings through the shared API client, applies labels with the default token, and posts suggestion comments when confidence drops below auto-apply thresholds. Removing the redundant App mint keeps the workflow lightweight on every issue event. -- **`agents-auto-pilot.yml`** — Full issue-to-PR automation pipeline (format → optimize → apply → capability check → PR creation → keepalive → verify). The workflow stayed in service as-is; documentation now clarifies that it orchestrates the reusable agents stack and hands tasks to keepalive without additional per-job token churn. +- **`agents-auto-pilot.yml`** — Full issue-to-PR automation pipeline (format → optimize → apply → capability check → PR creation → keepalive → verify). Apply `agents:auto-pilot` plus an optional `agent:` label (for example `agent:claude`) to control which runner executes the PR work; if no explicit `agent:*` label exists, auto-pilot falls back to the registry default, adds that `agent:` label for you, and hands tasks to keepalive without extra per-job token churn. - **`agents-autofix-dispatcher.yml`** — Triggered when Gate posts `autofix_gate_failure`. It still needs a GitHub App token to dispatch `agents-autofix-loop.yml`, but otherwise the job simply forwards the failing run ID/PR/head SHA so the loop can attempt repairs. - **`agents-autofix-loop.yml`** — Handles larger fix-ups (pyproject sync, scripted rewrites, merge conflict handling) after the dispatcher fires. It respects PAT/App availability, retries with the shared API client, and labels PRs (`autofix:conflict`, `needs-human`) when automation cannot complete the repair. - **`agents-autofix-rebase.yml`** — Rebase helper kicked off from Gate when `mergeable_state` is `dirty`/`behind`. It merges the base branch into same-repo PRs with the App token, tagging the PR with `autofix:conflict` plus a comment whenever a manual rebase is still required. @@ -112,7 +112,7 @@ _Inline Gate helper_ - **`agents-pr-meta-v4.yml`** — PR metadata/keepalive front door: listens to issue comments, PR updates, and Gate completions to detect `@agent` activations, enforce gate/run-cap rules, dispatch the orchestrator, and write dispatch summaries. Still needs a GitHub App/PAT token when it re-dispatches keepalive, so the workflow remains in service until the consolidated orchestration stack replaces it. - **`agents-verifier.yml`** — Label-driven verification runner. When a merged PR gets `verify:*` (or when dispatched manually), it routes the request through the reusable verifier workflow to run checkbox/evaluate/compare modes, posts the structured summary, and opens follow-up issues on failures. It now mints a GitHub App token before checking out the caller repo/Workflows scripts so cross-repo verification works under the service account while still falling back to the installation token when App credentials are missing. - **`agents-verify-to-issue-v2.yml`** — Converts verification feedback into an agent-ready follow-up issue whenever `verify:create-issue` is applied to a merged PR. It gathers the verification comments, original issue context, and PR metadata, runs LangChain templates, and opens a structured issue using the appropriate PAT/App token so ownership stays consistent. -- **`agents-verify-to-new-pr.yml`** — Handles the `verify:create-new-pr` label end-to-end: collects verification comments, reconstructs the original issue context, creates a follow-up issue, and now dispatches `agents-auto-pilot.yml` (optimize step) inline so automation continues without a separate bridge workflow. +- **`agents-verify-to-new-pr.yml`** — Handles the `verify:create-new-pr` label end-to-end: collects verification comments, reconstructs the original issue context, creates a follow-up issue (carrying over the `agent:*` label from the merged PR or defaulting via the registry), and now dispatches `agents-auto-pilot.yml` (optimize step) inline so automation continues without a separate bridge workflow. - **`agents-moderate-connector.yml`** — Moderates connector-style PR comments. Uses allow/deny lists plus heuristics (checklist/commit/code indicators and known noise phrases) to delete “I can’t run Codex here” spam while preserving legitimate updates. Skips moderation when `agents:debug` is on the PR so humans can inspect noisy runs. Relies on the shared API client; no workflow changes were required. - **`agents-weekly-metrics.yml`** — Weekly cron that aggregates artifacts from keepalive, autofix, verifier, and auto-pilot runs, generates a Markdown summary, uploads it, and posts/updates the tracking issue. Now relies entirely on the shared API client (installation token) for artifact downloads and issue updates—no bespoke App token. diff --git a/docs/keepalive/Agents.md b/docs/keepalive/Agents.md index a0305815e..b88d57c63 100644 --- a/docs/keepalive/Agents.md +++ b/docs/keepalive/Agents.md @@ -66,6 +66,7 @@ Auto-pilot pipeline: 1. **Task Focus**: Agents must work on PR tasks, not unrelated improvements. Tasks are explicitly injected via the task appendix. 2. **Agent Agnostic**: The keepalive prompt is agent-agnostic. Routing is determined by the `agent:*` label, not hardcoded agent names. + - Apply `agents:auto-pilot` together with an explicit `agent:codex`, `agent:claude`, etc. label when you want to pin the runner. If no `agent:*` label exists, auto-pilot uses the registry default and adds the matching label so keepalive, autofix, and verifier flows all stay aligned. 3. **No `@codex` in Prompts**: Do not use `@codex` or other agent mentions in automated prompts—this can trigger the UI version of agents. Let the routing handle which agent runs. diff --git a/templates/consumer-repo/.github/actions/setup-api-client/action.yml b/templates/consumer-repo/.github/actions/setup-api-client/action.yml index b9fe7d5bb..b912fe4ad 100644 --- a/templates/consumer-repo/.github/actions/setup-api-client/action.yml +++ b/templates/consumer-repo/.github/actions/setup-api-client/action.yml @@ -164,6 +164,46 @@ runs: create_vendor_aliases + # Strip unresolvable file: dependencies from package.json so npm install + # doesn't choke when vendored packages are missing (e.g. sparse checkout + # that doesn't include node_modules/). Back up the original first so we + # can restore it after npm install — avoids leaking modified state into + # downstream steps that expect a clean workspace. + STRIPPED_PKG_JSON=false + if [ -f "package.json" ]; then + node -e ' + const fs = require("fs"); + const path = require("path"); + const raw = fs.readFileSync("package.json", "utf8"); + const pkg = JSON.parse(raw); + let changed = false; + for (const section of ["dependencies", "devDependencies"]) { + if (!pkg[section]) continue; + for (const [name, spec] of Object.entries(pkg[section])) { + if (typeof spec === "string" && spec.startsWith("file:")) { + const target = path.resolve(spec.slice(5)); + if (!fs.existsSync(target)) { + delete pkg[section][name]; + changed = true; + console.log("::notice::Stripped unresolvable vendored dep: " + name + " (" + spec + ")"); + } + } + } + if (pkg[section] && Object.keys(pkg[section]).length === 0) { + delete pkg[section]; + } + } + if (changed) { + fs.copyFileSync("package.json", "package.json.bak"); + fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n"); + process.stdout.write("STRIPPED"); + } + ' + if [ -f "package.json.bak" ]; then + STRIPPED_PKG_JSON=true + fi + fi + # Vendored packages often ship lifecycle scripts (tshy builds, custom bundlers, etc.) # that expect repo-specific tooling. Disable lifecycle scripts globally so npm install # never invokes those hooks inside CI. @@ -176,8 +216,9 @@ runs: CREATED_PACKAGE_JSON=true fi - # Check if already installed - if [ -d "node_modules/@octokit/rest" ]; then + # Check if already installed (including lru-cache, a required transitive dep + # of @octokit/auth-app used for GitHub App token minting). + if [ -d "node_modules/@octokit/rest" ] && [ -d "node_modules/lru-cache" ]; then echo "✅ @octokit/rest already installed" else # Snapshot vendored package metadata before npm install. @@ -196,14 +237,18 @@ runs: echo "📸 Snapshotted vendored package metadata" fi - # Install with pinned versions for consistency + # Install with pinned versions for consistency. + # lru-cache is an explicit transitive dep of @octokit/auth-app required for + # GitHub App token minting; pin it here so npm always hoists it to the top + # level even if a prior cached node_modules state is missing it. # Capture stderr for debugging if the command fails npm_output=$(mktemp) npm_cmd=(npm install --no-save --location=project \ @octokit/rest@20.0.2 \ @octokit/plugin-retry@6.0.1 \ @octokit/plugin-paginate-rest@9.1.5 \ - @octokit/auth-app@6.0.3) + @octokit/auth-app@6.0.3 \ + lru-cache@^10.0.0) if "${npm_cmd[@]}" 2>"$npm_output"; then rm -f "$npm_output" else @@ -214,7 +259,8 @@ runs: @octokit/rest@20.0.2 \ @octokit/plugin-retry@6.0.1 \ @octokit/plugin-paginate-rest@9.1.5 \ - @octokit/auth-app@6.0.3) + @octokit/auth-app@6.0.3 \ + lru-cache@^10.0.0) "${npm_cmd[@]}" fi @@ -234,6 +280,12 @@ runs: echo "✅ @octokit dependencies installed" fi + # Restore original package.json if we stripped file: deps earlier, + # so downstream steps see an unmodified workspace. + if [ "$STRIPPED_PKG_JSON" = "true" ] && [ -f "package.json.bak" ]; then + mv "package.json.bak" "package.json" + fi + cleanup_vendor_aliases trap - EXIT - name: Export NODE_PATH for shared deps diff --git a/templates/consumer-repo/.github/workflows/agents-auto-pilot.yml b/templates/consumer-repo/.github/workflows/agents-auto-pilot.yml index dcfacbbdf..d29bc9958 100644 --- a/templates/consumer-repo/.github/workflows/agents-auto-pilot.yml +++ b/templates/consumer-repo/.github/workflows/agents-auto-pilot.yml @@ -342,8 +342,14 @@ jobs: const hasFormat = labels.includes('agents:formatted'); const hasOptimize = labels.includes('agents:optimize'); const hasApplySuggestions = labels.includes('agents:apply-suggestions'); - const nonRoutingAgentLabels = new Set(['agent:rate-limited', 'agent:needs-attention', 'agent:retry']); - const hasAgentLabel = labels.some(l => l.startsWith('agent:') && !nonRoutingAgentLabels.has(l)); + const nonRoutingAgentLabels = new Set([ + 'agent:rate-limited', + 'agent:needs-attention', + 'agent:retry', + ]); + const hasAgentLabel = labels.some( + (label) => label.startsWith('agent:') && !nonRoutingAgentLabels.has(label), + ); const hasAutofix = labels.includes('autofix'); const hasAutomerge = labels.includes('automerge'); const hasVerify = labels.includes('verify:evaluate'); @@ -1707,16 +1713,142 @@ jobs: const issueNumber = parseInt(process.env.ISSUE_NUMBER); const stepCount = parseInt(process.env.STEP_COUNT || '0') + 1; + const toLabelName = (label) => { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.name || ''; + }; + + const loadIssueLabels = async () => { + const payload = context.payload || {}; + if ( + payload.issue && + Number(payload.issue.number) === issueNumber && + Array.isArray(payload.issue.labels) + ) { + return payload.issue.labels; + } + if ( + payload.pull_request && + Number(payload.pull_request.number) === issueNumber && + Array.isArray(payload.pull_request.labels) + ) { + return payload.pull_request.labels; + } + try { + const { data: issue } = await withRetry((client) => + client.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }) + ); + return issue.labels || []; + } catch (fetchError) { + const warnContext = fetchError?.message || fetchError; + core.warning( + 'Failed to load issue labels before capability check: ' + warnContext, + ); + return []; + } + }; + + let issueLabels = await loadIssueLabels(); let agentKey = 'codex'; + let defaultAgent = agentKey; + let registry = null; + let recognizedAgentLabels = []; + const runnerOverride = issueLabels + .map((label) => toLabelName(label).toLowerCase()) + .find((name) => name.startsWith('runner:')); + try { - const { loadAgentRegistry } = require('./.github/scripts/agent_registry.js'); - const registry = loadAgentRegistry(); - const defaultAgent = registry.default_agent || agentKey; - agentKey = String(defaultAgent).trim().toLowerCase() || agentKey; - } catch (error) { - const prefix = `Failed to load agent registry; defaulting to ${agentKey}:`; - core.warning(`${prefix} ${error.message}`); + const registryLib = require('./.github/scripts/agent_registry.js'); + const { loadAgentRegistry, resolveAgentFromLabels } = registryLib; + registry = loadAgentRegistry(); + defaultAgent = + String(registry.default_agent || agentKey).trim().toLowerCase() || agentKey; + + if (Array.isArray(issueLabels) && issueLabels.length) { + const knownAgents = new Set(Object.keys(registry.agents || {})); + recognizedAgentLabels = issueLabels + .map((label) => { + const normalized = toLabelName(label).toLowerCase(); + const display = typeof label === 'string' ? label : (label?.name || normalized); + return { normalized, display }; + }) + .filter(({ normalized }) => normalized.startsWith('agent:')) + .filter(({ normalized }) => { + const suffix = normalized.slice('agent:'.length); + if (suffix === 'auto') { + return true; + } + return knownAgents.has(suffix); + }); + + const routingLabels = recognizedAgentLabels.length + ? recognizedAgentLabels.map(({ normalized }) => normalized) + : issueLabels; + + if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else if (routingLabels.length) { + try { + agentKey = + resolveAgentFromLabels( + routingLabels, + { registryPath: './.github/agents/registry.yml' }, + ) || defaultAgent; + } catch (resolveError) { + const resolveContext = resolveError?.message || resolveError; + core.warning( + 'Failed to resolve agent label; defaulting to ' + + defaultAgent + + ': ' + + resolveContext, + ); + agentKey = defaultAgent; + } + } else { + agentKey = defaultAgent; + } + } else if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else { + agentKey = defaultAgent; + } + } catch (error) { + const prefix = `Failed to load agent registry; defaulting to ${agentKey}:`; + core.warning(`${prefix} ${error.message}`); + } + + if (registry && recognizedAgentLabels.length) { + const removalTargets = new Set( + recognizedAgentLabels + .filter(({ normalized }) => normalized !== `agent:${agentKey}`) + .map(({ display, normalized }) => (display || normalized).trim()) + .filter(Boolean) + ); + for (const labelName of removalTargets) { + try { + await withRetry((client) => + client.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: labelName + }) + ); + } catch (removeError) { + const removeContext = removeError?.message || removeError; + core.warning( + `Failed to remove label ${labelName}: ${removeContext}`, + ); + } } + } await withRetry((client) => client.rest.issues.createComment({ owner: context.repo.owner, @@ -1969,13 +2101,103 @@ jobs: const issueTitle = process.env.ISSUE_TITLE || `Issue #${issueNumber}`; const stepCount = parseInt(process.env.STEP_COUNT || '0') + 1; let agentKey = 'codex'; + let issueLabels = []; let branchPrefix = 'codex/issue-'; + + const toLabelName = (label) => { + if (!label) return ''; + if (typeof label === 'string') return label; + return label.name || ''; + }; + + const loadIssueLabels = async () => { + const payload = context.payload || {}; + if ( + payload.issue && + Number(payload.issue.number) === issueNumber && + Array.isArray(payload.issue.labels) + ) { + return payload.issue.labels; + } + if ( + payload.pull_request && + Number(payload.pull_request.number) === issueNumber && + Array.isArray(payload.pull_request.labels) + ) { + return payload.pull_request.labels; + } + try { + const { data: issue } = await withRetry((client) => + client.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }) + ); + return issue.labels || []; + } catch (fetchError) { + const warnContext = fetchError?.message || fetchError; + core.warning( + 'Failed to load issue labels before create-pr: ' + warnContext, + ); + return []; + } + }; + + issueLabels = await loadIssueLabels(); + try { const registryLib = require('./.github/scripts/agent_registry.js'); - const { loadAgentRegistry, getAgentConfig } = registryLib; + const { loadAgentRegistry, getAgentConfig, resolveAgentFromLabels } = registryLib; const registry = loadAgentRegistry(); - const defaultAgent = registry.default_agent || agentKey; - agentKey = String(defaultAgent).trim().toLowerCase() || agentKey; + const defaultAgent = + String(registry.default_agent || agentKey).trim().toLowerCase() || agentKey; + const runnerOverride = issueLabels + .map((label) => toLabelName(label).toLowerCase()) + .find((name) => name.startsWith('runner:')); + + let recognizedAgentLabels = []; + if (Array.isArray(issueLabels) && issueLabels.length) { + const knownAgents = new Set(Object.keys(registry.agents || {})); + recognizedAgentLabels = issueLabels + .map((label) => toLabelName(label).toLowerCase()) + .filter((normalized) => normalized.startsWith('agent:')) + .filter((normalized) => { + const suffix = normalized.slice('agent:'.length); + if (suffix === 'auto') { + return true; + } + return knownAgents.has(suffix); + }); + } + + const routingLabels = recognizedAgentLabels.length + ? recognizedAgentLabels + : issueLabels; + + if (runnerOverride) { + const runnerKey = runnerOverride.slice('runner:'.length).trim(); + agentKey = runnerKey || defaultAgent; + } else if (routingLabels.length) { + try { + agentKey = + resolveAgentFromLabels( + routingLabels, + { registryPath: './.github/agents/registry.yml' }, + ) || defaultAgent; + } catch (resolveError) { + const resolveContext = resolveError?.message || resolveError; + core.warning( + 'Failed to resolve agent label; defaulting to ' + + defaultAgent + + ': ' + + resolveContext, + ); + agentKey = defaultAgent; + } + } else { + agentKey = defaultAgent; + } const cfg = getAgentConfig(agentKey); branchPrefix = String(cfg.branch_prefix || branchPrefix); } catch (error) { diff --git a/templates/consumer-repo/.github/workflows/agents-verify-to-issue-v2.yml b/templates/consumer-repo/.github/workflows/agents-verify-to-issue-v2.yml index 9a99d60da..4882d410b 100644 --- a/templates/consumer-repo/.github/workflows/agents-verify-to-issue-v2.yml +++ b/templates/consumer-repo/.github/workflows/agents-verify-to-issue-v2.yml @@ -80,6 +80,7 @@ jobs: .github/scripts/token_load_balancer.js scripts/langchain tools + sparse-checkout-cone-mode: false - name: Setup API client uses: ./.github/actions/setup-api-client diff --git a/templates/consumer-repo/.github/workflows/agents-verify-to-new-pr.yml b/templates/consumer-repo/.github/workflows/agents-verify-to-new-pr.yml index 112fee261..3f2536014 100644 --- a/templates/consumer-repo/.github/workflows/agents-verify-to-new-pr.yml +++ b/templates/consumer-repo/.github/workflows/agents-verify-to-new-pr.yml @@ -82,6 +82,7 @@ jobs: .github/scripts/token_load_balancer.js scripts/langchain tools + sparse-checkout-cone-mode: false - name: Setup API client uses: ./.github/actions/setup-api-client