Skip to content
Closed
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
73 changes: 70 additions & 3 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4805,6 +4805,19 @@ async function verifiedGlobalOpenItemCount(
* as NOT open (excluded from the count), never left as an unverified "counts toward the cap" default, because
* this count gates an irreversible close (#2479 gate finding, second pass).
*/
async function isBelowAccountAgeThreshold(
env: Env,
installationId: number,
authorLogin: string,
accountAgeThresholdDays: number | null | undefined,
): Promise<boolean> {
if (typeof accountAgeThresholdDays !== "number") return false;
const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin);
if (!createdAt) return false;
const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000);
return ageDays < accountAgeThresholdDays;
}

async function maybeCloseIssueOverContributorCap(
env: Env,
args: { installationId: number; repoFullName: string; issue: IssueRecord; settings: RepositorySettings },
Expand All @@ -4817,12 +4830,19 @@ async function maybeCloseIssueOverContributorCap(
const globalCap = resolveGlobalContributorOpenItemCap(env);
if ((typeof cap !== "number" && globalCap === null) || !authorLogin) return;

const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : "";
const repoOwner = repoFullName.includes("/")
? repoFullName.slice(0, repoFullName.indexOf("/"))
/* v8 ignore next -- defensive: GitHub always uses owner/repo form; empty repoOwner means authorIsOwner is always false */
: "";
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;

// Account-age throttle (#2561): mirror the PR-path cap tightening — a below-threshold author gets half
// the configured per-repo issue cap (rounded up, minimum 1). Fail-open when created_at cannot be resolved.
const isNewAccount = await isBelowAccountAgeThreshold(env, installationId, authorLogin, settings.accountAgeThresholdDays);

// Install-wide check first (#2562): reuses the shared autoCloseExemptLogins list, same as the PR path.
// verifiedGlobalOpenItemCount live-verifies every OTHER counted item before trusting it toward an
// irreversible close (#2562 gate-review follow-up), mirroring the per-repo cap's own sibling live-verify.
Expand Down Expand Up @@ -4873,6 +4893,11 @@ async function maybeCloseIssueOverContributorCap(
// cooldown already honor -- see the matching comment on the PR-side per-repo cap in the PR maintenance path.
if (typeof cap !== "number" || isAutoCloseExempt(authorLogin, settings.autoCloseExemptLogins)) return;

let effectiveIssueCap = cap;
if (isNewAccount) {
effectiveIssueCap = Math.max(1, Math.ceil(cap / 2));
}

const otherOpenIssues = await listOpenIssues(env, repoFullName);
const authorLoginLower = authorLogin.toLowerCase();
const otherAuthorIssueNumbers = otherOpenIssues
Expand Down Expand Up @@ -4910,7 +4935,7 @@ async function maybeCloseIssueOverContributorCap(
.filter((number) => confirmedOpen.has(number))
.concat(issue.number)
.sort((a, b) => a - b);
const overCapNumbers = new Set(authorOpenIssueNumbers.slice(cap));
const overCapNumbers = new Set(authorOpenIssueNumbers.slice(effectiveIssueCap));
if (overCapNumbers.size === 0) return;

const planned = planAgentMaintenanceActions({
Expand All @@ -4923,7 +4948,7 @@ async function maybeCloseIssueOverContributorCap(
authorIsAdmin,
authorIsAutomationBot,
ciState: "unverified",
contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap, itemKind: "issues" },
contributorCapMatch: { matched: true, authorLogin, openCount: authorOpenIssueNumbers.length, cap: effectiveIssueCap, itemKind: "issues" },
contributorCapLabel: settings.contributorCapLabel,
pr: { labels: [] },
});
Expand Down Expand Up @@ -5615,6 +5640,48 @@ async function processGitHubWebhook(
);
}
await persistAdvisory(env, advisory);
// Account-age visibility (#2561 issue-path parity): label newly opened issues from below-threshold
// accounts when review_state_label autonomy is auto — same contract as the PR maintenance path.
if (payload.action === "opened" && installationId && issue.authorLogin) {
const repoOwner = payload.repository.full_name.includes("/")
? payload.repository.full_name.slice(0, payload.repository.full_name.indexOf("/"))
/* v8 ignore next -- defensive: GitHub webhooks always use owner/repo form; empty repoOwner means authorIsOwner is always false */
: "";
const authorLogin = issue.authorLogin;
const authorIsOwner = authorLogin.toLowerCase() === repoOwner.toLowerCase();
const authorIsAdmin = parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(authorLogin.toLowerCase());
const authorIsAutomationBot = isProtectedAutomationAuthor(authorLogin);
const accountAgeThresholdDays = issueSettings.accountAgeThresholdDays;
if (
!authorIsOwner &&
!authorIsAdmin &&
!authorIsAutomationBot &&
typeof accountAgeThresholdDays === "number"
) {
if (await isBelowAccountAgeThreshold(env, installationId, authorLogin, accountAgeThresholdDays)) {
if (resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") {
const newAccountMode = resolveAgentActionMode({
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
agentPaused: issueSettings.agentPaused,
agentDryRun: issueSettings.agentDryRun,
});
const newAccountLabel = issueSettings.newAccountLabel
?? /* v8 ignore next -- settings resolution always supplies new-account before this handler runs */ "new-account";
await ensurePullRequestLabel(
env,
installationId,
payload.repository.full_name,
issue.number,
newAccountLabel,
{ createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode },
).catch(
/* v8 ignore next -- fail-safe: a label-application failure must never block the rest of the handler */
() => undefined,
);
}
}
}
}
// 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) {
Expand Down
9 changes: 4 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,11 +879,10 @@ export type RepositorySettings = {
* force -- a `mergeable_state: clean` read is trusted exactly as it is today. Layered like every other
* settings field (`.gittensory.yml` `gate.requireFreshRebaseWindow` > DB > `null`). */
requireFreshRebaseWindowMinutes?: number | null | undefined;
/** Account-age throttle (#2561, anti-abuse): a PR from an account younger than this many days gets the
* {@link newAccountLabel} and a tighter effective contributor cap -- friction/visibility, NEVER an
* automatic close on account age alone. `null`/undefined (default) = off, zero behavior change. Never
* fires for the repo owner, admin logins, or automation bots. PR-path only for now -- the issue-path
* enforcement `maybeCloseIssueOverContributorCap` already goes through does not yet read this setting. */
/** Account-age throttle (#2561, anti-abuse): an account younger than this many days gets the
* {@link newAccountLabel} and a tighter effective contributor cap — friction/visibility, NEVER an
* automatic close on account age alone. `null`/undefined (default) = off. Never fires for the repo
* owner, admin logins, or automation bots. Applies on both PR and issue contributor-cap paths. */
accountAgeThresholdDays?: number | null | undefined;
/** The label applied to a below-threshold-age account's PR (#2561), mirroring {@link blacklistLabel}'s
* configurable-with-fallback shape. Always populated by the DB layer (default `"new-account"`); optional so
Expand Down
Loading
Loading