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
15 changes: 15 additions & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -274,3 +274,18 @@ settings:

# Label applied to a PR/issue closed for exceeding a cap above. String. Default: over-contributor-limit.
# contributorCapLabel: over-contributor-limit

# Review-request nagging cooldown (#2463, anti-abuse): throttle a non-owner/non-admin/non-bot
# contributor who repeatedly pings @gittensory for review on the same PR/issue. "hold" replies with a
# cooldown notice and takes no further action; "close" closes the PR (issues degrade to "hold" until
# a dedicated closeIssue primitive lands) with a clear reason. Off by default.
# reviewNagPolicy: off # off | hold | close. Default: off.
# reviewNagMaxPings: 3 # Positive integer. Pings above this within the cooldown window trigger the policy. Default: 3.
# reviewNagCooldownDays: 5 # Positive integer. Window the ping count is measured over. Default: 5.
# reviewNagLabel: review-nag-cooldown # Label applied alongside the hold/close action. Default: review-nag-cooldown.

# Shared repo-scoped exemption list (#2463): GitHub logins never throttled/closed by gittensory's
# deterministic anti-abuse mechanisms (review-nag cooldown today; the per-contributor open-item cap
# above will reuse this list too), on top of the standing owner/admin/automation-bot exemption.
# List of GitHub logins. Default: [] (no additional exemptions).
# autoCloseExemptLogins: [some-trusted-regular]
27 changes: 27 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8602,6 +8602,33 @@
"minimum": 0,
"exclusiveMinimum": true
},
"reviewNagPolicy": {
"type": "string",
"enum": [
"off",
"hold",
"close"
]
},
"reviewNagMaxPings": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"reviewNagCooldownDays": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true
},
"reviewNagLabel": {
"type": "string"
},
"autoCloseExemptLogins": {
"type": "array",
"items": {
"type": "string"
}
},
"contributorCapLabel": {
"type": "string"
}
Expand Down
11 changes: 11 additions & 0 deletions migrations/0091_review_nag_cooldown.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Review-request nagging cooldown (#2463, anti-abuse): throttle a contributor repeatedly pinging @gittensory.
-- Defaults are byte-identical to today: review_nag_policy defaults to 'off' (disabled), so existing rows see no
-- behavior change. review_nag_max_pings / review_nag_cooldown_days / review_nag_label only take effect once a
-- repo opts in by setting the policy to 'hold' or 'close'.
ALTER TABLE repository_settings ADD COLUMN review_nag_policy TEXT NOT NULL DEFAULT 'off';
ALTER TABLE repository_settings ADD COLUMN review_nag_max_pings INTEGER NOT NULL DEFAULT 3;
ALTER TABLE repository_settings ADD COLUMN review_nag_cooldown_days INTEGER NOT NULL DEFAULT 5;
ALTER TABLE repository_settings ADD COLUMN review_nag_label TEXT NOT NULL DEFAULT 'review-nag-cooldown';
-- Shared repo-scoped exemption list (#2463): GitHub logins never throttled/closed by gittensory's deterministic
-- anti-abuse mechanisms, on top of the standing owner/admin/automation-bot exemption. Defaults to an empty list.
ALTER TABLE repository_settings ADD COLUMN auto_close_exempt_logins_json TEXT NOT NULL DEFAULT '[]';
57 changes: 57 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } fr
import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility";
import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization";
import { normalizeContributorBlacklist } from "../settings/contributor-blacklist";
import { normalizeAutoCloseExemptLogins } from "../settings/auto-close-exempt";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy, DEFAULT_AUTO_MAINTAIN_POLICY } from "../settings/autonomy";
import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto";
import { errorMessage, jsonString, nowIso, parseJson, repoParts } from "../utils/json";
Expand Down Expand Up @@ -504,6 +505,11 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorOpenPrCap: null,
contributorOpenIssueCap: null,
contributorCapLabel: "over-contributor-limit",
reviewNagPolicy: "off",
reviewNagMaxPings: 3,
reviewNagCooldownDays: 5,
reviewNagLabel: "review-nag-cooldown",
autoCloseExemptLogins: [],
};
}
return {
Expand Down Expand Up @@ -551,6 +557,11 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
contributorOpenPrCap: normalizeOpenItemCap(row.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(row.contributorOpenIssueCap),
contributorCapLabel: row.contributorCapLabel,
reviewNagPolicy: normalizeReviewNagPolicy(row.reviewNagPolicy),
reviewNagMaxPings: normalizePositiveIntWithDefault(row.reviewNagMaxPings, 3),
reviewNagCooldownDays: normalizePositiveIntWithDefault(row.reviewNagCooldownDays, 5),
reviewNagLabel: row.reviewNagLabel,
autoCloseExemptLogins: parseAutoCloseExemptLogins(row.autoCloseExemptLoginsJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
Expand Down Expand Up @@ -630,6 +641,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: normalizeOpenItemCap(settings.contributorOpenPrCap),
contributorOpenIssueCap: normalizeOpenItemCap(settings.contributorOpenIssueCap),
contributorCapLabel: settings.contributorCapLabel ?? "over-contributor-limit",
reviewNagPolicy: normalizeReviewNagPolicy(settings.reviewNagPolicy),
reviewNagMaxPings: normalizePositiveIntWithDefault(settings.reviewNagMaxPings, 3),
reviewNagCooldownDays: normalizePositiveIntWithDefault(settings.reviewNagCooldownDays, 5),
reviewNagLabel: settings.reviewNagLabel ?? "review-nag-cooldown",
autoCloseExemptLogins: normalizeAutoCloseExemptLogins(settings.autoCloseExemptLogins).logins,
};
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -679,6 +695,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
reviewNagPolicy: resolved.reviewNagPolicy,
reviewNagMaxPings: resolved.reviewNagMaxPings,
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
reviewNagLabel: resolved.reviewNagLabel,
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
updatedAt: nowIso(),
})
.onConflictDoUpdate({
Expand Down Expand Up @@ -729,6 +750,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
contributorOpenPrCap: resolved.contributorOpenPrCap,
contributorOpenIssueCap: resolved.contributorOpenIssueCap,
contributorCapLabel: resolved.contributorCapLabel,
reviewNagPolicy: resolved.reviewNagPolicy,
reviewNagMaxPings: resolved.reviewNagMaxPings,
reviewNagCooldownDays: resolved.reviewNagCooldownDays,
reviewNagLabel: resolved.reviewNagLabel,
autoCloseExemptLoginsJson: jsonString(resolved.autoCloseExemptLogins),
updatedAt: nowIso(),
},
});
Expand Down Expand Up @@ -2183,6 +2209,21 @@ export async function hasRecentAuditEvent(env: Env, actor: string, eventType: st
return rows.length > 0;
}

/** Count-returning variant of {@link hasRecentAuditEvent}, additionally scoped to one `targetKey` (e.g. a single
* `owner/repo#123` PR/issue) rather than the actor's activity across the whole repo. Backs the review-request
* nagging cooldown (#2463): counting how many `@gittensory` pings a contributor has sent on ONE thread within
* the configured cooldown window. */
export async function countRecentAuditEventsForActorAndTarget(env: Env, actor: string, eventType: string, targetKey: string, sinceIso: string): Promise<number> {
const db = getDb(env.DB);
const [row] = await db
.select({ count: sql<number>`count(*)` })
.from(auditEvents)
.where(and(eq(auditEvents.actor, actor), eq(auditEvents.eventType, eventType), eq(auditEvents.targetKey, targetKey), gte(auditEvents.createdAt, sinceIso)));
/* v8 ignore next -- count(*) always returns exactly one row; the empty-array guard only satisfies the destructure type. */
if (!row) return 0;
return row.count;
}

/** Observability for the queue dead-letter rate (#1276): how many jobs (across BOTH the maintenance and webhook
* lanes) were dead-lettered since `sinceIso`. Reads the `github_app.dlq_dead_lettered` audit events written by
* processDlqBatch — NOT gated behind any review-ops flag, so the infra drop rate is always visible. */
Expand Down Expand Up @@ -5642,6 +5683,22 @@ function parseContributorBlacklist(value: string): RepositorySettings["contribut
return normalizeContributorBlacklist(parseJson<unknown>(value, null)).entries;
}

function parseAutoCloseExemptLogins(value: string): string[] {
return normalizeAutoCloseExemptLogins(parseJson<unknown>(value, null)).logins;
}

function normalizeReviewNagPolicy(value: string | null | undefined): "off" | "hold" | "close" {
return value === "hold" || value === "close" ? value : "off";
}

// A review-nag threshold/window is a discrete positive count, not a score — reuses the same non-clamping,
// non-rounding shape as contributorOpenPrCap's normalizeOpenItemCap (#2270): an invalid value (fractional,
// non-positive, non-finite) falls back to the given default rather than being silently coerced.
function normalizePositiveIntWithDefault(value: number | null | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) return fallback;
return value;
}

function parseAutonomyPolicy(value: string): AutonomyPolicy {
return normalizeAutonomyPolicy(parseJson<unknown>(value, null));
}
Expand Down
7 changes: 7 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export const repositorySettings = sqliteTable("repository_settings", {
contributorOpenPrCap: integer("contributor_open_pr_cap"),
contributorOpenIssueCap: integer("contributor_open_issue_cap"),
contributorCapLabel: text("contributor_cap_label").notNull().default("over-contributor-limit"),
// Review-request nagging cooldown (#2463, anti-abuse): default 'off' (disabled).
reviewNagPolicy: text("review_nag_policy").notNull().default("off"),
reviewNagMaxPings: integer("review_nag_max_pings").notNull().default(3),
reviewNagCooldownDays: integer("review_nag_cooldown_days").notNull().default(5),
reviewNagLabel: text("review_nag_label").notNull().default("review-nag-cooldown"),
// Shared repo-scoped exemption list (#2463): a JSON array of GitHub logins.
autoCloseExemptLoginsJson: text("auto_close_exempt_logins_json").notNull().default("[]"),
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
});
Expand Down
5 changes: 5 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,11 @@ export const RepositorySettingsSchema = z
contributorOpenPrCap: z.number().int().positive().nullable().optional(),
contributorOpenIssueCap: z.number().int().positive().nullable().optional(),
contributorCapLabel: z.string().optional(),
reviewNagPolicy: z.enum(["off", "hold", "close"]).optional(),
reviewNagMaxPings: z.number().int().positive().optional(),
reviewNagCooldownDays: z.number().int().positive().optional(),
reviewNagLabel: z.string().optional(),
autoCloseExemptLogins: z.array(z.string()).optional(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
})
Expand Down
Loading
Loading