From fcc63b0f7300b1590900dfc45e761b49a63f88e3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:52:23 +0000 Subject: [PATCH] chore: sync actions from gh-aw@v0.79.4 --- setup/js/ai_credits_context.cjs | 25 ++++ setup/js/assign_milestone.cjs | 47 ++++--- .../js/check_daily_aic_workflow_guardrail.cjs | 16 ++- setup/js/create_check_run.cjs | 102 +++++++++++--- setup/js/generate_aw_info.cjs | 2 + setup/js/handle_agent_failure.cjs | 54 +++++++- setup/js/merge_frontmatter_models.cjs | 129 ++++++++++++++++++ setup/js/messages_footer.cjs | 67 ++++++++- setup/js/model_costs.cjs | 2 +- setup/js/safe_outputs_tools.json | 24 +++- setup/js/send_otlp_span.cjs | 43 +++++- 11 files changed, 450 insertions(+), 61 deletions(-) create mode 100644 setup/js/merge_frontmatter_models.cjs diff --git a/setup/js/ai_credits_context.cjs b/setup/js/ai_credits_context.cjs index 4ff23b4..f1b6ef2 100644 --- a/setup/js/ai_credits_context.cjs +++ b/setup/js/ai_credits_context.cjs @@ -284,6 +284,31 @@ function resolveAICreditsFailureState() { const { aiCredits: auditAICredits, maxAICredits: auditMaxAICredits, rateLimitError: auditRateLimitError, maxAICreditsExceeded: auditMaxAICreditsExceeded } = parseAuditLogCombined(); const envAICredits = parsePositiveNumberString(process.env.GH_AW_AIC); const envMaxAICredits = parsePositiveNumberString(process.env.GH_AW_MAX_AI_CREDITS); + + // Log provenance so failing issues can be diagnosed when credit data is missing. + if (auditAICredits) { + console.log(`[ai-credits] aiCredits source=audit_log value=${auditAICredits}`); + } else if (stdioSignals.aiCredits) { + console.log(`[ai-credits] aiCredits source=agent_stdio value=${stdioSignals.aiCredits}`); + } else if (envAICredits) { + console.log(`[ai-credits] aiCredits source=env(GH_AW_AIC) value=${envAICredits}`); + } else { + console.log(`[ai-credits] aiCredits source=none GH_AW_AIC=${process.env.GH_AW_AIC || "(unset)"}`); + } + + if (auditMaxAICredits) { + console.log(`[ai-credits] maxAICredits source=audit_log value=${auditMaxAICredits}`); + } else if (stdioSignals.maxAICredits) { + console.log(`[ai-credits] maxAICredits source=agent_stdio value=${stdioSignals.maxAICredits}`); + } else if (envMaxAICredits) { + console.log(`[ai-credits] maxAICredits source=env(GH_AW_MAX_AI_CREDITS) value=${envMaxAICredits}`); + } else { + console.log(`[ai-credits] maxAICredits source=none GH_AW_MAX_AI_CREDITS=${process.env.GH_AW_MAX_AI_CREDITS || "(unset)"}`); + } + + const rawRateLimitSignalSource = auditRateLimitError ? "audit_log" : stdioSignals.rateLimitError ? "agent_stdio" : process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true" ? "env(GH_AW_AI_CREDITS_RATE_LIMIT_ERROR)" : "none"; + console.log(`[ai-credits] rateLimitSignal source=${rawRateLimitSignalSource}`); + const aiCredits = auditAICredits || stdioSignals.aiCredits || envAICredits || ""; const maxAICredits = auditMaxAICredits || stdioSignals.maxAICredits || envMaxAICredits || ""; const rawAICreditsRateLimitError = auditRateLimitError || stdioSignals.rateLimitError || process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR === "true"; diff --git a/setup/js/assign_milestone.cjs b/setup/js/assign_milestone.cjs index dc13f6d..d14dc99 100644 --- a/setup/js/assign_milestone.cjs +++ b/setup/js/assign_milestone.cjs @@ -67,34 +67,41 @@ async function main(config = {}) { // Track how many items we've processed for max limit let processedCount = 0; - // Cached results from paginated title searches - /** @type {Map} */ + // Cached results from paginated title searches, scoped by "owner/repo" to prevent + // cross-repo cache pollution in multi-repo configurations. + /** @type {Map} key: "owner/repo::title" */ const milestoneByTitle = new Map(); - /** @type {Array} All milestones fetched so far (for error messages) */ - let allFetchedMilestones = []; - let milestonesExhausted = false; + /** @type {Set} entries: "owner/repo" — repos whose milestones have been fully fetched */ + const exhaustedRepos = new Set(); + /** @type {Map>} key: "owner/repo" → milestones fetched for that repo */ + const fetchedMilestonesByRepo = new Map(); /** * Find a milestone by title using lazy paginated search with early exit. - * Results are cached so repeated lookups don't re-paginate. + * Results are cached per-repo so repeated lookups don't re-paginate, and + * cache entries from one repo never affect lookups for another repo. * @param {string} title * @param {string} owner * @param {string} repo * @returns {Promise} */ async function findMilestoneByTitle(title, owner, repo) { - if (milestoneByTitle.has(title)) { - return milestoneByTitle.get(title); + const repoKey = `${owner}/${repo}`; + const cacheKey = `${repoKey}::${title}`; + if (milestoneByTitle.has(cacheKey)) { + return milestoneByTitle.get(cacheKey); } - if (milestonesExhausted) { + if (exhaustedRepos.has(repoKey)) { return null; } + const repoMilestones = fetchedMilestonesByRepo.get(repoKey) || []; let found = false; await githubClient.paginate(githubClient.rest.issues.listMilestones, { owner, repo, state: "all", per_page: 100 }, (response, done) => { for (const m of response.data) { - if (!milestoneByTitle.has(m.title)) { - milestoneByTitle.set(m.title, m); - allFetchedMilestones.push(m); + const key = `${repoKey}::${m.title}`; + if (!milestoneByTitle.has(key)) { + milestoneByTitle.set(key, m); + repoMilestones.push(m); } if (m.title === title) { found = true; @@ -103,11 +110,12 @@ async function main(config = {}) { } } }); + fetchedMilestonesByRepo.set(repoKey, repoMilestones); if (!found) { - milestonesExhausted = true; + exhaustedRepos.add(repoKey); } - core.info(`Searched ${allFetchedMilestones.length} milestones (exhausted=${milestonesExhausted})`); - return milestoneByTitle.get(title) || null; + core.info(`Searched ${repoMilestones.length} milestones for ${repoKey} (exhausted=${exhaustedRepos.has(repoKey)})`); + return milestoneByTitle.get(cacheKey) || null; } /** @@ -202,11 +210,14 @@ async function main(config = {}) { title: milestoneTitle, }); milestoneNumber = created.data.number; - milestoneByTitle.set(created.data.title, created.data); - allFetchedMilestones.push(created.data); + const repoKey = `${milestoneOwner}/${milestoneRepo}`; + milestoneByTitle.set(`${repoKey}::${created.data.title}`, created.data); + const repoMilestones = fetchedMilestonesByRepo.get(repoKey) || []; + repoMilestones.push(created.data); + fetchedMilestonesByRepo.set(repoKey, repoMilestones); core.info(`Auto-created milestone "${milestoneTitle}" as #${milestoneNumber}`); } else { - const available = formatAvailableMilestones(allFetchedMilestones); + const available = formatAvailableMilestones(fetchedMilestonesByRepo.get(`${milestoneOwner}/${milestoneRepo}`) || []); core.warning(`Milestone "${milestoneTitle}" not found in repository. Available: ${available}. Set auto_create: true to create it automatically.`); return { success: false, diff --git a/setup/js/check_daily_aic_workflow_guardrail.cjs b/setup/js/check_daily_aic_workflow_guardrail.cjs index 34024b5..a61a533 100644 --- a/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -223,6 +223,12 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, .join("\n") : "| _none_ | — | — | 0 |"; + const noRunData = stats.count === 0; + const totalAICFormatted = formatAICCredits(stats.total) || "0"; + const avgAICFormatted = noRunData ? "—" : formatAICCredits(stats.average) || "0"; + const stddevAICFormatted = noRunData ? "—" : formatAICCredits(stats.stddev) || "0"; + const minMaxAICFormatted = noRunData ? "— / —" : `${formatAICCredits(stats.min)} / ${formatAICCredits(stats.max)}`; + const noteLines = []; if (meta.truncatedByRateLimit) { noteLines.push(`- Stopped early to preserve GitHub API rate limit headroom (${rateLimit.remaining} remaining, reserve ${RATE_LIMIT_RESERVE}).`); @@ -236,14 +242,14 @@ function renderDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, "", "| Statistic | Value |", "| --- | ---: |", - `| 24h total AIC | ${formatAICCredits(stats.total)} |`, + `| 24h total AIC | ${totalAICFormatted} |`, `| Threshold | ${formatAICCredits(threshold)} |`, `| Threshold used | ${usagePercent}% |`, - `| Remaining headroom | ${formatAICCredits(remainingBudget)} |`, + `| Remaining headroom | ${formatAICCredits(remainingBudget) || "0"} |`, `| Runs counted | ${formatInteger(stats.count)} |`, - `| Avg AIC / run | ${formatAICCredits(stats.average)} |`, - `| Std dev AIC | ${formatAICCredits(stats.stddev)} |`, - `| Min / Max AIC | ${formatAICCredits(stats.min)} / ${formatAICCredits(stats.max)} |`, + `| Avg AIC / run | ${avgAICFormatted} |`, + `| Std dev AIC | ${stddevAICFormatted} |`, + `| Min / Max AIC | ${minMaxAICFormatted} |`, `| API remaining | ${formatInteger(rateLimit.remaining)} / ${formatInteger(rateLimit.limit)} |`, `| API used | ${formatInteger(rateLimit.used)} |`, `| API reset | ${rateLimit.reset || "unknown"} |`, diff --git a/setup/js/create_check_run.cjs b/setup/js/create_check_run.cjs index 50ab743..a5a9fe1 100644 --- a/setup/js/create_check_run.cjs +++ b/setup/js/create_check_run.cjs @@ -7,7 +7,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); -const { isStagedMode } = require("./safe_output_helpers.cjs"); +const { isStagedMode, resolveTarget } = require("./safe_output_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); @@ -33,6 +33,7 @@ async function main(config = {}) { // Extract configuration const configuredName = config.name || ""; const maxCount = config.max != null ? Number(config.max) : 1; + const checkRunTarget = typeof config.target === "string" && config.target.trim() ? config.target.trim() : null; const githubClient = await createAuthenticatedGitHubClient(config); const isStaged = isStagedMode(config); @@ -51,7 +52,7 @@ async function main(config = {}) { defaultName = `${defaultName} (Result)`; } - core.info(`Create check run configuration: name="${defaultName}", max=${maxCount}`); + core.info(`Create check run configuration: name="${defaultName}", max=${maxCount}${checkRunTarget ? `, target=${checkRunTarget}` : ""}`); if (configOutputTitle) core.info(`Config output.title fallback set (${configOutputTitle.length} chars)`); if (configOutputSummary) core.info(`Config output.summary fallback set (${configOutputSummary.length} chars)`); @@ -111,42 +112,101 @@ async function main(config = {}) { const owner = context.repo.owner; const repo = context.repo.repo; + let headSha = ""; + let resolvedPrNumber = null; - // For pull_request events, GITHUB_SHA is the ephemeral merge commit SHA which is - // not visible in the PR checks UI or the GitHub mobile app. Use the actual PR head - // SHA from the event payload instead so the check run appears on the PR. - const prHeadSha = context.payload?.pull_request?.head?.sha; - const headSha = prHeadSha || process.env.GITHUB_SHA || context.sha; + if (checkRunTarget) { + const targetResult = resolveTarget({ + targetConfig: checkRunTarget, + item: message, + context, + itemType: HANDLER_TYPE, + supportsPR: false, + supportsIssue: false, + }); + if (!targetResult.success) { + if (targetResult.shouldFail) { + core.error(targetResult.error); + } else { + core.info(targetResult.error); + } + return { + success: false, + error: targetResult.error, + skipped: !targetResult.shouldFail, + }; + } - if (!headSha) { - const msg = "create_check_run: cannot determine commit SHA for check run"; - core.error(msg); - return { success: false, error: msg }; - } + resolvedPrNumber = targetResult.number; - if (prHeadSha) { - core.info(`Using PR head SHA ${prHeadSha} (pull_request event)`); + // Fetch the current PR head SHA via the API. We intentionally go through the API + // even when the context payload already carries a SHA (e.g. target: "triggering" on + // a pull_request event) so that we always use the most recent head in case the PR + // was force-pushed between the triggering event and when this handler runs. + // Skipped in staged mode — there is nothing to attach a real check run to. + if (!isStaged) { + try { + const { data: pullRequest } = await withRetry( + () => + githubClient.rest.pulls.get({ + owner, + repo, + pull_number: resolvedPrNumber, + }), + RATE_LIMIT_RETRY_CONFIG + ); + headSha = pullRequest?.head?.sha || ""; + if (!headSha) { + const msg = `create_check_run: pull request #${resolvedPrNumber} has no head SHA`; + core.error(msg); + return { success: false, error: msg }; + } + core.info(`Using PR #${resolvedPrNumber} head SHA ${headSha} (target=${checkRunTarget})`); + } catch (error) { + const errorMessage = getErrorMessage(error); + const msg = `Failed to resolve pull request for create_check_run: ${errorMessage}`; + core.error(msg); + return { success: false, error: msg }; + } + } + } else { + // For pull_request events, GITHUB_SHA is the ephemeral merge commit SHA which is + // not visible in the PR checks UI or the GitHub mobile app. Use the actual PR head + // SHA from the event payload instead so the check run appears on the PR. + const prHeadSha = context.payload?.pull_request?.head?.sha; + headSha = prHeadSha || process.env.GITHUB_SHA || context.sha; + if (prHeadSha) { + core.info(`Using PR head SHA ${prHeadSha} (pull_request event)`); + } } - const checkRunName = defaultName; - - core.info(`Creating check run "${checkRunName}" on ${owner}/${repo}@${headSha} with conclusion=${conclusion}`); - - // If in staged mode, preview without executing + // In staged mode, preview without making live API calls to create the actual check run. + // Include the resolved PR number in the preview when targeting a specific PR. if (isStaged) { - logStagedPreviewInfo(`Would create check run "${checkRunName}" with conclusion=${conclusion}, title="${resolvedTitle}"`); + const prSuffix = resolvedPrNumber != null ? ` targeting PR #${resolvedPrNumber}` : ""; + logStagedPreviewInfo(`Would create check run "${defaultName}"${prSuffix} with conclusion=${conclusion}, title="${resolvedTitle}"`); processedCount++; return { success: true, staged: true, previewInfo: { - name: checkRunName, + name: defaultName, conclusion, title: resolvedTitle, }, }; } + if (!headSha) { + const msg = "create_check_run: cannot determine commit SHA for check run"; + core.error(msg); + return { success: false, error: msg }; + } + + const checkRunName = defaultName; + + core.info(`Creating check run "${checkRunName}" on ${owner}/${repo}@${headSha} with conclusion=${conclusion}`); + try { const output = { title: resolvedTitle, diff --git a/setup/js/generate_aw_info.cjs b/setup/js/generate_aw_info.cjs index 951cf48..49e9b4d 100644 --- a/setup/js/generate_aw_info.cjs +++ b/setup/js/generate_aw_info.cjs @@ -8,6 +8,7 @@ const { generateWorkflowOverview } = require("./generate_workflow_overview.cjs") const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { validateContextVariables } = require("./validate_context_variables.cjs"); const validateLockdownRequirements = require("./validate_lockdown_requirements.cjs"); +const { writeMergedModelsJSON } = require("./merge_frontmatter_models.cjs"); /** * Generate aw_info.json with workflow run metadata. @@ -160,6 +161,7 @@ async function main(core, ctx) { // Write to /tmp/gh-aw directory to avoid inclusion in PR fs.mkdirSync(TMP_GH_AW_PATH, { recursive: true }); writeMergedModelMultipliers(core, tokenWeights); + writeMergedModelsJSON(core); const tmpPath = TMP_GH_AW_PATH + "/aw_info.json"; fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); diff --git a/setup/js/handle_agent_failure.cjs b/setup/js/handle_agent_failure.cjs index 93b4638..c37ddcf 100644 --- a/setup/js/handle_agent_failure.cjs +++ b/setup/js/handle_agent_failure.cjs @@ -25,6 +25,8 @@ const FAILURE_ISSUE_DEDUP_WINDOW_HOURS = 24; const FAILURE_ISSUE_CATEGORY_DAILY_CAP = 50; const FAILURE_ISSUE_WINDOW_MS = FAILURE_ISSUE_DEDUP_WINDOW_HOURS * 60 * 60 * 1000; const DEFAULT_OTEL_JSONL_PATH = "/tmp/gh-aw/otel.jsonl"; +/** Path to the failure categories file written by handle_agent_failure and read by the OTLP conclusion span. */ +const FAILURE_CATEGORIES_PATH = "/tmp/gh-aw/failure_categories.json"; const GITHUB_API_VERSION = "2022-11-28"; const COPILOT_SESSION_STATE_DIR = path.join(os.tmpdir(), "gh-aw", "sandbox", "agent", "logs", "copilot-session-state"); // Engine-side 429/rate-limit signatures: @@ -1347,6 +1349,31 @@ function buildTimeoutContext(isTimedOut, timeoutMinutes) { return "\n" + renderTemplateFromFile(templatePath, { current_minutes: currentMinutes, suggested_minutes: suggestedMinutes }); } +/** + * Determine whether engine-failure context should be included. + * Timeout outcomes should rely on dedicated timeout messaging instead. + * @param {string} agentConclusion + * @param {boolean} hasToolDenialsExceeded + * @param {boolean} isTimedOut + * @returns {boolean} + */ +function shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) { + return agentConclusion === "failure" && !hasToolDenialsExceeded && !isTimedOut; +} + +/** + * Determine whether issue create/update failed due to token permission limits. + * @param {unknown} error + * @returns {boolean} + */ +function isIssueWritePermissionError(error) { + /** @type {{status?: unknown} | null} */ + const typedError = error && typeof error === "object" ? error : null; + const status = Number(typedError?.status); + const message = getErrorMessage(error).toLowerCase(); + return status === 403 && (message.includes("resource not accessible by integration") || message.includes("resource not accessible by personal access token") || message.includes("insufficient permissions")); +} + /** * Build a context string when the Copilot CLI failed due to the token lacking inference access. * @param {boolean} hasInferenceAccessError - Whether an inference access error was detected @@ -2623,6 +2650,15 @@ async function main() { hasDailyAICExceeded, }); + // Persist failure categories so the OTLP conclusion span can record them + // as gh-aw.failure.categories for metrics and counting in OTLP backends. + try { + fs.mkdirSync(path.dirname(FAILURE_CATEGORIES_PATH), { recursive: true }); + fs.writeFileSync(FAILURE_CATEGORIES_PATH, JSON.stringify(failureCategories)); + } catch (writeError) { + core.warning(`Failed to write failure categories: ${getErrorMessage(writeError)}`); + } + core.info(`Checking for existing issue with precise failure metadata for title: "${issueTitle}"`); try { @@ -2724,7 +2760,7 @@ async function main() { // Suppress when tool-denials-exceeded is present: the engine termination is a // direct consequence of the SDK hitting the denial threshold, so the tool-denials // context is the more actionable signal. - const engineFailureContext = agentConclusion === "failure" && !hasToolDenialsExceeded ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; + const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; // Build timeout context const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes); @@ -2804,7 +2840,9 @@ async function main() { workflowSource, workflowSourceUrl: workflowSourceURL, historyUrl: historyUrl || undefined, + aiCredits, }; + core.info(`Generating failure comment footer with aiCredits context: ${aiCredits || "(none)"}`); const footer = getFooterAgentFailureCommentMessage(ctx); // Prepend detection caution alert (when present) so it appears first in the comment body @@ -2948,7 +2986,7 @@ async function main() { // Suppress when tool-denials-exceeded is present: the engine termination is a // direct consequence of the SDK hitting the denial threshold, so the tool-denials // context is the more actionable signal. - const engineFailureContext = agentConclusion === "failure" && !hasToolDenialsExceeded ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; + const engineFailureContext = shouldBuildEngineFailureContext(agentConclusion, hasToolDenialsExceeded, isTimedOut) ? buildEngineFailureContext({ suppressEngineRateLimit429: maxAICreditsExceeded }) : ""; // Build timeout context const timeoutContext = buildTimeoutContext(isTimedOut, timeoutMinutes); @@ -3029,7 +3067,9 @@ async function main() { workflowSource, workflowSourceUrl: workflowSourceURL, historyUrl: historyUrl || undefined, + aiCredits, }; + core.info(`Generating failure issue footer with aiCredits context: ${aiCredits || "(none)"}`); const footer = getFooterAgentFailureIssueMessage(ctx); const failureMatchMarker = generateFailureMatchMarker({ workflowId: workflowID, @@ -3077,7 +3117,11 @@ async function main() { await detectAndHandleFailureCascade(owner, repo, newIssue.data.number); } } catch (error) { - core.warning(`Failed to create or update failure tracking issue: ${getErrorMessage(error)}`); + if (isIssueWritePermissionError(error)) { + core.info(`Skipping failure tracking issue creation/update: token lacks issues:write permission (${getErrorMessage(error)})`); + } else { + core.warning(`Failed to create or update failure tracking issue: ${getErrorMessage(error)}`); + } // Don't fail the workflow if we can't create the issue } } catch (error) { @@ -3095,6 +3139,8 @@ module.exports = { buildStaleLockFileFailedContext, buildDailyAICExceededContext, buildTimeoutContext, + shouldBuildEngineFailureContext, + isIssueWritePermissionError, buildAssignCopilotFailureContext, buildEngineFailureContext, buildReportIncompleteContext, @@ -3126,4 +3172,6 @@ module.exports = { CASCADE_ROLLUP_LABEL, CASCADE_ROLLUP_TITLE, FAILURE_TITLE_PATTERN, + buildFailureMatchCategories, + FAILURE_CATEGORIES_PATH, }; diff --git a/setup/js/merge_frontmatter_models.cjs b/setup/js/merge_frontmatter_models.cjs new file mode 100644 index 0000000..41d7082 --- /dev/null +++ b/setup/js/merge_frontmatter_models.cjs @@ -0,0 +1,129 @@ +// @ts-check +/// + +const fs = require("fs"); +const path = require("path"); +const { TMP_GH_AW_PATH } = require("./constants.cjs"); + +const DEFAULT_MODELS_JSON_PATH = path.join(__dirname, "models.json"); +const MERGED_MODELS_JSON_PATH = `${TMP_GH_AW_PATH}/models.json`; + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Deep-merge a models overlay on top of a base models.json document. + * + * Both documents share the structure: + * { "providers": { "": { "models": { "": { "cost": { ... } } } } } } + * + * Merge rules: + * - providers present only in the base are kept unchanged. + * - providers present only in the overlay are added to the result. + * - when a provider exists in both, its models maps are merged: overlay models + * win over base models (override or fill gaps at the individual model level). + * - other top-level keys (if any) are passed through from base; overlay keys + * take precedence. + * + * @param {Record} base - parsed models.json content + * @param {Record} overlay - parsed frontmatter `models` content + * @returns {Record} merged document + */ +function mergeModelCosts(base, overlay) { + const result = { ...base }; + + const baseProviders = isPlainObject(base.providers) ? /** @type {Record} */ base.providers : {}; + const overlayProviders = isPlainObject(overlay.providers) ? /** @type {Record} */ overlay.providers : {}; + + if (Object.keys(overlayProviders).length === 0) { + return result; + } + + /** @type {Record} */ + const mergedProviders = { ...baseProviders }; + + for (const [providerName, overlayProvider] of Object.entries(overlayProviders)) { + if (!isPlainObject(overlayProvider)) { + // Scalar or array: take overlay value as-is. + mergedProviders[providerName] = overlayProvider; + continue; + } + + const baseProvider = isPlainObject(baseProviders[providerName]) ? /** @type {Record} */ baseProviders[providerName] : {}; + + const baseModels = isPlainObject(baseProvider.models) ? /** @type {Record} */ baseProvider.models : {}; + const overlayModels = isPlainObject(overlayProvider.models) ? /** @type {Record} */ overlayProvider.models : {}; + + mergedProviders[providerName] = { + ...baseProvider, + ...overlayProvider, + models: { ...baseModels, ...overlayModels }, + }; + } + + result.providers = mergedProviders; + return result; +} + +/** + * Read the base models.json and merge any frontmatter `models` overlay on top, then + * write the combined catalog to /tmp/gh-aw/models.json so the agent job can + * use it via GH_AW_MODELS_JSON_PATH. + * + * @param {typeof import('@actions/core')} core + */ +function writeMergedModelsJSON(core) { + const baseModelsPath = process.env.GH_AW_MODELS_JSON_SRC_PATH || DEFAULT_MODELS_JSON_PATH; + + /** @type {Record} */ + let base = {}; + if (fs.existsSync(baseModelsPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(baseModelsPath, "utf8")); + if (isPlainObject(parsed)) { + base = parsed; + } else { + core.warning(`models.json is not a JSON object at ${baseModelsPath}`); + } + } catch (error) { + core.warning(`Failed to parse models.json at ${baseModelsPath}: ${String(error)}`); + } + } else { + core.warning(`models.json not found at ${baseModelsPath}`); + } + + // Parse optional frontmatter `models` overlay from env var (serialized by the compiler). + const modelCostsEnv = process.env.GH_AW_INFO_MODEL_COSTS; + /** @type {Record} */ + let overlay = {}; + if (modelCostsEnv) { + try { + const parsed = JSON.parse(modelCostsEnv); + if (isPlainObject(parsed)) { + overlay = parsed; + } else { + core.warning("GH_AW_INFO_MODEL_COSTS must be a JSON object, ignoring"); + } + } catch { + core.warning(`Failed to parse GH_AW_INFO_MODEL_COSTS: ${modelCostsEnv}`); + } + } + + const merged = Object.keys(overlay).length > 0 ? mergeModelCosts(base, overlay) : base; + + fs.writeFileSync(MERGED_MODELS_JSON_PATH, JSON.stringify(merged), "utf8"); + core.info(`Generated merged models.json at: ${MERGED_MODELS_JSON_PATH}`); +} + +module.exports = { + isPlainObject, + mergeModelCosts, + writeMergedModelsJSON, + DEFAULT_MODELS_JSON_PATH, + MERGED_MODELS_JSON_PATH, +}; diff --git a/setup/js/messages_footer.cjs b/setup/js/messages_footer.cjs index 3ee2d48..7267768 100644 --- a/setup/js/messages_footer.cjs +++ b/setup/js/messages_footer.cjs @@ -42,6 +42,15 @@ function parsePositiveAIC(raw) { return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } +/** + * @param {number|string|undefined} raw + * @returns {number|undefined} + */ +function parseExplicitContextAIC(raw) { + const parsed = raw !== undefined && raw !== null && raw !== "" ? Number.parseFloat(String(raw)) : NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + /** * @param {string|undefined} raw * @returns {number|undefined} @@ -129,7 +138,7 @@ function getAICFromEnv() { * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow * @property {string} [historyUrl] - GitHub search URL for items created by this workflow (for the history link) * @property {string} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable - * @property {number} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) + * @property {number|string} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) * @property {string} [slashCommand] - Slash command name (without leading slash) for the run-again hint, when applicable * @property {string} [slashCommandPlaceholder] - Custom hint text appended after the command name (replaces default "to run again") @@ -165,10 +174,11 @@ function getFooterMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); const hasExplicitContextAIC = ctx.aiCredits !== undefined && ctx.aiCredits !== null; + const explicitContextAIC = parseExplicitContextAIC(ctx.aiCredits); let aiCreditsFormatted = envAICFormatted; let aiCreditsSuffix = envAICSuffix; if (hasExplicitContextAIC) { - aiCreditsFormatted = aiCredits ? formatAIC(aiCredits) : undefined; + aiCreditsFormatted = explicitContextAIC ? formatAIC(explicitContextAIC) : undefined; aiCreditsSuffix = aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : ""; } @@ -310,6 +320,7 @@ function getFooterWorkflowRecompileCommentMessage(ctx) { * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source * @property {string} [historyUrl] - GitHub search URL for issues created by this workflow (for the history link) + * @property {number|string} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) */ /** @@ -326,8 +337,23 @@ function getFooterAgentFailureIssueMessage(ctx) { // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); - const { aiCredits, aiCreditsFormatted, aiCreditsSuffix, agentAiCredits, agentAiCreditsFormatted, agentAiCreditsSuffix, threatDetectionAiCredits, threatDetectionAiCreditsFormatted, threatDetectionAiCreditsSuffix } = getAICFromEnv(); + const { + aiCredits: envAIC, + aiCreditsFormatted: envAICFormatted, + aiCreditsSuffix: envAICSuffix, + agentAiCredits, + agentAiCreditsFormatted, + agentAiCreditsSuffix, + threatDetectionAiCredits, + threatDetectionAiCreditsFormatted, + threatDetectionAiCreditsSuffix, + } = getAICFromEnv(); const { ambientContext, ambientContextFormatted, ambientContextSuffix } = getAmbientContextFromEnv(); + const hasExplicitContextAIC = ctx.aiCredits !== undefined && ctx.aiCredits !== null; + const explicitContextAIC = parseExplicitContextAIC(ctx.aiCredits); + const aiCredits = hasExplicitContextAIC ? explicitContextAIC : envAIC; + const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted; + const aiCreditsSuffix = hasExplicitContextAIC ? (aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : "") : envAICSuffix; // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ @@ -385,8 +411,38 @@ function getFooterAgentFailureCommentMessage(ctx) { // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); + const { + aiCredits: envAIC, + aiCreditsFormatted: envAICFormatted, + aiCreditsSuffix: envAICSuffix, + agentAiCredits, + agentAiCreditsFormatted, + agentAiCreditsSuffix, + threatDetectionAiCredits, + threatDetectionAiCreditsFormatted, + threatDetectionAiCreditsSuffix, + } = getAICFromEnv(); + const hasExplicitContextAIC = ctx.aiCredits !== undefined && ctx.aiCredits !== null; + const explicitContextAIC = parseExplicitContextAIC(ctx.aiCredits); + const aiCredits = hasExplicitContextAIC ? explicitContextAIC : envAIC; + const aiCreditsFormatted = hasExplicitContextAIC ? (explicitContextAIC ? formatAIC(explicitContextAIC) : undefined) : envAICFormatted; + const aiCreditsSuffix = hasExplicitContextAIC ? (aiCreditsFormatted ? ` · ${aiCreditsFormatted} AIC` : "") : envAICSuffix; + // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url - const templateContext = toSnakeCase({ ...ctx, historyLink, agenticWorkflowUrl }); + const templateContext = toSnakeCase({ + ...ctx, + historyLink, + agenticWorkflowUrl, + aiCredits, + aiCreditsFormatted, + aiCreditsSuffix, + agentAiCredits, + agentAiCreditsFormatted, + agentAiCreditsSuffix, + threatDetectionAiCredits, + threatDetectionAiCreditsFormatted, + threatDetectionAiCreditsSuffix, + }); // Use custom agent failure comment footer if configured, otherwise use default footer let footer; @@ -395,6 +451,9 @@ function getFooterAgentFailureCommentMessage(ctx) { } else { // Default footer template with link to workflow run let defaultFooter = "> Generated from [{workflow_name}]({run_url})"; + if (aiCredits) { + defaultFooter += "{ai_credits_suffix}"; + } // Append history link when available if (ctx.historyUrl) { defaultFooter += " · [◷]({history_url})"; diff --git a/setup/js/model_costs.cjs b/setup/js/model_costs.cjs index 7960767..bfdd0c7 100644 --- a/setup/js/model_costs.cjs +++ b/setup/js/model_costs.cjs @@ -69,7 +69,7 @@ function normalizeProvider(provider) { const normalized = String(provider || "") .trim() .toLowerCase(); - if (normalized === "github" || normalized === "copilot") return "github-copilot"; + if (normalized === "github" || normalized === "copilot" || normalized === "github_models") return "github-copilot"; return normalized; } diff --git a/setup/js/safe_outputs_tools.json b/setup/js/safe_outputs_tools.json index e05b077..169bca5 100644 --- a/setup/js/safe_outputs_tools.json +++ b/setup/js/safe_outputs_tools.json @@ -345,7 +345,7 @@ "temporary_id": { "type": "string", "pattern": "^#?aw_[A-Za-z0-9_]{3,12}$", - "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) — e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", + "description": "Unique temporary identifier for this pull request. Canonical form: '#aw_' followed by 3 to 12 alphanumeric or underscore characters (A-Za-z0-9_) \u2014 e.g., '#aw_pr1', '#aw_fix_123'. The bare 'aw_pr1' form is also accepted and normalised to '#aw_pr1'. Use this same '#aw_ID' form in body text to cross-reference this PR; these references are replaced with the real pull request number after creation.", "x-synonyms": ["temporaryId"] }, "secrecy": { @@ -1621,7 +1621,7 @@ }, { "name": "create_check_run", - "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name.", + "description": "Create a GitHub Check Run to report agent analysis results on a commit or pull request. Check Runs appear in the PR checks UI and on commits with a pass/fail status. Use this to surface structured analysis results as a first-class GitHub check. The check run name is configured in the workflow frontmatter and is NOT accepted as a parameter \u2014 do not pass name. When `safe-outputs.create-check-run.target` is configured, pull request targeting follows standard PR target rules. With `target: \"*\"`, include `pull_request_number` (or `pr_number`/`pr`/`pull_number`) in each call.", "inputSchema": { "type": "object", "required": ["conclusion", "title", "summary"], @@ -1642,6 +1642,26 @@ "text": { "type": "string", "description": "Optional detailed Markdown content shown in the check run details. Use this for longer output such as full analysis reports, line-by-line findings, or remediation steps. Maximum 65535 characters." + }, + "pull_request_number": { + "type": ["number", "string"], + "description": "Pull request number to attach the check run to when the workflow uses `create-check-run: target: \"*\"` (or equivalent explicit PR targeting). This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876).", + "x-synonyms": ["pr_number", "pr", "pull_number"] + }, + "pr_number": { + "type": ["number", "string"], + "description": "Alias for pull_request_number. Prefer pull_request_number in new calls.", + "x-synonyms": ["prNumber"] + }, + "pr": { + "type": ["number", "string"], + "description": "Alias for pull_request_number. Prefer pull_request_number in new calls.", + "x-synonyms": ["pullRequest", "pull"] + }, + "pull_number": { + "type": ["number", "string"], + "description": "Alias for pull_request_number. Prefer pull_request_number in new calls.", + "x-synonyms": ["pullNumber"] } }, "additionalProperties": false diff --git a/setup/js/send_otlp_span.cjs b/setup/js/send_otlp_span.cjs index b6f9b71..eb18ebc 100644 --- a/setup/js/send_otlp_span.cjs +++ b/setup/js/send_otlp_span.cjs @@ -1387,6 +1387,15 @@ const OTLP_EXPORT_ERRORS_PATH = "/tmp/gh-aw/otlp-export-errors.count"; */ const OTLP_EXPORT_ERROR_DETAILS_PATH = "/tmp/gh-aw/otlp-export-errors.jsonl"; +/** + * Path to the failure categories file written by handle_agent_failure and read + * by the OTLP conclusion span so it can record them as gh-aw.failure.categories. + * Mirrors FAILURE_CATEGORIES_PATH from handle_agent_failure.cjs without + * introducing a runtime require() dependency on that module. + * @type {string} + */ +const FAILURE_CATEGORIES_PATH = "/tmp/gh-aw/failure_categories.json"; + /** * Path to the agent stdio log file. * @type {string} @@ -1929,7 +1938,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { const bodyModified = typeof awInfo.body_modified === "boolean" ? awInfo.body_modified : parseBooleanEnv(process.env.GH_AW_INFO_BODY_MODIFIED); const trackerId = process.env.GH_AW_TRACKER_ID || awInfo.tracker_id || ""; const jobName = process.env.INPUT_JOB_NAME || ""; - const jobEmitsOwnTokenUsage = jobName === "agent" || jobName === "detection"; + const jobEmitsOwnTokenUsage = jobName === "agent" || jobName === "detection" || (!!engineId && jobName === engineId); const runId = process.env.GITHUB_RUN_ID || ""; const runAttempt = awInfo.run_attempt || process.env.GITHUB_RUN_ATTEMPT || "1"; const actor = process.env.GITHUB_ACTOR || ""; @@ -1958,7 +1967,10 @@ async function sendJobConclusionSpan(spanName, options = {}) { const detectionReason = process.env.GH_AW_DETECTION_REASON || ""; const runtimeMetrics = readAgentRuntimeMetrics(); // Read once and reuse for both gh-aw.aic and gen_ai.usage.* attributes. - const agentUsage = normalizeRuntimeTokenUsage(readJSONIfExists("/tmp/gh-aw/agent_usage.json")) || runtimeMetrics.tokenUsage || {}; + const agentUsageFilePath = "/tmp/gh-aw/agent_usage.json"; + const agentUsageRaw = readJSONIfExists(agentUsageFilePath); + const agentUsageNormalized = normalizeRuntimeTokenUsage(agentUsageRaw); + const agentUsage = agentUsageNormalized || runtimeMetrics.tokenUsage || {}; // Mark the span as an error when the agent job failed, timed out, or was cancelled. const isAgentTimedOut = agentConclusion === "timed_out"; const isAgentFailure = agentConclusion === "failure" || isAgentTimedOut; @@ -2052,10 +2064,19 @@ async function sendJobConclusionSpan(spanName, options = {}) { if (frontmatterEmoji) attributes.push(buildAttr("gh-aw.frontmatter.emoji", frontmatterEmoji)); if (typeof bodyModified === "boolean") attributes.push(buildAttr("gh-aw.frontmatter.body_modified", bodyModified)); attributes.push(...buildEpisodeAttributesFromContext(awInfo, runId, runAttempt)); - // GH_AW_AIC is propagated to downstream jobs via needs.agent.outputs.*, so gate it - // behind jobEmitsOwnTokenUsage to prevent non-agent jobs from re-emitting it. - const aiCredits = jobEmitsOwnTokenUsage ? (normalizeNonNegativeNumber(process.env.GH_AW_AIC) ?? agentUsage.ai_credits) : undefined; - if (typeof aiCredits === "number" && aiCredits > 0) { + // GH_AW_AIC may be propagated to downstream jobs via workflow outputs, so gate it + // behind jobEmitsOwnTokenUsage to prevent non-owning jobs from re-emitting it. + // Ranked AIC sources: env var → non-zero file → engine metrics → file (may be zero). + // When the firewall proxy writes ai_credits=0 to agent_usage.json, the engine result + // event (from agent-stdio.log) is tried next so its non-zero value is not lost. + // The final fallback to aiCreditsFromFile (zero) makes observability gaps visible + // (zero != no-data) and lets the Sentry EAP schema infer the attribute as numeric + // so sum()/avg()/percentile() aggregations work without manual schema configuration. + const aiCreditsFromEnv = normalizeNonNegativeNumber(process.env.GH_AW_AIC); + const aiCreditsFromFile = agentUsage.ai_credits; + const aiCreditsFromMetrics = runtimeMetrics.tokenUsage?.ai_credits; + const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile))) : undefined; + if (typeof aiCredits === "number") { attributes.push(buildAttr("gh-aw.aic", aiCredits)); } if (typeof runtimeMetrics.turns === "number") { @@ -2085,6 +2106,13 @@ async function sendJobConclusionSpan(spanName, options = {}) { if (detectionReason) { attributes.push(buildAttr("gh-aw.detection.reason", detectionReason)); } + // Read failure categories written by handle_agent_failure so the reason/type + // of each failure issue is captured as a queryable OTLP attribute. + const rawFailureCategories = readJSONIfExists(FAILURE_CATEGORIES_PATH); + const failureCategories = Array.isArray(rawFailureCategories) ? rawFailureCategories.filter(c => typeof c === "string" && c) : []; + if (failureCategories.length > 0) { + attributes.push(buildArrayAttr("gh-aw.failure.categories", failureCategories)); + } attributes.push(buildAttr("gh-aw.otlp.export_errors", readOTLPExportErrorCount())); const otlpExportErrorDetails = formatOTLPExportErrorDetails(); if (otlpExportErrorDetails) { @@ -2302,7 +2330,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { } } - // Only attach token-usage attributes to jobs that actually executed an agent. + // Only attach token-usage attributes to jobs that actually executed model usage. // Most downstream jobs (conclusion, safe_outputs) may have agent_usage.json on // disk via artifact download but must NOT emit token data — otherwise every // sum(gen_ai.usage.*) query is inflated by the number of downstream jobs. @@ -2367,6 +2395,7 @@ module.exports = { buildEpisodeAttributesFromContext, resolveEngineId, GITHUB_RATE_LIMITS_JSONL_PATH, + FAILURE_CATEGORIES_PATH, sendJobSetupSpan, sendJobConclusionSpan, OTEL_JSONL_PATH,