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
57 changes: 24 additions & 33 deletions setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ const VALID_REACTIONS = Object.freeze(Object.keys(REACTION_MAP));
* @typedef {{ route: string, params: Record<string, unknown> }} RestEndpoint
*/

/**
* Validate a required field extracted from an event payload, calling setFailed if missing.
* @param {unknown} value - The extracted value
* @param {string} fieldName - Human-readable field name for the error message
* @param {string} errorCode - Error code prefix (ERR_NOT_FOUND or ERR_VALIDATION)
* @returns {boolean} true if valid, false if missing (setFailed already called)
*/
function requireEventField(value, fieldName, errorCode) {
if (value == null) {
core.setFailed(`${errorCode}: ${fieldName} not found in event payload`);
return false;
}
return true;
}

/**
* @param {unknown} endpoint
* @param {string} endpointName
Expand Down Expand Up @@ -59,10 +74,7 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
switch (eventName) {
case "issues": {
const issueNumber = payload?.issue?.number;
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return null;
}
if (!requireEventField(issueNumber, "Issue number", ERR_NOT_FOUND)) return null;
return {
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: issueNumber } },
commentUpdateEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", params: { owner, repo, issue_number: issueNumber } },
Expand All @@ -72,14 +84,8 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
case "issue_comment": {
const commentId = payload?.comment?.id;
const issueNumber = payload?.issue?.number;
if (!commentId) {
core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`);
return null;
}
if (!issueNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`);
return null;
}
if (!requireEventField(commentId, "Comment ID", ERR_VALIDATION)) return null;
if (!requireEventField(issueNumber, "Issue number", ERR_NOT_FOUND)) return null;
return {
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions", params: { owner, repo, comment_id: commentId } },
// Create new comment on the issue itself, not on the comment
Expand All @@ -89,10 +95,7 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {

case "pull_request": {
const prNumber = payload?.pull_request?.number;
if (!prNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return null;
}
if (!requireEventField(prNumber, "Pull request number", ERR_NOT_FOUND)) return null;
// PRs are "issues" for the reactions endpoint
return {
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions", params: { owner, repo, issue_number: prNumber } },
Expand All @@ -103,14 +106,8 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
case "pull_request_review_comment": {
const reviewCommentId = payload?.comment?.id;
const prNumber = payload?.pull_request?.number;
if (!reviewCommentId) {
core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`);
return null;
}
if (!prNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`);
return null;
}
if (!requireEventField(reviewCommentId, "Review comment ID", ERR_VALIDATION)) return null;
if (!requireEventField(prNumber, "Pull request number", ERR_NOT_FOUND)) return null;
return {
reactionEndpoint: { route: "POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions", params: { owner, repo, comment_id: reviewCommentId } },
// Create new comment on the PR itself (using issues endpoint since PRs are issues)
Expand All @@ -120,10 +117,7 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {

case "discussion": {
const discussionNumber = payload?.discussion?.number;
if (!discussionNumber) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`);
return null;
}
if (!requireEventField(discussionNumber, "Discussion number", ERR_NOT_FOUND)) return null;
// Discussions use GraphQL API - get the node ID
const discussionNodeId = await getDiscussionNodeId(owner, repo, discussionNumber);
return {
Expand All @@ -140,10 +134,7 @@ async function resolveEventEndpoints(eventName, owner, repo, payload) {
return null;
}
const commentNodeId = payload?.comment?.node_id;
if (!commentNodeId) {
core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`);
return null;
}
if (!requireEventField(commentNodeId, "Discussion comment node ID", ERR_NOT_FOUND)) return null;
return {
reactionEndpoint: commentNodeId, // Store node ID for GraphQL
commentUpdateEndpoint: `discussion_comment:${discussionNumber}:${commentId}`, // Special format
Expand Down Expand Up @@ -305,4 +296,4 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio
}
}

module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction, expectRestEndpoint, parseDiscussionEndpoint };
module.exports = { main, addCommentWithWorkflowLink, resolveEventEndpoints, VALID_REACTIONS, addReaction, addDiscussionReaction, expectRestEndpoint, parseDiscussionEndpoint, requireEventField };
141 changes: 141 additions & 0 deletions setup/js/check_cooldown.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { getErrorMessage } = require("./error_helpers.cjs");
const { fetchAndLogRateLimit } = require("./github_rate_limit_logger.cjs");

const MAX_WORKFLOW_RUN_PAGES = 5;
const MAX_AGENT_JOB_LOOKUPS = 50;

async function resolveWorkflowId(githubClient, owner, repo, runId) {
const currentRun = await githubClient.rest.actions.getWorkflowRun({
owner,
repo,
run_id: runId,
});
const workflowId = currentRun.data.workflow_id;
if (!workflowId) {
throw new Error(`Cannot resolve workflow id for run ${runId}`);
}
return workflowId;
}

function parseRunCompletedAt(run) {
// Prefer run_completed_at because it is the terminal timestamp; updated_at is
// only a compatibility fallback for API responses that do not expose it yet.
const completedAtMs = Date.parse(run.run_completed_at ?? run.updated_at ?? "");
if (Number.isNaN(completedAtMs)) {
return null;
}
return { completedAtMs };
}

function agentJobStarted(job) {
return job?.name === "agent" && job.conclusion !== "skipped" && job.started_at;
}

async function main() {
const {
repo: { owner, repo },
runId,
} = context;
const cooldownSeconds = Number(process.env.GH_AW_COOLDOWN_SECONDS);
if (!Number.isFinite(cooldownSeconds) || !Number.isSafeInteger(cooldownSeconds) || cooldownSeconds < 300) {
throw new Error("Workflow cooldown must be an integer of at least 300 seconds");
}

const threshold = Date.now() - cooldownSeconds * 1000;

try {
await fetchAndLogRateLimit(github, "check_cooldown_start");
const workflowId = await resolveWorkflowId(github, owner, repo, runId);
core.info(`Checking ${cooldownSeconds}-second cooldown for workflow '${workflowId}'`);

let page = 1;
const perPage = 100;
let agentJobLookups = 0;
let hitPageLimit = false;

while (true) {
if (page > MAX_WORKFLOW_RUN_PAGES) {
hitPageLimit = true;
break;
}
const response = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
status: "completed",
per_page: perPage,
page,
});
const runs = response.data.workflow_runs || [];
const candidateRuns = [];

for (const run of runs) {
if (run.id === runId) {
continue;
}

const parsedCompletion = parseRunCompletedAt(run);
if (!parsedCompletion) {
core.warning(`Skipping run ${run.id} with an invalid completion time`);
continue;
}
if (parsedCompletion.completedAtMs <= threshold) {
continue;
}
candidateRuns.push({ run, ...parsedCompletion });
}

// listWorkflowRuns is ordered by creation time, not completion time. Sort
// in-window runs by run_completed_at so the first executed agent run found
// is the newest completion among the inspected history.
candidateRuns.sort((left, right) => right.completedAtMs - left.completedAtMs);
for (const { run, completedAtMs } of candidateRuns) {
if (agentJobLookups >= MAX_AGENT_JOB_LOOKUPS) {
core.warning(`Cooldown check reached the ${MAX_AGENT_JOB_LOOKUPS}-run job lookup budget before confirming no agent execution within the cooldown`);
core.setOutput("cooldown_ok", "true");
return;
}
agentJobLookups++;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "latest",
per_page: 100,
});
const agentExecuted = jobs.some(agentJobStarted);
if (!agentExecuted) {
continue;
}

const remainingSeconds = Math.max(0, Math.ceil((completedAtMs + cooldownSeconds * 1000 - Date.now()) / 1000));
core.warning(`Skipping agent execution because run ${run.id} completed within the cooldown period (${remainingSeconds} seconds remaining)`);
core.setOutput("cooldown_ok", "false");
return;
}

// Do not stop just because every run on this page is older than the
// threshold: listWorkflowRuns is not ordered by run_completed_at, so a
// later-created page can still contain a newer completion.
if (runs.length < perPage) {
break;
}
page++;
}

if (hitPageLimit) {
core.warning(`Cooldown check stopped after scanning ${MAX_WORKFLOW_RUN_PAGES} workflow run pages`);
}
core.info("Cooldown passed; no recent completed run executed the agent job");
core.setOutput("cooldown_ok", "true");
} catch (error) {
core.warning(`Cooldown check failed: ${getErrorMessage(error)}`);
core.warning("Allowing agent execution because workflow run history could not be checked");
core.setOutput("cooldown_ok", "true");
}
}

module.exports = { agentJobStarted, main, parseRunCompletedAt, resolveWorkflowId };
77 changes: 75 additions & 2 deletions setup/js/check_stop_time.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,66 @@

const { ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");

// Matches a relative time delta such as "+25h", "+3d", "+1w", "+1mo", "+1d12h".
// Mirrors pkg/workflow/time_delta.go's parseTimeDeltaForStopAfter: minutes are not
// supported since the minimum unit for stop-after is hours.
const TIME_DELTA_PATTERN = /(\d+)(mo|w|d|h)/g;

/** @param {string} stopTime */
function isRelativeStopTime(stopTime) {
return stopTime.startsWith("+");
}

/** @param {string} deltaStr */
function parseTimeDeltaForStopAfter(deltaStr) {
const rest = deltaStr.slice(1);
if (!rest) {
throw new Error("empty time delta after '+'");
}

const matches = [...rest.matchAll(TIME_DELTA_PATTERN)];
if (matches.length === 0) {
throw new Error(`invalid time delta format: +${rest}. Expected format like +25h, +3d, +1w, +1mo, +1d12h`);
}

const consumed = matches.reduce((sum, match) => sum + match[0].length, 0);
if (consumed !== rest.length) {
throw new Error(`invalid time delta format: +${rest}. Extra characters detected`);
}

const delta = { months: 0, weeks: 0, days: 0, hours: 0 };
const seenUnits = new Set();
for (const [, valueStr, unit] of matches) {
if (seenUnits.has(unit)) {
throw new Error(`duplicate unit '${unit}' in time delta: +${rest}`);
}
seenUnits.add(unit);
const value = parseInt(valueStr, 10);
if (unit === "mo") delta.months = value;
else if (unit === "w") delta.weeks = value;
else if (unit === "d") delta.days = value;
else if (unit === "h") delta.hours = value;
}
return delta;
}

/**
* Resolves a relative stop-time delta (e.g. "+48h") to an absolute Date, relative to baseTime.
* Mirrors pkg/workflow/stop_after.go's resolveStopTime: months and days/weeks are applied
* together in a single calendar computation (so date-normalization overflow, e.g. Jan 31 + 1mo,
* is resolved consistently), then hours are added on top.
* @param {string} deltaStr
* @param {Date} baseTime
*/
function resolveRelativeStopTime(deltaStr, baseTime) {
const delta = parseTimeDeltaForStopAfter(deltaStr);
const totalDays = delta.weeks * 7 + delta.days;
return new Date(
Date.UTC(baseTime.getUTCFullYear(), baseTime.getUTCMonth() + delta.months, baseTime.getUTCDate() + totalDays, baseTime.getUTCHours() + delta.hours, baseTime.getUTCMinutes(), baseTime.getUTCSeconds(), baseTime.getUTCMilliseconds())
);
}

async function main() {
const stopTime = process.env.GH_AW_STOP_TIME;
const workflowName = process.env.GH_AW_WORKFLOW_NAME;
Expand All @@ -19,8 +79,21 @@ async function main() {

core.info(`Checking stop-time limit: ${stopTime}`);

// Parse the stop time (format: "YYYY-MM-DD HH:MM:SS")
const stopTimeDate = new Date(stopTime);
// Resolve the stop time. A GitHub Actions expression (e.g. "${{ inputs.stop-after }}")
// is passed through verbatim at compile time and evaluated by the runner before this
// step runs, so it may still be a relative delta (e.g. "+48h") rather than an already
// resolved absolute timestamp (format: "YYYY-MM-DD HH:MM:SS").
let stopTimeDate;
if (isRelativeStopTime(stopTime)) {
try {
stopTimeDate = resolveRelativeStopTime(stopTime, new Date());
} catch (err) {
core.setFailed(`${ERR_VALIDATION}: Invalid stop-time format: ${stopTime}. ${err instanceof Error ? err.message : String(err)}`);
return;
}
} else {
stopTimeDate = new Date(stopTime);
}

if (Number.isNaN(stopTimeDate.getTime())) {
core.setFailed(`${ERR_VALIDATION}: Invalid stop-time format: ${stopTime}. Expected format: YYYY-MM-DD HH:MM:SS`);
Expand Down
Loading
Loading