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
9 changes: 9 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,12 @@ settings:
# default: [maintainer, collaborator, confirmed_miner]
# commands:
# 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.
# contributorOpenPrCap: 2
# contributorOpenIssueCap: 5
12 changes: 12 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8589,6 +8589,18 @@
},
"blacklistLabel": {
"type": "string"
},
"contributorOpenPrCap": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
},
"contributorOpenIssueCap": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
}
},
"required": [
Expand Down
5 changes: 5 additions & 0 deletions migrations/0089_contributor_open_caps.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Per-contributor open PR/issue caps (#2270, anti-abuse): optional per-repo ceilings on how many PRs/issues a
-- single contributor may have open at once. NULL (the default) means no cap — byte-identical behavior for every
-- existing row. Enforcement (auto-close over the cap) lands in a follow-up PR; this column only carries config.
ALTER TABLE repository_settings ADD COLUMN contributor_open_pr_cap INTEGER;
ALTER TABLE repository_settings ADD COLUMN contributor_open_issue_cap INTEGER;
19 changes: 19 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorBlacklist: [],
autonomy: {},
autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY },
contributorOpenPrCap: null,
contributorOpenIssueCap: null,
};
}
return {
Expand Down Expand Up @@ -545,6 +547,8 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorBlacklist: parseContributorBlacklist(row.contributorBlacklistJson),
autonomy: parseAutonomyPolicy(row.autonomyJson),
autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson),
contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -621,6 +625,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorBlacklist: normalizeContributorBlacklist(settings.contributorBlacklist).entries,
autonomy: normalizeAutonomyPolicy(settings.autonomy),
autoMaintain: normalizeAutoMaintainPolicy(settings.autoMaintain),
contributorOpenPrCap: normalizeOpenItemCap(settings.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(settings.contributorOpenIssueCap),
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -667,6 +673,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -714,6 +722,8 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorBlacklistJson: jsonString(resolved.contributorBlacklist),
autonomyJson: jsonString(resolved.autonomy),
autoMaintainJson: jsonString(resolved.autoMaintain),
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -5586,6 +5596,15 @@ function normalizeQualityGateMinScore(value: number | null | undefined): number
return Math.max(0, Math.min(100, Math.round(value)));
}

// A per-contributor open-item cap (#2270) counts discrete open PRs/issues, not a 0-100 score, so unlike
// normalizeQualityGateMinScore it is neither clamped into a range nor rounded — a fractional or non-positive
// value is a malformed cap (there's no such thing as "allow 2.5 open PRs"), so it is dropped to null (no cap)
// rather than silently coerced into a nonsensical threshold.
function normalizeOpenItemCap(value: number | null | undefined): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) return null;
return value;
}

function parsePublicSurface(value: string): RepositorySettings["publicSurface"] {
if (value === "comment_only" || value === "label_only" || value === "off") return value;
return "comment_and_label";
Expand Down
3 changes: 3 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export const repositorySettings = sqliteTable("repository_settings", {
autoMaintainJson: text("auto_maintain_json").notNull().default("{}"),
agentPaused: integer("agent_paused", { mode: "boolean" }).notNull().default(false),
agentDryRun: integer("agent_dry_run", { mode: "boolean" }).notNull().default(false),
// 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"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ export const RepositorySettingsSchema = z
autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(),
agentPaused: z.boolean().optional(),
agentDryRun: z.boolean().optional(),
contributorOpenPrCap: z.number().int().positive().nullable().optional(),
contributorOpenIssueCap: z.number().int().positive().nullable().optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
23 changes: 23 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export type FocusManifestSettings = Partial<
| "commandAuthorization"
| "contributorBlacklist"
| "blacklistLabel"
| "contributorOpenPrCap"
| "contributorOpenIssueCap"
>
>;

Expand Down Expand Up @@ -792,6 +794,27 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
warnings.push(...blacklistWarnings);
if (entries.length > 0) out.contributorBlacklist = entries;
}
// Per-contributor open PR/issue caps (#2270): discrete counts, not scores — reuse the same positive-integer
// normalizer as contentLane.maxAppendedEntries so a fractional/non-positive typo is dropped with a warning
// instead of configuring a nonsensical cap. UNLIKE contributorBlacklist above, an explicit yml `null` here is
// load-bearing (not the same as omitting the key): the documented `yml > DB > null` precedence means a
// maintainer must be able to force a DB-configured cap back to "no cap" via `.gittensory.yml` without deleting
// the DB row. `normalizeOptionalPositiveInteger` collapses "absent" and "null" to the same silent `null`
// return, so that distinction has to be made HERE, before calling it: a literal `null` sets the key to `null`
// (clears); omitted (`undefined`) leaves the key unset (preserves the DB value via the resolver's spread); an
// invalid non-null value (fractional/non-positive/wrong type) warns and also leaves the key unset.
if (r.contributorOpenPrCap === null) {
out.contributorOpenPrCap = null;
} else {
const contributorOpenPrCap = normalizeOptionalPositiveInteger(r.contributorOpenPrCap, "settings.contributorOpenPrCap", warnings);
if (contributorOpenPrCap !== null) out.contributorOpenPrCap = contributorOpenPrCap;
}
if (r.contributorOpenIssueCap === null) {
out.contributorOpenIssueCap = null;
} else {
const contributorOpenIssueCap = normalizeOptionalPositiveInteger(r.contributorOpenIssueCap, "settings.contributorOpenIssueCap", warnings);
if (contributorOpenIssueCap !== null) out.contributorOpenIssueCap = contributorOpenIssueCap;
}
return out;
}

Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,14 @@ export type RepositorySettings = {
* the label a repo sets. Always populated by the DB layer (default `"slop"`); optional so existing settings
* fixtures/callers need not be touched (mirrors the sibling `contributorBlacklist`). */
blacklistLabel?: string | undefined;
/** Per-contributor open-PR cap (#2270, anti-abuse): the max PRs a single non-owner/admin/bot contributor may
* have open on this repo at once. `null`/absent (default) = no cap, byte-identical to today. Layered like
* every other settings field (`.gittensory.yml` `settings.contributorOpenPrCap` > DB > `null`). Enforcement
* (closing the newest PR(s) over the cap) is a separate follow-up; this field only carries the threshold. */
contributorOpenPrCap?: number | null | undefined;
/** Per-contributor open-issue cap (#2270, anti-abuse): same shape and precedence as {@link contributorOpenPrCap},
* applied to open issues instead of open PRs. `null`/absent (default) = no cap. */
contributorOpenIssueCap?: number | null | undefined;
/** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default
* `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers
* need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */
Expand Down
16 changes: 16 additions & 0 deletions test/unit/data-spine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,22 @@ describe("data spine repositories", () => {
await upsertRepositorySettings(env, { repoFullName: "owner/saferepo", agentPaused: false });
expect((await getRepositorySettings(env, "owner/saferepo")).agentPaused).toBe(false); // update persists
expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ agentPaused: false, agentDryRun: false }); // defaults
// #2270 per-contributor open PR/issue caps: no row and no cap set both default to null (disabled).
expect(await getRepositorySettings(env, "missing/repo")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null });
expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ contributorOpenPrCap: null, contributorOpenIssueCap: null });
// Round-trips on insert and persists on update.
await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 2, contributorOpenIssueCap: 5 });
expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 2, contributorOpenIssueCap: 5 });
await upsertRepositorySettings(env, { repoFullName: "owner/caprepo", contributorOpenPrCap: 3, contributorOpenIssueCap: null });
expect(await getRepositorySettings(env, "owner/caprepo")).toMatchObject({ contributorOpenPrCap: 3, contributorOpenIssueCap: null }); // update persists + can clear
// A cap must be a positive whole number: fractional, non-positive, and non-finite values are all
// dropped to null rather than silently coerced (there's no such thing as "allow 2.5 open PRs").
await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: 2.5 as never });
expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull();
await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: 0 });
expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull();
await upsertRepositorySettings(env, { repoFullName: "owner/badcaprepo", contributorOpenPrCap: Number.NaN as never });
expect((await getRepositorySettings(env, "owner/badcaprepo")).contributorOpenPrCap).toBeNull();
expect(updated.slopAiAdvisory).toBe(false);
expect(await getRepoSyncState(env, "missing/repo")).toBeNull();
expect(await getPullRequest(env, "owner/repo", 404)).toBeNull();
Expand Down
35 changes: 35 additions & 0 deletions test/unit/focus-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,41 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () =
expect(noOverride.contributorBlacklist?.map((e) => e.login)).toEqual(["keep-me"]);
});

it("parses + resolves contributorOpenPrCap/contributorOpenIssueCap from the settings: block, overlaying the DB (#2270)", () => {
const manifest = parseFocusManifest({ settings: { contributorOpenPrCap: 2, contributorOpenIssueCap: 5 } });
expect(manifest.settings.contributorOpenPrCap).toBe(2);
expect(manifest.settings.contributorOpenIssueCap).toBe(5);
// yml overlays a DB-configured cap.
const eff = resolveEffectiveSettings({ contributorOpenPrCap: 10, contributorOpenIssueCap: 10 } as unknown as RepositorySettings, manifest);
expect(eff.contributorOpenPrCap).toBe(2);
expect(eff.contributorOpenIssueCap).toBe(5);
// Omitted in yml ⇒ the DB-configured cap survives untouched (not blanked to undefined/null).
const noOverride = resolveEffectiveSettings({ contributorOpenPrCap: 4, contributorOpenIssueCap: null } as unknown as RepositorySettings, parseFocusManifest({}));
expect(noOverride.contributorOpenPrCap).toBe(4);
expect(noOverride.contributorOpenIssueCap).toBeNull();
// A cap is a discrete count, not a 0-100 score: fractional, non-positive, and non-numeric values are all
// dropped with a warning rather than silently coerced or clamped into range.
const invalid = parseFocusManifest({ settings: { contributorOpenPrCap: 2.5, contributorOpenIssueCap: 0 } });
expect(invalid.settings.contributorOpenPrCap).toBeUndefined();
expect(invalid.settings.contributorOpenIssueCap).toBeUndefined();
expect(invalid.warnings.some((w) => /settings\.contributorOpenPrCap/.test(w))).toBe(true);
expect(invalid.warnings.some((w) => /settings\.contributorOpenIssueCap/.test(w))).toBe(true);
const nonNumber = parseFocusManifest({ settings: { contributorOpenPrCap: "two" as never } });
expect(nonNumber.settings.contributorOpenPrCap).toBeUndefined();
});

it("an EXPLICIT yml null force-clears a DB-configured cap, distinct from an omitted key (regression, gate finding on #2467)", () => {
// Omitted key preserves the DB value (already covered above); an explicit `null` must ALSO be able to
// override a DB-configured cap back to "no cap" — the documented `yml > DB > null` precedence otherwise
// has no way to un-set a cap without a separate dashboard/DB write, which contradicts config-as-code.
const explicitNull = parseFocusManifest({ settings: { contributorOpenPrCap: null, contributorOpenIssueCap: null } });
expect(explicitNull.settings.contributorOpenPrCap).toBeNull();
expect(explicitNull.settings.contributorOpenIssueCap).toBeNull();
const eff = resolveEffectiveSettings({ contributorOpenPrCap: 4, contributorOpenIssueCap: 4 } as unknown as RepositorySettings, explicitNull);
expect(eff.contributorOpenPrCap).toBeNull();
expect(eff.contributorOpenIssueCap).toBeNull();
});

it("resolves contributor blacklist by unioning the shared/global list with effective per-repo settings", () => {
const manifest = parseFocusManifest({ settings: { contributorBlacklist: [{ login: "repo-only", reason: "manifest" }, { login: "Global-Repo", reason: "manifest-overrides-global" }] } });
const eff = resolveEffectiveSettings(
Expand Down
Loading