From 4362ed9afeab968363067abc076dc494a433b9b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:48:20 +0000 Subject: [PATCH 1/3] feat: measure and log GitHub API rate limit around daily AIC guardrail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchAndLogRateLimit now returns the core rate-limit snapshot ({remaining, limit, used, reset} or null) so callers can reuse the single API call for both logging and in-memory tracking - check_daily_aic_workflow_guardrail.cjs calls fetchAndLogRateLimit with "daily-aic-guardrail-start" before the first API call to establish a before-guardrail baseline in the JSONL log - After the artifact inspection loop, calls fetchAndLogRateLimit with "daily-aic-guardrail-end" and logs a consumption-delta entry (rateLimitBeforeInspection, rateLimitAfterInspection, consumed) via logDailyGuardrail so the rate limit hunger of the guardrail is observable in run artifacts - Tests: updated toBeUndefined → toBeNull; added return-value tests for fetchAndLogRateLimit; added main() test verifying the start/end logs and consumption delta are emitted Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/check_daily_aic_workflow_guardrail.cjs | 18 +++- ...heck_daily_aic_workflow_guardrail.test.cjs | 86 +++++++++++++++++++ actions/setup/js/github_rate_limit_logger.cjs | 17 +++- .../js/github_rate_limit_logger.test.cjs | 63 +++++++++++++- 4 files changed, 181 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs index 380c7e199e2..023964c4e56 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,6 +313,9 @@ 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. + await fetchAndLogRateLimit(githubClient, "daily-aic-guardrail-start"); const currentRun = await githubClient.rest.actions.getWorkflowRun({ owner, repo, @@ -460,6 +463,19 @@ 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"); + logDailyGuardrail("GitHub API rate limit consumed by daily AIC guardrail", { + rateLimitBeforeInspection: rateLimit.remaining, + rateLimitAfterInspection: rateLimitEnd?.remaining ?? null, + consumed: rateLimit.remaining - (rateLimitEnd?.remaining ?? rateLimit.remaining), + 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..b23f3590f3c 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,90 @@ 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 = 5000 - rateLimitCallCount * 5; + 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"); + + // fetchAndLogRateLimit must have been called at least twice (start + end). + expect(rateLimitCallCount).toBeGreaterThanOrEqual(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..75b87183482 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,18 @@ async function fetchAndLogRateLimit(github, operation = "fetch") { }; appendEntry(entry); } + + const coreData = resources.core; + if (!coreData || typeof coreData !== "object") return null; + return { + remaining: Number(coreData.remaining ?? 0), + limit: Number(coreData.limit ?? 0), + used: Number(coreData.used ?? 0), + reset: coreData.reset ? new Date(coreData.reset * 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..8c07e4e97a7 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,67 @@ 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(); + }); }); // --------------------------------------------------------------------------- From 2ac96166dcba5b82f0727657c3479f1e81c24dd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:33:35 +0000 Subject: [PATCH 2/3] Fix rate-limit snapshot handling in daily AIC guardrail logging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../js/check_daily_aic_workflow_guardrail.cjs | 12 ++++++----- ...heck_daily_aic_workflow_guardrail.test.cjs | 11 ++++++++-- actions/setup/js/github_rate_limit_logger.cjs | 15 ++++++++++---- .../js/github_rate_limit_logger.test.cjs | 20 +++++++++++++++++++ 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs index 023964c4e56..34024b592f0 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.cjs @@ -315,13 +315,13 @@ async function main() { 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. - await fetchAndLogRateLimit(githubClient, "daily-aic-guardrail-start"); + 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"; @@ -468,10 +468,12 @@ async function main() { // 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: rateLimit.remaining, - rateLimitAfterInspection: rateLimitEnd?.remaining ?? null, - consumed: rateLimit.remaining - (rateLimitEnd?.remaining ?? rateLimit.remaining), + rateLimitBeforeInspection, + rateLimitAfterInspection, + consumed: Math.max(0, rateLimitBeforeInspection - rateLimitAfterInspection), limit: rateLimit.limit, reset: rateLimit.reset, }); 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 b23f3590f3c..0d0da854603 100644 --- a/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs +++ b/actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs @@ -226,7 +226,7 @@ describe("check_daily_aic_workflow_guardrail", () => { rateLimit: { get: async () => { rateLimitCallCount += 1; - const remaining = 5000 - rateLimitCallCount * 5; + const remaining = rateLimitCallCount === 1 ? 4995 : 5000; return { data: { resources: { @@ -275,9 +275,16 @@ describe("check_daily_aic_workflow_guardrail", () => { 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).toBeGreaterThanOrEqual(2); + expect(rateLimitCallCount).toBe(2); } finally { delete global.core; delete global.github; diff --git a/actions/setup/js/github_rate_limit_logger.cjs b/actions/setup/js/github_rate_limit_logger.cjs index 75b87183482..fc139b897f3 100644 --- a/actions/setup/js/github_rate_limit_logger.cjs +++ b/actions/setup/js/github_rate_limit_logger.cjs @@ -156,11 +156,18 @@ async function fetchAndLogRateLimit(github, operation = "fetch") { 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: Number(coreData.remaining ?? 0), - limit: Number(coreData.limit ?? 0), - used: Number(coreData.used ?? 0), - reset: coreData.reset ? new Date(coreData.reset * 1000).toISOString() : "", + remaining, + limit, + used, + reset: new Date(resetSeconds * 1000).toISOString(), }; } catch (err) { core.warning(`github_rate_limit_logger: fetchAndLogRateLimit failed: ${getErrorMessage(err)}`); diff --git a/actions/setup/js/github_rate_limit_logger.test.cjs b/actions/setup/js/github_rate_limit_logger.test.cjs index 8c07e4e97a7..02fbb5e2698 100644 --- a/actions/setup/js/github_rate_limit_logger.test.cjs +++ b/actions/setup/js/github_rate_limit_logger.test.cjs @@ -298,6 +298,26 @@ describe("fetchAndLogRateLimit", () => { expect(result).toBeNull(); }); + + it("returns null when core resource payload is partial", 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(); + }); }); // --------------------------------------------------------------------------- From 38a2b10ce5d4508619b36e3ca4932ae398b7d619 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:36:19 +0000 Subject: [PATCH 3/3] Clarify missing used-field rate-limit test case Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/github_rate_limit_logger.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/github_rate_limit_logger.test.cjs b/actions/setup/js/github_rate_limit_logger.test.cjs index 02fbb5e2698..71feb5ee68a 100644 --- a/actions/setup/js/github_rate_limit_logger.test.cjs +++ b/actions/setup/js/github_rate_limit_logger.test.cjs @@ -299,7 +299,7 @@ describe("fetchAndLogRateLimit", () => { expect(result).toBeNull(); }); - it("returns null when core resource payload is partial", async () => { + it("returns null when core used field is missing", async () => { const mockGithub = { rest: { rateLimit: {