diff --git a/setup/js/Dockerfile.safe-outputs-mcp b/setup/js/Dockerfile.safe-outputs-mcp new file mode 100644 index 0000000..4246e00 --- /dev/null +++ b/setup/js/Dockerfile.safe-outputs-mcp @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_IMAGE=node:lts-alpine +FROM ${NODE_IMAGE} + +ARG NODE_IMAGE +ARG NODE_IMAGE_DIGEST="" +ARG NODE_IMAGE_UPDATED_AT="" +ARG DOCKERFILE_HASH="" + +RUN apk add --no-cache git + +LABEL org.opencontainers.image.source="https://github.com/github/gh-aw" \ + org.opencontainers.image.description="Node LTS Alpine with git for the local safe-outputs MCP runtime" \ + org.opencontainers.image.base.name="${NODE_IMAGE}" \ + org.opencontainers.image.base.digest="${NODE_IMAGE_DIGEST}" \ + io.github.gh-aw.safe-outputs.node.updated-at="${NODE_IMAGE_UPDATED_AT}" \ + io.github.gh-aw.safe-outputs.dockerfile-hash="${DOCKERFILE_HASH}" + +ENTRYPOINT ["sh", "-lc", "node \"${GITHUB_WORKSPACE:-$PWD}/actions/setup/js/safe_outputs_mcp_server.cjs\""] diff --git a/setup/js/add_labels.cjs b/setup/js/add_labels.cjs index a2c4865..469ce2a 100644 --- a/setup/js/add_labels.cjs +++ b/setup/js/add_labels.cjs @@ -32,6 +32,7 @@ const { attachExecutionState, fetchIssueState, normalizeLabelNames } = require(" const { MAX_LABELS } = require("./constants.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Main handler factory for add_labels @@ -78,7 +79,8 @@ const main = createCountGatedHandler({ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); if (!targetResult.success) return targetResult; - const itemNumber = targetResult.number ?? context.payload?.issue?.number ?? context.payload?.pull_request?.number; + const effectiveContext = resolveInvocationContext(context); + const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; @@ -86,7 +88,7 @@ const main = createCountGatedHandler({ return { success: false, error }; } - const contextType = context.payload?.pull_request ? "pull request" : "issue"; + const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue"; const requestedLabels = message.labels ?? []; core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); diff --git a/setup/js/artifact_client.cjs b/setup/js/artifact_client.cjs index 40b6974..652f308 100644 --- a/setup/js/artifact_client.cjs +++ b/setup/js/artifact_client.cjs @@ -345,32 +345,33 @@ class DefaultArtifactClient { const { workflowRunBackendId, workflowJobRunBackendId } = getBackendIdsFromRuntimeToken(); const createRequest = { - workflow_run_backend_id: workflowRunBackendId, - workflow_job_run_backend_id: workflowJobRunBackendId, + workflowRunBackendId, + workflowJobRunBackendId, name: artifactName, version: 7, - mime_type: { value: contentType }, + mimeType: contentType, }; const expiresAt = formatRetentionTimestamp(options.retentionDays); if (expiresAt) { - createRequest.expires_at = expiresAt; + createRequest.expiresAt = expiresAt; } /** @type {any} */ const createResponse = await twirpRequest("CreateArtifact", createRequest); - if (!createResponse?.ok || !createResponse?.signed_upload_url) { + const signedUploadUrl = createResponse?.signedUploadUrl || createResponse?.signed_upload_url; + if (!createResponse?.ok || !signedUploadUrl) { throw new Error("CreateArtifact returned an invalid response"); } - const uploadSize = await uploadFileToSignedURL(uploadPath, createResponse.signed_upload_url, contentType); + const uploadSize = await uploadFileToSignedURL(uploadPath, signedUploadUrl, contentType); const sha256 = await hashFile(uploadPath); const finalizeRequest = { - workflow_run_backend_id: workflowRunBackendId, - workflow_job_run_backend_id: workflowJobRunBackendId, + workflowRunBackendId, + workflowJobRunBackendId, name: artifactName, size: String(uploadSize), - hash: { value: `sha256:${sha256}` }, + hash: `sha256:${sha256}`, }; /** @type {any} */ const finalizeResponse = await twirpRequest("FinalizeArtifact", finalizeRequest); @@ -379,7 +380,7 @@ class DefaultArtifactClient { } return { - id: Number(finalizeResponse.artifact_id || 0) || undefined, + id: Number(finalizeResponse.artifactId ?? finalizeResponse.artifact_id ?? 0) || undefined, size: uploadSize, digest: sha256, }; diff --git a/setup/js/check_daily_aic_workflow_guardrail.cjs b/setup/js/check_daily_aic_workflow_guardrail.cjs index 905f42c..b32189c 100644 --- a/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -13,12 +13,17 @@ const { createRateLimitAwareGithub, fetchAndLogRateLimit } = require("./github_r const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"]; const DAILY_WORKFLOW_WINDOW_MS = 24 * 60 * 60 * 1000; +/** Cache entries older than this threshold (in ms) are skipped when loading. */ +const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; const MAX_WORKFLOW_RUN_PAGES = 10; const RATE_LIMIT_RESERVE = 100; const REQUEST_OVERHEAD_BUDGET = MAX_WORKFLOW_RUN_PAGES + 4; const ESTIMATED_API_OPERATIONS_PER_RUN = 2; const INTEGER_FORMATTER = new Intl.NumberFormat("en-US"); +/** Path where the per-workflow usage cache is restored by the activation job's cache-restore step. */ +const AIC_USAGE_CACHE_FILE_PATH = "/tmp/gh-aw/agentic-workflow-usage-cache.jsonl"; + /** * @returns {Promise} */ @@ -61,6 +66,20 @@ function logDailyGuardrail(message, details) { * typing a slash command in a comment, and the daily guardrail should not be skipped. */ const SLASH_COMMAND_EVENT_TYPES = ["issue_comment", "pull_request_review_comment", "discussion_comment"]; +const SLASH_COMMAND_TRIGGERING_EVENTS = ["issues", "issue_comment", "pull_request", "pull_request_review_comment", "discussion", "discussion_comment"]; +const LABEL_COMMAND_TRIGGERING_EVENTS = ["issues", "pull_request", "discussion"]; + +/** + * @param {string | undefined} value + * @returns {boolean} + */ +function envFlagEnabled(value) { + if (typeof value !== "string") { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === "true" || normalized === "1" || normalized === "yes"; +} /** * @returns {boolean} @@ -69,24 +88,103 @@ function shouldSkipDailyAICGuardrail() { const eventName = process.env.GITHUB_EVENT_NAME || ""; const isWorkflowCall = eventName === "workflow_call"; const isRepositoryDispatch = eventName === "repository_dispatch"; + const hasSlashCommand = envFlagEnabled(process.env.GH_AW_HAS_SLASH_COMMAND); + const hasLabelCommand = envFlagEnabled(process.env.GH_AW_HAS_LABEL_COMMAND); const rawContext = (process.env.GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT || "").trim(); const hasDispatchContext = rawContext !== ""; - if (!(isWorkflowCall || isRepositoryDispatch || (eventName === "workflow_dispatch" && hasDispatchContext))) { - return false; + if (isWorkflowCall || isRepositoryDispatch) { + return true; } - if (eventName === "workflow_dispatch" && hasDispatchContext) { + if (eventName === "workflow_dispatch") { + // Manual user-triggered runs intentionally bypass the daily guardrail. + if (!hasDispatchContext) { + return true; + } + // Dispatch-routed slash/label commands intentionally bypass the daily guardrail. try { const awContext = JSON.parse(rawContext); const isLabelCommand = typeof awContext.trigger_label === "string" && awContext.trigger_label.trim() !== ""; const isSlashCommand = SLASH_COMMAND_EVENT_TYPES.includes(awContext.event_type); if (isLabelCommand || isSlashCommand) { - return false; + return true; } } catch { - // Malformed aw_context: fall through and skip as before. + // Malformed aw_context: skip guardrail as a safe fallback for manual dispatch. } + // Existing behavior: dispatch-routed runs with aw_context bypass the guardrail. + return true; + } + if (hasSlashCommand && SLASH_COMMAND_TRIGGERING_EVENTS.includes(eventName)) { + return true; } - return true; + if (hasLabelCommand && LABEL_COMMAND_TRIGGERING_EVENTS.includes(eventName)) { + return true; + } + return false; +} + +/** + * Loads the per-workflow usage cache from the JSONL file restored by the activation job's + * cache-restore step. Each line is a JSON object `{ run_id: number, aic: number, timestamp?: string }`. + * + * Entries with a `timestamp` older than {@link CACHE_RETENTION_MS} (48 h) are skipped so that + * stale data cannot inflate the daily-AIC total. Entries without a `timestamp` (written by an + * older version of the write script) are kept for backward compatibility. + * + * Returns a `Map` so that callers can check whether a prior run's AIC is already + * known without downloading the run's artifact from the GitHub API. + * + * @param {string} [filePath] + * @returns {Map} + */ +function loadAICUsageCache(filePath) { + const cachePath = filePath || AIC_USAGE_CACHE_FILE_PATH; + /** @type {Map} */ + const cache = new Map(); + try { + if (!fs.existsSync(cachePath)) { + logDailyGuardrail("No usage cache file found; all runs will be resolved via API", { path: cachePath }); + return cache; + } + const content = fs.readFileSync(cachePath, "utf8"); + const now = Date.now(); + const cutoff = now - CACHE_RETENTION_MS; + let loaded = 0; + let skippedStale = 0; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || !line.startsWith("{")) { + continue; + } + try { + const entry = JSON.parse(line); + // Skip entries that have a timestamp and are older than the retention window. + if (typeof entry?.timestamp === "string") { + const ts = Date.parse(entry.timestamp); + if (Number.isFinite(ts) && ts < cutoff) { + skippedStale++; + continue; + } + } + const runId = Number(entry?.run_id); + const rawAic = entry?.aic; + const aic = typeof rawAic === "number" ? rawAic : NaN; + if (Number.isFinite(runId) && runId > 0 && Number.isFinite(aic) && aic >= 0) { + cache.set(runId, aic); + loaded++; + } + } catch { + // Ignore malformed lines. + } + } + logDailyGuardrail("Loaded usage cache", { path: cachePath, entriesLoaded: loaded, skippedStale }); + } catch (err) { + logDailyGuardrail("Failed to load usage cache; proceeding without it", { + path: cachePath, + error: typeof err === "object" && err !== null && "message" in err ? String(err.message) : String(err), + }); + } + return cache; } /** @@ -322,7 +420,7 @@ async function main() { return; } if (shouldSkipDailyAICGuardrail()) { - core.info("Skipping daily workflow AI Credits guardrail for workflow_call, repository_dispatch, or workflow_dispatch with aw_context."); + core.info("Skipping daily workflow AI Credits guardrail for manual or command-driven runs."); return; } @@ -441,13 +539,28 @@ async function main() { truncatedByRateLimit, }); + // Load the per-workflow usage cache restored by the activation job's cache-restore step. + // Entries that are already cached skip the artifact download entirely, reducing API usage. + const usageCache = module.exports.loadAICUsageCache(); + const artifactClient = await module.exports.getArtifactClient(); let totalAIC = 0; /** @type {Array<{id:number, html_url:string, created_at:string, conclusion:string, aic:number}>} */ const countedRuns = []; for (const run of candidateRuns) { try { - const runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo); + let runAIC; + if (usageCache.has(run.id)) { + // Cache hit: use the previously recorded AIC without downloading the artifact. + runAIC = usageCache.get(run.id) ?? 0; + logDailyGuardrail("Cache hit: using cached AIC for run", { + runId: run.id, + cachedAIC: runAIC, + }); + } else { + // Cache miss: fetch AIC from the run's usage artifact. + runAIC = await module.exports.getRunAIC(artifactClient, run.id, token, owner, repo); + } if (runAIC <= 0) { logDailyGuardrail("Skipping run without AIC usage artifact data", { runId: run.id, @@ -536,6 +649,7 @@ module.exports = { main, getArtifactClient, getRunAIC, + loadAICUsageCache, shouldSkipDailyAICGuardrail, matchesGuardrailArtifactName, findJSONLFiles, diff --git a/setup/js/codex_harness.cjs b/setup/js/codex_harness.cjs index a88d402..937fbd3 100644 --- a/setup/js/codex_harness.cjs +++ b/setup/js/codex_harness.cjs @@ -439,10 +439,11 @@ async function main() { } const nonRetryableGuard = detectNonRetryableHarnessGuard(result.output); - if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests) { + if (nonRetryableGuard.aiCreditsExceeded || nonRetryableGuard.awfAPIProxyBlockingRequests || nonRetryableGuard.goalAlreadyActive) { 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)"); log(`attempt ${attempt + 1}: ${reasons.join(" and ")} — not retrying (non-retryable guard condition)`); break; } diff --git a/setup/js/copilot_harness.cjs b/setup/js/copilot_harness.cjs index 5176bbf..a9cc3fc 100644 --- a/setup/js/copilot_harness.cjs +++ b/setup/js/copilot_harness.cjs @@ -61,6 +61,7 @@ const { const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); const { detectNonRetryableHarnessGuard } = require("./harness_retry_guard.cjs"); +const { isCAPIQuotaExceededError } = require("./detect_agent_errors.cjs"); // Maximum number of retry attempts after the initial run const MAX_RETRIES = 3; @@ -706,6 +707,7 @@ async function main() { // - Null-type tool_call 400 errors poison conversation history — always restart fresh and // permanently disable --continue so the corrupt state is never reloaded. const isCAPIError = isTransientCAPIError(result.output); + const isQuotaExceeded = isCAPIQuotaExceededError(result.output); const isMCPPolicy = isMCPPolicyError(result.output); const isModelNotSupported = isModelNotSupportedError(result.output); const isAuthErr = isNoAuthInfoError(result.output); @@ -718,6 +720,7 @@ async function main() { `attempt ${attempt + 1} failed:` + ` exitCode=${result.exitCode}` + ` isCAPIError400=${isCAPIError}` + + ` isCAPIQuotaExceededError=${isQuotaExceeded}` + ` isMCPPolicyError=${isMCPPolicy}` + ` isModelNotSupportedError=${isModelNotSupported}` + ` isNullTypeToolCallError=${isNullTypeToolCall}` + @@ -832,6 +835,12 @@ async function main() { log(`attempt ${attempt + 1}: scheduled startup interruption detected but retry budget exhausted — no attempts remain`); } + // The observed quota exhaustion error is not useful to retry with --continue. + if (isQuotaExceeded) { + log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`); + break; + } + if (attempt < MAX_RETRIES && result.hasOutput) { const reason = isCAPIError ? "CAPIError 400 (transient)" : "partial execution"; // --continue is only meaningful in CLI mode; SDK mode always restarts fresh. @@ -910,6 +919,7 @@ if (typeof module !== "undefined" && module.exports) { writeCopilotOutputs, resolvePromptFileArgs, parseCopilotSDKServerArgsFromEnv, + isCAPIQuotaExceededError, }; } diff --git a/setup/js/create_forecast_issue.cjs b/setup/js/create_forecast_issue.cjs index 4a4cc08..31f5d8e 100644 --- a/setup/js/create_forecast_issue.cjs +++ b/setup/js/create_forecast_issue.cjs @@ -70,6 +70,21 @@ function monthlyCost(workflow) { return Number(workflow?.monthly_monte_carlo?.p50_projected_aic ?? workflow?.monthly_projected_aic ?? 0); } +/** + * @param {Record} workflow + * @returns {{low:number,p50:number,high:number,stddev:number}} + */ +function getMonthlyForecastStats(workflow) { + const monthlyMonteCarlo = workflow?.monthly_monte_carlo; + const monthlyProjected = workflow?.monthly_projected_aic ?? 0; + return { + low: toFiniteNumber(monthlyMonteCarlo?.p10_projected_aic ?? monthlyProjected), + p50: toFiniteNumber(monthlyMonteCarlo?.p50_projected_aic ?? monthlyProjected), + high: toFiniteNumber(monthlyMonteCarlo?.p90_projected_aic ?? monthlyProjected), + stddev: toFiniteNumber(monthlyMonteCarlo?.std_dev_aic ?? 0), + }; +} + /** * @param {Record} workflow * @returns {number} @@ -89,11 +104,11 @@ function buildForecastIssueBody(report, options) { const categorized = workflows.map(workflow => { const p50PerRun = toFiniteNumber(workflow?.p50_aic_per_run); - const monthlyP50 = toFiniteNumber(workflow?.monthly_monte_carlo?.p50_projected_aic ?? workflow?.monthly_projected_aic); - const hasForecastData = [p50PerRun, monthlyP50].some(hasPositiveAIC); + const monthly = getMonthlyForecastStats(workflow); + const hasForecastData = [p50PerRun, monthly.p50, monthly.high, monthly.low].some(hasPositiveAIC); return { workflow, - row: [renderWorkflowLink(workflow, options), toFiniteNumber(workflow.sampled_runs), p50PerRun, monthlyP50], + row: [renderWorkflowLink(workflow, options), toFiniteNumber(workflow.sampled_runs), p50PerRun, monthly.low, monthly.p50, monthly.high, monthly.stddev], hasForecastData, }; }); @@ -117,7 +132,7 @@ function buildForecastIssueBody(report, options) { return !hasPositiveAIC(p50); }); - const allMonthlyZero = tableRows.length > 0 && tableRows.every(([, , , monthly]) => Number(monthly) === 0); + const allMonthlyZero = tableRows.length > 0 && tableRows.every(([, , , , monthlyP50]) => Number(monthlyP50) === 0); const allProjectedZero = legacyRows ? legacyRows.length > 0 && legacyRows.every(([, , p50]) => Number(p50) === 0) : allMonthlyZero; let reportTable; @@ -130,12 +145,15 @@ function buildForecastIssueBody(report, options) { if (tableRows.length === 0) { reportTable = "_No forecast rows were produced._"; } else { - const totalMonthly = tableRows.reduce((s, [, , , m]) => s + Number(m), 0); - const dataRows = tableRows.map(([workflowID, sampledRuns, p50Run, monthly]) => `| ${workflowID} | ${sampledRuns} | ${formatAIC(p50Run)} | ${formatAIC(monthly)} |`); + const totalMonthly = tableRows.reduce((s, [, , , , monthly]) => s + Number(monthly), 0); + const dataRows = tableRows.map( + ([workflowID, sampledRuns, p50Run, monthlyLow, monthlyP50, monthlyHigh, monthlyStdDev]) => + `| ${workflowID} | ${sampledRuns} | ${formatAIC(p50Run)} | ${formatAIC(monthlyLow)} | ${formatAIC(monthlyP50)} | ${formatAIC(monthlyHigh)} | ${formatAIC(monthlyStdDev)} |` + ); if (tableRows.length > 1) { - dataRows.push(`| **TOTAL** | | | **${formatAIC(totalMonthly)}** |`); + dataRows.push(`| **TOTAL** | | | | **${formatAIC(totalMonthly)}** | | |`); } - reportTable = ["| Workflow | Runs | P50/Run | Monthly (P50) |", "| --- | ---: | ---: | ---: |", ...dataRows].join("\n"); + reportTable = ["| Workflow | Runs | P50/Run | Monthly (Low) | Monthly (P50) | Monthly (High) | Monthly (Stdev) |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", ...dataRows].join("\n"); } } const withoutDataWorkflows = legacyRows ? legacyNoDataWorkflows : workflowsWithoutData; @@ -166,8 +184,9 @@ function buildForecastIssueBody(report, options) { "### How to read this report", "", "- **P50/Run** is the median per-run AIC from sampled historical runs.", - "- **Monthly (P50)** is the Monte Carlo median of total AIC over 30 days.", - "- Monthly values are distribution medians, not a direct `P50/Run × runs` multiplication.", + "- **Monthly (Low/P50/High)** are the Monte Carlo P10 / P50 / P90 total-AIC bounds over 30 days.", + "- **Monthly (Stdev)** is the Monte Carlo standard deviation of the 30-day total-AIC distribution.", + "- Monthly values come from the Monte Carlo distribution and are not a direct `P50/Run × runs` multiplication.", "", ].join("\n"); diff --git a/setup/js/create_pull_request.cjs b/setup/js/create_pull_request.cjs index ee994a0..cc72b66 100644 --- a/setup/js/create_pull_request.cjs +++ b/setup/js/create_pull_request.cjs @@ -408,6 +408,19 @@ async function createFallbackIssue(githubClient, repoParts, title, body, labels, * Can be overridden via the `max-patch-files` safe-outputs config option. */ const MAX_FILES = 100; +/** + * Parses a value as a positive integer, returning null for invalid/non-positive input. + * @param {unknown} value + * @returns {number | null} + */ +function parsePositiveInteger(value) { + if (typeof value !== "string" && typeof value !== "number") { + return null; + } + const parsed = Number.parseInt(String(value), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + /** * Parses one `diff --git` header line and returns the preferred file path key. * @@ -626,8 +639,8 @@ async function main(config = {}) { const signedCommits = config.signed_commits !== false; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const maxCount = config.max || 1; // PRs are typically limited to 1 - const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; - const maxFiles = config.max_patch_files ? parseInt(String(config.max_patch_files), 10) : MAX_FILES; + const maxSizeKb = parsePositiveInteger(config.max_patch_size) ?? 4096; + const maxFiles = parsePositiveInteger(config.max_patch_files) ?? MAX_FILES; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const allowedBaseBranches = parseAllowedBaseBranches(config.allowed_base_branches); const githubClient = await createAuthenticatedGitHubClient(config); diff --git a/setup/js/detect_agent_errors.cjs b/setup/js/detect_agent_errors.cjs index d616228..c380992 100644 --- a/setup/js/detect_agent_errors.cjs +++ b/setup/js/detect_agent_errors.cjs @@ -16,6 +16,8 @@ * - model_not_supported_error: The configured model is invalid or unsupported * for the selected engine/account (for example unknown model name, model not * found, or model unavailable for the plan). + * - capi_quota_exceeded_error: The Copilot CAPI quota has been exhausted + * (e.g., "CAPIError: 429 429 quota exceeded"). * * This replaces the individual bash scripts (detect_inference_access_error.sh, * detect_mcp_policy_error.sh) with a single JavaScript step. @@ -55,10 +57,24 @@ const AGENTIC_ENGINE_TIMEOUT_PATTERN = /signal=SIG(?:TERM|KILL|INT)/; const MODEL_NOT_SUPPORTED_PATTERN = /(?:The requested model is not supported|invalid model(?:\s+name)?\s+['"`]?[a-z0-9._:/@-]+['"`]?(?=(?:\s*$|\s*[\n\r.,;:!?)]))|unknown model\s+['"`]?[a-z0-9._:/@-]+['"`]?(?=(?:\s*$|\s*[\n\r.,;:!?)]))|model(?:\s+name)?\s+['"`]?[a-z0-9._:/@-]+['"`]?\s+(?:is\s+)?(?:not found|does not exist|not supported|not available|unavailable))/i; +// Pattern: Copilot/CAPI quota exhaustion. +// Matches the observed error: "CAPIError: 429 429 quota exceeded". +// Quota exhaustion is a persistent, non-retryable condition. +const CAPI_QUOTA_EXCEEDED_PATTERN = /CAPIError:\s*429\s+429\s+quota exceeded/i; + +/** + * Determines if the collected output contains the observed Copilot/CAPI quota exhaustion error. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isCAPIQuotaExceededError(output) { + return CAPI_QUOTA_EXCEEDED_PATTERN.test(output); +} + /** * Detect known error patterns in a log string and return detection results. * @param {string} logContent - Contents of the agent stdio log - * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean }} + * @returns {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, capiQuotaExceededError: boolean }} */ function detectErrors(logContent) { return { @@ -66,12 +82,13 @@ function detectErrors(logContent) { mcpPolicyError: MCP_POLICY_BLOCKED_PATTERN.test(logContent), agenticEngineTimeout: AGENTIC_ENGINE_TIMEOUT_PATTERN.test(logContent), modelNotSupportedError: MODEL_NOT_SUPPORTED_PATTERN.test(logContent), + capiQuotaExceededError: isCAPIQuotaExceededError(logContent), }; } /** * Write GitHub Actions outputs to $GITHUB_OUTPUT. - * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean }} results + * @param {{ inferenceAccessError: boolean, mcpPolicyError: boolean, agenticEngineTimeout: boolean, modelNotSupportedError: boolean, capiQuotaExceededError: boolean }} results */ function writeOutputs(results) { const outputFile = process.env.GITHUB_OUTPUT; @@ -85,6 +102,7 @@ function writeOutputs(results) { `mcp_policy_error=${results.mcpPolicyError}`, `agentic_engine_timeout=${results.agenticEngineTimeout}`, `model_not_supported_error=${results.modelNotSupportedError}`, + `capi_quota_exceeded_error=${results.capiQuotaExceededError}`, ]; fs.appendFileSync(outputFile, lines.join("\n") + "\n"); } @@ -112,6 +130,9 @@ function main() { if (results.modelNotSupportedError) { process.stderr.write("[detect-agent-errors] Detected model configuration error: configured model is invalid or unavailable for this engine/account\n"); } + if (results.capiQuotaExceededError) { + process.stderr.write("[detect-agent-errors] Detected CAPI quota exhaustion: Copilot quota has been exceeded\n"); + } writeOutputs(results); } @@ -120,4 +141,4 @@ if (require.main === module) { main(); } -module.exports = { detectErrors, INFERENCE_ACCESS_ERROR_PATTERN, MCP_POLICY_BLOCKED_PATTERN, AGENTIC_ENGINE_TIMEOUT_PATTERN, MODEL_NOT_SUPPORTED_PATTERN }; +module.exports = { detectErrors, isCAPIQuotaExceededError, INFERENCE_ACCESS_ERROR_PATTERN, MCP_POLICY_BLOCKED_PATTERN, AGENTIC_ENGINE_TIMEOUT_PATTERN, MODEL_NOT_SUPPORTED_PATTERN, CAPI_QUOTA_EXCEEDED_PATTERN }; diff --git a/setup/js/dispatch_workflow.cjs b/setup/js/dispatch_workflow.cjs index 4b14191..e67d552 100644 --- a/setup/js/dispatch_workflow.cjs +++ b/setup/js/dispatch_workflow.cjs @@ -55,7 +55,9 @@ async function main(config = {}) { // repo_helpers.cjs for consistent slug validation and glob-pattern matching (e.g. "org/*"). if (isCrossRepoDispatch) { if (allowedRepos.size === 0) { - throw new Error(`E004: Cross-repository dispatch to '${resolvedRepoSlug}' is not permitted. No allowlist is configured. Define 'allowed_repos' to enable cross-repository dispatch.`); + throw new Error( + `E004: Cross-repository dispatch to '${resolvedRepoSlug}' is not permitted. No allowlist is configured. Define 'allowed-repos' in the workflow's 'safe-outputs.dispatch-workflow' section to enable cross-repository dispatch.` + ); } const repoValidation = validateTargetRepo(resolvedRepoSlug, contextRepoSlug, allowedRepos); if (!repoValidation.valid) { diff --git a/setup/js/extract_inline_sub_agents.cjs b/setup/js/extract_inline_sub_agents.cjs index 5f15f38..1c7c55e 100644 --- a/setup/js/extract_inline_sub_agents.cjs +++ b/setup/js/extract_inline_sub_agents.cjs @@ -20,11 +20,8 @@ // An agent block ends at the next level-2 Markdown heading (## ...) or EOF. // There is no explicit end marker — any H2 heading closes the agent block. // -// Supported frontmatter fields (all others are stripped with a warning) -// ───────────────────────────────────────────────────────────────────── -// description Human-readable description of the sub-agent's role. -// model AI model to use. Default is "inherited" (uses the parent -// workflow's model when not set). +// Sub-agent frontmatter keys and their order are preserved without filtering; +// boundary whitespace is trimmed. // // If no ## agent: markers are present the content is returned unchanged and no // files are written. @@ -32,10 +29,6 @@ const fs = require("fs"); const path = require("path"); -// Supported frontmatter fields for inline sub-agents. -// Any other field is stripped with a warning. -const SUPPORTED_FRONTMATTER_FIELDS = ["description", "model"]; - // Regex for the start marker: ## agent: `name` (lowercase identifier) const START_MARKER_RE = /^##[ \t]+agent:[ \t]+`([a-z][a-z0-9_-]*)`[ \t]*$/gm; @@ -44,74 +37,17 @@ const START_MARKER_RE = /^##[ \t]+agent:[ \t]+`([a-z][a-z0-9_-]*)`[ \t]*$/gm; const H2_HEADING_RE = /^##[ \t]/gm; /** - * Filters sub-agent frontmatter to only retain supported fields. - * - * Only `description` and `model` are valid fields in a sub-agent frontmatter - * block. Any other top-level key is stripped and a warning is emitted. - * If `model` is not present its implicit default is "inherited" (the sub-agent - * uses the parent workflow's model), but the key is NOT written unless the - * workflow author explicitly sets it. + * Preserves sub-agent frontmatter exactly as authored. * - * When no YAML frontmatter delimiter (`---`) is found at the start of the - * content, the content is returned unchanged. + * This helper is kept to preserve the write-path structure used by the inline + * skills/sub-agents extractors and to provide a single hook if the runtime ever + * needs sub-agent-specific frontmatter normalization again. * - * @param {string} content - Raw agent block content (frontmatter + prompt). - * @param {string} agentName - Agent name used in log messages. - * @returns {string} Content with only supported frontmatter fields retained. + * @param {string} content - Raw agent block content (frontmatter + prompt). + * @returns {string} Unchanged content. */ -function filterSubAgentFrontmatter(content, agentName) { - // A YAML frontmatter block must start immediately at the beginning of the - // content (after trimming performed by the caller). - if (!content.startsWith("---\n")) { - return content; - } - - // Locate the closing delimiter. We search for "\n---" starting after the - // complete opening "---\n" (offset 4) to avoid matching the opening itself. - const closeIdx = content.indexOf("\n---", 4); - if (closeIdx === -1) { - return content; - } - - // Lines between the opening and closing "---". - const fmLines = content.slice(4, closeIdx).split("\n"); - // Everything after the closing "\n---" (including the optional newline). - const body = content.slice(closeIdx + 4); - - const kept = []; - const stripped = []; - - for (const line of fmLines) { - // Match a simple scalar YAML key at the start of the line. - // YAML keys for description and model are plain identifiers (no hyphens). - const keyMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)[ \t]*:/); - if (keyMatch) { - const key = keyMatch[1]; - if (SUPPORTED_FRONTMATTER_FIELDS.includes(key)) { - kept.push(line); - } else { - stripped.push(key); - } - } else { - // Continuation / comment / blank line — keep only when at least one - // supported key has already been accepted, so multi-line values (e.g. - // `description: |`) are preserved correctly. - if (kept.length > 0) { - kept.push(line); - } - } - } - - if (stripped.length > 0) { - core.warning(`[extractInlineSubAgents] sub-agent "${agentName}": unsupported frontmatter field(s) stripped: ${stripped.join(", ")} (only "description" and "model" are supported)`); - } - - // If no supported fields remain, omit the frontmatter block entirely. - if (kept.length === 0) { - return body.replace(/^\n/, ""); - } - - return `---\n${kept.join("\n")}\n---${body}`; +function preserveSubAgentFrontmatter(content) { + return content; } /** @@ -234,7 +170,7 @@ function writeInlineSubAgents(content, workspaceDir, agentsBaseDir, engineId) { for (const agent of agents) { const agentPath = path.join(agentsDir, agent.name + ext); - const filteredContent = filterSubAgentFrontmatter(agent.content, agent.name); + const filteredContent = preserveSubAgentFrontmatter(agent.content); const agentContent = filteredContent.endsWith("\n") ? filteredContent : filteredContent + "\n"; fs.writeFileSync(agentPath, agentContent, "utf8"); core.info(`[extractInlineSubAgents] Written sub-agent: ${agentPath} (${agentContent.length} bytes)`); @@ -244,4 +180,4 @@ function writeInlineSubAgents(content, workspaceDir, agentsBaseDir, engineId) { return mainContent; } -module.exports = { extractInlineSubAgents, writeInlineSubAgents, getEngineSubAgentTarget, filterSubAgentFrontmatter }; +module.exports = { extractInlineSubAgents, writeInlineSubAgents, getEngineSubAgentTarget, preserveSubAgentFrontmatter }; diff --git a/setup/js/generate_safe_outputs_tools.cjs b/setup/js/generate_safe_outputs_tools.cjs index 1651735..fae7e0c 100644 --- a/setup/js/generate_safe_outputs_tools.cjs +++ b/setup/js/generate_safe_outputs_tools.cjs @@ -31,6 +31,45 @@ const fs = require("fs"); const path = require("path"); const { ERR_CONFIG } = require("./error_codes.cjs"); +const ADD_COMMENT_DEFAULT_DISCUSSIONS_NOTE = + "NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission."; +const ADD_COMMENT_DISCUSSIONS_ENABLED_NOTE = "NOTE: Discussion comments are enabled for this workflow because discussions:write permission is available."; +const ADD_COMMENT_DISCUSSIONS_DISABLED_NOTE = + "NOTE: Discussion comments are disabled for this workflow because discussions:write permission is not available. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission."; +const ADD_COMMENT_REPLY_SUPPORT_SENTENCE = "Supports reply_to_id for discussion threading."; +const ADD_COMMENT_REPLY_SUPPORT_REGEX = /\s*Supports reply_to_id for discussion threading\./g; + +/** + * Update add_comment description to match runtime-safe-output permissions. + * @param {string} description + * @param {unknown} addCommentConfig + * @returns {string} + */ +function updateAddCommentDescription(description, addCommentConfig) { + const discussionCommentsEnabled = typeof addCommentConfig === "object" && addCommentConfig !== null && "discussions" in addCommentConfig && addCommentConfig.discussions === true; + + let updated = description || ""; + const note = discussionCommentsEnabled ? ADD_COMMENT_DISCUSSIONS_ENABLED_NOTE : ADD_COMMENT_DISCUSSIONS_DISABLED_NOTE; + if (updated.includes(ADD_COMMENT_DEFAULT_DISCUSSIONS_NOTE)) { + updated = updated.replace(ADD_COMMENT_DEFAULT_DISCUSSIONS_NOTE, note); + } else if (!updated.includes(ADD_COMMENT_DISCUSSIONS_ENABLED_NOTE) && !updated.includes(ADD_COMMENT_DISCUSSIONS_DISABLED_NOTE)) { + updated = `${updated} ${note}`.trim(); + } + + if (discussionCommentsEnabled) { + if (!updated.includes(ADD_COMMENT_REPLY_SUPPORT_SENTENCE)) { + updated = `${updated} ${ADD_COMMENT_REPLY_SUPPORT_SENTENCE}`.trim(); + } + } else { + updated = updated + .replace(ADD_COMMENT_REPLY_SUPPORT_REGEX, "") + .replace(/\s{2,}/g, " ") + .trim(); + } + + return updated; +} + async function main() { const toolsSourcePath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_SOURCE_PATH || `${process.env.RUNNER_TEMP}/gh-aw/actions/safe_outputs_tools.json`; const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH || `${process.env.RUNNER_TEMP}/gh-aw/safeoutputs/config.json`; @@ -92,6 +131,10 @@ async function main() { enhancedTool.description = (enhancedTool.description || "") + descSuffix; } + if (tool.name === "add_comment") { + enhancedTool.description = updateAddCommentDescription(enhancedTool.description, config.add_comment); + } + // Add repo parameter to inputSchema if configured const repoParam = toolsMeta.repo_params?.[tool.name]; if (repoParam) { diff --git a/setup/js/git_helpers.cjs b/setup/js/git_helpers.cjs index f81f2a9..46c4ff7 100644 --- a/setup/js/git_helpers.cjs +++ b/setup/js/git_helpers.cjs @@ -233,11 +233,22 @@ function hasMergeCommitsInRange(baseRef, headRef, options = {}) { } /** - * Deepen sequence (per call to `git fetch --deepen=N`). Each value adds N - * commits to the existing shallow history. Total reachable depth after the - * final step is the sum of these values (~7850 commits). + * Fallback deepen step size (commits added per `git fetch --deepen=N` call). + * + * The primary path fetches the exact prerequisite commit SHAs directly from + * origin (see `ensureFullHistoryForBundle`), so this iterative deepen only runs + * when fetch-by-SHA is unavailable or insufficient. We deepen in small + * increments so a single fetch never tries to pull a huge slice of history, + * which can time out on large monorepos with long, complex branch histories. + */ +const BUNDLE_DEEPEN_STEP = 5; + +/** + * Maximum number of fallback deepen iterations before giving up and attempting + * `--unshallow`. With a step of 5 this caps the fallback at ~1000 commits of + * deepening (200 * 5) before the last-resort unshallow. */ -const BUNDLE_DEEPEN_STEPS = [50, 100, 200, 500, 1000, 2000, 4000]; +const BUNDLE_DEEPEN_MAX_ITERATIONS = 200; /** * Extract prerequisite commit SHAs declared in a git bundle file. @@ -291,18 +302,25 @@ async function getBundlePrerequisites(execApi, bundleFilePath, options = {}) { } /** - * Check which of the given SHAs are NOT yet ancestors of `targetRef`. + * Check which of the given commit SHAs are NOT present in the local object + * store. Uses `git cat-file -e ^{commit}`, which exits non-zero when the + * object is missing. + * + * This is the correct gate for bundle application: `git fetch ` only + * needs the prerequisite *objects* to exist locally — it does not require them + * to be reachable from any particular branch. (A prerequisite commit can live + * on the pull request branch and never be an ancestor of the base branch, so an + * ancestry-based check would loop forever trying to deepen the base.) * * @param {{ getExecOutput: Function }} execApi * @param {string[]} shas - * @param {string} targetRef * @param {Object} [options] - * @returns {Promise} SHAs still missing (not ancestors / not present). + * @returns {Promise} SHAs whose commit object is not present locally. */ -async function findMissingAncestors(execApi, shas, targetRef, options = {}) { +async function findMissingObjects(execApi, shas, options = {}) { const missing = []; for (const sha of shas) { - const { exitCode } = await execApi.getExecOutput("git", ["merge-base", "--is-ancestor", sha, targetRef], { ...options, ignoreReturnCode: true, silent: true }); + const { exitCode } = await execApi.getExecOutput("git", ["cat-file", "-e", `${sha}^{commit}`], { ...options, ignoreReturnCode: true, silent: true }); if (exitCode !== 0) { missing.push(sha); } @@ -311,21 +329,31 @@ async function findMissingAncestors(execApi, shas, targetRef, options = {}) { } /** - * Probe shallow-repository status before fetching a git bundle, and deepen - * the local clone as needed so the bundle's prerequisite commits become - * reachable from `origin/`. + * Ensure a shallow checkout contains the prerequisite commits a git bundle + * needs before `git fetch ` is attempted. + * + * Bundles generated from a commit range declare prerequisite commits. A shallow + * checkout (e.g. `fetch-depth: 20`) may not contain them, and `git fetch + * ` rejects the bundle before the caller can update refs. * - * Bundles generated from a commit range can declare prerequisite commits. A - * shallow checkout (e.g. `fetch-depth: 20`) may not contain those prerequisites, - * and `git fetch ` will reject the bundle before the caller can update - * refs. On a high-churn monorepo, `git fetch --unshallow` is catastrophic — it - * downloads the entire history. Instead we iterate `git fetch origin - * --deepen=` with progressively larger N until every declared prerequisite - * satisfies `git merge-base --is-ancestor origin/`. + * Strategy (best → worst): + * 1. **Direct SHA fetch (primary).** The bundle declares *exactly* which + * commits it requires (`git bundle verify`). We fetch those SHAs directly + * from origin (`git fetch origin ...`). GitHub honors fetch-by-SHA, so + * this brings precisely the needed objects and is deterministic — it works + * even when a prerequisite lives on the PR branch and is not an ancestor of + * the base branch. This avoids walking back the base history entirely. + * 2. **Iterative deepen (fallback).** Only when fetch-by-SHA is unavailable or + * insufficient, deepen `origin/` in small `BUNDLE_DEEPEN_STEP` + * increments (re-checking object presence each step) up to + * `BUNDLE_DEEPEN_MAX_ITERATIONS`. Small steps keep any single fetch cheap so + * it cannot time out by pulling a huge slice of a large monorepo's history. + * 3. **`--unshallow` (last resort).** On a high-churn monorepo this downloads + * the entire history, so it is only attempted after the bounded deepen. * * When `deepenOptions.baseRef` or `deepenOptions.bundleFilePath` is missing - * (legacy callers), the function falls back to the previous behavior of a - * single `git fetch --unshallow origin`. + * (legacy callers), the function falls back to a single + * `git fetch --unshallow origin`. * * @param {{ getExecOutput: Function, exec: Function }} execApi - Exec API to run git commands. * @param {Object} [options] - Options passed through to exec calls. @@ -351,7 +379,7 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = // Legacy path: no base ref / bundle info known — fall back to a single // unshallow. Callers in monorepos should always supply baseRef + bundleFilePath - // to get incremental deepening instead. + // to get targeted prerequisite fetching instead. if (!baseRef || !bundleFilePath) { core.info("Repository is shallow; fetching full history before bundle processing (no baseRef/bundle info; using --unshallow)"); await execApi.exec("git", ["fetch", "--unshallow", "origin"], options); @@ -364,31 +392,52 @@ async function ensureFullHistoryForBundle(execApi, options = {}, deepenOptions = return; } - const targetRef = `origin/${baseRef}`; - const alreadyMissing = await findMissingAncestors(execApi, prereqs, targetRef, options); - if (alreadyMissing.length === 0) { - core.info(`Bundle prerequisites already reachable from ${targetRef}; no deepen required`); + let missing = await findMissingObjects(execApi, prereqs, options); + if (missing.length === 0) { + core.info("Bundle prerequisite commits already present locally; no fetch required"); return; } - core.info(`Repository is shallow; iteratively deepening ${targetRef} to satisfy ${alreadyMissing.length} bundle prerequisite commit(s)`); - let missing = alreadyMissing; - for (const depth of BUNDLE_DEEPEN_STEPS) { - core.info(`Fetching origin ${baseRef} with --deepen=${depth} (${missing.length} prerequisite(s) still missing)`); + // PRIMARY: fetch the exact prerequisite commit SHAs directly from origin. + // The bundle tells us precisely which commits it needs, so a targeted fetch by + // SHA brings exactly those objects without deepening the base branch history. + core.info(`Repository is shallow; fetching ${missing.length} bundle prerequisite commit(s) directly from origin by SHA`); + const useBlobFilter = await isShallowOrSparseCheckout(execApi, options); + const directFetchArgs = useBlobFilter ? ["fetch", "--filter=blob:none", "origin", ...missing] : ["fetch", "origin", ...missing]; + if (useBlobFilter) { + core.info("Using --filter=blob:none for prerequisite SHA fetch (shallow or sparse checkout detected)"); + } + try { + await execApi.exec("git", directFetchArgs, options); + missing = await findMissingObjects(execApi, prereqs, options); + if (missing.length === 0) { + core.info("Bundle prerequisite commits fetched directly from origin; no deepen required"); + return; + } + core.warning(`${missing.length} prerequisite commit(s) still missing after direct SHA fetch; falling back to iterative deepen`); + } catch (directFetchError) { + core.warning(`Direct prerequisite SHA fetch failed: ${getErrorMessage(directFetchError)}; falling back to iterative deepen`); + } + + // FALLBACK: deepen origin/ in small increments, re-checking object + // presence after each step, until the prerequisites are present or the + // iteration cap is reached. + core.info(`Iteratively deepening origin/${baseRef} by ${BUNDLE_DEEPEN_STEP} commit(s) at a time to satisfy ${missing.length} prerequisite commit(s)`); + for (let iteration = 1; iteration <= BUNDLE_DEEPEN_MAX_ITERATIONS; iteration++) { try { - await execApi.exec("git", ["fetch", `--deepen=${depth}`, "origin", baseRef], options); + await execApi.exec("git", ["fetch", `--deepen=${BUNDLE_DEEPEN_STEP}`, "origin", baseRef], options); } catch (fetchError) { - core.warning(`git fetch --deepen=${depth} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`); + core.warning(`git fetch --deepen=${BUNDLE_DEEPEN_STEP} origin ${baseRef} failed: ${getErrorMessage(fetchError)}; aborting iterative deepen`); break; } - missing = await findMissingAncestors(execApi, prereqs, targetRef, options); + missing = await findMissingObjects(execApi, prereqs, options); if (missing.length === 0) { - core.info(`Bundle prerequisites reachable after --deepen=${depth}`); + core.info(`Bundle prerequisite commits present after deepening ${iteration * BUNDLE_DEEPEN_STEP} commit(s)`); return; } } - core.warning(`Bundle prerequisites still not reachable after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`); + core.warning(`Bundle prerequisites still not present after iterative deepen (${missing.length} remaining); attempting --unshallow as a last resort`); try { await execApi.exec("git", ["fetch", "--unshallow", "origin", baseRef], options); } catch (unshallowError) { diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 6728a50..3431345 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -50,6 +50,17 @@ function getActionFailureIssueExpiresHours() { return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS; } +/** + * Extracts the numeric run ID from a GitHub Actions run URL. + * @param {string} runUrl e.g. "https://github.com/owner/repo/actions/runs/12345678" + * @returns {string} run ID, or empty string if not found + */ +function extractRunId(runUrl) { + if (!runUrl) return ""; + const m = runUrl.match(/\/actions\/runs\/(\d+)/); + return m ? m[1] : ""; +} + /** * Build a GitHub markdown warning alert line. * @param {string} title @@ -790,14 +801,45 @@ function buildCodePushFailureContext(codePushFailureErrors, pullRequest = null, push_to_pull_request_branch: "push-to-pull-request-branch", }; const affectedTypes = [...new Set(patchSizeErrors.map(e => e.type))]; + // Derive the suggested value from the actual limit in the first error message + const maxAllowedMatch = patchSizeErrors[0]?.error.match(/maximum allowed size \((\d+) KB\)/); + const maxAllowedKb = maxAllowedMatch ? Number.parseInt(maxAllowedMatch[1], 10) : 4096; + const suggestedKb = maxAllowedKb * 2; let yamlSnippet = "```yaml\nsafe-outputs:\n"; for (const type of affectedTypes) { const yamlKey = typeToYamlKey[type] || type.replace(/_/g, "-"); - yamlSnippet += ` ${yamlKey}:\n max-patch-size: 2048 # Example: double the default limit (in KB, default: 1024)\n`; + yamlSnippet += ` ${yamlKey}:\n max-patch-size: ${suggestedKb} # Example: double the default limit (in KB, default: ${maxAllowedKb})\n`; } yamlSnippet += "```\n"; context += "\nTo allow larger patches, increase `max-patch-size` in your workflow's front matter (value in KB):\n"; context += yamlSnippet; + + // Provide download instructions so the user can inspect what the agent generated + const runId = extractRunId(runUrl); + + context += "\n
\n📥 Download the oversized patch to inspect or apply manually\n\n"; + if (runId) { + context += `\`\`\`sh +# Download the patch artifact from the workflow run +gh run download ${runId} -n agent -D /tmp/agent-${runId} + +# List available patches +ls /tmp/agent-${runId}/*.patch + +# Inspect the patch +cat /tmp/agent-${runId}/YOUR_PATCH_FILE.patch | head -100 + +# Optionally apply the patch manually on a new branch +git checkout -b aw/manual-apply +git am --3way /tmp/agent-${runId}/YOUR_PATCH_FILE.patch +git push origin aw/manual-apply +gh pr create --head aw/manual-apply +\`\`\` +\nThe patch artifact is available at: [View run and download artifacts](${runUrl})\n`; + } else { + context += "Download the patch artifact from the workflow run, then inspect or apply it with `git am --3way `.\n"; + } + context += "\n
\n"; } // Patch apply failure section — shown when the patch could not be applied (e.g. merge conflict) @@ -812,13 +854,7 @@ function buildCodePushFailureContext(codePushFailureErrors, pullRequest = null, } // Extract run ID from runUrl for use in the download command - let runId = ""; - if (runUrl) { - const runIdMatch = runUrl.match(/\/actions\/runs\/(\d+)/); - if (runIdMatch) { - runId = runIdMatch[1]; - } - } + const runId = extractRunId(runUrl); context += "\n
\n📋 Apply the patch manually\n\n"; if (runId) { @@ -843,7 +879,7 @@ git am --3way /tmp/agent-${runId}/YOUR_PATCH_FILE.patch git push origin aw/manual-apply gh pr create --head aw/manual-apply \`\`\` -${runUrl ? `\nThe patch artifact is available at: [View run and download artifacts](${runUrl})\n` : ""}`; +\nThe patch artifact is available at: [View run and download artifacts](${runUrl})\n`; } else { context += "Download the patch artifact from the workflow run, then apply it with `git am --3way `.\n"; } @@ -977,15 +1013,41 @@ function loadMissingDataMessages(items) { } } +/** + * Resolve whether any cache-memory restore step matched a cache entry. + * Reads per-cache restore outputs propagated via GH_AW_CACHE_MEMORY_RESTORE__* env vars. + * @returns {boolean} + */ +function resolveCacheMemoryRestored() { + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GH_AW_CACHE_MEMORY_RESTORE_")) { + continue; + } + if (key.endsWith("_MATCHED_KEY") && String(value || "").trim() !== "") { + return true; + } + if ( + key.endsWith("_CACHE_HIT") && + String(value || "") + .trim() + .toLowerCase() === "true" + ) { + return true; + } + } + return false; +} + /** * Build missing_data context string for display in failure issues/comments. - * When cache-memory is enabled and a cache_miss is detected, appends a - * configuration-problem warning to the context. + * When cache-memory is enabled, a restore matched, and a cache_miss is detected, + * appends a configuration-problem warning to the context. * @param {boolean} cacheMemoryEnabled - Whether cache-memory is configured for this workflow + * @param {boolean} cacheMemoryRestored - Whether cache restore matched an existing cache key in this run * @param {Array} [items] - Optional pre-loaded agent output items. When provided, avoids re-reading the output file. * @returns {string} Formatted missing data context */ -function buildMissingDataContext(cacheMemoryEnabled, items) { +function buildMissingDataContext(cacheMemoryEnabled, cacheMemoryRestored, items) { const missingDataMessages = loadMissingDataMessages(items); if (missingDataMessages.length === 0) { @@ -994,13 +1056,14 @@ function buildMissingDataContext(cacheMemoryEnabled, items) { core.info(`Found ${missingDataMessages.length} missing_data message(s)`); - // Detect cache_miss: if cache-memory is available and the agent reported a cache miss, + // Detect cache_miss: if cache-memory restore matched and the agent reported a cache miss, // this indicates the prompt is referencing an incorrect file path within the cache directory. const hasCacheMiss = missingDataMessages.some(m => m.reason === "cache_memory_miss"); // When cache-memory is configured and cache_miss is present, avoid repeating the same // signal in the generic "Missing Data" section. Keep the specialised cache warning below. - const displayableMissingData = cacheMemoryEnabled && hasCacheMiss ? missingDataMessages.filter(m => m.reason !== "cache_memory_miss") : missingDataMessages; + const shouldShowCacheWarning = cacheMemoryEnabled && cacheMemoryRestored && hasCacheMiss; + const displayableMissingData = shouldShowCacheWarning ? missingDataMessages.filter(m => m.reason !== "cache_memory_miss") : missingDataMessages; let context = ""; if (displayableMissingData.length > 0) { @@ -1010,8 +1073,8 @@ function buildMissingDataContext(cacheMemoryEnabled, items) { context += "\n\n"; } - if (cacheMemoryEnabled && hasCacheMiss) { - core.info("Cache-miss detected despite cache-memory being available — likely a configuration problem"); + if (shouldShowCacheWarning) { + core.info("Cache-miss detected after a successful cache restore — likely a configuration problem"); const templatePath = getPromptPath("cache_memory_miss.md"); context += "\n" + renderTemplateFromFile(templatePath, {}) + "\n"; } @@ -1229,7 +1292,7 @@ function buildPermissionDeniedContext(items, workflowId) { /** * Load max-tool-denials guard events from Copilot SDK session events.jsonl files. - * @returns {Array<{denialCount: number, threshold: number, reason: string}>} + * @returns {Array<{denialCount: number, threshold: number, reason: string, recentToolCalls: Array, timestamp: string}>} */ function loadToolDenialsExceededEvents() { try { @@ -1245,11 +1308,22 @@ function loadToolDenialsExceededEvents() { if (!fs.existsSync(eventsPath)) continue; const content = fs.readFileSync(eventsPath, "utf8"); const lines = content.split("\n"); + /** @type {Array} */ + const recentToolCalls = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; try { const parsed = JSON.parse(line); + if (parsed.type === "tool.execution_start" && parsed.data && typeof parsed.data === "object") { + const toolName = typeof parsed.data.toolName === "string" ? parsed.data.toolName.trim() : ""; + if (toolName) { + const mcpServerName = typeof parsed.data.mcpServerName === "string" ? parsed.data.mcpServerName.trim() : ""; + recentToolCalls.push(mcpServerName ? `${mcpServerName}.${toolName}` : toolName); + if (recentToolCalls.length > 5) recentToolCalls.shift(); + } + continue; + } if (parsed.type !== "guard.tool_denials_exceeded" || !parsed.data || typeof parsed.data !== "object") { continue; } @@ -1262,6 +1336,8 @@ function loadToolDenialsExceededEvents() { denialCount, threshold, reason: typeof parsed.data.reason === "string" ? parsed.data.reason.trim() : "", + recentToolCalls: recentToolCalls.slice(), + timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : "", }); } catch { // Skip malformed lines @@ -1277,7 +1353,7 @@ function loadToolDenialsExceededEvents() { /** * Build context for max-tool-denials guardrail failures from Copilot SDK events. - * @param {Array<{denialCount: number, threshold: number, reason: string}>} events + * @param {Array<{denialCount: number, threshold: number, reason: string, recentToolCalls?: Array, timestamp?: string}>} events * @param {string} [workflowId] * @returns {string} */ @@ -1285,7 +1361,13 @@ function buildToolDenialsExceededContext(events, workflowId) { if (!Array.isArray(events) || events.length === 0) { return ""; } - const latestEvent = events[events.length - 1]; + // Select the event with the newest ISO timestamp so the correct lead-up context is + // shown when guard events arrive from multiple session directories in arbitrary order. + const latestEvent = events.reduce((best, ev) => { + const evTs = typeof ev.timestamp === "string" ? ev.timestamp : ""; + const bestTs = typeof best.timestamp === "string" ? best.timestamp : ""; + return evTs > bestTs ? ev : best; + }); const denialCount = String(latestEvent.denialCount); const threshold = String(latestEvent.threshold); const reason = latestEvent.reason || "permission denied by workflow tool permissions"; @@ -1293,6 +1375,7 @@ function buildToolDenialsExceededContext(events, workflowId) { // Normalize the reason for display: multi-line programs (e.g. Python 3 heredocs) are // collapsed to a single-line summary so the issue body renders cleanly. const normalizedReason = normalizeDeniedPermissionCommand(reason); + const recentToolCallsList = Array.isArray(latestEvent.recentToolCalls) && latestEvent.recentToolCalls.length > 0 ? latestEvent.recentToolCalls.map(toolCall => `- \`${toolCall}\``).join("\n") : "- _No tool calls captured_"; const templatePath = getPromptPath("tool_denials_exceeded_context.md"); const template = fs.readFileSync(templatePath, "utf8"); @@ -1302,6 +1385,7 @@ function buildToolDenialsExceededContext(events, workflowId) { denial_count: denialCount, threshold, reason: normalizedReason, + recent_tool_calls_list: recentToolCallsList, workflow_id: workflowId || "the workflow", }) ); @@ -1665,6 +1749,40 @@ function buildDailyAICExceededContext(hasDailyAICExceeded, totalAIC, threshold) ); } +/** + * Build the "Optimize token consumption" details section for the failure issue when a guardrail + * limit was the root cause of the failure. + * + * Guardrails that trigger this section: + * - max-ai-credits: per-run AI Credits budget exceeded + * - max-daily-ai-credits: 24-hour per-workflow AI Credits quota exhausted + * - max-tool-denials: Copilot SDK tool-denial threshold hit + * - max-turns / timeout: agent ran out of turns or wall-clock time + * + * @param {object} options + * @param {boolean} options.maxAICreditsExceeded - max-ai-credits guardrail triggered + * @param {boolean} options.hasDailyAICExceeded - max-daily-ai-credits guardrail triggered + * @param {boolean} options.hasToolDenialsExceeded - max-tool-denials guardrail triggered + * @param {boolean} options.isTimedOut - timeout / max-turns guardrail triggered + * @param {string} options.runUrl - URL to the failed workflow run + * @returns {string} Rendered section or empty string when no guardrail was triggered + */ +function buildOptimizeTokenConsumptionContext({ maxAICreditsExceeded, hasDailyAICExceeded, hasToolDenialsExceeded, isTimedOut, runUrl }) { + const guardrailTriggered = maxAICreditsExceeded || hasDailyAICExceeded || hasToolDenialsExceeded || isTimedOut; + if (!guardrailTriggered) { + return ""; + } + + let guardrailName = "guardrail limit"; + if (maxAICreditsExceeded) guardrailName = "max-ai-credits"; + else if (hasDailyAICExceeded) guardrailName = "max-daily-ai-credits"; + else if (hasToolDenialsExceeded) guardrailName = "max-tool-denials"; + else if (isTimedOut) guardrailName = "max-turns / timeout"; + + const templatePath = getPromptPath("optimize_token_consumption_context.md"); + return renderTemplateFromFile(templatePath, { guardrail_name: guardrailName, run_url: runUrl }); +} + // Maps engine ID (GH_AW_ENGINE_ID) to credential name for use with GH_AW_ENGINE_API_HOSTS. const ENGINE_ID_TO_CREDENTIAL = /** @type {Record} */ { copilot: "`COPILOT_GITHUB_TOKEN`", @@ -2411,6 +2529,7 @@ async function main() { // Cache-memory availability flag — set when cache-memory is configured for the workflow. // Used to detect cache-miss misconfigurations reported by the agent. const cacheMemoryEnabled = process.env.GH_AW_CACHE_MEMORY_ENABLED === "true"; + const cacheMemoryRestored = cacheMemoryEnabled ? resolveCacheMemoryRestored() : false; // Collect repo-memory validation errors from all memory configurations const repoMemoryValidationErrors = []; @@ -2458,6 +2577,7 @@ async function main() { core.info(`Lockdown check failed: ${hasLockdownCheckFailed}`); core.info(`Stale lock file check failed: ${hasStaleLockFileFailed}`); core.info(`Cache memory enabled: ${cacheMemoryEnabled}`); + core.info(`Cache memory restored (from restore outputs): ${cacheMemoryRestored}`); core.info(`Missing tool report-as-failure: ${missingToolReportAsFailure}`); core.info(`Missing data report-as-failure: ${missingDataReportAsFailure}`); @@ -2582,24 +2702,26 @@ async function main() { } // Detect cache-miss misconfiguration: the agent reported a missing_data with reason - // "cache_memory_miss" while cache-memory was configured and available. This indicates the - // prompt is referencing an incorrect path inside the cache directory. + // "cache_memory_miss" after a cache restore matched. This indicates the prompt + // is referencing an incorrect path inside the cache directory. // Check for items regardless of agentOutputResult.success so that cache-miss signals // emitted alongside other output are not missed when the agent job also fails. let hasCacheMissMisconfiguration = false; - if (cacheMemoryEnabled && agentOutputResult.items) { + if (cacheMemoryEnabled && cacheMemoryRestored && agentOutputResult.items) { const cacheMissItems = agentOutputResult.items.filter(item => item.type === "missing_data" && item.reason === "cache_memory_miss"); if (cacheMissItems.length > 0) { hasCacheMissMisconfiguration = true; - core.info(`Cache-miss misconfiguration detected: ${cacheMissItems.length} missing_data item(s) with reason "cache_memory_miss" despite cache-memory being available`); + core.info(`Cache-miss misconfiguration detected: ${cacheMissItems.length} missing_data item(s) with reason "cache_memory_miss" after cache restore matched an existing key`); } + } else if (cacheMemoryEnabled && !cacheMemoryRestored) { + core.info("Cache-memory is configured but no cache restore match was found; cache_memory_miss is treated as expected cache miss (actions/cache branch scoping and first-run behavior)"); } // Only proceed if the agent job actually failed OR timed out OR there are assignment errors OR // create_discussion errors OR code-push failures OR push_repo_memory failed OR missing safe outputs // OR a GitHub App token minting step failed OR the lockdown check failed OR copilot assignment failed // OR the stale lock file check failed OR the agent reported task incompletion via report_incomplete - // OR a cache-miss was detected despite cache-memory being available (configuration problem) + // OR a cache-miss was detected after cache restore succeeded (configuration problem) // OR the agent reported missing tools or missing data (treated as agent failures by default). // BUT skip if we only have noop outputs (that's a successful no-action scenario) if ( @@ -2778,11 +2900,7 @@ async function main() { const commentTemplate = fs.readFileSync(commentTemplatePath, "utf8"); // Extract run ID from URL (e.g., https://github.com/owner/repo/actions/runs/123 -> "123") - let runId = ""; - const runIdMatch = runUrl.match(/\/actions\/runs\/(\d+)/); - if (runIdMatch) { - runId = runIdMatch[1]; - } + const runId = extractRunId(runUrl); // Build assignment errors context let assignmentErrorsContext = ""; @@ -2826,7 +2944,7 @@ async function main() { const pushRepoMemoryFailureContext = buildPushRepoMemoryFailureContext(hasPushRepoMemoryFailure, repoMemoryPatchSizeExceededIDs, runUrl); // Build missing_data context (only when report-as-failure is enabled for this signal type) - const missingDataContext = missingDataReportAsFailure ? buildMissingDataContext(cacheMemoryEnabled, agentOutputResult.items) : ""; + const missingDataContext = missingDataReportAsFailure ? buildMissingDataContext(cacheMemoryEnabled, cacheMemoryRestored, agentOutputResult.items) : ""; // Build tool-denials-exceeded guard context from events.jsonl const toolDenialsExceededContext = buildToolDenialsExceededContext(toolDenialsExceededEvents, workflowID); @@ -3049,7 +3167,7 @@ async function main() { const pushRepoMemoryFailureContext = buildPushRepoMemoryFailureContext(hasPushRepoMemoryFailure, repoMemoryPatchSizeExceededIDs, runUrl); // Build missing_data context (only when report-as-failure is enabled for this signal type) - const missingDataContext = missingDataReportAsFailure ? buildMissingDataContext(cacheMemoryEnabled, agentOutputResult.items) : ""; + const missingDataContext = missingDataReportAsFailure ? buildMissingDataContext(cacheMemoryEnabled, cacheMemoryRestored, agentOutputResult.items) : ""; // Build tool-denials-exceeded guard context from events.jsonl const toolDenialsExceededContext = buildToolDenialsExceededContext(toolDenialsExceededEvents, workflowID); @@ -3116,6 +3234,9 @@ async function main() { // Build credential auth error context (firewall audit.jsonl 401/403 from provider endpoints) const credentialAuthErrorContext = buildCredentialAuthErrorContext(); + // Build optimize token consumption context (shown when a guardrail was the failure root cause) + const optimizeTokenConsumptionContext = buildOptimizeTokenConsumptionContext({ maxAICreditsExceeded, hasDailyAICExceeded, hasToolDenialsExceeded, isTimedOut, runUrl }); + // Create template context with sanitized workflow name const templateContext = { workflow_name: sanitizedWorkflowName, @@ -3151,6 +3272,7 @@ async function main() { lockdown_check_failed_context: lockdownCheckFailedContext, stale_lock_file_failed_context: staleLockFileFailedContext, daily_ai_credits_exceeded_context: dailyAICExceededContext, + optimize_token_consumption_context: optimizeTokenConsumptionContext, }; // Render the issue template @@ -3234,6 +3356,7 @@ module.exports = { buildLockdownCheckFailedContext, buildStaleLockFileFailedContext, buildDailyAICExceededContext, + buildOptimizeTokenConsumptionContext, buildTimeoutContext, shouldBuildEngineFailureContext, isIssueWritePermissionError, @@ -3243,6 +3366,7 @@ module.exports = { buildMCPPolicyErrorContext, buildModelNotSupportedErrorContext, buildMissingDataContext, + resolveCacheMemoryRestored, buildMissingToolContext, buildPermissionDeniedContext, normalizeDeniedPermissionCommand, diff --git a/setup/js/harness_retry_guard.cjs b/setup/js/harness_retry_guard.cjs index c641f11..042c76b 100644 --- a/setup/js/harness_retry_guard.cjs +++ b/setup/js/harness_retry_guard.cjs @@ -5,17 +5,19 @@ const AI_CREDITS_EXCEEDED_PATTERNS = [/\bmax[\s_-]*ai[\s_-]*credits[\s_-]*exceeded\b/i, /\bai[\s_-]*credits[\s_-]*rate[\s_-]*limit[\s_-]*error\b/i, /ai[\s_-]*credits?.*(?:rate[\s-]*limit|limit exceeded|budget exceeded|exceeded)/i]; 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]; /** * Detect retry guard conditions that should stop harness retries immediately. * @param {unknown} output - * @returns {{ aiCreditsExceeded: boolean, awfAPIProxyBlockingRequests: boolean }} + * @returns {{ aiCreditsExceeded: boolean, awfAPIProxyBlockingRequests: boolean, goalAlreadyActive: boolean }} */ function detectNonRetryableHarnessGuard(output) { const safeOutput = typeof output === "string" ? output : ""; return { 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)), }; } @@ -24,5 +26,6 @@ if (typeof module !== "undefined" && module.exports) { detectNonRetryableHarnessGuard, AI_CREDITS_EXCEEDED_PATTERNS, AWF_API_PROXY_BLOCKING_REQUESTS_PATTERNS, + GOAL_ALREADY_ACTIVE_PATTERNS, }; } diff --git a/setup/js/package.json b/setup/js/package.json index be9040c..d695344 100644 --- a/setup/js/package.json +++ b/setup/js/package.json @@ -1,20 +1,21 @@ { "devDependencies": { + "@actions/artifact": "^6.2.1", "@actions/core": "^3.0.1", "@actions/exec": "^3.0.0", "@actions/github": "^9.1.1", "@actions/github-script": "github:actions/github-script#v9.0.0", "@actions/glob": "^0.7.0", "@actions/io": "^3.0.2", - "@github/copilot-sdk": "^1.0.0", + "@github/copilot-sdk": "^1.0.1", "@types/node": "^25.9.2", "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.7", + "@vitest/ui": "^4.1.9", "minimatch": ">=3.1.3", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "typescript": "^6.0.3", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "overrides": { "undici": "^6.23.0" @@ -24,6 +25,7 @@ "test": "npm run typecheck && vitest run --no-file-parallelism", "test:js": "vitest run", "test:js-integration-live-api": "vitest run --config vitest.integration.config.mjs --no-file-parallelism", + "test:js-integration-artifact": "vitest run --config vitest.artifact-integration.config.mjs --no-file-parallelism", "test:js-watch": "vitest", "test:js-coverage": "vitest run --coverage", "format:cjs": "npx prettier --write '**/*.cjs' '**/*.ts' '**/*.json' --ignore-path ../../../.prettierignore", diff --git a/setup/js/push_to_pull_request_branch.cjs b/setup/js/push_to_pull_request_branch.cjs index 22e056c..1e5f027 100644 --- a/setup/js/push_to_pull_request_branch.cjs +++ b/setup/js/push_to_pull_request_branch.cjs @@ -119,7 +119,7 @@ async function main(config = {}) { const checkBranchProtection = config.check_branch_protection !== false; const signedCommits = config.signed_commits !== false; const commitTitleSuffix = config.commit_title_suffix || ""; - const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; + const maxSizeKb = parsePositiveInteger(config.max_patch_size) ?? 4096; const maxCount = config.max || 0; // 0 means no limit // Cross-repo support: resolve target repository from config diff --git a/setup/js/remove_labels.cjs b/setup/js/remove_labels.cjs index 6b90769..494f4cf 100644 --- a/setup/js/remove_labels.cjs +++ b/setup/js/remove_labels.cjs @@ -15,6 +15,7 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { resolveSafeOutputIssueTarget } = require("./temporary_id.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Main handler factory for remove_labels @@ -69,7 +70,8 @@ const main = createCountGatedHandler({ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); if (!targetResult.success) return targetResult; - const itemNumber = targetResult.number ?? context.payload?.issue?.number ?? context.payload?.pull_request?.number; + const effectiveContext = resolveInvocationContext(context); + const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; @@ -77,7 +79,7 @@ const main = createCountGatedHandler({ return { success: false, error }; } - const contextType = context.payload?.pull_request ? "pull request" : "issue"; + const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue"; const requestedLabels = message.labels ?? []; core.info(`Requested labels to remove: ${JSON.stringify(requestedLabels)}`); diff --git a/setup/js/restore_aic_usage_cache_fallback.cjs b/setup/js/restore_aic_usage_cache_fallback.cjs new file mode 100644 index 0000000..f0724cc --- /dev/null +++ b/setup/js/restore_aic_usage_cache_fallback.cjs @@ -0,0 +1,223 @@ +// @ts-check +/// + +/** + * restore_aic_usage_cache_fallback.cjs + * + * Called from the activation job only when actions/cache/restore reports a cache miss. + * Downloads the most recent `aic-usage-cache` artifact from the same workflow's + * recent runs to populate the local cache file without requiring the artifact to + * have been saved on the current branch. + * + * Background: GitHub Actions `actions/cache` is branch-scoped. Workflows that + * trigger on `pull_request` events run on a unique per-PR branch, so caches saved + * by the conclusion job of one PR run are not visible to the activation job of a + * different PR run. This script compensates by falling back to a named artifact + * (`aic-usage-cache`) that the conclusion job uploads after writing the cache file. + * Artifacts are accessible cross-branch via the GitHub REST API. + * + * Requires setupGlobals() to have been called first (sets global.core, global.github, + * global.context). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { DefaultArtifactClient } = require("./artifact_client.cjs"); +const { getErrorMessage } = require("./error_helpers.cjs"); + +/** + * Retrieve the bearer token from the builtin `github` Octokit instance. + * Returns an empty string if the auth plugin does not support the "token" type. + * + * @returns {Promise} + */ +async function getTokenFromGithub() { + try { + const auth = await github.auth({ type: "token" }); + return (auth && typeof auth.token === "string" && auth.token) || ""; + } catch { + return ""; + } +} + +/** Path where the activation job expects the usage cache to be restored. */ +const CACHE_FILE_PATH = "/tmp/gh-aw/agentic-workflow-usage-cache.jsonl"; + +/** Name of the artifact uploaded by the conclusion job that holds the aggregated cache. */ +const AIC_USAGE_CACHE_ARTIFACT_NAME = "aic-usage-cache"; + +/** Maximum number of recent workflow runs to search for a usable cache artifact. */ +const MAX_RUNS_TO_SEARCH = 5; + +/** + * @param {string} message + * @param {Record} [details] + */ +function logFallback(message, details) { + const suffix = + details && Object.keys(details).length > 0 + ? ": " + + (() => { + try { + return JSON.stringify(details); + } catch { + return "{}"; + } + })() + : ""; + core.info(`[daily-aic-cache-fallback] ${message}${suffix}`); +} + +/** + * Downloads the most recent `aic-usage-cache` artifact from the same workflow's + * recent runs and writes it to {@link CACHE_FILE_PATH}. + * + * @param {string} [cacheFilePath] Override for the target cache file path (used in tests). + * @param {{ createArtifactClient?: () => import("./artifact_client.cjs").DefaultArtifactClient, cacheHit?: string, cacheMatchedKey?: string }} [options] + * Optional overrides for testing (e.g. inject a mock artifact client factory, override env var values). + * @returns {Promise} + */ +async function mainWithPaths(cacheFilePath, options = {}) { + const cachePath = cacheFilePath || CACHE_FILE_PATH; + const createArtifactClient = options.createArtifactClient || (() => new DefaultArtifactClient()); + + // Detect true cache miss using the restore outputs forwarded via env vars. + // A true miss is when cache-hit is absent/empty (step was skipped or errored), or + // when cache-hit is "false" and no restore-key match was found (cache-matched-key is empty). + // A restore-key match (cache-hit "false" but cache-matched-key present) counts as a hit. + const cacheHit = "cacheHit" in options ? options.cacheHit : process.env.GH_AW_RESTORE_DAILY_AIC_CACHE_HIT || ""; + const cacheMatchedKey = "cacheMatchedKey" in options ? options.cacheMatchedKey : process.env.GH_AW_RESTORE_DAILY_AIC_CACHE_MATCHED_KEY || ""; + const isCacheMiss = !cacheHit || (cacheHit === "false" && !cacheMatchedKey); + if (!isCacheMiss) { + logFallback("Cache was restored; skipping artifact fallback", { cacheHit, cacheMatchedKey }); + return; + } + + // If the file already exists (e.g., restored by a prior step), skip the artifact fallback. + if (fs.existsSync(cachePath)) { + logFallback("Cache file already exists; skipping artifact fallback", { path: cachePath }); + return; + } + + const { owner, repo } = context.repo; + + try { + // Resolve the numeric GitHub workflow ID so we can list runs for this specific workflow. + const currentRunData = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: context.runId, + }); + const workflowNumericId = currentRunData.data.workflow_id; + if (!workflowNumericId) { + logFallback("Could not determine numeric workflow ID; skipping artifact fallback"); + return; + } + + logFallback("Searching for aic-usage-cache artifact from recent runs", { + workflowId: workflowNumericId, + currentRunId: context.runId, + maxRunsToSearch: MAX_RUNS_TO_SEARCH, + }); + + const { data: runsData } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: workflowNumericId, + status: "completed", + per_page: MAX_RUNS_TO_SEARCH, + }); + + for (const run of runsData.workflow_runs) { + // Skip the current run — its conclusion job hasn't written the artifact yet. + if (run.id === context.runId) { + continue; + } + try { + // Use the builtin github instance (already authenticated) to list artifacts. + const { data: artifactsData } = await github.rest.actions.listWorkflowRunArtifacts({ + owner, + repo, + run_id: run.id, + }); + + const cacheArtifact = artifactsData.artifacts.find(a => a.name === AIC_USAGE_CACHE_ARTIFACT_NAME && !a.expired); + if (!cacheArtifact) { + logFallback("No aic-usage-cache artifact in run", { runId: run.id }); + continue; + } + + logFallback("Found aic-usage-cache artifact; downloading", { + runId: run.id, + artifactId: cacheArtifact.id, + }); + + // Get the token from the builtin github instance for the download step. + const token = await getTokenFromGithub(); + + const downloadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-aic-cache-fallback-")); + const artifactClient = createArtifactClient(); + const download = await artifactClient.downloadArtifact(cacheArtifact.id, { + path: downloadRoot, + findBy: { + token, + workflowRunId: run.id, + repositoryOwner: owner, + repositoryName: repo, + }, + }); + + const downloadPath = download.downloadPath || downloadRoot; + + // Locate the JSONL file inside the extracted artifact directory. + const files = fs.readdirSync(downloadPath); + const jsonlFile = files.find(f => f.endsWith(".jsonl")); + if (!jsonlFile) { + logFallback("No JSONL file in downloaded artifact; trying next run", { + downloadPath, + files, + }); + continue; + } + + const srcPath = path.join(downloadPath, jsonlFile); + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(srcPath, cachePath); + + logFallback("Restored cache from artifact", { + runId: run.id, + artifactId: cacheArtifact.id, + path: cachePath, + }); + return; + } catch (runErr) { + logFallback("Error processing run; trying next", { + runId: run.id, + error: getErrorMessage(runErr), + }); + } + } + + logFallback("No aic-usage-cache artifact found in recent runs; proceeding without cache", { runsSearched: runsData.workflow_runs.length }); + } catch (error) { + // Non-fatal: a failure here should never block the activation job. + logFallback("Failed to restore cache from artifact fallback; proceeding without cache", { + error: getErrorMessage(error), + }); + } +} + +/** + * Entry point called from the GitHub Actions step. + * + * @returns {Promise} + */ +async function main() { + return mainWithPaths(); +} + +module.exports = { main, mainWithPaths }; diff --git a/setup/js/safe_output_handler_manager.cjs b/setup/js/safe_output_handler_manager.cjs index 2e80d3c..1809df2 100644 --- a/setup/js/safe_output_handler_manager.cjs +++ b/setup/js/safe_output_handler_manager.cjs @@ -542,16 +542,21 @@ function isFailedProcessingResult(result) { return Boolean(result?.success === false && !result?.deferred && !result?.skipped && !result?.cancelled); } +/** Types whose failures are surfaced as warnings rather than failing the safe_outputs job. */ +const REPORT_ONLY_FAILURE_TYPES = new Set(["assign_to_agent", "upload_artifact"]); + /** * Determine whether a failed result should be reported without failing the safe_outputs job. * Agent assignment can fail after other safe outputs already succeeded, so those failures * are surfaced through dedicated outputs and summaries instead of failing the entire job. + * Artifact uploads are best-effort and non-critical: a failed upload should not fail an + * otherwise-successful run. * * @param {{type?: string, success?: boolean, deferred?: boolean, skipped?: boolean, cancelled?: boolean}|null|undefined} result * @returns {boolean} */ function isReportOnlyFailureResult(result) { - return isFailedProcessingResult(result) && result?.type === "assign_to_agent"; + return isFailedProcessingResult(result) && !!(result?.type && REPORT_ONLY_FAILURE_TYPES.has(result.type)); } /** @@ -562,8 +567,8 @@ function isReportOnlyFailureResult(result) { */ function partitionFailureResults(results) { const failedResults = results.filter(isFailedProcessingResult); - const reportOnlyFailures = failedResults.filter(r => r?.type === "assign_to_agent"); - const fatalFailures = failedResults.filter(r => r?.type !== "assign_to_agent"); + const reportOnlyFailures = failedResults.filter(r => REPORT_ONLY_FAILURE_TYPES.has(r?.type ?? "")); + const fatalFailures = failedResults.filter(r => !REPORT_ONLY_FAILURE_TYPES.has(r?.type ?? "")); return { fatalFailures, reportOnlyFailures }; } @@ -1506,7 +1511,8 @@ async function main() { core.setFailed(`${failureCount} safe output(s) failed:\n${failedItems}`); } if (reportOnlyFailureCount > 0) { - core.warning(`${reportOnlyFailureCount} agent assignment(s) failed but were reported without failing safe_outputs`); + const reportOnlyTypes = [...new Set(reportOnlyFailures.map(r => r.type || "unknown"))]; + core.warning(`${reportOnlyFailureCount} non-fatal safe output(s) failed but were reported without failing safe_outputs: ${reportOnlyTypes.join(", ")}`); } if (cancelledCount > 0) { core.warning(`${cancelledCount} message(s) were cancelled because a code push operation failed`); diff --git a/setup/js/safe_output_helpers.cjs b/setup/js/safe_output_helpers.cjs index 9e6f02b..c89ab62 100644 --- a/setup/js/safe_output_helpers.cjs +++ b/setup/js/safe_output_helpers.cjs @@ -9,6 +9,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { matchesSimpleGlob } = require("./glob_pattern_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Parse a comma-separated list of allowed items from environment variable @@ -70,12 +71,24 @@ function parseMaxCount(envValue, defaultValue = 3) { */ function resolveTarget(params) { const { targetConfig, item, context, itemType, supportsPR = false, supportsIssue = false } = params; + let invocationContext; + try { + invocationContext = resolveInvocationContext(context); + } catch (err) { + return { + success: false, + error: `Failed to resolve invocation context for ${itemType}: ${getErrorMessage(err)}`, + shouldFail: true, + }; + } + const effectiveEventName = invocationContext?.eventName || context.eventName; + const effectivePayload = invocationContext?.eventPayload || context.payload; // Check context type const prEventNames = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); - const isIssueCommentOnPR = context.eventName === "issue_comment" && Boolean(context.payload?.issue?.pull_request); - const isIssueContext = context.eventName === "issues" || (context.eventName === "issue_comment" && !isIssueCommentOnPR); - const isPRContext = prEventNames.has(context.eventName) || isIssueCommentOnPR; + const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); + const isIssueContext = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); + const isPRContext = prEventNames.has(effectiveEventName) || isIssueCommentOnPR; // Default target is "triggering" const target = targetConfig || "triggering"; @@ -202,8 +215,8 @@ function resolveTarget(params) { } else { // Use triggering context if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; + if (effectivePayload.issue) { + itemNumber = effectivePayload.issue.number; contextType = "issue"; } else { return { @@ -213,11 +226,11 @@ function resolveTarget(params) { }; } } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; + if (effectivePayload.pull_request) { + itemNumber = effectivePayload.pull_request.number; contextType = "pull request"; } else if (isIssueCommentOnPR) { - itemNumber = context.payload.issue.number; + itemNumber = effectivePayload.issue.number; contextType = "pull request"; } else { return { diff --git a/setup/js/safe_outputs_handlers.cjs b/setup/js/safe_outputs_handlers.cjs index bafa4f2..6d16fae 100644 --- a/setup/js/safe_outputs_handlers.cjs +++ b/setup/js/safe_outputs_handlers.cjs @@ -25,6 +25,18 @@ const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); const { parseDeduplicateByTitle, normalizeTitleForDedup, findDuplicateByTitle } = require("./issue_title_dedup.cjs"); const { validateCreatePullRequestIntent, validatePushToPullRequestBranchIntent, validateCreateIssueIntent, validateAddCommentIntent } = require("./intent_probe.cjs"); const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); +/** + * Read and parse a JSON file. + * @param {string} filePath + * @returns {any} + */ +function readJSONFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +const safeOutputsTools = readJSONFile(path.join(__dirname, "safe_outputs_tools.json")); + +const safeOutputsToolMap = new Map(safeOutputsTools.map(tool => [tool.name, tool])); /** * @param {string} error @@ -60,12 +72,30 @@ function buildMissingTemporaryIdError(toolName, configKey) { return `${toolName} requires 'temporary_id' when safe-outputs.${configKey}.require-temporary-id is enabled. Set temporary_id (for example "${example}") and retry.`; } +/** + * @param {Record} safeOutputsConfig + * @param {string} toolName + * @returns {Record} + */ +function getSafeOutputsToolConfig(safeOutputsConfig, toolName) { + return safeOutputsConfig?.[toolName] || safeOutputsConfig?.[toolName.replace(/_/g, "-")] || {}; +} + /** * @param {Record} entry + * @param {string[]} fieldNames * @returns {boolean} */ -function hasExplicitAddCommentTargetNumber(entry) { - return ["item_number", "pr_number", "pr"].some(field => entry[field] !== undefined && entry[field] !== null && String(entry[field]).trim() !== ""); +function hasExplicitTargetParameter(entry, fieldNames) { + return fieldNames.some(field => entry[field] !== undefined && entry[field] !== null && String(entry[field]).trim() !== ""); +} + +/** + * @param {string} toolName + * @returns {{primary?: string, anyOf?: string[]} | null} + */ +function getWildcardTargetRequirement(toolName) { + return safeOutputsToolMap.get(toolName)?.["x-safe-outputs-target-requirements"]?.["*"] || null; } /** @@ -154,8 +184,34 @@ function resolvePatchWorkspacePath(workspacePath) { */ function createHandlers(server, appendSafeOutput, config = {}) { const TOKEN_THRESHOLD = 16000; - const addCommentConfig = config.add_comment || config["add-comment"] || {}; - const wildcardAddCommentTargetRequiresItemNumber = addCommentConfig.target === "*"; + + /** + * Validate schema-declared explicit target parameters for wildcard-target tools. + * @param {Record} entry + * @returns {{content: Array<{type: "text", text: string}>, isError: true} | null} + */ + const validateWildcardTargetRequirement = entry => { + const toolName = entry?.type; + const requirement = getWildcardTargetRequirement(toolName); + if (!requirement) { + return null; + } + + const toolConfig = getSafeOutputsToolConfig(config, toolName); + if (toolConfig.target !== "*") { + return null; + } + + const anyOf = Array.isArray(requirement.anyOf) ? requirement.anyOf : []; + if (anyOf.length === 0 || hasExplicitTargetParameter(entry, anyOf)) { + return null; + } + + const configKey = toolName.replace(/_/g, "-"); + const primary = requirement.primary || anyOf[0]; + const guidance = anyOf.length === 1 ? primary : `one of: ${anyOf.join(", ")}`; + return buildIntentErrorResponse(`${toolName} requires ${primary} when safe-outputs.${configKey}.target is '*'. Provide ${guidance} and retry.`); + }; /** * Detect and offload large string fields to files. @@ -204,6 +260,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { */ const defaultHandler = type => args => { const entry = { ...(args || {}), type }; + const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); + if (wildcardTargetValidationError) { + return wildcardTargetValidationError; + } const largeContentResponse = maybeHandleLargeContent(entry); if (largeContentResponse) return largeContentResponse; @@ -811,6 +871,10 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Drop it so the agent cannot override the derived source branch. const { branch: _agentBranch, ...sanitizedArgs } = args || {}; const entry = { ...sanitizedArgs, type: "push_to_pull_request_branch" }; + const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); + if (wildcardTargetValidationError) { + return wildcardTargetValidationError; + } // Resolve target repo configuration and validate the target repo early // This is needed before getBaseBranch to ensure we resolve the base branch @@ -1561,10 +1625,9 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Build the entry with a temporary_id const entry = { ...(args || {}), type: "add_comment" }; - if (wildcardAddCommentTargetRequiresItemNumber) { - if (!hasExplicitAddCommentTargetNumber(entry)) { - return buildIntentErrorResponse("add_comment requires item_number when safe-outputs.add-comment.target is '*'. Provide item_number (or pr_number/pr alias)."); - } + const wildcardTargetValidationError = validateWildcardTargetRequirement(entry); + if (wildcardTargetValidationError) { + return wildcardTargetValidationError; } const intentValidationError = validateAddCommentIntent(entry); if (intentValidationError) { @@ -1619,7 +1682,9 @@ function createHandlers(server, appendSafeOutput, config = {}) { // Increment only after the default handler returns successfully; if it throws // (e.g. due to large-content rejection or an append write error) the counter // must not advance so the empty-review guard remains accurate. - inlineReviewCommentCount++; + if (!result?.isError) { + inlineReviewCommentCount++; + } return result; }; diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index 60d9d49..551f542 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -154,6 +154,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "discussion_number", + "anyOf": ["discussion_number"] + } } }, { @@ -230,7 +236,7 @@ }, "pull_request_number": { "type": ["number", "string"], - "description": "Pull request number to close. This is the numeric ID from the GitHub URL (e.g., 432 in github.com/owner/repo/pull/432). If omitted, closes the PR that triggered this workflow (requires a pull_request event trigger).", + "description": "Pull request number to close. This is the numeric ID from the GitHub URL (e.g., 432 in github.com/owner/repo/pull/432). If omitted, closes the PR that triggered this workflow (requires a pull_request event trigger). Required when the workflow target is '*' (any PR).", "x-synonyms": ["pullRequestNumber"] }, "secrecy": { @@ -243,11 +249,17 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number"] + } } }, { "name": "add_comment", - "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool requires discussions:write permission. If your GitHub App lacks Discussions permission, set 'discussions: false' in the workflow's safe-outputs.add-comment configuration to exclude this permission.", + "description": "WRITE-ONCE: do NOT call this tool with empty or placeholder arguments to probe or discover its schema \u2014 the required `body` field is listed in this schema; if you are not ready to post a real comment, call `noop` instead. Adds a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.", "inputSchema": { "type": "object", "required": ["body"], @@ -302,6 +314,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "item_number", + "anyOf": ["item_number", "pr_number", "pr"] + } } }, { @@ -408,6 +426,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number"] + } } }, { @@ -444,6 +468,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number"] + } } }, { @@ -905,6 +935,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number", "pr_number", "pr"] + } } }, { @@ -977,6 +1013,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number"] + } } }, { @@ -1661,6 +1703,12 @@ } }, "additionalProperties": false + }, + "x-safe-outputs-target-requirements": { + "*": { + "primary": "pull_request_number", + "anyOf": ["pull_request_number", "pr_number", "pr", "pull_number"] + } } } ] diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index afd8563..882fd5f 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -475,8 +475,8 @@ function buildGitHubActionsResourceAttributes({ /** * Parse an `OTEL_RESOURCE_ATTRIBUTES` value into OTLP resource attributes. * - * Supports the OpenTelemetry escaping rules for commas, equals signs, and - * backslashes (`\,`, `\=`, `\\`). + * Supports OpenTelemetry percent-encoded values and legacy gh-aw backslash + * escapes for commas, equals signs, and backslashes (`\,`, `\=`, `\\`). * * @param {string} raw * @returns {Array<{key: string, value: object}>} @@ -490,11 +490,18 @@ function parseOTELResourceAttributes(raw) { let value = ""; let seenEquals = false; let escaped = false; + const decodeComponent = component => { + try { + return decodeURIComponent(component); + } catch { + return component; + } + }; const pushCurrent = () => { const trimmedKey = key.trim(); if (trimmedKey) { - attributes.push(buildAttr(trimmedKey, value.trim())); + attributes.push(buildAttr(decodeComponent(trimmedKey), decodeComponent(value.trim()))); } key = ""; value = ""; diff --git a/setup/js/set_issue_field.cjs b/setup/js/set_issue_field.cjs index 292c553..37d59d8 100644 --- a/setup/js/set_issue_field.cjs +++ b/setup/js/set_issue_field.cjs @@ -47,7 +47,6 @@ async function fetchIssueFields(githubClient, owner, repo) { issueFields(first: 100) { nodes { __typename - ... on IssueField { id name } ... on IssueFieldText { id name } ... on IssueFieldNumber { id name } ... on IssueFieldDate { id name } @@ -61,7 +60,6 @@ async function fetchIssueFields(githubClient, owner, repo) { issueFields(first: 100) { nodes { __typename - ... on IssueField { id name } ... on IssueFieldText { id name } ... on IssueFieldNumber { id name } ... on IssueFieldDate { id name } diff --git a/setup/js/update_handler_factory.cjs b/setup/js/update_handler_factory.cjs index 1630e3a..35cac86 100644 --- a/setup/js/update_handler_factory.cjs +++ b/setup/js/update_handler_factory.cjs @@ -138,15 +138,13 @@ function createUpdateHandlerFactory(handlerConfig) { // Check if we're in staged mode const isStaged = isStagedMode(config); - // Build configuration log message - const configParts = [`max=${maxCount}`, `target=${updateTarget}`]; - - // Add additional config items to log - Object.entries(additionalConfig).forEach(([key, value]) => { - if (config[key] !== undefined) { - configParts.push(`${key}=${config[key]}`); - } - }); + const configParts = [ + `max=${maxCount}`, + `target=${updateTarget}`, + ...Object.entries(additionalConfig) + .filter(([key]) => config[key] !== undefined) + .map(([key]) => `${key}=${config[key]}`), + ]; core.info(`Update ${itemTypeName} configuration: ${configParts.join(", ")}`); diff --git a/setup/js/update_project.cjs b/setup/js/update_project.cjs index 0c08781..d06d1cb 100644 --- a/setup/js/update_project.cjs +++ b/setup/js/update_project.cjs @@ -364,6 +364,265 @@ async function findExistingDraftByTitle(github, projectId, targetTitle) { return null; } +/** + * Find an existing project item by content ID (issue or PR) + * @param {Object} github - GitHub client (Octokit instance) + * @param {string} projectId - Project node ID + * @param {string} contentId - Content node ID (issue or PR) + * @returns {Promise<{id: string} | null>} Existing project item or null if not found + */ +async function findExistingItemByContentId(github, projectId, contentId) { + let hasNextPage = true; + let endCursor = null; + + while (hasNextPage) { + const result = await github.graphql( + `query($projectId: ID!, $after: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $after) { + nodes { + id + content { + ... on Issue { + id + } + ... on PullRequest { + id + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + }`, + { projectId, after: endCursor } + ); + + if (!result?.node?.items) { + core.warning(`Project ${projectId} not found or inaccessible; stopping item search.`); + break; + } + + const found = result.node.items.nodes.find(item => item.content?.id === contentId); + if (found) return found; + + hasNextPage = result.node.items.pageInfo.hasNextPage; + endCursor = result.node.items.pageInfo.endCursor; + } + + return null; +} + +/** + * Infer the expected GraphQL data type for a project field based on its name and value. + * @param {string} fieldName - Field name from YAML + * @param {unknown} fieldValue - Field value from YAML + * @param {RegExp} datePattern - Pattern to validate date values (YYYY-MM-DD) + * @returns {"DATE" | "TEXT" | "SINGLE_SELECT"} + */ +function inferFieldDataType(fieldName, fieldValue, datePattern) { + const isDateField = fieldName.toLowerCase().includes("date"); + // "classification" is always treated as a free-text field rather than single-select; + // pipe-delimited values ("A|B|C") also signal a free-text field storing multi-option strings. + const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); + if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { + return "DATE"; + } + if (isTextField) { + return "TEXT"; + } + return "SINGLE_SELECT"; +} + +/** + * Apply field value updates to a project item, creating fields as needed. + * @param {Object} github - GitHub client (Octokit instance) + * @param {string} projectId - Project node ID + * @param {string} itemId - Project item node ID + * @param {Record} fields - Field name/value pairs to update + * @returns {Promise} + */ +async function applyFieldUpdates(github, projectId, itemId, fields) { + const projectFields = await fetchAllProjectFields(github, projectId); + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + + for (const [fieldName, fieldValue] of Object.entries(fields)) { + const normalizedFieldName = fieldName + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + + if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { + continue; + } + + const expectedDataType = inferFieldDataType(fieldName, fieldValue, datePattern); + const isDateField = fieldName.toLowerCase().includes("date"); + const isTextField = expectedDataType === "TEXT"; + + if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { + continue; + } + + if (!field) { + if (isDateField) { + if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + dataType + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "DATE" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } else { + core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); + continue; + } + } else if (isTextField) { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType + }) { + projectV2Field { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "TEXT" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } else { + try { + field = ( + await github.graphql( + `mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { + createProjectV2Field(input: { + projectId: $projectId, + name: $name, + dataType: $dataType, + singleSelectOptions: $options + }) { + projectV2Field { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + ... on ProjectV2Field { + id + name + } + } + } + }`, + { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); + continue; + } + } + } + + if (!field) { + core.warning(`Field "${fieldName}" could not be created or resolved; skipping.`); + continue; + } + + let valueToSet; + if (field.dataType === "DATE") { + valueToSet = { date: String(fieldValue) }; + } else if (field.dataType === "NUMBER") { + const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); + if (isNaN(numValue)) { + core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); + continue; + } + valueToSet = { number: numValue }; + } else if (field.dataType === "ITERATION") { + if (!field.configuration?.iterations) { + core.warning(`Iteration field "${fieldName}" has no configured iterations`); + continue; + } + const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); + if (!iteration) { + const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); + core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); + continue; + } + valueToSet = { iterationId: iteration.id }; + } else if (field.options) { + const option = field.options.find(o => o.name.toLowerCase() === String(fieldValue).toLowerCase()); + if (!option) { + const availableOptions = field.options.map(o => o.name).join(", "); + core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); + continue; + } + valueToSet = { singleSelectOptionId: option.id }; + } else { + valueToSet = { text: String(fieldValue) }; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: $value + }) { + projectV2Item { + id + } + } + }`, + { projectId, itemId, fieldId: field.id, value: valueToSet } + ); + } +} + /** * Fetch all fields for a GitHub Project v2, paginating through all results. * @param {Object} github - GitHub client (Octokit instance) @@ -843,126 +1102,8 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } } - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = await fetchAllProjectFields(github, projectId); - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - - // Check if field name conflicts with unsupported built-in types - if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { - continue; - } - - // Detect expected field type based on field name and value heuristics - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); - const isTextField = "classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")); - let expectedDataType; - if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { - expectedDataType = "DATE"; - } else if (isTextField) { - expectedDataType = "TEXT"; - } else { - expectedDataType = "SINGLE_SELECT"; - } - - // Check for type mismatch if field already exists - if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { - continue; // Skip fields with unsupported built-in types - } - - if (!field) - if (fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date")) { - // Check if field name suggests it's a date field (e.g., start_date, end_date, due_date) - // Date field values must match ISO 8601 format (YYYY-MM-DD) - if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "DATE" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - } else { - core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); - continue; - } - } else if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) }; - else if (field.dataType === "NUMBER") { - // NUMBER fields use ProjectV2FieldValue input type with number property - // The number value must be a valid float or integer - // Convert string values to numbers if needed - const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); - if (isNaN(numValue)) { - core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); - continue; - } - valueToSet = { number: numValue }; - } else if (field.dataType === "ITERATION") { - // ITERATION fields use ProjectV2FieldValue input type with iterationId property - // The value should match an iteration title or ID - if (!field.configuration || !field.configuration.iterations) { - core.warning(`Iteration field "${fieldName}" has no configured iterations`); - continue; - } - // Try to find iteration by title (case-insensitive match) - const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); - if (!iteration) { - const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); - core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); - continue; - } - valueToSet = { iterationId: iteration.id }; - } else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) { - // GitHub's GraphQL API does not support adding new options to existing single-select fields - // The updateProjectV2Field mutation does not exist - users must add options manually via UI - const availableOptions = field.options.map(o => o.name).join(", "); - core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } + if (output.fields && Object.keys(output.fields).length > 0) { + await applyFieldUpdates(github, projectId, itemId, output.fields); } core.setOutput("item-id", itemId); @@ -976,13 +1117,13 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = draftItemId: itemId, }; } + let contentNumber = null; if (hasContentNumber || hasIssue || hasPullRequest) { const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request; - const sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); + const sanitizedContentNumber = rawContentNumber == null ? "" : typeof rawContentNumber === "number" ? rawContentNumber.toString() : String(rawContentNumber).trim(); if (sanitizedContentNumber) { - // Try to resolve as temporary ID first const resolved = resolveIssueNumber(sanitizedContentNumber, temporaryIdMap); if (resolved.wasTemporaryId) { @@ -992,7 +1133,6 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.info(`✓ Resolved temporary ID ${sanitizedContentNumber} to issue #${resolved.resolved.number}`); contentNumber = resolved.resolved.number; } else { - // Not a temporary ID - validate as numeric if (!/^\d+$/.test(sanitizedContentNumber)) { throw new Error(`${ERR_VALIDATION}: Invalid content number "${rawContentNumber}". Provide a positive integer or a valid temporary ID (format: aw_ followed by 3-12 alphanumeric characters).`); } @@ -1002,165 +1142,57 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = core.warning("Content number field provided but empty; skipping project item update."); } } - if (null !== contentNumber) { - const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", - contentQuery = - "Issue" === contentType - ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n }\n }\n }" - : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n }\n }\n }", - contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }), - contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest, - contentId = contentData.id, - existingItem = await (async function (projectId, contentId) { - let hasNextPage = !0, - endCursor = null; - for (; hasNextPage; ) { - const result = await github.graphql( - "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", - { projectId, after: endCursor } - ), - found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); - if (found) return found; - ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); - } - return null; - })(projectId, contentId); + + if (contentNumber !== null) { + const contentType = output.content_type === "pull_request" ? "PullRequest" : output.content_type === "issue" || output.issue ? "Issue" : "PullRequest"; + const contentQuery = + contentType === "Issue" + ? `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + id + } + } + }` + : `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + } + } + }`; + const contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }); + const contentData = contentType === "Issue" ? contentResult.repository.issue : contentResult.repository.pullRequest; + if (!contentData) { + throw new Error(`${ERR_VALIDATION}: ${contentType} #${contentNumber} not found in ${contentOwner}/${targetRepo}.`); + } + const contentId = contentData.id; + const existingItem = await findExistingItemByContentId(github, projectId, contentId); + let itemId; - if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); - else { + if (existingItem) { + itemId = existingItem.id; + core.info("✓ Item already on board"); + } else { itemId = ( await github.graphql( - "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { + projectId: $projectId, + contentId: $contentId + }) { + item { + id + } + } + }`, { projectId, contentId } ) ).addProjectV2ItemById.item.id; } - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = await fetchAllProjectFields(github, projectId); - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - - // Check if field name conflicts with unsupported built-in types - if (isUnsupportedBuiltInFieldType(fieldName, normalizedFieldName)) { - continue; - } - - // Detect expected field type based on field name and value heuristics - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - const isDateField = fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date"); - const isTextField = "classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|")); - let expectedDataType; - if (isDateField && typeof fieldValue === "string" && datePattern.test(fieldValue)) { - expectedDataType = "DATE"; - } else if (isTextField) { - expectedDataType = "TEXT"; - } else { - expectedDataType = "SINGLE_SELECT"; - } - // Check for type mismatch if field already exists - if (checkFieldTypeMismatch(fieldName, field, expectedDataType)) { - continue; // Skip fields with unsupported built-in types - } - - if (!field) - if (fieldName.toLowerCase().includes("_date") || fieldName.toLowerCase().includes("date")) { - // Check if field name suggests it's a date field (e.g., start_date, end_date, due_date) - // Date field values must match ISO 8601 format (YYYY-MM-DD) - if (typeof fieldValue === "string" && datePattern.test(fieldValue)) { - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "DATE" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create date field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - } else { - core.warning(`Field "${fieldName}" looks like a date field but value "${fieldValue}" is not in YYYY-MM-DD format. Skipping field creation.`); - continue; - } - } else if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${getErrorMessage(createError)}`); - continue; - } - // Check dataType first to properly handle DATE fields before checking for options - // This prevents date fields from being misidentified as single-select fields - if (field.dataType === "DATE") { - // Date fields use ProjectV2FieldValue input type with date property - // The date value must be in ISO 8601 format (YYYY-MM-DD) with no time component - // Unlike other field types that may require IDs, date fields accept the date string directly - valueToSet = { date: String(fieldValue) }; - } else if (field.dataType === "NUMBER") { - // NUMBER fields use ProjectV2FieldValue input type with number property - // The number value must be a valid float or integer - // Convert string values to numbers if needed - const numValue = typeof fieldValue === "number" ? fieldValue : parseFloat(String(fieldValue)); - if (isNaN(numValue)) { - core.warning(`Invalid number value "${fieldValue}" for field "${fieldName}"`); - continue; - } - valueToSet = { number: numValue }; - } else if (field.dataType === "ITERATION") { - // ITERATION fields use ProjectV2FieldValue input type with iterationId property - // The value should match an iteration title or ID - if (!field.configuration || !field.configuration.iterations) { - core.warning(`Iteration field "${fieldName}" has no configured iterations`); - continue; - } - // Try to find iteration by title (case-insensitive match) - const iteration = field.configuration.iterations.find(iter => iter.title.toLowerCase() === String(fieldValue).toLowerCase()); - if (!iteration) { - const availableIterations = field.configuration.iterations.map(i => i.title).join(", "); - core.warning(`Iteration "${fieldValue}" not found in field "${fieldName}". Available iterations: ${availableIterations}`); - continue; - } - valueToSet = { iterationId: iteration.id }; - } else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) { - // GitHub's GraphQL API does not support adding new options to existing single-select fields - // The updateProjectV2Field mutation does not exist - users must add options manually via UI - const availableOptions = field.options.map(o => o.name).join(", "); - core.warning(`Option "${fieldValue}" not found in field "${fieldName}". Available options: ${availableOptions}. To add this option, please update the field manually in the GitHub Projects UI.`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } + if (output.fields && Object.keys(output.fields).length > 0) { + await applyFieldUpdates(github, projectId, itemId, output.fields); } core.setOutput("item-id", itemId); @@ -1395,8 +1427,7 @@ async function main(config = {}, githubClient = null) { viewsCreated = true; core.info(`Creating ${configuredViews.length} configured view(s) on project: ${firstProjectUrl}`); - for (let i = 0; i < configuredViews.length; i++) { - const viewConfig = configuredViews[i]; + for (const [i, viewConfig] of configuredViews.entries()) { try { // Create a synthetic output item for view creation const viewOutput = { @@ -1447,4 +1478,4 @@ async function main(config = {}, githubClient = null) { }; } -module.exports = { updateProject, parseProjectInput, main }; +module.exports = { updateProject, parseProjectInput, main, normalizeUpdateProjectOutput, summarizeProjectsV2, summarizeEmptyProjectsV2List, inferFieldDataType }; diff --git a/setup/js/validate_lockdown_requirements.cjs b/setup/js/validate_lockdown_requirements.cjs index 14f74a9..d69d45f 100644 --- a/setup/js/validate_lockdown_requirements.cjs +++ b/setup/js/validate_lockdown_requirements.cjs @@ -1,5 +1,7 @@ // @ts-check +const { renderLockdownTokenErrorMessage, renderPublicStrictModeErrorMessage, renderPullRequestTargetErrorMessage } = require("./validate_lockdown_requirements_templates.cjs"); + /** * Validates that lockdown mode requirements are met at runtime. * @@ -22,8 +24,17 @@ * @param {any} core - GitHub Actions core library * @returns {void} */ -const { ERR_VALIDATION } = require("./error_codes.cjs"); function validateLockdownRequirements(core) { + /** + * @param {string} message + * @returns {never} + */ + function failWithError(message) { + core.setOutput("lockdown_check_failed", "true"); + core.setFailed(message); + throw new Error(message); + } + // Check if lockdown mode is explicitly enabled (set to "true" in frontmatter) const lockdownEnabled = process.env.GITHUB_MCP_LOCKDOWN_EXPLICIT === "true"; @@ -46,22 +57,7 @@ function validateLockdownRequirements(core) { core.info(`Custom github-token configured: ${hasCustomToken}`); if (!hasAnyCustomToken) { - const errorMessage = - "Lockdown mode is enabled (lockdown: true) but no custom GitHub token is configured.\\n" + - "\\n" + - "Please configure one of the following as a repository secret:\\n" + - " - GH_AW_GITHUB_TOKEN (recommended)\\n" + - " - GH_AW_GITHUB_MCP_SERVER_TOKEN (alternative)\\n" + - " - Custom github-token in your workflow frontmatter\\n" + - "\\n" + - "See: https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/auth.mdx\\n" + - "\\n" + - "To set a token:\\n" + - ' gh aw secrets set GH_AW_GITHUB_TOKEN --value "YOUR_FINE_GRAINED_PAT"'; - - core.setOutput("lockdown_check_failed", "true"); - core.setFailed(errorMessage); - throw new Error(errorMessage); + failWithError(renderLockdownTokenErrorMessage()); } core.info("✓ Lockdown mode requirements validated: Custom GitHub token is configured"); @@ -77,20 +73,7 @@ function validateLockdownRequirements(core) { core.info(`Compiled with strict mode: ${isStrict}`); if (isPublic && !isStrict) { - const errorMessage = - "This workflow is running on a public repository but was not compiled with strict mode.\\n" + - "\\n" + - "Public repository workflows must be compiled with strict mode enabled to meet\\n" + - "the security requirements for public exposure.\\n" + - "\\n" + - "To fix this, recompile the workflow with strict mode:\\n" + - " gh aw compile --strict\\n" + - "\\n" + - "See: https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/security.mdx"; - - core.setOutput("lockdown_check_failed", "true"); - core.setFailed(errorMessage); - throw new Error(errorMessage); + failWithError(renderPublicStrictModeErrorMessage()); } if (isPublic && isStrict) { @@ -104,20 +87,7 @@ function validateLockdownRequirements(core) { // and potentially exfiltrate secrets or cause unintended side effects. const eventName = process.env.GITHUB_EVENT_NAME; if (isPublic && eventName === "pull_request_target") { - const errorMessage = - "This workflow is triggered by the pull_request_target event on a public repository.\\n" + - "\\n" + - "The pull_request_target event is not allowed on public repositories because it runs\\n" + - "workflows with access to repository secrets even when triggered from a fork, which\\n" + - 'creates a significant security risk (known as a "pwn request").\\n' + - "\\n" + - "To fix this, use the pull_request event instead, or migrate to a private repository.\\n" + - "\\n" + - "See: https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/security.mdx"; - - core.setOutput("lockdown_check_failed", "true"); - core.setFailed(errorMessage); - throw new Error(errorMessage); + failWithError(renderPullRequestTargetErrorMessage()); } } diff --git a/setup/js/validate_lockdown_requirements_templates.cjs b/setup/js/validate_lockdown_requirements_templates.cjs new file mode 100644 index 0000000..ac564c2 --- /dev/null +++ b/setup/js/validate_lockdown_requirements_templates.cjs @@ -0,0 +1,59 @@ +// @ts-check + +const { renderTemplate } = require("./messages_core.cjs"); + +const LOCKDOWN_TOKEN_ERROR_TEMPLATE = `Lockdown mode is enabled (lockdown: true) but no custom GitHub token is configured. + +Please configure one of the following as a repository secret: + - GH_AW_GITHUB_TOKEN (recommended) + - GH_AW_GITHUB_MCP_SERVER_TOKEN (alternative) + - Custom github-token in your workflow frontmatter + +See: {auth_docs_url} + +To set a token: + gh aw secrets set GH_AW_GITHUB_TOKEN --value "YOUR_FINE_GRAINED_PAT"`; + +const PUBLIC_STRICT_MODE_ERROR_TEMPLATE = `This workflow is running on a public repository but was not compiled with strict mode. + +Public repository workflows must be compiled with strict mode enabled to meet +the security requirements for public exposure. + +To fix this, recompile the workflow with strict mode: + {strict_compile_command} + +See: {security_docs_url}`; + +const PULL_REQUEST_TARGET_ERROR_TEMPLATE = `This workflow is triggered by the pull_request_target event on a public repository. + +The pull_request_target event is not allowed on public repositories because it runs +workflows with access to repository secrets even when triggered from a fork, which +creates a significant security risk (known as a "pwn request"). + +To fix this, use the pull_request event instead, or migrate to a private repository. + +See: {security_docs_url}`; + +const TEMPLATE_CONTEXT = { + auth_docs_url: "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/auth.mdx", + security_docs_url: "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/security.mdx", + strict_compile_command: "gh aw compile --strict", +}; + +function renderLockdownTokenErrorMessage() { + return renderTemplate(LOCKDOWN_TOKEN_ERROR_TEMPLATE, TEMPLATE_CONTEXT); +} + +function renderPublicStrictModeErrorMessage() { + return renderTemplate(PUBLIC_STRICT_MODE_ERROR_TEMPLATE, TEMPLATE_CONTEXT); +} + +function renderPullRequestTargetErrorMessage() { + return renderTemplate(PULL_REQUEST_TARGET_ERROR_TEMPLATE, TEMPLATE_CONTEXT); +} + +module.exports = { + renderLockdownTokenErrorMessage, + renderPublicStrictModeErrorMessage, + renderPullRequestTargetErrorMessage, +}; diff --git a/setup/js/vitest.artifact-integration.config.mjs b/setup/js/vitest.artifact-integration.config.mjs new file mode 100644 index 0000000..47879ad --- /dev/null +++ b/setup/js/vitest.artifact-integration.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + include: ["artifact_client_live_api.test.cjs"], + // Allow enough time for real network I/O against GitHub's artifact storage. + testTimeout: 120000, + hookTimeout: 10000, + }, +}); diff --git a/setup/js/vitest.integration.config.mjs b/setup/js/vitest.integration.config.mjs index 9d46fbe..c18b5d1 100644 --- a/setup/js/vitest.integration.config.mjs +++ b/setup/js/vitest.integration.config.mjs @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: "node", globals: true, - include: ["frontmatter_hash_github_api.test.cjs"], + include: ["frontmatter_hash_github_api.test.cjs", "set_issue_field_api_query.integration.test.cjs"], testTimeout: 30000, hookTimeout: 10000, }, diff --git a/setup/js/write_daily_aic_usage_cache.cjs b/setup/js/write_daily_aic_usage_cache.cjs new file mode 100644 index 0000000..bb9f074 --- /dev/null +++ b/setup/js/write_daily_aic_usage_cache.cjs @@ -0,0 +1,151 @@ +// @ts-check +/// + +/** + * write_daily_aic_usage_cache.cjs + * + * Called from the conclusion job to record this run's AI Credits consumption in the + * per-workflow usage cache. The cache is later restored in the activation job so the + * daily-AIC guardrail can look up prior run costs without re-downloading artifacts. + * + * Requires setupGlobals() to have been called first (sets global.core). + */ + +const fs = require("fs"); +const path = require("path"); + +const { findJSONLFiles, sumAICFromUsageJSONLFiles } = require("./daily_aic_workflow_helpers.cjs"); +const { getErrorMessage } = require("./error_helpers.cjs"); + +/** Path where the restored (and updated) usage cache lives on the runner. */ +const CACHE_FILE_PATH = "/tmp/gh-aw/agentic-workflow-usage-cache.jsonl"; + +/** Entries older than this threshold (in ms) are pruned when rewriting the cache. */ +const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; + +/** + * Directory prepared by the "Collect usage artifact files" step in the conclusion job. + * Contains agent_usage.jsonl and agent/token_usage.jsonl which mirror the contents of + * the "usage" artifact that getRunAIC() downloads during the daily-AIC guardrail check. + */ +const USAGE_DIR = "/tmp/gh-aw/usage"; + +/** + * @param {string} message + * @param {Record} [details] + */ +function logCache(message, details) { + const suffix = + details && Object.keys(details).length > 0 + ? ": " + + (() => { + try { + return JSON.stringify(details); + } catch { + return "{}"; + } + })() + : ""; + core.info(`[daily-aic-cache] ${message}${suffix}`); +} + +/** + * Appends a `{run_id, aic, timestamp}` JSONL entry to the cache file, preserving any existing + * entries that were restored from the previous cache snapshot and are within the 48-hour + * retention window. Entries older than {@link CACHE_RETENTION_MS} are pruned to keep the + * cache file bounded. + * + * @param {string} [cacheFilePath] Override the cache file path (defaults to {@link CACHE_FILE_PATH}; useful in tests). + * @param {string} [usageDir] Override the usage directory (defaults to {@link USAGE_DIR}; useful in tests). + * @returns {Promise} + */ +async function mainWithPaths(cacheFilePath, usageDir) { + const cachePath = cacheFilePath || CACHE_FILE_PATH; + const usageDirPath = usageDir || USAGE_DIR; + try { + const runId = Number(process.env.GITHUB_RUN_ID || 0); + if (!runId) { + core.warning("[daily-aic-cache] GITHUB_RUN_ID not set; skipping cache write."); + return; + } + + // Compute AIC from the usage JSONL files prepared by buildUsageArtifactUploadSteps. + const usageFiles = findJSONLFiles(usageDirPath); + logCache("Scanning usage JSONL files", { dir: usageDirPath, count: usageFiles.length, files: usageFiles }); + const aic = sumAICFromUsageJSONLFiles(usageFiles); + logCache("Computed AIC for current run", { runId, aic }); + + // Skip writing a non-finite or negative AIC: those values indicate an unexpected computation + // error. A zero AIC is written intentionally — it records runs where the agent was blocked + // (e.g. daily-AIC guardrail exceeded) so that subsequent activations do not waste an + // artifact-download round-trip trying to re-fetch usage data that does not exist. + if (!Number.isFinite(aic) || aic < 0) { + core.warning(`[daily-aic-cache] Computed AIC is ${aic} (negative or non-finite); skipping cache write.`); + return; + } + + // Read existing cache content (restored from the previous run's cache snapshot, if any). + // Entries with a `timestamp` older than CACHE_RETENTION_MS are pruned to keep the file + // bounded. Entries without a `timestamp` (written by an older version of this script) + // are preserved for backward compatibility. + /** @type {string[]} */ + let keptLines = []; + try { + if (fs.existsSync(cachePath)) { + const raw = fs.readFileSync(cachePath, "utf8").trimEnd(); + const now = Date.now(); + const cutoff = now - CACHE_RETENTION_MS; + let total = 0; + let pruned = 0; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + total++; + try { + const entry = JSON.parse(line); + if (typeof entry?.timestamp === "string") { + const ts = Date.parse(entry.timestamp); + if (Number.isFinite(ts) && ts < cutoff) { + pruned++; + continue; + } + } + keptLines.push(line); + } catch { + // Preserve lines that cannot be parsed (defensive: avoids data loss). + keptLines.push(line); + } + } + logCache("Loaded existing cache entries", { path: cachePath, total, kept: keptLines.length, pruned }); + } else { + logCache("No existing cache file found; starting fresh", { path: cachePath }); + } + } catch (readErr) { + core.warning(`[daily-aic-cache] Could not read existing cache file: ${getErrorMessage(readErr)}`); + } + + // Build the updated JSONL content. + const newEntry = JSON.stringify({ run_id: runId, aic, timestamp: new Date().toISOString() }); + const updatedContent = keptLines.length > 0 ? `${keptLines.join("\n")}\n${newEntry}\n` : `${newEntry}\n`; + + // Ensure the directory exists and write the updated file. + const dir = path.dirname(cachePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(cachePath, updatedContent, "utf8"); + logCache("Wrote cache entry", { runId, aic, path: cachePath }); + } catch (error) { + // Non-fatal: a cache write failure should never block the conclusion job. + core.warning(`[daily-aic-cache] Failed to write usage cache: ${getErrorMessage(error)}`); + } +} + +/** + * Entry point called from the GitHub Actions step. + * + * @returns {Promise} + */ +async function main() { + return mainWithPaths(); +} + +module.exports = { main, mainWithPaths }; diff --git a/setup/md/agent_failure_issue.md b/setup/md/agent_failure_issue.md index a2162cf..df928f9 100644 --- a/setup/md/agent_failure_issue.md +++ b/setup/md/agent_failure_issue.md @@ -10,6 +10,7 @@ **Assign this issue to an agent** to debug and fix the issue. +{optimize_token_consumption_context}
Debug with any coding agent diff --git a/setup/md/cache_memory_miss.md b/setup/md/cache_memory_miss.md index 6b7fc09..fd8bd72 100644 --- a/setup/md/cache_memory_miss.md +++ b/setup/md/cache_memory_miss.md @@ -1,8 +1,10 @@ > [!WARNING] >
-> Cache Configuration Problem: cache miss detected despite cache-memory being configured. +> Cache Configuration Problem: cache miss detected after cache restore succeeded. > -> The agent reported a cache miss (`missing_data` with `reason: cache_memory_miss`) even though cache-memory is configured and was available. This likely indicates the prompt is misconfigured and the agent cannot locate the correct file path within the cache directory. +> The agent reported a cache miss (`missing_data` with `reason: cache_memory_miss`) after the workflow successfully restored cache-memory for this run. This likely indicates the prompt is misconfigured and the agent cannot locate the correct file path within the cache directory. +> +> This warning is shown only when a cache restore matched an existing key. Cache misses can be expected on first runs and on branches where `actions/cache` has no visible entries due to branch scoping. > > Review the [cache-memory configuration](https://github.github.com/gh-aw/reference/cache-memory/) and ensure the agent prompt correctly references files inside the cache directory. > diff --git a/setup/md/optimize_token_consumption_context.md b/setup/md/optimize_token_consumption_context.md new file mode 100644 index 0000000..df596d1 --- /dev/null +++ b/setup/md/optimize_token_consumption_context.md @@ -0,0 +1,13 @@ +
+Optimize token consumption + +This failure was triggered by a guardrail limit ({guardrail_name}). Use this prompt with any coding agent (GitHub Copilot, Claude, Gemini, etc.) to analyze token usage and reduce costs: + +```` +Optimize the agentic workflow token consumption using https://raw.githubusercontent.com/github/gh-aw/main/optimize.md + +The workflow run is at {run_url} +```` + +
+ diff --git a/setup/md/tool_denials_exceeded_context.md b/setup/md/tool_denials_exceeded_context.md index 399a17b..474e0ec 100644 --- a/setup/md/tool_denials_exceeded_context.md +++ b/setup/md/tool_denials_exceeded_context.md @@ -11,6 +11,13 @@
+
+Last 5 tool calls + +{recent_tool_calls_list} + +
+ This is a structured guardrail event (`guard.tool_denials_exceeded`) captured in `events.jsonl`.
diff --git a/setup/setup.sh b/setup/setup.sh index e13caa6..b176581 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -374,8 +374,12 @@ else echo "::warning::Safe-outputs MCP entry point not found: safe-outputs-mcp-server.cjs" fi -# Copy safe_outputs_tools.json to tools.json (required by safe-outputs server) +# Copy safe_outputs_tools.json to both canonical and runtime names if [ -f "${JS_SOURCE_DIR}/safe_outputs_tools.json" ]; then + cp "${JS_SOURCE_DIR}/safe_outputs_tools.json" "${SAFE_OUTPUTS_DEST}/safe_outputs_tools.json" + debug_log "Copied safe-outputs tools definition: safe_outputs_tools.json" + SAFE_OUTPUTS_COUNT=$((SAFE_OUTPUTS_COUNT + 1)) + cp "${JS_SOURCE_DIR}/safe_outputs_tools.json" "${SAFE_OUTPUTS_DEST}/tools.json" debug_log "Copied safe-outputs tools definition: tools.json" SAFE_OUTPUTS_COUNT=$((SAFE_OUTPUTS_COUNT + 1))