Skip to content
Closed
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
830 changes: 424 additions & 406 deletions apps/gittensory-ui/public/openapi.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions migrations/0046_reviewer_routing_mode.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- #540/#830 reviewer-routing: add opt-in reviewer auto-request mode. `off` = feature disabled (default);
-- `advisory` = surface ranked CODEOWNERS suggestions in the PR panel only, no GitHub API side-effects;
-- `auto_request` = also call GitHub's request-reviewers API for the top suggestion (outward-facing,
-- never for first-time external contributors without explicit opt-in per #552).
ALTER TABLE repository_settings ADD COLUMN reviewer_routing_mode TEXT NOT NULL DEFAULT 'off';
11 changes: 11 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm";

Check warning on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/db/repositories.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { getDb } from "./client";
import {
advisories,
Expand Down Expand Up @@ -144,6 +144,7 @@
RepoSyncStateRecord,
RepositorySettings,
RepositoryRecord,
ReviewerRoutingMode,
ScorePreviewRecord,
ScoringModelSnapshotRecord,
SignalSnapshotRecord,
Expand Down Expand Up @@ -429,6 +430,7 @@
requireLinkedIssue: false,
backfillEnabled: true,
privateTrustEnabled: true,
reviewerRoutingMode: "off",
badgeEnabled: false,
agentPaused: false,
agentDryRun: false,
Expand Down Expand Up @@ -468,6 +470,7 @@
requireLinkedIssue: row.requireLinkedIssue,
backfillEnabled: row.backfillEnabled,
privateTrustEnabled: row.privateTrustEnabled,
reviewerRoutingMode: parseReviewerRoutingMode(row.reviewerRoutingMode),
badgeEnabled: row.badgeEnabled,
agentPaused: row.agentPaused,
agentDryRun: row.agentDryRun,
Expand Down Expand Up @@ -511,6 +514,7 @@
requireLinkedIssue: settings.requireLinkedIssue ?? false,
backfillEnabled: settings.backfillEnabled ?? true,
privateTrustEnabled: settings.privateTrustEnabled ?? true,
reviewerRoutingMode: parseReviewerRoutingMode(settings.reviewerRoutingMode ?? "off"),
badgeEnabled: settings.badgeEnabled ?? false,
agentPaused: settings.agentPaused ?? false,
agentDryRun: settings.agentDryRun ?? false,
Expand Down Expand Up @@ -552,6 +556,7 @@
requireLinkedIssue: resolved.requireLinkedIssue,
backfillEnabled: resolved.backfillEnabled,
privateTrustEnabled: resolved.privateTrustEnabled,
reviewerRoutingMode: resolved.reviewerRoutingMode,
badgeEnabled: resolved.badgeEnabled,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
Expand Down Expand Up @@ -594,6 +599,7 @@
requireLinkedIssue: resolved.requireLinkedIssue,
backfillEnabled: resolved.backfillEnabled,
privateTrustEnabled: resolved.privateTrustEnabled,
reviewerRoutingMode: resolved.reviewerRoutingMode,
badgeEnabled: resolved.badgeEnabled,
agentPaused: resolved.agentPaused,
agentDryRun: resolved.agentDryRun,
Expand Down Expand Up @@ -5069,6 +5075,11 @@
return "comment_and_label";
}

function parseReviewerRoutingMode(value: string | null | undefined): ReviewerRoutingMode {
if (value === "advisory" || value === "auto_request") return value;
return "off";
}

function parseCommandAuthorizationPolicy(value: string): RepositorySettings["commandAuthorization"] {
return normalizeCommandAuthorizationPolicy(parseJson<unknown>(value, null)).policy;
}
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

Check warning on line 1 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/db/schema.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
// Timestamp columns use a drizzle $defaultFn so an insert that omits the column gets a real ISO-8601
// timestamp. A static `.default("CURRENT_TIMESTAMP")` would make drizzle inject the literal STRING
// "CURRENT_TIMESTAMP" (it applies static defaults client-side, never reaching SQLite's CURRENT_TIMESTAMP),
Expand Down Expand Up @@ -69,6 +69,7 @@
requireLinkedIssue: integer("require_linked_issue", { mode: "boolean" }).notNull().default(false),
backfillEnabled: integer("backfill_enabled", { mode: "boolean" }).notNull().default(true),
privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true),
reviewerRoutingMode: text("reviewer_routing_mode").notNull().default("off"),
badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false),
commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"),
autonomyJson: text("autonomy_json").notNull().default("{}"),
Expand Down
92 changes: 92 additions & 0 deletions src/github/reviewer-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Octokit } from "@octokit/core";

Check warning on line 1 in src/github/reviewer-request.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/github/reviewer-request.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/github/reviewer-request.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { createInstallationToken } from "./app";

/** GitHub checks these paths in order when resolving CODEOWNERS. */
const CODEOWNERS_CANDIDATES = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] as const;
const CODEOWNERS_MAX_BYTES = 512_000;

/**
* Fetch the CODEOWNERS file for a repository from the public GitHub raw endpoint.
* Tries CODEOWNERS, .github/CODEOWNERS, and docs/CODEOWNERS in order (GitHub resolution order).
* Returns null when none exists or network errors occur — callers treat this as "no routing data".
*/
export async function fetchCodeownersFile(repoFullName: string): Promise<string | null> {
const slash = repoFullName.indexOf("/");
if (slash <= 0 || slash === repoFullName.length - 1) return null;
const owner = encodeURIComponent(repoFullName.slice(0, slash));
const name = encodeURIComponent(repoFullName.slice(slash + 1));
for (const path of CODEOWNERS_CANDIDATES) {
const url = `https://raw.githubusercontent.com/${owner}/${name}/HEAD/${path}`;
try {
const response = await fetch(url, { headers: { "User-Agent": "gittensory" } });
if (!response.ok) continue;
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsed = Number.parseInt(contentLength, 10);
if (Number.isFinite(parsed) && parsed > CODEOWNERS_MAX_BYTES) continue;
}
const text = await response.text();
if (text.length <= CODEOWNERS_MAX_BYTES) return text;
} catch {
// try next candidate
}
}
return null;
}

type RequestedReviewersResponse = {
users?: Array<{ login?: string | null }>;
};

/**
* Return the set of logins (lowercase) that are already pending review-request on a PR so the
* caller can skip re-requesting them (idempotency guard).
*/
export async function getRequestedReviewers(env: Env, installationId: number, repoFullName: string, pullNumber: number): Promise<Set<string>> {
const [owner, repo] = repoFullName.split("/");
if (!owner || !repo) return new Set();
const token = await createInstallationToken(env, installationId);
const octokit = new Octokit({ auth: token });
try {
const response = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", {
owner,
repo,
pull_number: pullNumber,
});
const data = response.data as RequestedReviewersResponse;
const logins = new Set<string>();
for (const user of data.users ?? []) {
if (user.login) logins.add(user.login.toLowerCase());
}
return logins;
} catch {
// Non-fatal: if we can't check, proceed conservatively (caller will skip).
return new Set();
}
}

/**
* Request individual reviewers on a pull request using the GitHub installation token. Teams are
* NOT passed — pass only user logins. Returns whether the request was sent.
*
* Throws on non-2xx responses so the caller can catch and audit the failure.
*/
export async function requestPullRequestReviewers(
env: Env,
installationId: number,
repoFullName: string,
pullNumber: number,
reviewerLogins: string[],
): Promise<void> {
if (reviewerLogins.length === 0) return;
const [owner, repo] = repoFullName.split("/");
if (!owner || !repo) throw new Error(`Invalid repository full name: ${repoFullName}`);
const token = await createInstallationToken(env, installationId);
const octokit = new Octokit({ auth: token });
await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", {
owner,
repo,
pull_number: pullNumber,
reviewers: reviewerLogins,
});
}
2 changes: 2 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from "zod";

Check warning on line 1 in src/openapi/schemas.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/openapi/schemas.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/openapi/schemas.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);
Expand Down Expand Up @@ -569,6 +569,7 @@
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
reviewerRoutingMode: z.enum(["off", "advisory", "auto_request"]),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
createMissingLabel: z.boolean(),
Expand Down Expand Up @@ -614,6 +615,7 @@
manifestPolicyGateMode: z.enum(["off", "advisory", "block"]),
firstTimeContributorGrace: z.boolean(),
slopGateMinScore: z.number().nullable().optional(),
reviewerRoutingMode: z.enum(["off", "advisory", "auto_request"]),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
createMissingLabel: z.boolean(),
Expand Down
50 changes: 50 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check warning on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
countOpenIssues,
countOpenPullRequests,
getAgentCommandAnswer,
Expand Down Expand Up @@ -81,6 +81,8 @@
sanitizePublicComment,
} from "../github/commands";
import { ensurePullRequestLabel } from "../github/labels";
import { fetchCodeownersFile, getRequestedReviewers, requestPullRequestReviewers } from "../github/reviewer-request";
import { buildReviewerRouting } from "../signals/reviewer-routing";
import { fetchPublicContributorProfile } from "../github/public";
import { refreshRegistry } from "../registry/sync";
import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory";
Expand Down Expand Up @@ -1354,6 +1356,10 @@
let gateEvaluation: ReturnType<typeof evaluateGateCheck> | undefined;
let aiReview: { notes: string } | undefined;
let gateFinalized = false;
// Tracks the PR author's merged-PR count in this repo for the reviewer auto-request newcomer
// guard. Set inside the try block when authorHistory is computed; null = try block failed,
// treat as unknown and skip auto-request conservatively.
let authorMergedPrCount: number | null = null;
try {
const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([
listIssues(env, repoFullName),
Expand Down Expand Up @@ -1465,6 +1471,7 @@
mergedPrCount: authorPrs.filter((candidate) => candidate.mergedAt || candidate.state === "merged").length,
closedUnmergedPrCount: authorPrs.filter((candidate) => candidate.state === "closed" && !candidate.mergedAt).length,
};
authorMergedPrCount = authorHistory.mergedPrCount;

const gatePolicy = gateCheckPolicy(settings, readiness.total, confirmedContributor, slopRisk, authorHistory);
gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gatePolicy) : undefined;
Expand Down Expand Up @@ -1525,6 +1532,48 @@
throw error;
}

// Reviewer auto-request (#540/#830): when auto_request mode is on and the PR is open, find the
// top CODEOWNERS reviewer for the changed files and request them — unless the author is a first-time
// contributor (0 merged PRs in this repo) or they are already a requested reviewer (idempotency).
// Best-effort: failures are audited but never abort the main surface publish.
if (settings.reviewerRoutingMode === "auto_request" && pr.state === "open" && author && webhook.action !== "closed") {
// Newcomer guard: skip auto-request for first-time external contributors.
// authorMergedPrCount is null when the gate try-block failed — treat unknown as newcomer.
const isNewcomer = authorMergedPrCount === null || authorMergedPrCount === 0;
if (!isNewcomer) {
try {
const codeownersContent = await fetchCodeownersFile(repoFullName);
if (codeownersContent) {
const prFiles = await listPullRequestFiles(env, repoFullName, pr.number);
const filePaths = prFiles.map((f) => f.path).filter(Boolean);
const routing = buildReviewerRouting(filePaths, codeownersContent);
const authorLogin = author.toLowerCase();
const alreadyRequested = await getRequestedReviewers(env, installationId, repoFullName, pr.number);
const candidate = routing.suggestions.find((s) => s.login !== authorLogin && !alreadyRequested.has(s.login));
if (candidate) {
await requestPullRequestReviewers(env, installationId, repoFullName, pr.number, [candidate.login]);
await recordAuditEvent(env, {
eventType: "github_app.reviewer_auto_requested",
actor: author,
targetKey: `${repoFullName}#${pr.number}`,
outcome: "completed",
metadata: { deliveryId: webhook.deliveryId, repoFullName, reviewerLogin: candidate.login },
});
}
}
} catch (error) {
await recordAuditEvent(env, {
eventType: "github_app.reviewer_auto_request_failed",
actor: author,
targetKey: `${repoFullName}#${pr.number}`,
outcome: "error",
detail: errorMessage(error),
metadata: { deliveryId: webhook.deliveryId, repoFullName },
}).catch(() => undefined);
}
}
}

if (!prelimHasPublicOutput) return;
if (publicSurfaceSkipped || !official || !author) return;

Expand Down Expand Up @@ -1596,6 +1645,7 @@
await recordPublicSurfaceOutputFailure(env, "label", author, repoFullName, pr.number, webhook.deliveryId, message);
}
}

if (publishedOutputs.length === 0) {
if (failedOutputs.length > 0) {
await recordAuditEvent(env, {
Expand Down
5 changes: 4 additions & 1 deletion src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parse as parseYaml } from "yaml";

Check warning on line 1 in src/signals/focus-manifest.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Possible duplicate overlap

Titles/paths share 9 meaningful terms.

Check notice on line 1 in src/signals/focus-manifest.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/signals/focus-manifest.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Open PR queue is busy

This repo has a busy open PR queue in the local Gittensory cache.
import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types";
import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings, ReviewerRoutingMode } from "../types";
import { normalizeAutonomyPolicy, normalizeAutoMaintainPolicy } from "../settings/autonomy";

export type FocusManifestSource = "repo_file" | "api_record" | "none";
Expand Down Expand Up @@ -67,6 +67,7 @@
| "requireLinkedIssue"
| "backfillEnabled"
| "privateTrustEnabled"
| "reviewerRoutingMode"
| "autonomy"
| "autoMaintain"
| "agentPaused"
Expand Down Expand Up @@ -430,6 +431,8 @@
const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings);
if (flag !== null) out[key] = flag;
}
const reviewerRoutingMode = normalizeOptionalEnum(r.reviewerRoutingMode, "settings.reviewerRoutingMode", ["off", "advisory", "auto_request"] as const, warnings);
if (reviewerRoutingMode !== null) out.reviewerRoutingMode = reviewerRoutingMode as ReviewerRoutingMode;
// Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it
// when at least one valid class→level pair survives normalization, so a malformed block never blanks the
// DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay.
Expand Down
Loading
Loading