Skip to content
14 changes: 9 additions & 5 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,14 @@ settings:
# gate-override: [maintainer, collaborator]

# Per-contributor open-PR/open-issue caps (#2270, anti-abuse): the max PRs/issues a single
# non-owner/non-admin/non-bot contributor may have open on this repo at once. These fields are
# currently INERT (stored and parsed, not yet enforced) — enforcement (closing the newest item(s)
# above the cap with a clear reason) lands in a follow-up PR; see #2270 for status. Positive whole
# number, or omit/null for no cap. Default: null (disabled) for both. Set explicitly to `null` (not
# just omitted) to force-clear a cap that a dashboard/DB write previously set.
# non-owner/non-admin/non-bot contributor may have open on this repo at once. Uncomment and set a
# number to opt in — a contributor's newest item above the cap is closed with a clear reason on the
# PR-opened/issue-opened webhook; their oldest items up to the cap stay open and reviewable.
# Both contributorOpenPrCap and contributorOpenIssueCap ARE ENFORCED. Positive whole number, or
# omit/null for no cap. Default: null (disabled) for both. Set explicitly to `null` (not just omitted)
# to force-clear a cap that a dashboard/DB write previously set.
# contributorOpenPrCap: 2
# contributorOpenIssueCap: 5

# Label applied to a PR/issue closed for exceeding a cap above. String. Default: over-contributor-limit.
# contributorCapLabel: over-contributor-limit
3 changes: 3 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8601,6 +8601,9 @@
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
},
"contributorCapLabel": {
"type": "string"
}
},
"required": [
Expand Down
4 changes: 4 additions & 0 deletions migrations/0090_contributor_cap_label.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Label applied to a PR/issue closed for exceeding a per-contributor open-item cap (#2270, anti-abuse).
-- Configurable so the disposition works regardless of the label a repo sets; defaults to "over-contributor-limit"
-- so existing rows are byte-identical to the enforcement's built-in fallback.
ALTER TABLE repository_settings ADD COLUMN contributor_cap_label TEXT NOT NULL DEFAULT 'over-contributor-limit';
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY },
contributorOpenPrCap: null,
contributorOpenIssueCap: null,
contributorCapLabel: "over-contributor-limit",
};
}
return {
Expand Down Expand Up @@ -549,6 +550,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap),
contributorCapLabel: row.contributorCapLabel,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -627,6 +629,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
autoMaintain: normalizeAutoMaintainPolicy(settings.autoMaintain),
contributorOpenPrCap: normalizeOpenItemCap(settings.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(settings.contributorOpenIssueCap),
contributorCapLabel: settings.contributorCapLabel ?? "over-contributor-limit",
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -675,6 +678,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
autoMaintainJson: jsonString(resolved.autoMaintain),
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -724,6 +728,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
autoMaintainJson: jsonString(resolved.autoMaintain),
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
updatedAt: nowIso(),
},
});
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
// Per-contributor open PR/issue caps (#2270, anti-abuse): null = no cap (default). Enforcement lands separately.
contributorOpenPrCap: integer("contributor_open_pr_cap"),
contributorOpenIssueCap: integer("contributor_open_issue_cap"),
contributorCapLabel: text("contributor_cap_label").notNull().default("over-contributor-limit"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
17 changes: 17 additions & 0 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2590,6 +2590,23 @@ export async function fetchLivePullRequestState(
return result?.data.state ?? undefined;
}

/** The issue's LIVE state ("open" / "closed") via REST `GET /issues/{n}`. Mirrors {@link fetchLivePullRequestState}
* for issues: the stored open-issue cache lags GitHub, so a sibling closed on GitHub (or elsewhere) can still
* read `open` locally. The per-contributor open-issue cap (#2479 gate finding) confirms each counted sibling's
* live state before treating a newly opened issue as over cap, so a stale row never inflates the count and
* wrongly closes an issue that is within the real cap. Best-effort: returns undefined on any error so the
* caller fails open to the stored state (same fail-open contract as the PR-side helper). */
export async function fetchLiveIssueState(
env: Env,
repoFullName: string,
issueNumber: number,
token: string | undefined,
admissionKey?: GitHubRateLimitAdmissionKey,
): Promise<string | undefined> {
const result = await githubJsonWithHeaders<{ state?: string | null }>(env, repoFullName, `/issues/${issueNumber}`, token, githubRateLimitOptions(admissionKey)).catch(() => undefined);
return result?.data.state ?? undefined;
}

/** The PR's LIVE head commit SHA via REST `GET /pulls/{n}`. The stored `pr.headSha` lags GitHub when a commit
* lands between a webhook and its processing; the gate-override command (#16 / audit) re-fetches the live head
* so the neutral check-run targets the commit a maintainer is actually looking at, not a phantom old SHA.
Expand Down
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
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,7 @@ export const RepositorySettingsSchema = z
agentDryRun: z.boolean().optional(),
contributorOpenPrCap: z.number().int().positive().nullable().optional(),
contributorOpenIssueCap: z.number().int().positive().nullable().optional(),
contributorCapLabel: z.string().optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
Loading
Loading