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
17 changes: 17 additions & 0 deletions src/github/pr-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,23 @@ export async function closePullRequest(env: Env, installationId: number, repoFul
});
}

/** Close a plain issue (sets state=closed). #2270's first issue-side actuation: unlike closePullRequest, this
* hits the generic Issues API (`PATCH /issues/{issue_number}`), not the Pulls API — a plain issue number is not
* a valid `pull_number`, so closePullRequest cannot be reused here. */
export async function closeIssue(env: Env, installationId: number, repoFullName: string, issueNumber: number): Promise<{ state: string }> {
const { owner, repo } = splitRepo(repoFullName);
return withInstallationTokenRetry(env, installationId, async (token) => {
const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId));
const response = await octokit.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
owner,
repo,
issue_number: issueNumber,
state: "closed",
});
return { state: (response.data as { state: string }).state };
});
}

/** The last-closer lookup result. `coveredAllPages` is false when the bounded newest-events window did NOT reach
* back to page 1 (a very long timeline), so a `login: null` may mean "no close found" OR "a close exists beyond
* the inspected window". The reopen guard uses this to fail CLOSED rather than allow a window-evasion bypass. */
Expand Down
99 changes: 97 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
listSignalSnapshots,
listRepoGithubTotalsSnapshotHistory,
listOtherOpenPullRequests,
listOpenIssues,
listOpenPullRequests,
listPullRequests,
listPullRequestFiles,
Expand Down Expand Up @@ -232,6 +233,7 @@ import {
} from "../settings/agent-actions";
import {
executeAgentMaintenanceActions,
executeIssueMaintenanceActions,
pendingClosureLabelApplied,
} from "../services/agent-action-executor";
import { processSubmitDraft } from "../services/draft";
Expand Down Expand Up @@ -1869,7 +1871,7 @@ async function runAgentMaintenancePlanAndExecute(
// exemption is applied by the planner itself (defense-in-depth, mirroring how blacklistEntry above is resolved
// unconditionally and exempted only inside planAgentMaintenanceActions). Disabled (null/undefined cap, the
// default) ⇒ this block is a no-op.
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number } | undefined;
let contributorCapMatch: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
const contributorOpenPrCap = settings.contributorOpenPrCap;
if (typeof contributorOpenPrCap === "number" && pr.authorLogin) {
const authorLoginLower = pr.authorLogin.toLowerCase();
Expand All @@ -1880,7 +1882,7 @@ async function runAgentMaintenancePlanAndExecute(
.sort((a, b) => a - b);
const overCapNumbers = new Set(authorOpenPrNumbers.slice(contributorOpenPrCap));
if (overCapNumbers.has(pr.number)) {
contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: authorOpenPrNumbers.length, cap: contributorOpenPrCap };
contributorCapMatch = { matched: true, authorLogin: pr.authorLogin, openCount: authorOpenPrNumbers.length, cap: contributorOpenPrCap, itemKind: "pull requests" };
}
// Webhook-delivery-order guard (#2479 gate finding): delivery order is not guaranteed to match PR creation
// order, so a sibling PR's own webhook can process before THIS PR exists in the DB and wrongly conclude the
Expand Down Expand Up @@ -3565,6 +3567,77 @@ async function loadOpenQueueCounts(
};
}

/**
* Per-contributor open-ISSUE cap (#2270, anti-abuse): the first `eventName === "issues"` actuation branch —
* issues have no other auto-close path today. Mirrors the PR-path cap in runAgentMaintenancePlanAndExecute:
* counts the author's currently-open issues on this repo (including this one), ranked by issue NUMBER
* (GitHub's own creation order, not webhook-arrival order). Reuses planAgentMaintenanceActions to build the
* SAME label+close plan the PR path uses (identical closeKind/label/close-comment construction): passing
* `conclusion: "skipped"` and no `blacklistMatch` guarantees the function returns at the contributor_cap
* short-circuit (the very next check in the planner) before ever touching any PR/CI-specific field, so
* building a plan for an issue this way is safe.
*
* Webhook-delivery-order guard (#2479 gate finding, mirrored here for issues): delivery order is not
* guaranteed to match issue creation order, so an OLDER sibling's own webhook can process before a NEWER
* sibling exists in the DB and wrongly conclude the author is within the cap — closing only "the incoming
* issue, if it's over cap" would let that stale verdict stand forever, since nothing else ever re-evaluates
* it. Closes EVERY number in the over-cap set discovered by THIS delivery, not just the incoming issue, so
* whichever delivery happens to see the complete picture corrects any sibling a prior delivery missed. Unlike
* the PR path (which enqueues a `agent-regate-pr` wake job so each sibling gets its own live-head/CI-freshness
* re-check before acting), issues have no such staleness risk to guard against — a plain issue has no head SHA
* or CI to go stale, and there's no issue-side "regate" job type to reuse — so acting directly on the
* already-fetched snapshot here is safe.
*/
async function maybeCloseIssueOverContributorCap(
env: Env,
args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings },
): Promise<void> {
const { installationId, repoFullName, issue, settings } = args;
const cap = settings.contributorOpenIssueCap;
const authorLogin = issue.authorLogin;
if (typeof cap !== "number" || !authorLogin) return;

const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : "";
const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase();
const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase());
const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin);
if (authorIsOwner || authorIsAdmin || authorIsAutomationBot) return;

const otherOpenIssues = await listOpenIssues(env, repoFullName);
const authorLoginLower = authorLogin.toLowerCase();
const authorOpenIssueNumbers = otherOpenIssues
.filter((other) => (other.authorLogin ?? "").toLowerCase() === authorLoginLower && other.number !== issue.number)
.map((other) => other.number)
.concat(issue.number)
.sort((a, b) => a - b);
const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap));
if (overCapNumbers.size === 0) return;

const planned = planAgentMaintenanceActions({
conclusion: "skipped",
blockerTitles: [],
autonomy: settings.autonomy,
changedPaths: [],
hardGuardrailGlobs: [],
authorIsOwner,
authorIsAdmin,
authorIsAutomationBot,
ciState: "unverified",
contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" },
contributorCapLabel: settings.contributorCapLabel,
pr: { labels: [] },
});
if (planned.length === 0) return;

for (const overCapNumber of overCapNumbers) {
await executeIssueMaintenanceActions(
env,
{ installationId, repoFullName, issueNumber: overCapNumber, autonomy: settings.autonomy, agentPaused: settings.agentPaused, agentDryRun: settings.agentDryRun },
planned,
);
}
}

async function processGitHubWebhook(
env: Env,
deliveryId: string,
Expand Down Expand Up @@ -4141,6 +4214,28 @@ async function processGitHubWebhook(
);
}
await persistAdvisory(env, advisory);
// Per-contributor open-issue cap (#2270, anti-abuse): the first issue-side auto-close path. Best-effort —
// a failure here must never affect the advisory/notification handling above or the webhook overall.
if (payload.action === "opened" && installationId) {
await maybeCloseIssueOverContributorCap(env, {
installationId,
repoFullName: payload.repository.full_name,
issue,
settings: issueSettings,
}).catch((error) => {
/* v8 ignore next -- best-effort: an issue-cap enforcement failure is logged, never surfaced to the webhook. */
console.error(
JSON.stringify({
level: "warn",
event: "contributor_issue_cap_failed",
deliveryId,
repository: payload.repository?.full_name,
issueNumber: issue.number,
error: errorMessage(error),
}),
);
});
}
// #699 path B: a newly opened grabbable, high-multiplier issue notifies the miners watching this repo
// (fanned out through the same #535 pipeline below).
if (payload.action === "opened")
Expand Down
79 changes: 78 additions & 1 deletion src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createInstallationToken, githubErrorStatus } from "../github/app";
import { fetchLiveCiAggregate, refreshInstallationHealthForInstallation } from "../github/backfill";
import { githubRateLimitAdmissionKeyForToken } from "../github/client";
import { ensurePullRequestLabel, removePullRequestLabel } from "../github/labels";
import { closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions";
import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../github/pr-actions";
import { fetchPullRequestFreshness, pullRequestFreshnessDetail } from "../github/pr-freshness";
import { isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
Expand Down Expand Up @@ -197,6 +197,83 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
return outcomes;
}

export type IssueActionExecutionContext = {
installationId: number;
repoFullName: string;
issueNumber: number;
autonomy: AutonomyPolicy | null | undefined;
agentPaused?: boolean | undefined;
agentDryRun?: boolean | undefined;
};

/**
* Execute (or dry-run) a planned label/close action set on an ISSUE — #2270's first issue-side actuation
* (`planAgentMaintenanceActions`'s `contributor_cap` short-circuit is currently the only source of an
* issue-targeted plan). Deliberately NARROWER than {@link executeAgentMaintenanceActions}:
* - Only `label` (add) and `close` are handled — the only classes the contributor_cap short-circuit ever
* produces. Any other class is denied defensively rather than mis-executed against an issue.
* - No freshness/live-CI-re-verification/pull_requests:write gate: none of those PR concepts apply to a
* plain issue (no head SHA, no CI, and a close needs `issues: write`, a different permission than the PR
* executor's write-readiness check covers).
* - `requiresApproval` (`auto_with_approval`) is DENIED, not staged: the pending-action queue is PR-shaped
* (pullNumber-typed staging + a `/pull/{n}` notification deeplink); extending it to issues is out of scope
* here. Denying — rather than silently executing or silently skipping the approval gate — keeps the
* configured autonomy honest: an operator who set `auto_with_approval` never gets an un-approved action.
*/
export async function executeIssueMaintenanceActions(env: Env, ctx: IssueActionExecutionContext, planned: PlannedAgentAction[]): Promise<AgentActionOutcome[]> {
const outcomes: AgentActionOutcome[] = [];
const targetKey = `${ctx.repoFullName}#${ctx.issueNumber}`;
const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun });

for (const action of planned) {
const autonomyLevel = resolveAutonomy(ctx.autonomy, action.actionClass);
const audit = (outcome: AgentActionOutcome["outcome"], detail: string) => {
const auditOutcome = outcome === "dry_run" ? "completed" : outcome;
outcomes.push({ actionClass: action.actionClass, outcome, detail });
return recordAuditEvent(
env,
buildAgentActionAudit({ actionClass: action.actionClass, autonomyLevel, mode, outcome: auditOutcome, repoFullName: ctx.repoFullName, targetKey, actor: AGENT_ACTOR, reason: detail }),
);
};

if (mode === "paused") {
await audit("denied", "agent actions paused");
continue;
}
if (!isActingAutonomyLevel(autonomyLevel)) {
await audit("denied", `autonomy for ${action.actionClass} is ${autonomyLevel} — action not currently enabled`);
continue;
}
if (mode === "dry_run") {
await audit("dry_run", `dry-run: would ${action.actionClass} — ${action.reason}`);
continue;
}
if (action.requiresApproval) {
await audit("denied", `awaiting maintainer approval — issue-side staging is not yet supported (${action.reason})`);
continue;
}
if (action.actionClass !== "label" && action.actionClass !== "close") {
/* v8 ignore next -- defensive: planAgentMaintenanceActions's contributor_cap short-circuit (this
* executor's only caller today) never produces any class besides label/close. */
await audit("denied", `unsupported action class for an issue: ${action.actionClass}`);
continue;
}
try {
if (action.actionClass === "label") {
await ensurePullRequestLabel(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber, action.label ?? "", { createMissingLabel: true });
} else {
if (action.closeComment) await createIssueComment(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber, action.closeComment);
await closeIssue(env, ctx.installationId, ctx.repoFullName, ctx.issueNumber);
}
await audit("completed", action.reason);
} catch (error) {
await audit("error", errorMessage(error));
}
}

return outcomes;
}

// RC3: persist the outcome of a FAILED merge so it is never retried blindly forever. A non-transient failure
// (403/405 perms, 409 required-check-absent, merge conflict) is terminal immediately; an otherwise-unclassified
// failure (e.g. base moved during the merge — a benign TOCTOU race) is retried up to MERGE_RETRY_CAP and then
Expand Down
8 changes: 5 additions & 3 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ export type AgentActionPlanInput = {
// Fires for a CONTRIBUTOR only (owner/admin/automation bots are NEVER auto-closed by this). `openCount` and
// `cap` are PUBLIC (the author's own open-item count on a public repo, and the repo's own configured limit),
// so — unlike the blacklist's private-reason close — they ARE interpolated into the public close comment.
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number } | undefined;
// `itemKind` selects the close-comment noun ("pull requests" for the PR-path caller, "issues" for the
// issue-path caller, #2270) — REQUIRED (not defaulted) so a caller can't silently mislabel the other kind.
contributorCapMatch?: { matched: boolean; authorLogin: string; openCount: number; cap: number; itemKind: "pull requests" | "issues" } | undefined;
// The repo-configured label applied to an over-cap author's PR/issue (#2270), resolved from `.gittensory.yml`.
// Absent ⇒ the default (`DEFAULT_CONTRIBUTOR_CAP_LABEL` = "over-contributor-limit").
contributorCapLabel?: string | undefined;
Expand Down Expand Up @@ -315,15 +317,15 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne
// independently of the caller (defense-in-depth, matching the blacklist block's own redundant check above).
const capContributor = !input.authorIsOwner && !input.authorIsAdmin && !input.authorIsAutomationBot;
if (input.contributorCapMatch?.matched === true && capContributor) {
const { authorLogin, openCount, cap } = input.contributorCapMatch;
const { authorLogin, openCount, cap, itemKind } = input.contributorCapMatch;
const label = input.contributorCapLabel ?? DEFAULT_CONTRIBUTOR_CAP_LABEL;
if (acting("label")) actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: "over the per-contributor open-item cap", label, labelOp: "add" });
if (acting("close")) {
actions.push({
actionClass: "close",
requiresApproval: approval("close"),
reason: "over the per-contributor open-item cap",
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, "pull requests")),
closeComment: sanitizePublicComment(contributorCapCloseMessage(authorLogin, openCount, cap, itemKind)),
closeKind: "contributor_cap",
});
}
Expand Down
Loading
Loading