diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs index 380c7e199e2..34024b592f0 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -8,7 +8,7 @@ const path = require("path"); const { calculateDailyAICStats, findJSONLFiles, formatAICCredits, sumAICFromUsageJSONLFiles } = require("./daily_aic_workflow_helpers.cjs"); const { parsePositiveCompactNumber } = require("./numeric_limits.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { createRateLimitAwareGithub } = require("./github_rate_limit_logger.cjs"); +const { createRateLimitAwareGithub, fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs"); const PRIMARY_GUARDRAIL_ARTIFACT_NAMES = ["usage"]; const DAILY_WORKFLOW_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -313,12 +313,15 @@ async function main() { try { const githubClient = createRateLimitAwareGithub(github); const { owner, repo } = context.repo; + // Capture a before-guardrail rate-limit snapshot and log it to the JSONL + // so consumers can determine the baseline available quota before inspection starts. + const rateLimitStart = await fetchAndLogRateLimit(githubClient, "daily-aic-guardrail-start"); const currentRun = await githubClient.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId, }); - const rateLimit = await getCoreRateLimitSnapshot(githubClient); + const rateLimit = rateLimitStart ?? (await getCoreRateLimitSnapshot(githubClient)); const workflowID = process.env.GH_AW_WORKFLOW_ID || ""; const workflowName = process.env.GH_AW_WORKFLOW_NAME || workflowID || "workflow"; @@ -460,6 +463,21 @@ async function main() { exceeded: totalAIC > threshold, }); + // Capture an after-guardrail rate-limit snapshot and log it to the JSONL so + // the full cost of the inspection window (workflow-run listing + artifact downloads) + // can be measured. The delta between the before and after snapshots answers + // whether the daily AIC guardrail is too hungry in GitHub API rate limits. + const rateLimitEnd = await fetchAndLogRateLimit(githubClient, "daily-aic-guardrail-end"); + const rateLimitBeforeInspection = rateLimitStart?.remaining ?? rateLimit.remaining; + const rateLimitAfterInspection = rateLimitEnd?.remaining ?? rateLimitBeforeInspection; + logDailyGuardrail("GitHub API rate limit consumed by daily AIC guardrail", { + rateLimitBeforeInspection, + rateLimitAfterInspection, + consumed: Math.max(0, rateLimitBeforeInspection - rateLimitAfterInspection), + limit: rateLimit.limit, + reset: rateLimit.reset, + }); + if (totalAIC <= threshold) { await appendDailyAICSummary(workflowName, actorLogin, threshold, countedRuns, rateLimit, summaryMeta); core.info(`Daily workflow AIC guardrail not exceeded (${totalAIC}/${threshold}).`); diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs index e5adc7a2787..0d0da854603 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs @@ -200,4 +200,97 @@ describe("check_daily_aic_workflow_guardrail", () => { delete process.env.GH_AW_GITHUB_TOKEN; } }); + + it("main() logs rate limit consumption delta when guardrail runs without candidate runs", async () => { + // Verify that fetchAndLogRateLimit is called at the start and end of the guardrail + // and that a consumption-delta diagnostic log is emitted. + const coreInfos = []; + const coreOutputs = {}; + const mockCore = { + setOutput: (key, value) => { + coreOutputs[key] = value; + }, + info: msg => coreInfos.push(msg), + warning: () => {}, + summary: { + addDetails: function () { + return this; + }, + write: async () => {}, + }, + }; + + let rateLimitCallCount = 0; + const mockGithub = { + rest: { + rateLimit: { + get: async () => { + rateLimitCallCount += 1; + const remaining = rateLimitCallCount === 1 ? 4995 : 5000; + return { + data: { + resources: { + core: { limit: 5000, remaining, used: 5000 - remaining, reset: Math.floor(Date.now() / 1000) + 3600 }, + }, + }, + headers: {}, + }; + }, + }, + actions: { + getWorkflowRun: async () => ({ + data: { + workflow_id: 777, + actor: { login: "octocat" }, + triggering_actor: { login: "octocat" }, + }, + headers: {}, + }), + listWorkflowRuns: async () => ({ + data: { workflow_runs: [] }, + headers: {}, + }), + }, + }, + }; + + const mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, + runId: 99, + }; + + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + + process.env.GH_AW_MAX_DAILY_AI_CREDITS = "50000"; + process.env.GH_AW_GITHUB_TOKEN = "fake-token"; + + try { + await expect(exports.main()).resolves.toBeUndefined(); + + // A consumption-delta log must have been emitted. + const consumptionLog = coreInfos.find(msg => msg.includes("rate limit consumed by daily AIC guardrail")); + expect(consumptionLog).toBeDefined(); + expect(consumptionLog).toContain("rateLimitBeforeInspection"); + expect(consumptionLog).toContain("rateLimitAfterInspection"); + expect(consumptionLog).toContain("consumed"); + const detailsPrefix = "[daily-workflow-aic] GitHub API rate limit consumed by daily AIC guardrail: "; + const details = JSON.parse(consumptionLog.slice(detailsPrefix.length)); + expect(details).toMatchObject({ + rateLimitBeforeInspection: 4995, + rateLimitAfterInspection: 5000, + consumed: 0, + }); + + // fetchAndLogRateLimit must have been called at least twice (start + end). + expect(rateLimitCallCount).toBe(2); + } finally { + delete global.core; + delete global.github; + delete global.context; + delete process.env.GH_AW_MAX_DAILY_AI_CREDITS; + delete process.env.GH_AW_GITHUB_TOKEN; + } + }); }); diff --git a/actions/setup/js/github_rate_limit_logger.cjs b/actions/setup/js/github_rate_limit_logger.cjs index a5287d235fc..fc139b897f3 100644 --- a/actions/setup/js/github_rate_limit_logger.cjs +++ b/actions/setup/js/github_rate_limit_logger.cjs @@ -123,14 +123,19 @@ function logRateLimitFromResponse(response, operation) { * Use this for a point-in-time snapshot at the start or end of a script, * rather than after every individual API call. * + * Returns the core rate-limit snapshot so callers can use a single API call + * for both logging and in-memory rate-limit tracking. + * * @param {any} github - The github object injected by actions/github-script * @param {string} [operation="fetch"] - Label recorded in each log entry + * @returns {Promise<{remaining:number,limit:number,used:number,reset:string}|null>} + * Core rate-limit data, or null if the call fails or the core resource is absent. */ async function fetchAndLogRateLimit(github, operation = "fetch") { try { const response = await github.rest.rateLimit.get(); const resources = response?.data?.resources; - if (!resources) return; + if (!resources) return null; const timestamp = new Date().toISOString(); for (const [resource, data] of Object.entries(resources)) { @@ -148,8 +153,25 @@ async function fetchAndLogRateLimit(github, operation = "fetch") { }; appendEntry(entry); } + + const coreData = resources.core; + if (!coreData || typeof coreData !== "object") return null; + const remaining = Number(coreData.remaining); + const limit = Number(coreData.limit); + const used = Number(coreData.used); + const resetSeconds = Number(coreData.reset); + if (!Number.isFinite(remaining) || !Number.isFinite(limit) || !Number.isFinite(used) || !Number.isFinite(resetSeconds)) { + return null; + } + return { + remaining, + limit, + used, + reset: new Date(resetSeconds * 1000).toISOString(), + }; } catch (err) { core.warning(`github_rate_limit_logger: fetchAndLogRateLimit failed: ${getErrorMessage(err)}`); + return null; } } diff --git a/actions/setup/js/github_rate_limit_logger.test.cjs b/actions/setup/js/github_rate_limit_logger.test.cjs index 56857125c40..71feb5ee68a 100644 --- a/actions/setup/js/github_rate_limit_logger.test.cjs +++ b/actions/setup/js/github_rate_limit_logger.test.cjs @@ -212,7 +212,7 @@ describe("fetchAndLogRateLimit", () => { }, }; - await expect(fetchAndLogRateLimit(mockGithub)).resolves.toBeUndefined(); + await expect(fetchAndLogRateLimit(mockGithub)).resolves.toBeNull(); expect(mockCore.warning).toHaveBeenCalled(); expect(appendSpy).not.toHaveBeenCalled(); }); @@ -237,6 +237,87 @@ describe("fetchAndLogRateLimit", () => { const entry = JSON.parse(appendSpy.mock.calls[0][1].trimEnd()); expect(entry.operation).toBe("fetch"); }); + + it("returns core rate-limit snapshot on success", async () => { + const resetSeconds = 1700000000; + const mockGithub = { + rest: { + rateLimit: { + get: vi.fn().mockResolvedValue({ + data: { + resources: { + core: { limit: 5000, remaining: 4321, used: 679, reset: resetSeconds }, + search: { limit: 30, remaining: 28, used: 2, reset: resetSeconds }, + }, + }, + }), + }, + }, + }; + + const result = await fetchAndLogRateLimit(mockGithub, "guardrail-start"); + + expect(result).not.toBeNull(); + expect(result.remaining).toBe(4321); + expect(result.limit).toBe(5000); + expect(result.used).toBe(679); + expect(result.reset).toBe(new Date(resetSeconds * 1000).toISOString()); + }); + + it("returns null when resources are absent", async () => { + const mockGithub = { + rest: { + rateLimit: { + get: vi.fn().mockResolvedValue({ data: {} }), + }, + }, + }; + + const result = await fetchAndLogRateLimit(mockGithub, "guardrail-end"); + + expect(result).toBeNull(); + expect(appendSpy).not.toHaveBeenCalled(); + }); + + it("returns null when core resource is absent", async () => { + const mockGithub = { + rest: { + rateLimit: { + get: vi.fn().mockResolvedValue({ + data: { + resources: { + search: { limit: 30, remaining: 28, used: 2, reset: 1700000000 }, + }, + }, + }), + }, + }, + }; + + const result = await fetchAndLogRateLimit(mockGithub, "guardrail-end"); + + expect(result).toBeNull(); + }); + + it("returns null when core used field is missing", async () => { + const mockGithub = { + rest: { + rateLimit: { + get: vi.fn().mockResolvedValue({ + data: { + resources: { + core: { limit: 5000, remaining: 4900, reset: 1700000000 }, + }, + }, + }), + }, + }, + }; + + const result = await fetchAndLogRateLimit(mockGithub, "guardrail-end"); + + expect(result).toBeNull(); + }); }); // ---------------------------------------------------------------------------