From 4ec109c8c05fcbd5cf0e153bf712592091538e8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:13:05 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.81.4 --- setup/js/assign_agent_helpers.cjs | 248 ++++++------------ setup/js/assign_to_agent.cjs | 73 ++++-- setup/js/claude_harness.cjs | 3 +- setup/js/codex_harness.cjs | 48 +++- setup/js/copilot_sdk_session.cjs | 125 ++++++++- setup/js/copilot_sdk_sidecar.cjs | 1 + setup/js/generate_usage_activity_summary.cjs | 6 + setup/js/handle_agent_failure.cjs | 157 ++++++++--- setup/js/harness_retry_guard.cjs | 12 +- setup/js/parse_threat_detection_results.cjs | 4 +- setup/js/process_runner.cjs | 6 + setup/js/push_to_pull_request_branch.cjs | 51 +++- setup/js/set_issue_field.cjs | 22 +- setup/js/set_issue_type.cjs | 99 ++++++- setup/js/update_issue.cjs | 4 +- setup/js/upload_assets.cjs | 7 +- setup/sh/log_runtime_features_summary.sh | 23 ++ setup/sh/log_runtime_features_summary_test.sh | 63 +++++ 18 files changed, 696 insertions(+), 256 deletions(-) create mode 100644 setup/sh/log_runtime_features_summary.sh create mode 100755 setup/sh/log_runtime_features_summary_test.sh diff --git a/setup/js/assign_agent_helpers.cjs b/setup/js/assign_agent_helpers.cjs index a075643..96d231d 100644 --- a/setup/js/assign_agent_helpers.cjs +++ b/setup/js/assign_agent_helpers.cjs @@ -15,7 +15,9 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @type {Record} */ const AGENT_LOGIN_NAMES = { - copilot: ["copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], + // Prefer [bot] aliases first so assignability checks and assignment requests + // use the canonical bot login when both plain and [bot] aliases exist. + copilot: ["copilot-swe-agent[bot]", "github-copilot-enterprise[bot]", "github-copilot[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot"], }; /** @@ -70,6 +72,20 @@ function getAgentName(assignee) { return AGENT_NAME_BY_LOGIN[normalized] || null; } +/** + * Parse and validate an issue/PR number for assignee REST endpoints. + * @param {number|string|null|undefined} issueNumber + * @param {string} contextLabel + * @returns {number} + */ +function parseIssueNumber(issueNumber, contextLabel) { + const parsedIssueNumber = Number(issueNumber); + if (!Number.isInteger(parsedIssueNumber) || parsedIssueNumber <= 0) { + throw new Error(`Invalid issue number for ${contextLabel}: received '${String(issueNumber)}', expected a positive integer`); + } + return parsedIssueNumber; +} + /** * Return list of coding agent bot login names that are currently available as assignable actors * in this repository, preferring issue-scoped checks when issue/PR context is available @@ -109,47 +125,18 @@ async function getAvailableAgentLogins(owner, repo, issueNumber = null, githubCl * @param {Object} githubClient */ async function validateAssigneeAlias(owner, repo, assignee, issueNumber, githubClient) { - const parsedIssueNumber = Number(issueNumber); - const hasValidIssueNumber = Number.isInteger(parsedIssueNumber) && parsedIssueNumber > 0; - const hasIssueScopedRequest = typeof githubClient?.request === "function"; - - if (issueNumber && hasValidIssueNumber && hasIssueScopedRequest) { - core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); - try { - const issueScopedResponse = await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { - owner, - repo, - issue_number: parsedIssueNumber, - assignee, - }); - const issueScopedStatus = issueScopedResponse && typeof issueScopedResponse === "object" && "status" in issueScopedResponse ? Number(issueScopedResponse.status) : undefined; - if (issueScopedStatus !== undefined && Number.isInteger(issueScopedStatus) && issueScopedStatus >= 200 && issueScopedStatus < 300) { - core.info(`Assignee alias ${assignee} is assignable via issue-scoped check`); - return; - } - core.info(`Issue-scoped assignee check returned unexpected response for ${assignee} (status ${issueScopedStatus ?? "unknown"}); falling back to repository-scoped check`); - } catch (e) { - const status = e && typeof e === "object" && "status" in e ? e.status : undefined; - // Some coding-agent bot aliases can return 404 on issue-scoped checks even when - // assignment may still succeed; use repository-scoped endpoint as fallback. - if (status !== 404 && status !== 422) { - core.info(`Issue-scoped assignee check failed for ${assignee} with status ${status ?? "unknown"}: ${getErrorMessage(e)}`); - throw e; - } - core.info(`Issue-scoped assignee check returned ${status} for ${assignee}; falling back to repository-scoped check`); - } - } else if (issueNumber && !hasValidIssueNumber) { - core.info(`Skipping issue-scoped assignee check for ${assignee}: invalid issue number ${String(issueNumber)}`); - } else if (issueNumber && !hasIssueScopedRequest) { - core.info(`Skipping issue-scoped assignee check for ${assignee}: github client does not support request()`); + const parsedIssueNumber = parseIssueNumber(issueNumber, "assignee check"); + if (typeof githubClient?.request !== "function") { + throw new Error("GitHub client does not support request() method required for REST issue assignee checks"); } - core.info(`Checking assignee alias ${assignee} via repository-scoped endpoint for ${owner}/${repo}`); - await githubClient.rest.issues.checkUserCanBeAssigned({ + core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); + await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner, repo, + issue_number: parsedIssueNumber, assignee, }); - core.info(`Assignee alias ${assignee} is assignable via repository-scoped check`); + core.info(`Assignee alias ${assignee} is assignable via issue-scoped check`); } /** @@ -199,7 +186,7 @@ async function getAssignableBots(owner, repo, githubClient = github) { * @param {string} agentName - Agent name (copilot) * @param {number|string|null} [issueNumber] - Optional issue/PR number for issue-scoped assignability check * @param {Object} [githubClient] - Authenticated GitHub client (defaults to global github) - * @returns {Promise} Agent ID or null if not found + * @returns {Promise} Agent login or null if not found */ async function findAgent(owner, repo, agentName, issueNumber = null, githubClient = github) { const loginNames = getAgentLogins(agentName); @@ -233,14 +220,8 @@ async function findAgent(owner, repo, agentName, issueNumber = null, githubClien core.info(`Assignee alias ${loginName} was not assignable: ${errorMessage}`); continue; } - // Alias confirmed assignable — resolve the user ID separately - try { - const { data: agentUser } = await githubClient.rest.users.getByUsername({ username: loginName }); - core.info(`Resolved ${agentName} agent via assignee alias ${loginName}`); - return String(agentUser.id); - } catch (lookupError) { - core.warning(`Alias ${loginName} is assignable but user lookup failed: ${getErrorMessage(lookupError)}`); - } + core.info(`Resolved ${agentName} agent via assignee alias ${loginName}`); + return loginName; } const bots = await getAssignableBots(owner, repo, githubClient); @@ -255,7 +236,7 @@ async function findAgent(owner, repo, agentName, issueNumber = null, githubClien core.info("No assignable bots found in this repository."); } if (agentName === "copilot") { - core.info("Please visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot"); + core.info("Please visit https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api"); } return null; } @@ -275,6 +256,11 @@ async function getIssueDetails(owner, repo, issueNumber, githubClient = github) core.error("Could not get issue data"); return null; } + // GitHub's issues API returns pull requests too; reject them here so callers + // never accidentally treat a PR as an assignable issue. + if (issue.pull_request) { + throw Object.assign(new Error(`#${issueNumber} is a pull request, not an issue — use pull_number instead of issue_number to assign to a pull request`), { isPullRequest: true }); + } const currentAssignees = (issue.assignees || []).map(assignee => ({ id: String(assignee.id), login: assignee.login, @@ -290,7 +276,9 @@ async function getIssueDetails(owner, repo, issueNumber, githubClient = github) }; } catch (error) { const errorMessage = getErrorMessage(error); - core.error(`Failed to get issue details: ${errorMessage}`); + if (!(/** @type {any} */ error.isPullRequest)) { + core.error(`Failed to get issue details: ${errorMessage}`); + } throw error; } } @@ -333,7 +321,7 @@ async function getPullRequestDetails(owner, repo, pullNumber, githubClient = git /** * Start an agent task for issue or pull request context using REST * @param {string} assignableId - Synthetic target ID in format owner/repo#issue:N or owner/repo#pull:N - * @param {string} agentId - Agent login name + * @param {string} agentLogin - Agent login name * @param {Array<{id: string, login: string}>} currentAssignees - List of current assignees with id and login * @param {string} agentName - Agent name for error messages * @param {string[]|null} allowedAgents - Optional list of allowed agent names. If provided, filters out non-allowed agents from current assignees. @@ -348,7 +336,7 @@ async function getPullRequestDetails(owner, repo, pullNumber, githubClient = git */ async function assignAgentToIssue( assignableId, - agentId, + agentLogin, currentAssignees, agentName, allowedAgents = null, @@ -391,104 +379,28 @@ async function assignAgentToIssue( core.error(`Invalid assignment context: ${assignableId}`); return false; } - const sourceOwner = taskContext.owner; - const sourceRepo = taskContext.repo; - const itemType = taskContext.type === "pull" ? "pull request" : "issue"; - const itemNumber = String(taskContext.number); - const sourceUrl = `https://github.com/${sourceOwner}/${sourceRepo}/${itemType === "pull request" ? "pull" : "issues"}/${itemNumber}`; - const targetRepoSlug = pullRequestRepoSlug || `${sourceOwner}/${sourceRepo}`; - const targetParts = targetRepoSlug.split("/"); - if (targetParts.length !== 2) { - core.error(`Invalid target repository slug: ${targetRepoSlug}`); + const targetOwner = taskContext.owner; + const targetRepo = taskContext.repo; + let issueNumber; + try { + issueNumber = parseIssueNumber(taskContext.number, "assignment"); + } catch (e) { + core.error(getErrorMessage(e)); return false; } - const targetOwner = targetParts[0]; - const targetRepo = targetParts[1]; - const promptParts = [`Start work for ${itemType} ${sourceOwner}/${sourceRepo}#${itemNumber}.`, `Use this as the primary context: ${sourceUrl}`]; - if (targetRepoSlug !== `${sourceOwner}/${sourceRepo}`) promptParts.push(`Create the branch and pull request in ${targetRepoSlug}.`); - if (customAgent) { - core.warning(`customAgent is not a dedicated REST parameter; it will be included as prompt context. If the agent runner does not parse this field, the custom agent selection may be ignored.`); - promptParts.push(`Custom agent: ${customAgent}`); - } - if (customInstructions) promptParts.push(`Additional instructions:\n${customInstructions}`); - const prompt = promptParts.join("\n\n"); try { - core.info("Starting agent task via REST API"); - const response = await githubClient.request("POST /agents/repos/{owner}/{repo}/tasks", { + core.info(`Assigning via issues assignees REST API with login: ${agentLogin}`); + await githubClient.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", { owner: targetOwner, repo: targetRepo, - prompt, - create_pull_request: true, - ...(model ? { model } : {}), - ...(baseBranch ? { base_ref: baseBranch } : {}), - headers: { "X-GitHub-Api-Version": "2026-03-10" }, + issue_number: issueNumber, + assignees: [agentLogin], }); - if (response?.data?.id) return true; - core.error("Unexpected response from GitHub API"); - return false; + return true; } catch (error) { const errorMessage = getErrorMessage(error); - const err = /** @type {any} */ error; - const is502Error = err?.response?.status === 502 || errorMessage.includes("502 Bad Gateway"); - - if (is502Error) { - core.warning(`Received 502 error from cloud gateway during agent task creation, but task may have been created`); - core.info(`502 error details logged for troubleshooting`); - - try { - if (error && typeof error === "object") { - const details = { - ...(err.errors && { errors: err.errors }), - ...(err.response && { response: err.response }), - ...(err.data && { data: err.data }), - }; - const serialized = JSON.stringify(details, null, 2); - if (serialized !== "{}") { - core.info("502 error details (for troubleshooting):"); - serialized - .split("\n") - .filter(line => line.trim()) - .forEach(line => core.info(line)); - } - } - } catch (loggingErr) { - const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr); - core.debug(`Failed to serialize 502 error details: ${loggingErrMsg}`); - } - - core.info(`Treating 502 error as success - agent task likely created`); - return true; - } - - // Debug: surface the raw REST error structure for troubleshooting fine-grained permission issues - try { - core.debug(`Raw REST error message: ${errorMessage}`); - if (error && typeof error === "object") { - const details = { - ...(err.errors && { errors: err.errors }), - ...(err.response && { response: err.response }), - ...(err.data && { data: err.data }), - }; - if (Array.isArray(err.errors)) { - details.compactMessages = err.errors.map(e => e.message).filter(Boolean); - } - const serialized = JSON.stringify(details, null, 2); - if (serialized !== "{}") { - core.debug(`Raw REST error details: ${serialized}`); - core.error("Raw REST error details (for troubleshooting):"); - serialized - .split("\n") - .filter(line => line.trim()) - .forEach(line => core.error(line)); - } - } - } catch (loggingErr) { - const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr); - core.debug(`Failed to serialize REST error details: ${loggingErrMsg}`); - } - if ( errorMessage.includes("Bad credentials") || errorMessage.includes("Not Authenticated") || @@ -511,25 +423,21 @@ async function assignAgentToIssue( function logPermissionError(agentName) { core.error(`Failed to assign ${agentName}: Insufficient permissions`); core.error(""); - core.error("Assigning Copilot coding agent requires:"); - core.error(" 1. Repository permissions:"); - core.error(" - actions: write"); - core.error(" - contents: write"); - core.error(" - agent-tasks: write"); + core.error("Assigning Copilot coding agent requires the following token permissions:"); + core.error(" Fine-grained PAT:"); + core.error(" - Read access to metadata"); + core.error(" - Read and write access to actions, contents, issues, and pull requests"); + core.error(" Classic PAT:"); + core.error(" - repo scope"); core.error(""); - core.error(" 2. A fine-grained PAT or GitHub App user token with agent-tasks: write"); - core.error(" (Installation tokens are not supported for agent task creation)"); + core.error(" Repository settings:"); + core.error(" - Ensure assignee has access to the repository"); core.error(""); - core.error(" 3. Repository settings:"); - core.error(" - Actions must have write permissions"); - core.error(" - Go to: Settings > Actions > General > Workflow permissions"); - core.error(" - Select: 'Read and write permissions'"); + core.error(" Organization/Enterprise settings and Copilot policy:"); + core.error(" - Check if your org restricts bot assignments"); + core.error(" - Verify Copilot is enabled for your repository"); core.error(""); - core.error(" 4. Organization/Enterprise settings and Copilot policy:"); - core.error(" - Check if your org restricts bot assignments"); - core.error(" - Verify Copilot is enabled for your repository"); - core.error(""); - core.info("For more information, see: https://docs.github.com/en/rest/agent-tasks/agent-tasks?apiVersion=2026-03-10#start-a-task"); + core.info("For more information, see: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api"); } /** @@ -540,28 +448,26 @@ function generatePermissionErrorSummary() { return ` ### ⚠️ Permission Requirements -Assigning Copilot coding agent requires **ALL** of these permissions: +Assigning Copilot coding agent requires a token with the correct permissions. See the [official GitHub Copilot cloud agent API documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api) for details. + +**Fine-grained personal access token** — requires these repository permissions: +- Read access to **metadata** +- Read and write access to **actions**, **contents**, **issues**, and **pull requests** -\`\`\`yaml -permissions: - actions: write - contents: write - agent-tasks: write -\`\`\` +**Classic personal access token** — requires the **\`repo\`** scope. **Token capability note:** -- Current token lacks permission for \`POST /agents/repos/{owner}/{repo}/tasks\`. -- Agent task creation requires a fine-grained PAT or GitHub App user token with **Agent tasks: read and write**. -- GitHub App installation access tokens are not supported for this endpoint. +- Current token lacks permission for \`POST /repos/{owner}/{repo}/issues/{issue_number}/assignees\`. +- Token must be able to assign users to issues in the target repository. **Recommended remediation paths:** -1. Use a fine-grained PAT with repository access and **Agent tasks (read/write)**. -2. Use a GitHub App **user access token** (not installation token) with Agent tasks permission. -3. Verify Copilot coding agent is enabled for the repository and organization policy allows task creation. +1. Use a fine-grained PAT with the permissions listed above, or a classic PAT with the \`repo\` scope. +2. Ensure repository settings allow assignee updates. +3. Verify Copilot coding agent is enabled for the repository and organization policy allows bot assignments. -**Why this failed:** The token could not create an agent task via the REST API. +**Why this failed:** The token could not update issue assignees via the REST API. -📖 Reference: https://docs.github.com/en/rest/agent-tasks/agent-tasks?apiVersion=2026-03-10#start-a-task +📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api `; } @@ -589,7 +495,7 @@ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { if (!agentId) { return { success: false, error: `${agentName} coding agent is not available for this repository` }; } - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); + core.info(`Found ${agentName} coding agent (login: ${agentId})`); // Get issue details and current assignees via REST core.info("Getting issue details..."); @@ -602,7 +508,7 @@ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { // Check if agent is already assigned const knownLogins = getAgentLogins(agentName); - if (issueDetails.currentAssignees.some(a => a.id === agentId || knownLogins.includes(a.login))) { + if (issueDetails.currentAssignees.some(a => a.login === agentId || knownLogins.includes(a.login))) { core.info(`${agentName} is already assigned to issue #${issueNumber}`); return { success: true }; } diff --git a/setup/js/assign_to_agent.cjs b/setup/js/assign_to_agent.cjs index 9263ec3..ff1dff9 100644 --- a/setup/js/assign_to_agent.cjs +++ b/setup/js/assign_to_agent.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, getPullRequestDetails, assignAgentToIssue, generatePermissionErrorSummary } = require("./assign_agent_helpers.cjs"); +const { AGENT_LOGIN_NAMES, getAgentLogins, getAvailableAgentLogins, findAgent, getIssueDetails, getPullRequestDetails, assignAgentToIssue, generatePermissionErrorSummary } = require("./assign_agent_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { resolveTarget, isStagedMode } = require("./safe_output_helpers.cjs"); const { generateStagedPreview } = require("./staged_preview.cjs"); @@ -327,15 +327,15 @@ async function main(config = {}) { try { // Find agent (use cache to avoid repeated lookups) - let agentId = agentCache[agentName]; - if (!agentId) { + let agentLogin = agentCache[agentName]; + if (!agentLogin) { core.info(`Looking for ${agentName} coding agent...`); - agentId = await findAgent(effectiveOwner, effectiveRepo, agentName, issueNumber || pullNumber, githubClient); - if (!agentId) { + agentLogin = await findAgent(effectiveOwner, effectiveRepo, agentName, issueNumber || pullNumber, githubClient); + if (!agentLogin) { throw new Error(`${agentName} coding agent is not available for this repository`); } - agentCache[agentName] = agentId; - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); + agentCache[agentName] = agentLogin; + core.info(`Found ${agentName} coding agent (login: ${agentLogin})`); } // Get issue or PR details @@ -371,7 +371,8 @@ async function main(config = {}) { // Skip if agent is already assigned and no explicit per-item pull_request_repo is specified. // When a different pull_request_repo is provided on the message, allow re-assignment // so Copilot can be triggered for a different target repository on the same issue. - if (currentAssignees.some(a => a.id === agentId) && !shouldAllowReassignment) { + const knownLogins = getAgentLogins(agentName); + if (currentAssignees.some(a => a.login === agentLogin || knownLogins.includes(a.login)) && !shouldAllowReassignment) { core.info(`${agentName} is already assigned to ${type} #${number}`); _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true }); return { success: true }; @@ -383,7 +384,7 @@ async function main(config = {}) { if (customInstructions) core.info(`Using custom instructions: ${customInstructions.substring(0, 100)}${customInstructions.length > 100 ? "..." : ""}`); if (effectiveBaseBranch) core.info(`Using base branch: ${effectiveBaseBranch}`); - const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, model, customAgent, customInstructions, effectiveBaseBranch, githubClient, taskContext, effectivePullRequestRepoSlug); + const success = await assignAgentToIssue(assignableId, agentLogin, currentAssignees, agentName, allowedAgents, model, customAgent, customInstructions, effectiveBaseBranch, githubClient, taskContext, effectivePullRequestRepoSlug); if (!success) throw new Error(`Failed to assign ${agentName} via REST`); core.info(`Successfully assigned ${agentName} coding agent to ${type} #${number}`); @@ -392,6 +393,24 @@ async function main(config = {}) { } catch (error) { let errorMessage = getErrorMessage(error); + // When the agent specified an issue_number that turns out to be a PR, skip + // silently without posting a comment — error comments on PRs are confusing. + if (/** @type {any} */ error.isPullRequest) { + core.warning(`Skipping assign_to_agent for #${number}: target is a pull request, not an issue.`); + _allResults.push({ + issue_number: issueNumber, + pull_number: pullNumber, + agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, + pull_request_repo: effectivePullRequestRepoSlug, + success: false, + skipped: true, + error: errorMessage, + }); + return { success: false, skipped: true, error: errorMessage }; + } + const isAuthError = ["Bad credentials", "Not Authenticated", "Resource not accessible", "Insufficient permissions", "requires authentication"].some(msg => errorMessage.includes(msg)); const isAvailabilityError = errorMessage.includes("coding agent is not available for this repository"); @@ -399,7 +418,17 @@ async function main(config = {}) { const errorType = isAuthError ? "authentication/permission" : "agent availability"; core.warning(`Agent assignment failed for ${agentName} on ${type} #${number} due to ${errorType} error. Skipping due to ignore-if-error=true.`); core.info(`Error details: ${errorMessage}`); - _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true, skipped: true }); + _allResults.push({ + issue_number: issueNumber, + pull_number: pullNumber, + agent: agentName, + owner: effectiveOwner, + repo: effectiveRepo, + pull_request_repo: effectivePullRequestRepoSlug, + success: true, + skipped: true, + error: errorMessage, + }); return { success: true, skipped: true }; } @@ -451,18 +480,24 @@ function getAssignToAgentAssigned() { /** * Returns the "assignment_errors" output string for step outputs. - * Format: "issue:N:agent:error" or "pr:N:agent:error" per failure, newline-separated. + * Format: "issue:N:agent:error" or "pr:N:agent:error" per failure/skipped-with-error, + * newline-separated. * @returns {string} */ function getAssignToAgentErrors() { - return _allResults - .filter(r => !r.success && !r.skipped) - .map(r => { - const number = r.issue_number || r.pull_number; - const prefix = r.issue_number ? "issue" : "pr"; - return `${prefix}:${number}:${r.agent}:${r.error}`; - }) - .join("\n"); + return ( + _allResults + // Include skipped(ignore-if-error) entries that still captured an error so + // downstream failure handling can surface assignment problems in issue/comment reports. + // Include hard failures (!success) and ignored failures (skipped=true with error). + .filter(r => r.error && (r.skipped || !r.success)) + .map(r => { + const number = r.issue_number || r.pull_number; + const prefix = r.issue_number ? "issue" : "pr"; + return `${prefix}:${number}:${r.agent}:${r.error}`; + }) + .join("\n") + ); } /** diff --git a/setup/js/claude_harness.cjs b/setup/js/claude_harness.cjs index 1cc6752..52d0857 100644 --- a/setup/js/claude_harness.cjs +++ b/setup/js/claude_harness.cjs @@ -410,10 +410,11 @@ async function main() { } const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests) { + if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.maxRunsExceeded) { const reasons = []; if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded"); if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests"); + if (nonRetryableGuard.maxRunsExceeded) reasons.push("maximum LLM invocations exceeded"); log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); break; } diff --git a/setup/js/codex_harness.cjs b/setup/js/codex_harness.cjs index 937fbd3..9f5706d 100644 --- a/setup/js/codex_harness.cjs +++ b/setup/js/codex_harness.cjs @@ -58,10 +58,23 @@ const BACKOFF_MULTIPLIER = 2; // Maximum delay cap in milliseconds const MAX_DELAY_MS = 60000; -// Pattern to detect OpenAI rate-limit errors (HTTP 429). -// Matches "rate_limit_exceeded" from the OpenAI error type field and the "429" status code -// that Codex emits when the API rate limit is hit. -const RATE_LIMIT_ERROR_PATTERN = /rate_limit_exceeded|429 Too Many Requests|RateLimitError/i; +// Pattern to detect OpenAI rate-limit errors. +// Matches the JSON error type field ("rate_limit_exceeded"), the HTTP status code +// ("429 Too Many Requests"), the client-side exception class ("RateLimitError"), and +// the human-readable message Codex emits inside "Reconnecting..." / error lines: +// "Rate limit reached for in organization on tokens per min (TPM): ..." +const RATE_LIMIT_ERROR_PATTERN = /rate_limit_exceeded|429 Too Many Requests|RateLimitError|Rate limit reached for [^\s]+(?: in organization [^\s]+)? on tokens per min/i; + +// Pattern to detect when Codex's internal stream-reconnect budget is fully spent. +// Codex emits "Reconnecting... N/N (reason)" where both numbers are the same when +// the reconnect is the last allowed attempt. Seeing this pattern together with a +// rate-limit error means the session cannot make forward progress: every reconnect +// attempt immediately fails with the same rate-limit, and a fresh harness run will +// re-encounter the same limit since the same work pattern consumes the same TPM budget. +// +// The backreference \1 requires the two numeric parts of "N/N" to be identical — +// "5/5" matches (exhausted) but "1/5", "3/5", "4/5" do not (still retrying). +const RECONNECT_EXHAUSTED_PATTERN = /Reconnecting\.\.\.\s+(\d+)\/\1\b/; const AUTHENTICATION_FAILED_PATTERN = /Authentication failed(?:\s*\(Request ID:[^)]+\))?/i; // Pattern to detect a missing API key at startup — Codex emits this before making any API @@ -130,6 +143,20 @@ function isInvalidModelError(output) { return INVALID_MODEL_ERROR_PATTERN.test(output); } +/** + * Determines if the collected output shows that Codex's internal stream-reconnect + * retries are exhausted (i.e., the output contains "Reconnecting... N/N" where both + * numbers are the same, indicating the last reconnect attempt). + * + * When this is true together with a rate-limit error, retrying from scratch would + * immediately encounter the same rate limit and drain the token budget further. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isReconnectExhaustedError(output) { + return RECONNECT_EXHAUSTED_PATTERN.test(output); +} + /** * Resolve --prompt-file arguments for the Codex run. * Strips the --prompt-file pair from args and appends the file content @@ -439,11 +466,12 @@ async function main() { } const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive) { + if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive || nonRetryableGuard.maxRunsExceeded) { const reasons = []; if (nonRetryableGuard.aiCreditsExceeded) reasons.push("AI credits budget exceeded"); if (nonRetryableGuard.awfAPIProxyBlockingRequests) reasons.push("AWF API proxy is blocking requests"); if (nonRetryableGuard.goalAlreadyActive) reasons.push("goal is already active for this thread (use update_goal when the current goal is complete)"); + if (nonRetryableGuard.maxRunsExceeded) reasons.push("maximum LLM invocations exceeded"); log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); break; } @@ -470,6 +498,15 @@ async function main() { break; } + // Codex's internal stream-reconnect retries are exhausted and the root cause is a + // rate-limit error. Each reconnect attempt immediately failed with the same limit, + // so a fresh harness run will encounter the same rate-limit at the same point in the + // session and drain the token budget further without making progress. + if (isRateLimit && isReconnectExhaustedError(result.output)) { + log(`attempt ${attempt + 1}: rate-limit with exhausted reconnects — not retrying (fresh run would hit the same rate limit)`); + break; + } + // Retry when the session was partially executed (has output) or on well-known // transient errors (rate limit, server error) even without output. const isTransient = isRateLimit || isServer; @@ -504,6 +541,7 @@ if (typeof module !== "undefined" && module.exports) { isMissingApiKeyError, isServerError, isInvalidModelError, + isReconnectExhaustedError, countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, diff --git a/setup/js/copilot_sdk_session.cjs b/setup/js/copilot_sdk_session.cjs index 9f3129c..be2e6aa 100644 --- a/setup/js/copilot_sdk_session.cjs +++ b/setup/js/copilot_sdk_session.cjs @@ -12,6 +12,12 @@ * SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName, command?) * SDK "tool.execution_complete" → JSONL "tool.execution_complete" (toolName, mcpServerName, success, result) * SDK "assistant.message" → JSONL "assistant.message" (content) + * SDK "assistant.turn_start" → watchdog disarmed (inAssistantTurn = true) + * SDK "assistant.turn_end" → watchdog re-enabled (inAssistantTurn = false) + * SDK "session.task_complete" → JSONL "session.task_complete" (success, summary) + * SDK "subagent.started" → JSONL "subagent.started" (agentName, agentDisplayName, toolCallId) + * SDK "subagent.completed" → JSONL "subagent.completed" (agentName, toolCallId) + * SDK "subagent.failed" → JSONL "subagent.failed" (agentName, toolCallId, error) * * The JSONL file is written to: * /tmp/gh-aw/sandbox/agent/logs/copilot-session-state/{sessionId}/events.jsonl @@ -43,6 +49,15 @@ const SDK_SEND_TIMEOUT_MS_DEFAULT = 10 * 60 * 1000; // Keep in sync with SDK_SESSION_IDLE_TIMEOUT_PATTERN in copilot_harness.cjs. const SDK_IDLE_TIMEOUT_PATTERN = /Timeout after \d+ms waiting for session\.idle/; +// Default idle period for the post-completion watchdog: 5 minutes. +// When the agent has produced output and all tracked tool calls have completed, +// the driver arms a watchdog timer. If no new SDK events arrive within this +// window, the driver force-disconnects the session and treats it as a successful +// completion — covering the SDK driver bug where sendAndWait never resolves after +// the final tool result is returned. +// Override via the GH_AW_SDK_IDLE_MS environment variable. +const SDK_POST_COMPLETION_IDLE_MS_DEFAULT = 5 * 60 * 1000; + /** * Extract the prompt text from a resolved args array. * Looks for the first occurrence of "-p " or "--prompt ". @@ -156,6 +171,23 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c */ const pendingToolCalls = new Map(); + // Post-completion idle watchdog. + // When the agent has produced output and all tracked tool calls have completed, + // this timer is armed. If no new SDK events arrive within GH_AW_SDK_IDLE_MS + // (default 5 minutes), the watchdog force-disconnects the session and the catch + // block treats the result as a successful completion. This bounds the damage + // from the SDK driver bug where sendAndWait never resolves after the final + // tool result is returned. + const postCompletionIdleMs = getEnvPositiveIntOrDefault("GH_AW_SDK_IDLE_MS", SDK_POST_COMPLETION_IDLE_MS_DEFAULT); + let postCompletionWatchdogTriggered = false; + /** @type {ReturnType | null} */ + let postCompletionWatchdog = null; + // Tracks whether the LLM is mid-inference (between assistant.turn_start and + // assistant.turn_end). The watchdog must not fire during this window because + // pendingToolCalls may legitimately be empty before the model dispatches its + // first tool call of the new turn. + let inAssistantTurn = false; + /** * Best-effort write of a driver-level event to events.jsonl and stderr. * @param {string} type @@ -293,10 +325,87 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c break; } + case "assistant.turn_start": + // LLM inference started for a new turn. Disarm the watchdog — the model + // may not have dispatched any tool calls yet, so pendingToolCalls can be + // empty while real work is still in progress. + inAssistantTurn = true; + if (postCompletionWatchdog) { + clearTimeout(postCompletionWatchdog); + postCompletionWatchdog = null; + } + break; + + case "assistant.turn_end": + // LLM inference finished. Allow the watchdog to re-arm on the next event + // that satisfies the completion conditions. + inAssistantTurn = false; + break; + + case "session.task_complete": + writeEvent("session.task_complete", { success: event.data?.success, summary: event.data?.summary }, event.timestamp); + break; + + case "subagent.started": + writeEvent( + "subagent.started", + { + agentName: event.data?.agentName, + agentDisplayName: event.data?.agentDisplayName, + toolCallId: event.data?.toolCallId, + }, + event.timestamp + ); + break; + + case "subagent.completed": + writeEvent("subagent.completed", { agentName: event.data?.agentName, toolCallId: event.data?.toolCallId }, event.timestamp); + break; + + case "subagent.failed": + writeEvent( + "subagent.failed", + { + agentName: event.data?.agentName, + toolCallId: event.data?.toolCallId, + error: event.data?.error, + }, + event.timestamp + ); + break; + default: // Other event types are not consumed by unified_timeline.cjs; skip them. break; } + + // After processing each event, update the post-completion watchdog: + // - Arm (or rearm) the watchdog when the session looks complete: output + // collected, no tool calls still in flight, and not mid-LLM-inference. + // - Disarm the watchdog whenever the session is still mid-turn (a new + // tool call was just started, LLM inference is in progress, or no output yet). + // The watchdog fires only if sendAndWait never resolves on its own after + // the final tool result is returned — the common SDK post-completion hang. + if (hasOutput && pendingToolCalls.size === 0 && !inAssistantTurn) { + if (postCompletionWatchdog) clearTimeout(postCompletionWatchdog); + postCompletionWatchdog = setTimeout(() => { + postCompletionWatchdog = null; + // Re-check conditions at fire time: a new tool call could have started + // or a new turn could have begun between arming the watchdog and the + // timer firing (race condition guard). + if (!hasOutput || pendingToolCalls.size !== 0 || inAssistantTurn || !session) return; + log(`warning: post-completion idle watchdog fired after ${postCompletionIdleMs}ms — force-disconnecting session`); + postCompletionWatchdogTriggered = true; + void session.disconnect().catch(err => { + log(`warning: post-completion watchdog disconnect failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, postCompletionIdleMs); + } else { + if (postCompletionWatchdog) { + clearTimeout(postCompletionWatchdog); + postCompletionWatchdog = null; + } + } }); log("sending prompt..."); @@ -326,6 +435,15 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c const failure = catastrophicToolDenialsError ?? (err instanceof Error ? err : new Error(String(err))); log(`error: ${failure.message}`); + // When the post-completion idle watchdog force-disconnected the session, the + // agent's work is done — the SDK simply failed to resolve sendAndWait after + // the final tool result was returned. Treat it as a successful completion. + if (postCompletionWatchdogTriggered && !catastrophicToolDenialsError && hasOutput && pendingToolCalls.size === 0) { + log(`warning: post-completion watchdog triggered disconnect — treating as completed`); + log(`session completed: hasOutput=${hasOutput} durationMs=${durationMs}`); + return { exitCode: 0, output, hasOutput, durationMs }; + } + // When sendAndWait times out waiting for session.idle but the agent produced // output and all tracked tool calls have already completed, the session work is // done — the SDK simply failed to emit the idle signal. Treat it as a successful @@ -346,6 +464,11 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c durationMs, }; } finally { + // Clear the post-completion watchdog if it has not already fired. + if (postCompletionWatchdog) { + clearTimeout(postCompletionWatchdog); + postCompletionWatchdog = null; + } // Snapshot for null-safe cleanup in this scope. const stream = eventsStream; if (stream) { @@ -368,4 +491,4 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c } } -module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, SDK_IDLE_TIMEOUT_PATTERN, extractPromptFromArgs, runWithCopilotSDK }; +module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, SDK_POST_COMPLETION_IDLE_MS_DEFAULT, SDK_IDLE_TIMEOUT_PATTERN, extractPromptFromArgs, runWithCopilotSDK }; diff --git a/setup/js/copilot_sdk_sidecar.cjs b/setup/js/copilot_sdk_sidecar.cjs index fa64731..8e9ce92 100644 --- a/setup/js/copilot_sdk_sidecar.cjs +++ b/setup/js/copilot_sdk_sidecar.cjs @@ -131,6 +131,7 @@ async function startCopilotSDKServer(options) { const child = spawnImpl(options.command, args, { stdio: ["ignore", "pipe", "pipe"], env, + cwd: process.env.GH_AW_ENGINE_CWD || process.env.GITHUB_WORKSPACE || undefined, }); child.stdout.on("data", data => { diff --git a/setup/js/generate_usage_activity_summary.cjs b/setup/js/generate_usage_activity_summary.cjs index 6fcd204..cd83b53 100644 --- a/setup/js/generate_usage_activity_summary.cjs +++ b/setup/js/generate_usage_activity_summary.cjs @@ -111,6 +111,12 @@ function parseFirewallLogs() { continue; } + // Skip non-Squid diagnostic lines (WARNING:, DNS, Accepting, etc.) by + // validating that the first field is a numeric Unix timestamp. + if (!/^\d+(\.\d+)?$/.test(parts[0])) { + continue; + } + const domain = parts[SQUID_DOMAIN_INDEX]; const dest = parts[SQUID_DEST_INDEX]; const client = parts[SQUID_CLIENT_INDEX] || ""; diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 08d45e4..8e410f0 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -217,6 +217,7 @@ function buildFailureMatchCategories(options) { if (options.hasLockdownCheckFailed) categories.push("lockdown_check_failed"); if (options.hasStaleLockFileFailed) categories.push("stale_lock_file_failed"); if (options.hasDailyAICExceeded) categories.push("daily_ai_credits_exceeded"); + if (options.isAWFFirewallStartupFailed) categories.push("awf_firewall_startup_failed"); if (options.agentConclusion === "failure" && !options.isTimedOut) { categories.push("agent_failure"); @@ -243,6 +244,7 @@ function buildFailureMatchCategories(options) { * @param {boolean} options.hasDailyAICExceeded * @param {boolean} options.aiCreditsRateLimitError * @param {boolean} options.maxAICreditsExceeded + * @param {boolean} options.hasAssignmentErrors * @returns {string} */ function buildFailureIssueTitle(options) { @@ -260,6 +262,7 @@ function buildFailureIssueTitle(options) { if (options.hasMissingSafeOutputs) return `[aw] ${workflowName} produced no safe outputs`; if (options.hasMissingTool) return `[aw] ${workflowName} is missing required tool`; if (options.hasMissingData) return `[aw] ${workflowName} is missing required data`; + if (options.hasAssignmentErrors) return `[aw] ${workflowName} failed to assign agent`; return `[aw] ${workflowName} failed`; } @@ -2030,6 +2033,40 @@ function buildCredentialAuthErrorContext(auditJsonlPathOverride) { const templatePath = getPromptPath("credential_auth_error.md"); return "\n" + renderTemplateFromFile(templatePath, { providers: providersList }); } + +/** + * Build a context string when assign_to_agent reported assignment errors. + * Includes remediation guidance for token and Copilot access setup. + * @param {string} assignmentErrors + * @returns {string} + */ +function buildAssignmentErrorsContext(assignmentErrors) { + if (!assignmentErrors || !assignmentErrors.trim()) { + return ""; + } + + let context = buildWarningAlertLine("Agent Assignment Failed", "Failed to assign agent to issues or pull requests."); + context += "\n**Assignment Errors:**\n"; + + const errorLines = assignmentErrors.split("\n").filter(line => line.trim()); + for (const errorLine of errorLines) { + const parts = errorLine.split(":"); + if (parts.length >= 4) { + const type = parts[0]; // "issue" or "pr" + const number = parts[1]; + const agent = parts[2]; + const error = parts.slice(3).join(":"); + context += `- ${type === "issue" ? "Issue" : "PR"} #${number} (agent: ${agent}): ${error}\n`; + } + } + + context += "\nTo resolve this, verify the agent token and Copilot access configuration:\n"; + context += "- Configure a valid `GH_AW_AGENT_TOKEN` as a fine-grained PAT with **Agent tasks: read and write** permission (GitHub App installation tokens are not supported)\n"; + context += "- Ensure Copilot coding agent is enabled for this repository and a Copilot Business or Enterprise subscription is active\n"; + context += "- Docs: https://github.github.com/gh-aw/reference/copilot-cloud-agent/#authentication\n\n"; + + return context; +} /** * Build a context string when assigning the Copilot coding agent to created issues failed. * @param {boolean} hasAssignCopilotFailures - Whether any copilot assignments failed @@ -2119,6 +2156,46 @@ function hasAgentTerminalReasonCompleted() { return false; } +/** + * Detect AWF firewall startup failure signals from log content. + * Uses specific failure patterns to avoid false positives on successful runs + * where container lifecycle lines (e.g., " Container awf-cli-proxy Started") + * also mention awf-cli-proxy. + * @param {string} logContent Full content of agent-stdio.log + * @param {Set} [errorMessages] Collected error messages from the log parsing loop (optional) + * @returns {{ isFirewallFailed: boolean, hasDNSFailure: boolean, hasDNSEAIAgain: boolean }} + */ +function detectAWFStartupSignals(logContent, errorMessages = undefined) { + const hasFirewallFailedMsg = logContent.includes("AWF firewall failed to start"); + const hasDependencyFailedMsg = logContent.includes("dependency failed to start: container awf-cli-proxy"); + const hasErrorMsgWithProxy = errorMessages !== undefined && Array.from(errorMessages).some(msg => msg.includes("awf-cli-proxy")); + const isFirewallFailed = hasFirewallFailedMsg || hasDependencyFailedMsg || hasErrorMsgWithProxy; + + const hasDNSEAIAgain = logContent.includes("EAI_AGAIN"); + const hasDNSDiagnosisUnknown = logContent.includes("diagnosis=unknown") && logContent.includes("awmg-cli-proxy"); + const hasDNSFailure = isFirewallFailed && (hasDNSEAIAgain || hasDNSDiagnosisUnknown); + + return { isFirewallFailed, hasDNSFailure, hasDNSEAIAgain }; +} + +/** + * Detect whether the agent-stdio.log contains evidence of an AWF firewall startup failure. + * Reads the log file from the path derived from GH_AW_AGENT_OUTPUT, falling back to the + * default path. Returns false when the log file does not exist or cannot be read. + * @returns {boolean} + */ +function detectAWFFirewallStartupFailureFromLog() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + const stdioLogPath = agentOutputFile ? path.join(path.dirname(agentOutputFile), "agent-stdio.log") : "/tmp/gh-aw/agent-stdio.log"; + try { + if (!fs.existsSync(stdioLogPath)) return false; + const logContent = fs.readFileSync(stdioLogPath, "utf8"); + return detectAWFStartupSignals(logContent).isFirewallFailed; + } catch { + return false; + } +} + /** * Extract terminal error messages from agent-stdio.log to surface engine failures. * First tries to match known error patterns (ERROR:, Error:, Fatal:, panic:, Reconnecting...). @@ -2240,6 +2317,25 @@ function buildEngineFailureContext(options = {}) { } } + // Check for AWF firewall startup failure (cli-proxy / DIFC proxy could not start) + const { isFirewallFailed, hasDNSFailure, hasDNSEAIAgain } = detectAWFStartupSignals(logContent, errorMessages); + if (isFirewallFailed) { + core.info("Detected AWF firewall startup failure — using dedicated context message"); + let context = buildWarningAlertLine("AWF Firewall Startup Failure", `The AWF firewall failed to start — the${engineLabel} agent was never invoked.`) + "\n"; + if (hasDNSFailure) { + const dnsDiagnosis = hasDNSEAIAgain + ? "**Diagnosis:** DNS resolution of `awmg-cli-proxy` returned `EAI_AGAIN` (temporary DNS failure). The DIFC probe exhausted its retry budget before the name resolved.\n\n" + : "**Diagnosis:** The DIFC proxy (`awmg-cli-proxy`) failed to respond (`diagnosis=unknown`). The probe exhausted its retry budget before the service became reachable.\n\n"; + context += dnsDiagnosis; + } + context += "\n
\nError details\n\n"; + for (const message of errorMessages) { + context += `- ${message}\n`; + } + context += `\n
\n\nSee [Diagnosing AWF Failures](https://github.com/github/gh-aw-firewall/blob/main/docs/diagnosing-awf-failures.md) for troubleshooting guidance.\n\n`; + return context; + } + let context = buildWarningAlertLine("Engine Failure", `The${engineLabel} engine terminated before producing output.`) + "\n**Error details:**\n"; for (const message of errorMessages) { context += `- ${message}\n`; @@ -2269,7 +2365,22 @@ function buildEngineFailureContext(options = {}) { if (agentLines.length === 0) { // The log contains only AWF infrastructure lines — the engine exited before producing - // any substantive output. This pattern is characteristic of a transient startup failure + // any substantive output. Check first if this is an AWF firewall startup failure. + const { isFirewallFailed: isAWFFirewallStartupFailedInfra, hasDNSFailure: hasDNSFailureInfra, hasDNSEAIAgain: hasDNSEAIAgainInfra } = detectAWFStartupSignals(logContent); + if (isAWFFirewallStartupFailedInfra) { + core.info("Detected AWF firewall startup failure in infra-only log — using dedicated context message"); + let context = buildWarningAlertLine("AWF Firewall Startup Failure", `The AWF firewall failed to start — the${engineLabel} agent was never invoked.`) + "\n"; + if (hasDNSFailureInfra) { + const dnsDiagnosis = hasDNSEAIAgainInfra + ? "**Diagnosis:** DNS resolution of `awmg-cli-proxy` returned `EAI_AGAIN` (temporary DNS failure). The DIFC probe exhausted its retry budget before the name resolved.\n\n" + : "**Diagnosis:** The DIFC proxy (`awmg-cli-proxy`) failed to respond (`diagnosis=unknown`). The probe exhausted its retry budget before the service became reachable.\n\n"; + context += dnsDiagnosis; + } + context += `\nSee [Diagnosing AWF Failures](https://github.com/github/gh-aw-firewall/blob/main/docs/diagnosing-awf-failures.md) for troubleshooting guidance.\n\n`; + return context; + } + + // This pattern is characteristic of a transient startup failure // (e.g., API service unavailable, rate-limiting, token not yet provisioned). core.info("agent-stdio.log contains only infrastructure lines — engine likely failed at startup (possible transient failure)"); const recurringFailureGuidance = @@ -2699,8 +2810,10 @@ async function main() { // in the engine output and sets the agentic_engine_timeout output. const isTimedOut = agentConclusion === "timed_out" || agenticEngineTimeout; - // Check if there are assignment errors (regardless of agent job status) - const hasAssignmentErrors = parseInt(assignmentErrorCount, 10) > 0; + // Check if there are assignment errors (regardless of agent job status). + // Use assignment_errors as the single source of truth because it includes + // both hard failures and skipped(ignore-if-error) assignment errors. + const hasAssignmentErrors = assignmentErrors.split("\n").some(line => line.trim()); // Check if there are copilot assignment failures for created issues (regardless of agent job status) const hasAssignCopilotFailures = parseInt(assignCopilotFailureCount, 10) > 0; @@ -2954,6 +3067,7 @@ async function main() { hasDailyAICExceeded, aiCreditsRateLimitError, maxAICreditsExceeded, + hasAssignmentErrors, }); const failureCategories = buildFailureMatchCategories({ agentConclusion, @@ -2981,6 +3095,7 @@ async function main() { hasLockdownCheckFailed, hasStaleLockFileFailed, hasDailyAICExceeded, + isAWFFirewallStartupFailed: detectAWFFirewallStartupFailureFromLog(), }); // Persist failure categories so the OTLP conclusion span can record them @@ -3061,22 +3176,7 @@ async function main() { const runId = extractRunId(runUrl); // Build assignment errors context - let assignmentErrorsContext = ""; - if (hasAssignmentErrors && assignmentErrors) { - assignmentErrorsContext = buildWarningAlertLine("Agent Assignment Failed", "Failed to assign agent to issues due to insufficient permissions or missing token.") + "\n**Assignment Errors:**\n"; - const errorLines = assignmentErrors.split("\n").filter(line => line.trim()); - for (const errorLine of errorLines) { - const parts = errorLine.split(":"); - if (parts.length >= 4) { - const type = parts[0]; // "issue" or "pr" - const number = parts[1]; - const agent = parts[2]; - const error = parts.slice(3).join(":"); // Rest is the error message - assignmentErrorsContext += `- ${type === "issue" ? "Issue" : "PR"} #${number} (agent: ${agent}): ${error}\n`; - } - } - assignmentErrorsContext += "\n"; - } + const assignmentErrorsContext = buildAssignmentErrorsContext(assignmentErrors); // Build create_discussion errors context const createDiscussionErrorsContext = hasCreateDiscussionErrors ? buildCreateDiscussionErrorsContext(createDiscussionErrors) : ""; @@ -3284,22 +3384,7 @@ async function main() { const issueTemplate = fs.readFileSync(issueTemplatePath, "utf8"); // Build assignment errors context - let assignmentErrorsContext = ""; - if (hasAssignmentErrors && assignmentErrors) { - assignmentErrorsContext = buildWarningAlertLine("Agent Assignment Failed", "Failed to assign agent to issues due to insufficient permissions or missing token.") + "\n**Assignment Errors:**\n"; - const errorLines = assignmentErrors.split("\n").filter(line => line.trim()); - for (const errorLine of errorLines) { - const parts = errorLine.split(":"); - if (parts.length >= 4) { - const type = parts[0]; // "issue" or "pr" - const number = parts[1]; - const agent = parts[2]; - const error = parts.slice(3).join(":"); // Rest is the error message - assignmentErrorsContext += `- ${type === "issue" ? "Issue" : "PR"} #${number} (agent: ${agent}): ${error}\n`; - } - } - assignmentErrorsContext += "\n"; - } + const assignmentErrorsContext = buildAssignmentErrorsContext(assignmentErrors); // Build create_discussion errors context const createDiscussionErrorsContext = hasCreateDiscussionErrors ? buildCreateDiscussionErrorsContext(createDiscussionErrors) : ""; @@ -3520,6 +3605,7 @@ module.exports = { isIssueWritePermissionError, buildAssignCopilotFailureContext, buildEngineFailureContext, + detectAWFFirewallStartupFailureFromLog, buildReportIncompleteContext, buildMCPPolicyErrorContext, buildModelNotSupportedErrorContext, @@ -3531,6 +3617,7 @@ module.exports = { loadToolDenialsExceededEvents, buildToolDenialsExceededContext, buildCredentialAuthErrorContext, + buildAssignmentErrorsContext, buildAICreditsRateLimitErrorContext, buildUnknownModelAICreditsContext, hasEngineMaxRunsExceededSignal, diff --git a/setup/js/harness_retry_guard.cjs b/setup/js/harness_retry_guard.cjs index 042c76b..a9dc41c 100644 --- a/setup/js/harness_retry_guard.cjs +++ b/setup/js/harness_retry_guard.cjs @@ -7,10 +7,18 @@ const AI_CREDITS_EXCEEDED_PATTERNS = [/\bmax[\s_-]*ai[\s_-]*credits[\s_-]*exceed const AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS = [/\bawf\b.*\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocking requests\b/i, /\bapi[\s_-]*proxy\b.*\bblocked requests?\b/i, /\bDIFC_FILTERED\b/]; const GOAL_ALREADY_ACTIVE_PATTERNS = [/\bthis thread already has a goal\b[\s\S]*?\buse update_goal\b/i]; +// Patterns to detect Anthropic "max_runs_exceeded" (HTTP 403). +// This occurs when the per-session LLM invocation quota is exhausted. +// Retrying is pointless because each fresh-run attempt immediately fails with +// the same 403 until the quota resets. Matches both the JSON error type +// ("max_runs_exceeded") and the human-readable message +// ("Maximum LLM invocations exceeded"). +const MAX_RUNS_EXCEEDED_PATTERNS = [/\bmax_runs_exceeded\b/i, /Maximum LLM invocations exceeded/i]; + /** * Detect retry guard conditions that should stop harness retries immediately. * @param {unknown} output - * @returns {{ aiCreditsExceeded: boolean, awfAPIProxyBlockingRequests: boolean, goalAlreadyActive: boolean }} + * @returns {{ aiCreditsExceeded: boolean, awfAPIProxyBlockingRequests: boolean, goalAlreadyActive: boolean, maxRunsExceeded: boolean }} */ function detectNonRetryableHarnessGuard(output) { const safeOutput = typeof output === "string" ? output : ""; @@ -18,6 +26,7 @@ function detectNonRetryableHarnessGuard(output) { aiCreditsExceeded: AI_CREDITS_EXCEEDED_PATTERNS.some(pattern => pattern.test(safeOutput)), awfAPIProxyBlockingRequests: AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS.some(pattern => pattern.test(safeOutput)), goalAlreadyActive: GOAL_ALREADY_ACTIVE_PATTERNS.some(pattern => pattern.test(safeOutput)), + maxRunsExceeded: MAX_RUNS_EXCEEDED_PATTERNS.some(pattern => pattern.test(safeOutput)), }; } @@ -27,5 +36,6 @@ if (typeof module !== "undefined" && module.exports) { AI_CREDITS_EXCEEDED_PATTERNS, AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS, GOAL_ALREADY_ACTIVE_PATTERNS, + MAX_RUNS_EXCEEDED_PATTERNS, }; } diff --git a/setup/js/parse_threat_detection_results.cjs b/setup/js/parse_threat_detection_results.cjs index 747f640..d4179f8 100644 --- a/setup/js/parse_threat_detection_results.cjs +++ b/setup/js/parse_threat_detection_results.cjs @@ -453,7 +453,6 @@ async function main() { const runDetection = process.env.RUN_DETECTION; const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== "false"; const detectionExecutionOutcome = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME || ""; - const detectionExecutionFailed = detectionExecutionOutcome === "failure"; const isWarnMode = continueOnError; /** @@ -464,10 +463,9 @@ async function main() { * @param {string} message - Human-readable error message */ function setDetectionFailure(reason, message) { - const mustFail = detectionExecutionFailed && (reason === "agent_failure" || reason === "parse_error"); core.setOutput("reason", reason); core.exportVariable("GH_AW_DETECTION_REASON", reason); - if (isWarnMode && !mustFail) { + if (isWarnMode) { core.warning(`⚠️ ${message}`); core.setOutput("conclusion", "warning"); core.exportVariable("GH_AW_DETECTION_CONCLUSION", "warning"); diff --git a/setup/js/process_runner.cjs b/setup/js/process_runner.cjs index bc1405b..6505d6e 100644 --- a/setup/js/process_runner.cjs +++ b/setup/js/process_runner.cjs @@ -44,6 +44,11 @@ function sleep(ms) { * Run a command with the given arguments, transparently forwarding stdin/stdout/stderr. * Also collects combined stdout+stderr output for error pattern detection. * + * The child process is spawned with `cwd` set to `process.env.GH_AW_ENGINE_CWD` when + * available, falling back to `process.env.GITHUB_WORKSPACE`, so that engines and their + * skill-discovery paths resolve relative to the configured or repository checkout root + * rather than the harness working directory. + * * @param {{ * command: string, * args: string[], @@ -80,6 +85,7 @@ function runProcess({ command, args, attempt, log, logArgs, env }) { const child = spawn(command, args, { stdio: ["inherit", "pipe", "pipe"], env: env ?? process.env, + cwd: process.env.GH_AW_ENGINE_CWD || process.env.GITHUB_WORKSPACE || undefined, }); log(`attempt ${attempt + 1}: process started (pid=${child.pid ?? "unknown"})`); diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs index 9708860..4d9ebce 100644 --- a/setup/js/push_to_pull_request_branch.cjs +++ b/setup/js/push_to_pull_request_branch.cjs @@ -102,6 +102,23 @@ function parsePositiveInteger(value) { return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } +/** + * Uses git as the source of truth for the files modified by a fetched bundle ref. + * + * @param {{ getExecOutput: (command: string, args?: string[], options?: any) => Promise<{ stdout: string }> }} exec + * @param {Record} gitOptions + * @param {string} rangeBaseRef + * @param {string} bundleRef + * @returns {Promise} + */ +async function getBundlePreApplyFiles(exec, gitOptions, rangeBaseRef, bundleRef) { + const bundleDiffResult = await exec.getExecOutput("git", ["diff", "--name-only", "--no-renames", `${rangeBaseRef}..${bundleRef}`], gitOptions); + return bundleDiffResult.stdout + .split("\n") + .map(f => f.trim()) + .filter(Boolean); +} + /** * Main handler factory for push_to_pull_request_branch * Returns a message handler function that processes individual push_to_pull_request_branch messages @@ -208,9 +225,9 @@ async function main(config = {}) { core.warning(`Bundle file path was provided but file is not present on disk: ${bundleFilePath}; falling back to patch transport`); } - // Always require a patch file for policy enforcement. Bundle is used for apply-time - // transport, but allowed-files/protected-files checks must run on patch content - // (see validation block below that calls checkFileProtection on patchContent). + // Always require a patch file. The patch remains the preview/debug artifact and + // the first-pass validation input; bundle transport adds an authoritative + // pre-apply git diff check later after the bundle ref has been fetched. if (!hasPatchFile) { const msg = "No patch file found - cannot push without changes"; @@ -789,6 +806,32 @@ async function main(config = {}) { } core.info(`Fetched bundle to ${bundleRef}`); + // SECURITY: Use git's own diff against the fetched bundle ref as the + // authoritative pre-apply file set for bundle transport. This keeps + // bundle pre-check and post-apply verification aligned even when the + // patch artifact under-detects files (for example, merge-resolution + // content preserved only by the bundle transport). + { + const bundleFiles = await getBundlePreApplyFiles(exec, baseGitOpts, rangeBaseRef, bundleRef); + if (bundleFiles.length > 0) { + core.info(`Pre-apply bundle verification: ${bundleFiles.length} file(s) detected from bundle transport`); + const bundleProtection = checkFileProtectionPostApply(bundleFiles, config); + if (bundleProtection.action === "deny") { + const filesStr = bundleProtection.files.join(", "); + const msg = + bundleProtection.source === "post-apply" + ? `Cannot push to pull request branch: bundle modifies files outside the allowed-files list (${filesStr}). Add the files to the allowed-files configuration field or remove them from the bundle.` + : `Cannot push to pull request branch: bundle modifies protected files (${filesStr}). Add them to the allowed-files configuration field or set protected-files: fallback-to-issue to create a review issue instead.`; + core.error(msg); + return { success: false, error: msg }; + } + if (bundleProtection.action === "fallback") { + core.warning(`Protected file protection triggered (fallback-to-issue): ${bundleProtection.files.join(", ")}. Will create review issue instead of pushing.`); + return await createProtectedFilesFallbackIssue(bundleProtection.files); + } + } + } + // Point the checked-out branch at the bundle tip directly. In shallow // checkouts, merge --ff-only can fail to discover the ancestry even // when the bundle tip is based on the current branch tip and the @@ -1300,4 +1343,4 @@ async function main(config = {}) { }; } -module.exports = { main, HANDLER_TYPE }; +module.exports = { main, HANDLER_TYPE, getBundlePreApplyFiles }; diff --git a/setup/js/set_issue_field.cjs b/setup/js/set_issue_field.cjs index dd8d979..3986845 100644 --- a/setup/js/set_issue_field.cjs +++ b/setup/js/set_issue_field.cjs @@ -157,9 +157,18 @@ function buildFieldUpdatePayload(field, rawValue) { * @param {Object} githubClient - Authenticated GitHub client * @param {string} issueNodeId - GraphQL node ID of the issue * @param {{fieldId: string, singleSelectOptionId?: string, numberValue?: number, dateValue?: string, textValue?: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}} fieldUpdate + * @param {boolean} [useIntentHeader] - When true, includes the GraphQL-Features header to expose intent input types * @returns {Promise} */ -async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) { +async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader) { + /** @type {Record} */ + const variables = { + issueId: issueNodeId, + issueFields: [fieldUpdate], + }; + if (useIntentHeader) { + variables.headers = { "GraphQL-Features": "update_issue_suggestions" }; + } await githubClient.graphql( `mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) { setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) { @@ -168,10 +177,7 @@ async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) { } } }`, - { - issueId: issueNodeId, - issueFields: [fieldUpdate], - } + variables ); } @@ -349,11 +355,13 @@ async function main(config = {}) { ...fieldUpdateResult.update, }; - if (hasIssueIntentsRuntimeFeature()) { + const useIntentHeader = hasIssueIntentsRuntimeFeature(); + if (useIntentHeader) { Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item)); + core.info(`Using GraphQL-Features header for issue field mutation (issue_intents runtime feature enabled)`); } - await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate); + await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader); core.info(`Successfully set issue field ${JSON.stringify(fieldName || fieldNodeId)} to ${JSON.stringify(value)} on issue #${issueNumber}`); diff --git a/setup/js/set_issue_type.cjs b/setup/js/set_issue_type.cjs index afbdbe4..2468720 100644 --- a/setup/js/set_issue_type.cjs +++ b/setup/js/set_issue_type.cjs @@ -20,6 +20,69 @@ const AVAILABLE_TYPES_PATTERNS = [/one of:\s*(.+)$/i, /available(?: types?)?:\s* const NO_ISSUE_TYPES_PATTERNS = [/no issue types? (?:are )?available/i, /issue types? (?:is|are) not (?:enabled|configured)/i]; const NO_ISSUE_TYPES_AVAILABLE_ERROR = "No issue types are available for this repository. Issue types must be configured in the repository or organization settings."; +/** + * Fetches the node ID of an issue for use in GraphQL mutations. + * @param {Object} githubClient - Authenticated GitHub client + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @returns {Promise} Issue node ID + */ +async function getIssueNodeId(githubClient, owner, repo, issueNumber) { + const { data } = await githubClient.rest.issues.get({ owner, repo, issue_number: issueNumber }); + return data.node_id; +} + +/** + * Fetches the available issue types for an organization. + * For personal-account owners the query returns null and the call site receives an empty array. + * @param {Object} githubClient - Authenticated GitHub client + * @param {string} owner - Organization login + * @returns {Promise>} Issue type nodes + */ +async function fetchIssueTypesForOrg(githubClient, owner) { + const result = await githubClient.graphql( + `query($owner: String!) { + organization(login: $owner) { + issueTypes(first: 100) { + nodes { + id + name + } + } + } + }`, + { owner } + ); + return result?.organization?.issueTypes?.nodes ?? []; +} + +/** + * Sets the issue type via GraphQL mutation using `IssueTypeUpdateInput`. + * @param {Object} githubClient - Authenticated GitHub client + * @param {string} issueNodeId - GraphQL node ID of the issue + * @param {string} issueTypeId - GraphQL node ID of the issue type + * @param {{ rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean }} intentMetadata - Intent metadata in GraphQL format + * @returns {Promise} + */ +async function setIssueTypeById(githubClient, issueNodeId, issueTypeId, intentMetadata) { + const issueType = { id: issueTypeId, ...intentMetadata }; + await githubClient.graphql( + `mutation($issueId: ID!, $issueType: IssueTypeUpdateInput!) { + updateIssue(input: { id: $issueId, issueType: $issueType }) { + issue { + id + } + } + }`, + { + issueId: issueNodeId, + issueType, + headers: { "GraphQL-Features": "update_issue_suggestions" }, + } + ); +} + /** * @param {{ rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean }} intentMetadata Intent metadata in GraphQL format. * @returns {{ rationale?: string, confidence?: "low"|"medium"|"high", suggest?: boolean }} Intent metadata formatted for REST. @@ -271,13 +334,35 @@ async function main(config = {}) { try { const { owner, repo } = repoParts; const intentMetadata = normalizeIssueIntentMetadata(item); - const typeValue = buildIssueTypeValue(isClear, resolvedIssueTypeName, intentMetadata); - await githubClient.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - type: typeValue, - }); + + if (hasIssueIntentsRuntimeFeature() && !isClear) { + // GraphQL intent path: resolve the type's node ID from org issue types, then + // call setIssueTypeById with IssueTypeUpdateInput + the GraphQL-Features header. + core.info(`Using GraphQL intent path (issue_intents runtime feature enabled)`); + core.info(`Fetching issue node ID for issue #${issueNumber}`); + const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber); + core.info(`Fetching issue types for org ${owner}`); + const issueTypes = await fetchIssueTypesForOrg(githubClient, owner); + core.info(`Found ${issueTypes.length} issue type(s) for org ${owner}`); + const typeNode = issueTypes.find(t => t.name.toLowerCase() === resolvedIssueTypeName.toLowerCase()); + if (!typeNode) { + const availableNames = issueTypes.map(t => t.name).join(", "); + const error = availableNames ? `Issue type ${JSON.stringify(resolvedIssueTypeName)} not found. Available types: ${availableNames}` : NO_ISSUE_TYPES_AVAILABLE_ERROR; + core.error(`Failed to set issue type on issue #${issueNumber}: ${error}`); + return { success: false, error }; + } + core.info(`Resolved issue type ${JSON.stringify(resolvedIssueTypeName)} to node ID ${typeNode.id}`); + await setIssueTypeById(githubClient, issueNodeId, typeNode.id, intentMetadata); + } else { + // REST path: used for the clear case and when the issue_intents feature is off. + const typeValue = buildIssueTypeValue(isClear, resolvedIssueTypeName, intentMetadata); + await githubClient.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + type: typeValue, + }); + } const successMsg = isClear ? `Successfully cleared issue type on issue #${issueNumber}` : `Successfully set issue type to ${JSON.stringify(resolvedIssueTypeName)} on issue #${issueNumber}`; core.info(successMsg); diff --git a/setup/js/update_issue.cjs b/setup/js/update_issue.cjs index 092fa8f..2854fc3 100644 --- a/setup/js/update_issue.cjs +++ b/setup/js/update_issue.cjs @@ -135,9 +135,11 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) { throw new Error(`Failed to resolve GraphQL node ID for issue #${issueNumber}`); } + core.info(`Using GraphQL intent path for label update with GraphQL-Features header (issue_intents runtime feature enabled)`); const repoLabels = await fetchAllRepoLabels(github, context.repo.owner, context.repo.repo); const labelIdByName = new Map(repoLabels.map(label => [label.name.toLowerCase(), label.id])); const labels = buildIssueIntentLabelUpdates(labelSpecs, labelIdByName); + core.info(`Updating ${labels.length} label(s) on issue #${issueNumber} via GraphQL intent mutation`); const result = await github.graphql( `mutation($issueId: ID!, $labels: [LabelUpdateInput!]!) { updateIssue(input: { id: $issueId, labels: $labels }) { @@ -151,7 +153,7 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) { } } }`, - { issueId: issueNodeId, labels } + { issueId: issueNodeId, labels, headers: { "GraphQL-Features": "update_issue_suggestions" } } ); issue = { diff --git a/setup/js/upload_assets.cjs b/setup/js/upload_assets.cjs index 3a25d7e..46b2ea1 100644 --- a/setup/js/upload_assets.cjs +++ b/setup/js/upload_assets.cjs @@ -8,6 +8,10 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_API, ERR_CONFIG, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs"); +/** + * @typedef {{ type: string, fileName: string, sha: string, size: number, targetFileName: string, url?: string }} UploadAssetItem + */ + /** * Normalizes a branch name to be a valid git branch name. * @@ -75,7 +79,8 @@ async function main() { } // Find all upload-asset items - const uploadItems = result.items.filter(/** @param {any} item */ item => item.type === "upload_asset"); + /** @type {UploadAssetItem[]} */ + const uploadItems = result.items.filter(item => item.type === "upload_asset"); if (uploadItems.length === 0) { core.info("No upload-asset items found in agent output"); diff --git a/setup/sh/log_runtime_features_summary.sh b/setup/sh/log_runtime_features_summary.sh new file mode 100644 index 0000000..100e917 --- /dev/null +++ b/setup/sh/log_runtime_features_summary.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set +o histexpand +set -euo pipefail + +# Writes a collapsed Runtime features section to $GITHUB_STEP_SUMMARY. +# The step is only run when GH_AW_RUNTIME_FEATURES is present in the vars context +# (guarded by the step's `if:` condition), so we only need to check for non-empty here. +# A variable that exists in vars as an empty string produces no summary output — this +# is intentional: an empty value has no meaningful content to surface. +if [[ -n "${GH_AW_RUNTIME_FEATURES:-}" ]]; then + { + echo "### Runtime features" + echo + echo "
" + echo "Show configured runtime features" + echo + echo '```text' + printf '%s\n' "$GH_AW_RUNTIME_FEATURES" + echo '```' + echo + echo "
" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" +fi diff --git a/setup/sh/log_runtime_features_summary_test.sh b/setup/sh/log_runtime_features_summary_test.sh new file mode 100755 index 0000000..1a7aae2 --- /dev/null +++ b/setup/sh/log_runtime_features_summary_test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set +o histexpand + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="${SCRIPT_DIR}/log_runtime_features_summary.sh" +SUMMARY_FILE="$(mktemp)" +trap 'rm -f "$SUMMARY_FILE"' EXIT + +echo "Testing log_runtime_features_summary.sh..." +echo "" + +# Case 1: non-empty GH_AW_RUNTIME_FEATURES -> writes heading + details block +echo "Test 1: non-empty value — should write heading and details" +export GH_AW_RUNTIME_FEATURES="feature1=on" +export GITHUB_STEP_SUMMARY="$SUMMARY_FILE" +bash "$SCRIPT" +if grep -q "### Runtime features" "$SUMMARY_FILE"; then + echo "✅ Test 1a passed: heading written" +else + echo "❌ Test 1a failed: missing heading" + exit 1 +fi +if grep -q "
" "$SUMMARY_FILE"; then + echo "✅ Test 1b passed: wrapped in details block" +else + echo "❌ Test 1b failed: missing details block" + exit 1 +fi +if grep -q "feature1=on" "$SUMMARY_FILE"; then + echo "✅ Test 1c passed: feature value written" +else + echo "❌ Test 1c failed: missing feature value" + exit 1 +fi +echo "" + +# Case 2: empty GH_AW_RUNTIME_FEATURES -> no output written +echo "Test 2: empty value — should suppress output" +> "$SUMMARY_FILE" +export GH_AW_RUNTIME_FEATURES="" +bash "$SCRIPT" +if [[ ! -s "$SUMMARY_FILE" ]]; then + echo "✅ Test 2 passed: no output when value is empty" +else + echo "❌ Test 2 failed: unexpectedly wrote output for empty value" + exit 1 +fi +echo "" + +# Case 3: unset GH_AW_RUNTIME_FEATURES -> no output written +echo "Test 3: unset GH_AW_RUNTIME_FEATURES — should suppress output" +> "$SUMMARY_FILE" +unset GH_AW_RUNTIME_FEATURES +bash "$SCRIPT" +if [[ ! -s "$SUMMARY_FILE" ]]; then + echo "✅ Test 3 passed: no output when GH_AW_RUNTIME_FEATURES is unset" +else + echo "❌ Test 3 failed: unexpectedly wrote output when GH_AW_RUNTIME_FEATURES is unset" + exit 1 +fi +echo "" + +echo "🎉 All tests passed!"