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
18 changes: 18 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8144,6 +8144,14 @@
},
"agentDryRun": {
"type": "boolean"
},
"selfAuthoredLinkedIssueGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8161,6 +8169,7 @@
"slopGateMode",
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"selfAuthoredLinkedIssueGateMode",
"firstTimeContributorGrace",
"slopAiAdvisory",
"autoLabelEnabled",
Expand Down Expand Up @@ -8760,6 +8769,14 @@
"advisory",
"block"
]
},
"selfAuthoredLinkedIssueGateMode": {
"type": "string",
"enum": [
"off",
"advisory",
"block"
]
}
},
"required": [
Expand All @@ -8777,6 +8794,7 @@
"slopGateMode",
"mergeReadinessGateMode",
"manifestPolicyGateMode",
"selfAuthoredLinkedIssueGateMode",
"firstTimeContributorGrace",
"autoLabelEnabled",
"gittensorLabel",
Expand Down
5 changes: 5 additions & 0 deletions migrations/0055_self_authored_linked_issue_gate_mode.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Per-repo gate mode for self-authored linked issues (#self-authored-linked-issue-gate).
-- When `advisory` (default), the `self_authored_linked_issue` finding is surfaced in the review
-- panel but never blocks the gate — no behavior change for existing repos. When `block`, the gate
-- closes the PR when the contributor opens a PR that links an issue they themselves filed.
ALTER TABLE repository_settings ADD COLUMN self_authored_linked_issue_gate_mode TEXT NOT NULL DEFAULT 'advisory';
5 changes: 5 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
slopGateMode: "off",
mergeReadinessGateMode: "off",
manifestPolicyGateMode: "off",
selfAuthoredLinkedIssueGateMode: "advisory",
firstTimeContributorGrace: false,
slopGateMinScore: null,
slopAiAdvisory: false,
Expand Down Expand Up @@ -454,6 +455,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
slopGateMode: parseGateRuleMode(row.slopGateMode),
mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode),
manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode),
selfAuthoredLinkedIssueGateMode: parseGateRuleMode(row.selfAuthoredLinkedIssueGateMode),
firstTimeContributorGrace: row.firstTimeContributorGrace,
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
Expand Down Expand Up @@ -497,6 +499,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
slopGateMode: settings.slopGateMode ?? "off",
mergeReadinessGateMode: settings.mergeReadinessGateMode ?? "off",
manifestPolicyGateMode: settings.manifestPolicyGateMode ?? "off",
selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode ?? "advisory",
firstTimeContributorGrace: settings.firstTimeContributorGrace ?? false,
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
slopAiAdvisory: settings.slopAiAdvisory ?? false,
Expand Down Expand Up @@ -538,6 +541,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
manifestPolicyGateMode: resolved.manifestPolicyGateMode,
selfAuthoredLinkedIssueGateMode: resolved.selfAuthoredLinkedIssueGateMode,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
Expand Down Expand Up @@ -580,6 +584,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
slopGateMode: resolved.slopGateMode,
mergeReadinessGateMode: resolved.mergeReadinessGateMode,
manifestPolicyGateMode: resolved.manifestPolicyGateMode,
selfAuthoredLinkedIssueGateMode: resolved.selfAuthoredLinkedIssueGateMode,
firstTimeContributorGrace: resolved.firstTimeContributorGrace,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
slopGateMode: text("slop_gate_mode").notNull().default("off"),
mergeReadinessGateMode: text("merge_readiness_gate_mode").notNull().default("off"),
manifestPolicyGateMode: text("manifest_policy_gate_mode").notNull().default("off"),
selfAuthoredLinkedIssueGateMode: text("self_authored_linked_issue_gate_mode").notNull().default("advisory"),
firstTimeContributorGrace: integer("first_time_contributor_grace", { mode: "boolean" }).notNull().default(false),
slopGateMinScore: integer("slop_gate_min_score"),
slopAiAdvisory: integer("slop_ai_advisory", { mode: "boolean" }).notNull().default(false),
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ export const RepositorySettingsSchema = z
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
Expand Down Expand Up @@ -643,6 +644,7 @@ export const RepoSettingsPreviewSchema = z
slopGateMode: z.enum(["off", "advisory", "block"]),
mergeReadinessGateMode: z.enum(["off", "advisory", "block"]),
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
selfAuthoredLinkedIssueGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
autoLabelEnabled: z.boolean(),
Expand Down
22 changes: 20 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
listBounties,
listBountiesByRepo,
listContributorIssues,
getIssue,
listContributorPullRequests,
listContributorRepoStats,
listIssues,
Expand Down Expand Up @@ -808,11 +809,15 @@ async function reReviewStoredPullRequest(
// a rebase fired a synchronize, or CI is still running — the synchronize / CI-completion webhook re-triggers
// once the head is current and CI has settled (the sweep backstops a missed event).
if (!(await prReadyForReview(env, installationId, repoFullName, pr, settings, deliveryId))) return;
const otherOpenPullRequests = await listOtherOpenPullRequests(env, repoFullName, prNumber);
const [otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([
listOtherOpenPullRequests(env, repoFullName, prNumber),
resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues),
]);
const advisory = buildPullRequestAdvisory(repo, pr, {
otherOpenPullRequests,
requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings),
duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true",
linkedIssueAuthorLogins,
});
await persistAdvisory(env, advisory);
if (shouldCollectSlopEvidence(settings) || settings.manifestPolicyGateMode !== "off") {
Expand Down Expand Up @@ -1563,15 +1568,17 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str
if (payload.action === "reopened" && installationId && (await maybeRecloseDisallowedReopen(env, deliveryId, installationId, repoFullName, pr, payload).catch(() => false))) {
return;
}
const [repo, settings, otherOpenPullRequests] = await Promise.all([
const [repo, settings, otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([
getRepository(env, repoFullName),
resolveRepositorySettings(env, repoFullName),
listOtherOpenPullRequests(env, repoFullName, pr.number),
resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues),
]);
const advisory = buildPullRequestAdvisory(repo, pr, {
otherOpenPullRequests,
requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings),
duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true",
linkedIssueAuthorLogins,
});
await persistAdvisory(env, advisory);
if (installationId && shouldProcessPullRequestPublicSurface(payload.action)) {
Expand Down Expand Up @@ -1707,6 +1714,16 @@ export function shouldCollectLinkedIssueEvidence(settings: Pick<RepositorySettin
return settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off" || mergeReadinessGateEnabled(settings);
}

// Fetch the author login for each linked issue number from the local DB. Returns a parallel array of
// logins (null when the issue is not in the DB or has no recorded author). Errors are swallowed per-issue
// so a DB hiccup on one issue never prevents the advisory from running — the detection is fail-open
// (an unknown author login never triggers the self_authored_linked_issue finding).
export async function resolveLinkedIssueAuthorLogins(env: Env, repoFullName: string, linkedIssues: number[]): Promise<(string | null)[]> {
if (linkedIssues.length === 0) return [];
const results = await Promise.all(linkedIssues.map((n) => getIssue(env, repoFullName, n).then((i) => i?.authorLogin ?? null).catch(() => null)));
return results;
}

export function shouldCollectSlopEvidence(settings: Pick<RepositorySettings, "slopGateMode" | "mergeReadinessGateMode">): boolean {
return settings.slopGateMode !== "off" || mergeReadinessGateEnabled(settings);
}
Expand Down Expand Up @@ -1742,6 +1759,7 @@ export function gateCheckPolicy(
slopGateMode: settings.slopGateMode,
mergeReadinessGateMode: settings.mergeReadinessGateMode,
manifestPolicyGateMode: settings.manifestPolicyGateMode,
selfAuthoredLinkedIssueGateMode: settings.selfAuthoredLinkedIssueGateMode,
firstTimeContributorGrace: settings.firstTimeContributorGrace,
authorMergedPrCount: authorHistory?.mergedPrCount,
authorClosedUnmergedPrCount: authorHistory?.closedUnmergedPrCount,
Expand Down
31 changes: 30 additions & 1 deletion src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export type GateCheckPolicy = {
* blockers. An INDEPENDENT dimension, deliberately NOT folded into the merge-readiness composite so #555
* stays focused. `off`/`advisory` = the findings stay advisory (never block). Default off. */
manifestPolicyGateMode?: GateRuleMode | undefined;
/** Self-authored linked-issue gate. When `block`, a `self_authored_linked_issue` finding — raised when
* the PR author also filed the linked issue — becomes a hard blocker. Defaults to `advisory` — the
* finding is surfaced but never blocks unless the maintainer opts in. */
selfAuthoredLinkedIssueGateMode?: GateRuleMode | undefined;
/** First-time-contributor grace (#552). When true AND the author is a genuine newcomer (0 merged PRs in
* this repo) who is NOT a repeat offender (< 3 closed-unmerged PRs), a would-be BLOCK is softened to a
* neutral/advisory gate. `undefined`/false = the grace rule does not apply and blockers gate normally. */
Expand Down Expand Up @@ -89,6 +93,10 @@ export function buildPullRequestAdvisory(
* closed as a duplicate. Default/false ⇒ every duplicate sibling keeps the finding (byte-identical). The
* caller sets this to `env.GITTENSORY_DUPLICATE_WINNER === "true"`. */
duplicateWinnerEnabled?: boolean;
/** Author logins of the linked issues (one entry per resolved issue, may be null when unknown). Used to
* surface a `self_authored_linked_issue` finding when the PR author also opened the linked issue. Absent
* or empty ⇒ the finding is never raised (fail-open: unknown issue authorship stays advisory-only). */
linkedIssueAuthorLogins?: (string | null | undefined)[];
} = {},
): Advisory {
const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown";
Expand All @@ -114,7 +122,7 @@ export function buildPullRequestAdvisory(
action: "Re-deliver the webhook or wait for the next sync.",
});
} else {
addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled));
addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? [], Boolean(context.requireLinkedIssue), Boolean(context.duplicateWinnerEnabled), context.linkedIssueAuthorLogins ?? []);
}
return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined);
}
Expand Down Expand Up @@ -504,6 +512,7 @@ function addPullRequestFindings(
otherOpenPullRequests: PullRequestRecord[],
requireLinkedIssue: boolean,
duplicateWinnerEnabled: boolean,
linkedIssueAuthorLogins: (string | null | undefined)[],
): void {
if (pr.state !== "open") {
findings.push({
Expand Down Expand Up @@ -540,6 +549,23 @@ function addPullRequestFindings(
});
}
}
// Self-authored linked-issue detection: the PR author also filed the linked issue. Raised when at least
// one linked issue's author login is a case-insensitive match for the PR author. Gated by
// selfAuthoredLinkedIssueGateMode — advisory by default so this never blocks without maintainer opt-in.
// Absent/null issue author logins are treated as unknown and never trigger the finding (fail-open).
if (pr.linkedIssues.length > 0 && pr.authorLogin) {
const prAuthor = pr.authorLogin.toLowerCase();
const selfAuthored = linkedIssueAuthorLogins.some((login) => login != null && login.toLowerCase() === prAuthor);
if (selfAuthored) {
findings.push({
code: "self_authored_linked_issue",
severity: "warning",
title: "PR author also opened the linked issue",
detail: "The contributor who opened this PR also filed the linked issue. This pattern can indicate artificial issue-discovery work rather than solving an independently discovered problem.",
action: "Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.",
});
}
}
if (otherOpenPullRequests.length >= 10) {
findings.push({
code: "busy_pr_queue",
Expand Down Expand Up @@ -667,6 +693,9 @@ function isConfiguredGateBlocker(code: string, policy: GateCheckPolicy): boolean
if (code === "manifest_blocked_path" || code === "manifest_linked_issue_required" || code === "manifest_missing_tests") {
return gateMode(policy.manifestPolicyGateMode ?? "off") === "block";
}
// Self-authored linked-issue gate: blocks only when the maintainer opts in with `block`. Defaults to
// advisory — the finding surfaces in the panel without ever closing the PR unless explicitly configured.
if (code === "self_authored_linked_issue") return gateMode(policy.selfAuthoredLinkedIssueGateMode ?? "advisory") === "block";
return false;
}

Expand Down
1 change: 1 addition & 0 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export function buildPredictedGateVerdict(args: {
qualityGateMinScore: gate.readinessMinScore ?? null,
aiReviewGateMode: gate.aiReviewMode ?? undefined,
mergeReadinessGateMode: gate.mergeReadiness ?? undefined,
selfAuthoredLinkedIssueGateMode: gate.selfAuthoredLinkedIssue ?? undefined,
readinessScore: readiness.total,
confirmedContributor: effectiveConfirmedContributor,
firstTimeContributorGrace: gate.firstTimeContributorGrace ?? undefined,
Expand Down
9 changes: 9 additions & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type FocusManifestGateConfig = {
aiReviewModel: string | null;
mergeReadiness: GateRuleMode | null;
manifestPolicy: GateRuleMode | null;
selfAuthoredLinkedIssue: GateRuleMode | null;
firstTimeContributorGrace: boolean | null;
};

Expand All @@ -53,6 +54,7 @@ export type FocusManifestSettings = Partial<
| "gateCheckMode"
| "linkedIssueGateMode"
| "duplicatePrGateMode"
| "selfAuthoredLinkedIssueGateMode"
| "qualityGateMode"
| "qualityGateMinScore"
| "aiReviewMode"
Expand Down Expand Up @@ -167,6 +169,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
aiReviewModel: null,
mergeReadiness: null,
manifestPolicy: null,
selfAuthoredLinkedIssue: null,
firstTimeContributorGrace: null,
};

Expand Down Expand Up @@ -310,6 +313,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
aiReviewModel: normalizeOptionalString(aiReviewRecord?.model, "gate.aiReview.model", warnings),
mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings),
manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings),
selfAuthoredLinkedIssue: normalizeOptionalGateMode(record.selfAuthoredLinkedIssue, "gate.selfAuthoredLinkedIssue", warnings),
firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings),
};
gate.present =
Expand All @@ -328,6 +332,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
gate.aiReviewModel !== null ||
gate.mergeReadiness !== null ||
gate.manifestPolicy !== null ||
gate.selfAuthoredLinkedIssue !== null ||
gate.firstTimeContributorGrace !== null;
return gate;
}
Expand Down Expand Up @@ -366,6 +371,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
}
if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness;
if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy;
if (gate.selfAuthoredLinkedIssue !== null) out.selfAuthoredLinkedIssue = gate.selfAuthoredLinkedIssue;
if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace;
return out;
}
Expand Down Expand Up @@ -412,6 +418,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[])
if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode;
const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings);
if (duplicatePrGateMode !== null) out.duplicatePrGateMode = duplicatePrGateMode;
const selfAuthoredLinkedIssueGateMode = normalizeOptionalGateMode(r.selfAuthoredLinkedIssueGateMode, "settings.selfAuthoredLinkedIssueGateMode", warnings);
if (selfAuthoredLinkedIssueGateMode !== null) out.selfAuthoredLinkedIssueGateMode = selfAuthoredLinkedIssueGateMode;
const qualityGateMode = normalizeOptionalGateMode(r.qualityGateMode, "settings.qualityGateMode", warnings);
if (qualityGateMode !== null) out.qualityGateMode = qualityGateMode;
const qualityGateMinScore = normalizeOptionalScore(r.qualityGateMinScore, "settings.qualityGateMinScore", warnings);
Expand Down Expand Up @@ -526,6 +534,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes
if (gate.aiReviewModel !== null) effective.aiReviewModel = gate.aiReviewModel;
if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness;
if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy;
if (gate.selfAuthoredLinkedIssue !== null) effective.selfAuthoredLinkedIssueGateMode = gate.selfAuthoredLinkedIssue;
if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace;
// The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the
// boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797).
Expand Down
Loading
Loading