Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
47 changes: 29 additions & 18 deletions setup/js/assign_milestone.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Object>} */
// Cached results from paginated title searches, scoped by "owner/repo" to prevent
// cross-repo cache pollution in multi-repo configurations.
/** @type {Map<string, Object>} key: "owner/repo::title" */
const milestoneByTitle = new Map();
/** @type {Array<Object>} All milestones fetched so far (for error messages) */
let allFetchedMilestones = [];
let milestonesExhausted = false;
/** @type {Set<string>} entries: "owner/repo" — repos whose milestones have been fully fetched */
const exhaustedRepos = new Set();
/** @type {Map<string, Array<Object>>} 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<Object|null>}
*/
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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 11 additions & 5 deletions setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}).`);
Expand All @@ -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"} |`,
Expand Down
102 changes: 81 additions & 21 deletions setup/js/create_check_run.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);

Expand All @@ -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)`);

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions setup/js/generate_aw_info.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));

Expand Down
Loading
Loading