Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion actions/setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { createRateLimitAwareGithub } = require("./github_rate_limit_logger.cjs");
const { scanDailyAIC } = require("./daily_aic_scan.cjs");
const { API_READ_OPERATIONS, createAPIBudget, retryNotBefore, safeResponseHeaders, isQuotaError, getAPIReadFailure } = require("./daily_aic_api_budget.cjs");
const { getGitHubAPIDiagnostic } = require("./github_api_diagnostics.cjs");
const { loadBillableJobs, allBillableJobsNotStarted, allBillableJobsSkipped, sumCoveredComponents } = require("./daily_aic_component_coverage.cjs");
const { readLedgerEntries } = require("./daily_aic_repo_memory_ledger.cjs");

Expand Down Expand Up @@ -843,7 +844,8 @@ async function main(options = {}) {
const reason = readFailure
? `${readFailure.operation} read stopped (${readFailure.status ? `HTTP ${readFailure.status}` : "transport or read error"}; attempts: ${readFailure.attempts}; reason: ${readFailure.reason}). Accounting remains unverified; agent startup is blocked.`
: getErrorMessage(error);
const message = `Daily workflow AI Credits are unknown: ${reason}`;
const diagnostic = getGitHubAPIDiagnostic(error);
const message = `Daily workflow AI Credits are unknown: ${reason}${diagnostic ? ` ${diagnostic.message}` : ""}`;
core.setOutput("daily_ai_credits_guardrail_status", status);
core.setOutput("daily_ai_credits_guardrail_error", message);
logDailyGuardrail("AIC inspection failed", { status, error: readFailure || getErrorMessage(error) });
Expand Down
9 changes: 8 additions & 1 deletion actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ describe("check_daily_aic_workflow_guardrail", () => {
["repository_history", 502, {}, 3, "retry_exhausted", "transient_error"],
["workflow_history", 503, { "retry-after": "120" }, 1, "retry_restricted", "transient_error"],
["workflow_history", 502, { "x-ratelimit-remaining": "100" }, 1, "quota_stop", "transient_error"],
["workflow_history", 403, { "x-ratelimit-remaining": "0" }, 1, "quota_stop", "transient_error"],
["workflow_history", 403, {}, 1, "non_transient", "structural_error"],
])("main() safely attributes %s HTTP %i while keeping unknown history blocked", async (operation, httpStatus, headers, attempts, reason, guardrailStatus) => {
vi.useFakeTimers();
Expand Down Expand Up @@ -534,7 +535,13 @@ describe("check_daily_aic_workflow_guardrail", () => {
const pending = runMain();
await vi.runAllTimersAsync();
await pending;
const message = `Daily workflow AI Credits are unknown: ${operation} read stopped (HTTP ${httpStatus}; attempts: ${attempts}; reason: ${reason}). Accounting remains unverified; agent startup is blocked.`;
const diagnostic =
reason === "quota_stop"
? ` GitHub API ${headers["x-ratelimit-remaining"] === "0" ? "quota_exhausted" : "quota_reserve"} (HTTP ${httpStatus}; resource=unknown; remaining=${headers["x-ratelimit-remaining"]}; limit=unknown; used=unknown; reset=unknown; retry_not_before=unknown)`
: httpStatus === 403
? " GitHub API permission_denied (HTTP 403; Check the credential's access to this operation; quota exhaustion was not established.)"
: "";
const message = `Daily workflow AI Credits are unknown: ${operation} read stopped (HTTP ${httpStatus}; attempts: ${attempts}; reason: ${reason}). Accounting remains unverified; agent startup is blocked.${diagnostic}`;
expect(outputs).toMatchObject({ daily_ai_credits_guardrail_status: guardrailStatus, daily_ai_credits_guardrail_error: message, daily_ai_credits_total: "", daily_ai_credits_estimated: "", daily_ai_credits_exceeded: "false" });
expect(global.core.setFailed).toHaveBeenCalledWith(message);
expect(failedRead).toHaveBeenCalledTimes(attempts);
Expand Down
29 changes: 16 additions & 13 deletions actions/setup/js/daily_aic_api_budget.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check

const SAFE_HEADERS = ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-used", "x-ratelimit-reset", "retry-after"];
const { getGitHubAPIQuota, getGitHubAPIDiagnostic } = require("./github_api_diagnostics.cjs");

const SAFE_HEADERS = ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-used", "x-ratelimit-reset", "x-ratelimit-resource", "retry-after"];
const API_READ_OPERATIONS = Object.freeze({
CURRENT_RUN: "current_run",
WORKFLOW_HISTORY: "workflow_history",
Expand Down Expand Up @@ -37,12 +39,7 @@ function apiError(status, headers, message) {
}

function retryNotBefore(headers, now = Date.now(), quotaStopped = false) {
const safe = safeResponseHeaders(headers);
const reset = quotaStopped || safe["x-ratelimit-remaining"] === "0" ? Number(safe["x-ratelimit-reset"]) * 1000 : NaN;
const retry = safe["retry-after"];
const retryAt = retry == null ? NaN : /^\d+$/.test(retry) ? now + Number(retry) * 1000 : Date.parse(retry);
const times = [reset, retryAt].filter(value => Number.isFinite(value) && value > now);
return times.length ? new Date(Math.max(...times)).toISOString() : "";
return getGitHubAPIQuota(headers, now, quotaStopped).retryNotBefore;
}

function getErrorStatus(error) {
Expand All @@ -52,7 +49,7 @@ function getErrorStatus(error) {
function isQuotaError(error) {
const headers = safeResponseHeaders(error?.response?.headers);
const status = getErrorStatus(error);
return error?.code === "DAILY_AIC_QUOTA_STOP" || status === 429 || (status === 403 && (headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] != null));
return error?.code === "DAILY_AIC_QUOTA_STOP" || status === 429 || (status === 403 && (headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] != null)) || getGitHubAPIDiagnostic(error)?.quota === true;
}

function isTransientError(error) {
Expand All @@ -78,7 +75,10 @@ function createAPIBudget(reserve = 100) {
const remaining = latest["x-ratelimit-remaining"];
const limited = response.status === 429 || (response.status === 403 && (remaining === "0" || latest["retry-after"] != null));
if (limited || (remaining != null && Number(remaining) <= reserve)) {
throw Object.assign(apiError(response.status, latest, "Daily AIC inspection stopped to preserve GitHub API quota"), { code: "DAILY_AIC_QUOTA_STOP" });
const error = Object.assign(apiError(response.status, latest, "Daily AIC inspection stopped to preserve GitHub API quota"), { code: "DAILY_AIC_QUOTA_STOP" });
const diagnostic = getGitHubAPIDiagnostic(error);
if (diagnostic) error.message += `: ${diagnostic.message}`;
throw error;
}
},
async request(operation, observe = true, readOperation) {
Expand Down Expand Up @@ -111,6 +111,8 @@ function createAPIBudget(reserve = 100) {
const failure = Object.freeze({ attempts: attempt + 1, status: status !== null && Number.isInteger(status) && status >= 100 && status <= 599 ? status : null, reason, requests, operation: operationName });
if (error && typeof error === "object") READ_FAILURES.set(error, failure);
core.info(`[daily-workflow-aic] Stopped daily AIC read: ${JSON.stringify(failure)}`);
const diagnostic = getGitHubAPIDiagnostic(error);
if (diagnostic) core.info(`[daily-workflow-aic] ${operationName}: ${diagnostic.message}`);
throw error;
}
const delayMs = 1000 * 2 ** attempt;
Expand All @@ -120,12 +122,13 @@ function createAPIBudget(reserve = 100) {
}
},
snapshot() {
const metadata = getGitHubAPIQuota(latest);
return {
requests,
remaining: Number(latest["x-ratelimit-remaining"] || 0),
limit: Number(latest["x-ratelimit-limit"] || 0),
used: Number(latest["x-ratelimit-used"] || 0),
reset: latest["x-ratelimit-reset"] ? new Date(Number(latest["x-ratelimit-reset"]) * 1000).toISOString() : "",
remaining: metadata.remaining ?? 0,
limit: metadata.limit ?? 0,
used: metadata.used ?? 0,
reset: metadata.reset,
};
},
};
Expand Down
21 changes: 21 additions & 0 deletions actions/setup/js/daily_aic_api_budget.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ afterEach(() => {
const response = (status = 200, headers = { "x-ratelimit-remaining": "4000" }) => ({ status, headers });

describe("bounded daily AIC read retries", () => {
it("classifies a headerless installation rate limit without retrying or publishing its payload", async () => {
const error = apiError(403, {}, "API rate limit exceeded for installation; secret-test-value");
const request = vi.fn().mockRejectedValue(error);
await expect(createAPIBudget().request(request, true, API_READ_OPERATIONS.CURRENT_RUN)).rejects.toBe(error);
expect(getAPIReadFailure(error)).toMatchObject({ attempts: 1, reason: "quota_stop", operation: "current_run" });
expect(request).toHaveBeenCalledOnce();
const logs = global.core.info.mock.calls.flat().join("\n");
expect(logs).toContain("current_run: GitHub API rate_limited");
expect(logs).toContain("remaining=unknown");
expect(logs).not.toContain("secret-test-value");
});

it("does not let malformed reset metadata replace the original failure", async () => {
const request = vi.fn().mockRejectedValue(apiError(403, { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "9999999999999999" }, "private payload"));
const budget = createAPIBudget();
await expect(budget.request(request)).rejects.toMatchObject({ code: "DAILY_AIC_QUOTA_STOP" });
expect(budget.snapshot().reset).toBe("");
expect(retryNotBefore({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": "9999999999999999" })).toBe("");
expect(request).toHaveBeenCalledOnce();
});

it("retries only the failed read and observes each response once", async () => {
const budget = createAPIBudget();
const request = vi
Expand Down
30 changes: 30 additions & 0 deletions actions/setup/js/daily_aic_scan.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,36 @@ describe("business response circuit breaker", () => {
});

describe("trusted artifact fallback without writable Actions cache", () => {
it("summarizes first-read quota exhaustion and blocks all further restore requests", async () => {
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-10-14T10:04:13Z"));
const getWorkflowRun = vi.fn().mockRejectedValue(apiError(403, { "x-ratelimit-remaining": "0", "x-ratelimit-limit": "15000", "x-ratelimit-used": "15000", "x-ratelimit-reset": "1791972360" }, "secret-test-value"));
const listWorkflowRuns = vi.fn();
global.context = { repo: { owner: "example", repo: "project" }, runId: 99, payload: {} };
global.github = { rest: { actions: { getWorkflowRun, listWorkflowRuns } } };
const summary = { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue(undefined) };
global.core.summary = summary;
await expect(restore(cachePath)).rejects.toMatchObject({ code: "DAILY_AIC_QUOTA_STOP", status: 403 });
expect(getWorkflowRun).toHaveBeenCalledOnce();
expect(listWorkflowRuns).not.toHaveBeenCalled();
expect(summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("restore stopped at current_run after 1 attempt(s)"));
expect(summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("quota_exhausted"));
expect(summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("reset=2026-10-14T10:06:00.000Z"));
expect(summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Accounting remains unverified; agent startup is blocked"));
expect(JSON.stringify([global.core.info.mock.calls, summary.addRaw.mock.calls])).not.toContain("secret-test-value");
expect(fs.existsSync(cachePath)).toBe(false);
});

it("does not let a summary-write failure hide the original permission failure", async () => {
const failure = apiError(403, {}, "secret-test-value");
global.context = { repo: { owner: "example", repo: "project" }, runId: 99, payload: {} };
global.github = { rest: { actions: { getWorkflowRun: vi.fn().mockRejectedValue(failure) } } };
global.core.summary = { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockRejectedValue(new Error("private summary payload")) };
await expect(restore(cachePath)).rejects.toBe(failure);
expect(global.core.warning).toHaveBeenCalledWith("Failed to write daily AIC snapshot restore failure summary.");
expect(global.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("permission_denied"));
expect(JSON.stringify([global.core.info.mock.calls, global.core.warning.mock.calls])).not.toContain("private summary payload");
});

it("restores required-workflow observations from repository history after workflow history returns 404", async () => {
vi.spyOn(Date, "now").mockReturnValue(now);
const current = { workflow_id: 7, path: ".github/workflows/example.yml" };
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/dismiss_pull_request_review.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

const { getErrorMessage } = require("./error_helpers.cjs");
const { getGitHubAPIDiagnostic } = require("./github_api_diagnostics.cjs");
const { resolveTarget, isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
Expand Down Expand Up @@ -247,7 +248,7 @@ async function main(config = {}) {
repo: `${owner}/${repo}`,
};
}
throw new Error(`${SAFE_OUTPUT_E099}: Failed to fetch review ${reviewId} on ${owner}/${repo}#${pullRequestNumber}: ${getErrorMessage(getReviewError)}`, {
throw new Error(`${SAFE_OUTPUT_E099}: Failed to fetch review ${reviewId} on ${owner}/${repo}#${pullRequestNumber}: ${getGitHubAPIDiagnostic(getReviewError)?.message || getErrorMessage(getReviewError)}`, {
cause: getReviewError,
});
}
Expand Down
20 changes: 20 additions & 0 deletions actions/setup/js/dismiss_pull_request_review.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,24 @@ describe("dismiss_pull_request_review", () => {
expect(result.error).toContain("Internal Server Error");
expect(mockDismissReview).not.toHaveBeenCalled();
});

it.each([
[403, { "x-ratelimit-remaining": "0", "x-ratelimit-limit": "15000", "x-ratelimit-used": "15000", "x-ratelimit-reset": "1791972360" }, "API rate limit exceeded for installation", "quota_exhausted"],
[403, {}, "API rate limit exceeded for installation", "rate_limited"],
[403, {}, "Resource not accessible by integration", "permission_denied"],
[401, {}, "Bad credentials", "authentication_failed"],
])("reports the failed review-read boundary and %s classification without an API payload", async (status, headers, message, classification) => {
mockGetReview.mockRejectedValueOnce(Object.assign(new Error(`${message}; secret-test-value`), { status, response: { headers } }));
const result = await handler({
type: "dismiss_pull_request_review",
review_id: 123,
justification: "This stale review no longer reflects the updated implementation.",
});
expect(result.success).toBe(false);
expect(result.error).toContain("E099: Failed to fetch review 123 on test-owner/test-repo#42");
expect(result.error).toContain(`GitHub API ${classification}`);
expect(result.error).not.toContain("secret-test-value");
expect(mockGetReview).toHaveBeenCalledOnce();
expect(mockDismissReview).not.toHaveBeenCalled();
});
});
53 changes: 53 additions & 0 deletions actions/setup/js/github_api_diagnostics.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @ts-check

const { isRateLimitError } = require("./error_helpers.cjs");

function getGitHubAPIQuota(headers, now = Date.now(), quotaStopped = false) {
const header = name => (typeof headers?.get === "function" ? headers.get(name) : headers?.[name]);
const integer = value => {
if (typeof value !== "string" || !/^\d{1,16}$/.test(value)) return null;
const number = Number(value);
return Number.isSafeInteger(number) ? number : null;
};
const remaining = integer(header("x-ratelimit-remaining"));
const limit = integer(header("x-ratelimit-limit"));
const used = integer(header("x-ratelimit-used"));
const resetSeconds = integer(header("x-ratelimit-reset"));
const resetMs = resetSeconds === null ? NaN : resetSeconds * 1000;
const reset = Number.isFinite(resetMs) && resetMs > 0 && resetMs <= 8640000000000000 ? new Date(resetMs).toISOString() : "";
const retrySeconds = integer(header("retry-after"));
const retryHeader = header("retry-after");
const retryMs = retrySeconds === null ? (typeof retryHeader === "string" && retryHeader.length <= 64 ? Date.parse(retryHeader) : NaN) : now + retrySeconds * 1000;
Comment thread
pelikhan marked this conversation as resolved.
Outdated
const retryAt = Number.isFinite(retryMs) && retryMs > now && retryMs <= 8640000000000000 ? new Date(retryMs).toISOString() : "";
const deadlines = [reset && (remaining === 0 || quotaStopped) && resetMs > now ? resetMs : NaN, retryAt ? retryMs : NaN].filter(Number.isFinite);
const deadline = Math.max(...deadlines);
const retryNotBefore = Number.isFinite(deadline) && deadline > now && deadline <= 8640000000000000 ? new Date(deadline).toISOString() : "";
const resourceHeader = header("x-ratelimit-resource");
const resource = ["core", "search", "graphql", "code_search", "integration_manifest", "actions_runner_registration"].includes(resourceHeader) ? resourceHeader : "unknown";
return { resource, remaining, limit, used, reset, retryRestricted: retryHeader != null, retryNotBefore };
}

/**
* Derive diagnostics only from the failed business response, never another
* client's /rate_limit precheck. Publish validated metadata, not API payloads.
* @param {any} error
* @param {number} [now]
*/
function getGitHubAPIDiagnostic(error, now = Date.now()) {
const quotaStopped = error?.code === "DAILY_AIC_QUOTA_STOP";
const metadata = getGitHubAPIQuota(error?.response?.headers, now, quotaStopped);
Comment thread
pelikhan marked this conversation as resolved.
const { resource, remaining, limit, used, reset, retryRestricted, retryNotBefore } = metadata;
const status = Number(error?.status || error?.response?.status || 0);
const secondary = typeof error?.message === "string" && /\bsecondary rate limit\b/i.test(error.message);
const limited = status === 429 || (status === 403 && (remaining === 0 || retryRestricted || isRateLimitError(error) || secondary));
Comment thread
pelikhan marked this conversation as resolved.
Outdated
const quota = quotaStopped || limited;
if (!quota && status !== 401 && status !== 403) return null;

const classification = status === 401 ? "authentication_failed" : status === 403 && !limited ? "permission_denied" : quota ? (remaining === 0 ? "quota_exhausted" : quotaStopped ? "quota_reserve" : "rate_limited") : "permission_denied";
Comment thread
pelikhan marked this conversation as resolved.
Outdated
const details = quota
? `resource=${resource}; remaining=${remaining ?? "unknown"}; limit=${limit ?? "unknown"}; used=${used ?? "unknown"}; reset=${reset || "unknown"}; retry_not_before=${retryNotBefore || "unknown"}${quotaStopped && !limited && (status === 401 || status === 403) ? "; quota reserve guard also stopped further reads" : ""}`
: "Check the credential's access to this operation; quota exhaustion was not established.";
return { quota, classification, message: `GitHub API ${classification} (HTTP ${status >= 100 && status <= 599 && Number.isInteger(status) ? status : "unknown"}; ${details})` };
}

module.exports = { getGitHubAPIQuota, getGitHubAPIDiagnostic };
Loading
Loading