Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
26 changes: 26 additions & 0 deletions src/queue/account-age-throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getGithubUserCreatedAt } from "../github/app";

/** Fail-open account-age check shared by issue cap tightening and issue-open labeling (#2561). */
export 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;
}

export function repoOwnerLoginFromFullName(fullName: string): string {
const slashIdx = fullName.indexOf("/");
if (slashIdx === -1) return "";
return fullName.slice(0, slashIdx);
}

export function effectiveIssueCapForAccountAge(cap: number, isNewAccount: boolean): number {
if (isNewAccount) return Math.max(1, Math.ceil(cap / 2));
return cap;
}
54 changes: 51 additions & 3 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ import {
upsertRepositoryFromGitHub,
} from "../db/repositories";
import { pruneExpiredRecords } from "../db/retention";
import {
effectiveIssueCapForAccountAge,
isBelowAccountAgeThreshold,
repoOwnerLoginFromFullName,
} from "./account-age-throttle";
import {
backfillOpenPullRequestDetails,
backfillRegisteredRepositories,
Expand Down Expand Up @@ -4818,12 +4823,16 @@ 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 = repoOwnerLoginFromFullName(repoFullName);
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 @@ -4874,6 +4883,8 @@ 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 = effectiveIssueCapForAccountAge(cap, isNewAccount);

const otherOpenIssues = await listOpenIssues(env, repoFullName);
const authorLoginLower = authorLogin.toLowerCase();
const otherAuthorIssueNumbers = otherOpenIssues
Expand Down Expand Up @@ -4911,7 +4922,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 @@ -4924,7 +4935,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 @@ -5616,6 +5627,43 @@ 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 = repoOwnerLoginFromFullName(payload.repository.full_name);
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,
});
await ensurePullRequestLabel(
env,
installationId,
payload.repository.full_name,
issue.number,
issueSettings.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
68 changes: 68 additions & 0 deletions test/unit/account-age-throttle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestEnv } from "../helpers/d1";
import {
effectiveIssueCapForAccountAge,
isBelowAccountAgeThreshold,
repoOwnerLoginFromFullName,
} from "../../src/queue/account-age-throttle";

function generatePrivateKeyPem(): string {
return generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs8", format: "pem" }) as string;
}

describe("account-age throttle helpers (#2561 issue path)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("repoOwnerLoginFromFullName returns the owner segment for owner/repo names", () => {
expect(repoOwnerLoginFromFullName("JSONbored/gittensory")).toBe("JSONbored");
});

it("repoOwnerLoginFromFullName returns empty for a no-slash repo name", () => {
expect(repoOwnerLoginFromFullName("noslash")).toBe("");
});

it("effectiveIssueCapForAccountAge halves and rounds up for new accounts", () => {
expect(effectiveIssueCapForAccountAge(4, true)).toBe(2);
expect(effectiveIssueCapForAccountAge(5, true)).toBe(3);
expect(effectiveIssueCapForAccountAge(1, true)).toBe(1);
});

it("effectiveIssueCapForAccountAge preserves the full cap for established accounts", () => {
expect(effectiveIssueCapForAccountAge(4, false)).toBe(4);
});

it("isBelowAccountAgeThreshold returns false when the threshold is off", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() });
let fetched = false;
vi.stubGlobal("fetch", async () => { fetched = true; return Response.json({}); });
expect(await isBelowAccountAgeThreshold(env, 123, "newbie", null)).toBe(false);
expect(fetched).toBe(false);
});

it("isBelowAccountAgeThreshold fail-opens when created_at is unavailable", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() });
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/access_tokens")) return Response.json({ token: "t" });
if (url.includes("/users/")) return new Response("missing", { status: 404 });
return Response.json({});
});
expect(await isBelowAccountAgeThreshold(env, 123, "newbie", 30)).toBe(false);
});

it("isBelowAccountAgeThreshold returns true for a below-threshold account", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generatePrivateKeyPem() });
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/access_tokens")) return Response.json({ token: "t" });
if (url.includes("/users/")) {
return Response.json({ login: "newbie", created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString() });
}
return Response.json({});
});
expect(await isBelowAccountAgeThreshold(env, 123, "newbie", 30)).toBe(true);
});
});
Loading
Loading