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
22 changes: 20 additions & 2 deletions actions/setup/js/check_daily_aic_workflow_guardrail.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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}).`);
Expand Down
93 changes: 93 additions & 0 deletions actions/setup/js/check_daily_aic_workflow_guardrail.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
24 changes: 23 additions & 1 deletion actions/setup/js/github_rate_limit_logger.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
}
}

Expand Down
83 changes: 82 additions & 1 deletion actions/setup/js/github_rate_limit_logger.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading