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
57 changes: 55 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4699,6 +4699,18 @@ async function maybeCloseIssueOverContributorCap(
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.
let isNewAccount = false;
const accountAgeThresholdDays = settings.accountAgeThresholdDays;
if (typeof accountAgeThresholdDays === "number") {
const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin);
if (createdAt) {
const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000);
isNewAccount = ageDays < 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 @@ -4749,6 +4761,9 @@ 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;

const effectiveIssueCap =
isNewAccount ? Math.max(1, Math.ceil(cap / 2)) : cap;

const otherOpenIssues = await listOpenIssues(env, repoFullName);
const authorLoginLower = authorLogin.toLowerCase();
const otherAuthorIssueNumbers = otherOpenIssues
Expand Down Expand Up @@ -4786,7 +4801,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 @@ -4799,7 +4814,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 @@ -5472,6 +5487,44 @@ 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("/"))
: "";
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"
) {
const createdAt = await getGithubUserCreatedAt(env, installationId, authorLogin);
if (createdAt) {
const ageDays = (Date.now() - Date.parse(createdAt)) / (24 * 60 * 60 * 1000);
if (ageDays < accountAgeThresholdDays && resolveAutonomy(issueSettings.autonomy, "review_state_label") === "auto") {
const newAccountMode = resolveAgentActionMode({
globalPaused: isGlobalAgentPause(env) || (await isGlobalAgentFrozen(env)),
agentPaused: issueSettings.agentPaused,
agentDryRun: issueSettings.agentDryRun,
});
await ensurePullRequestLabel(
env,
installationId,
payload.repository.full_name,
issue.number,
issueSettings.newAccountLabel ?? "new-account",
{ createMissingLabel: issueSettings.createMissingLabel, mode: newAccountMode },
).catch(() => 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 @@ -845,11 +845,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
95 changes: 95 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10192,6 +10192,101 @@ describe("queue processors", () => {
expect(seen.labels).not.toContain("new-account");
});

function stubIssueAccountAgeFetch(issueNumber: number, createdAt: string, seen: { labels: string[]; closed: boolean }) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
if (url.includes("/users/")) return Response.json({ login: "newbie", created_at: createdAt });
if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" });
if (url.endsWith(`/issues/${issueNumber}`) && method === "PATCH") {
seen.closed = JSON.parse(String(init?.body ?? "{}")).state === "closed";
return Response.json({ state: "closed" });
}
if (url.includes(`/issues/${issueNumber}/labels`) && method === "GET") return Response.json([]);
if (url.includes(`/issues/${issueNumber}/labels`) && method === "POST") {
seen.labels.push(...((JSON.parse(String(init?.body ?? "{}")).labels ?? []) as string[]));
return Response.json([]);
}
if (url.includes(`/issues/${issueNumber}/comments`) && method === "POST") return Response.json({ id: 1 }, { status: 201 });
if (url.endsWith("/labels") && method === "POST") return Response.json({ name: "x" }, { status: 201 });
return Response.json({});
};
}

it("account-age throttle (#2561 issue path): a below-threshold-age account gets the new-account label AND a tighter effective issue cap", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await upsertInstallation(env, {
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] },
repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }],
});
await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" });
await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" });
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autonomy: { close: "auto", review_state_label: "auto" },
contributorOpenIssueCap: 4,
accountAgeThresholdDays: 30,
});
const seen = { labels: [] as string[], closed: false };
vi.stubGlobal("fetch", stubIssueAccountAgeFetch(62, new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), seen));

await processJob(env, {
type: "github-webhook",
deliveryId: "account-age-issue-tighter-cap",
eventName: "issues",
payload: {
action: "opened",
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" },
},
});

expect(seen.labels).toContain("new-account");
expect(seen.closed).toBe(true);
});

it("account-age throttle (#2561 issue path): when accountAgeThresholdDays is off, no user lookup runs", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await upsertInstallation(env, {
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "all", permissions: { metadata: "read", issues: "write" }, events: ["issues"] },
repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }],
});
await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 60, title: "Newbie issue one", state: "open", user: { login: "newbie" }, labels: [], body: "x" });
await upsertIssueFromGitHub(env, "JSONbored/gittensory", { number: 61, title: "Newbie issue two", state: "open", user: { login: "newbie" }, labels: [], body: "y" });
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
autonomy: { close: "auto", review_state_label: "auto" },
contributorOpenIssueCap: 4,
});
let accountAgeUsersFetched = false;
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
if (url.includes("/users/")) { accountAgeUsersFetched = true; return Response.json({ login: "newbie", created_at: new Date().toISOString() }); }
if ((url.endsWith("/issues/60") || url.endsWith("/issues/61")) && method === "GET") return Response.json({ state: "open" });
if (url.endsWith("/issues/62") && method === "PATCH") return Response.json({ state: "open" });
if (url.includes("/issues/62/comments") && method === "POST") return Response.json({ id: 1 }, { status: 201 });
return Response.json({});
});

await processJob(env, {
type: "github-webhook",
deliveryId: "account-age-issue-off",
eventName: "issues",
payload: {
action: "opened",
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
issue: { number: 62, title: "Newbie's 3rd issue", state: "open", user: { login: "newbie" }, labels: [], body: "x" },
},
});

expect(accountAgeUsersFetched).toBe(false);
});

it("contributor open-PR cap (#2270): out-of-order webhook delivery wakes and self-corrects the missed sibling (regression, gate finding on #2479)", async () => {
// PR56 (the NEWER PR) is delivered BEFORE PR55 exists in the DB — a real possibility under concurrent/
// retried webhook delivery. At that moment PR56 only sees {54, 56} (2 total, AT the cap of 2, not over),
Expand Down
Loading