diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index a282fa0de3..fa431fb5f5 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -313,6 +313,46 @@ "isPrivate" ] }, + "PublicRepoStats": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "htmlUrl": { + "type": "string" + }, + "stargazers_count": { + "type": "number" + }, + "forks_count": { + "type": "number" + }, + "fetched_at": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "github", + "cache", + "stale_cache" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "repoFullName", + "htmlUrl", + "stargazers_count", + "forks_count", + "fetched_at", + "source", + "stale" + ] + }, "Advisory": { "type": "object", "properties": { @@ -1434,18 +1474,6 @@ "unknown" ] }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - } - }, "multiplierTier": { "type": "string", "enum": [ @@ -1459,6 +1487,18 @@ "ready", "maintainer_wip" ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -2362,6 +2402,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2385,9 +2428,6 @@ }, "maintainerLaneTotal": { "type": "number" - }, - "rejected": { - "type": "number" } }, "required": [ @@ -2404,6 +2444,21 @@ "maintainerLaneTotal" ] }, + "sources": { + "type": "object", + "properties": { + "explicit": { + "type": "number" + }, + "inferred": { + "type": "number" + } + }, + "required": [ + "explicit", + "inferred" + ] + }, "states": { "type": "array", "items": { @@ -2436,21 +2491,6 @@ }, "privateSummary": { "type": "string" - }, - "sources": { - "type": "object", - "properties": { - "explicit": { - "type": "number" - }, - "inferred": { - "type": "number" - } - }, - "required": [ - "explicit", - "inferred" - ] } }, "required": [ @@ -2504,6 +2544,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2540,9 +2583,6 @@ "mixed", "neutral" ] - }, - "rejected": { - "type": "number" } }, "required": [ @@ -4255,13 +4295,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -4507,13 +4547,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -4759,13 +4799,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -5011,13 +5051,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -5263,13 +5303,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -6086,13 +6126,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -6411,13 +6451,13 @@ "openPrMultiplier": { "type": "number" }, - "estimatedMergedScore": { + "timeDecayMultiplier": { "type": "number" }, - "pendingSaturationScore": { + "estimatedMergedScore": { "type": "number" }, - "timeDecayMultiplier": { + "pendingSaturationScore": { "type": "number" } }, @@ -7883,6 +7923,83 @@ "enabled" ] }, + "gatePack": { + "type": "string", + "enum": [ + "gittensor", + "oss-anti-slop" + ] + }, + "linkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "duplicatePrGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMinScore": { + "type": "number", + "nullable": true + }, + "slopGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "mergeReadinessGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "manifestPolicyGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "firstTimeContributorGrace": { + "type": "boolean" + }, + "slopGateMinScore": { + "type": "number", + "nullable": true + }, + "slopAiAdvisory": { + "type": "boolean" + }, + "reviewerRoutingMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "auto_request" + ] + }, "autoLabelEnabled": { "type": "boolean" }, @@ -7949,83 +8066,6 @@ "commands" ] }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { - "type": "string", - "nullable": true - }, - "linkedIssueGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "duplicatePrGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMinScore": { - "type": "number", - "nullable": true - }, - "gatePack": { - "type": "string", - "enum": [ - "gittensor", - "oss-anti-slop" - ] - }, - "slopGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "slopGateMinScore": { - "type": "number", - "nullable": true - }, - "slopAiAdvisory": { - "type": "boolean" - }, - "mergeReadinessGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "firstTimeContributorGrace": { - "type": "boolean" - }, - "manifestPolicyGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, "autonomy": { "type": "object", "properties": { @@ -8116,6 +8156,14 @@ }, "agentDryRun": { "type": "boolean" + }, + "createdAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true } }, "required": [ @@ -8135,6 +8183,7 @@ "manifestPolicyGateMode", "firstTimeContributorGrace", "slopAiAdvisory", + "reviewerRoutingMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", @@ -8604,6 +8653,80 @@ "enabled" ] }, + "gatePack": { + "type": "string", + "enum": [ + "gittensor", + "oss-anti-slop" + ] + }, + "linkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "duplicatePrGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMinScore": { + "type": "number", + "nullable": true + }, + "slopGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "mergeReadinessGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "manifestPolicyGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "firstTimeContributorGrace": { + "type": "boolean" + }, + "slopGateMinScore": { + "type": "number", + "nullable": true + }, + "reviewerRoutingMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "auto_request" + ] + }, "autoLabelEnabled": { "type": "boolean" }, @@ -8666,72 +8789,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "linkedIssueGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "duplicatePrGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMinScore": { - "type": "number", - "nullable": true - }, - "gatePack": { - "type": "string", - "enum": [ - "gittensor", - "oss-anti-slop" - ] - }, - "slopGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "slopGateMinScore": { - "type": "number", - "nullable": true - }, - "mergeReadinessGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "firstTimeContributorGrace": { - "type": "boolean" - }, - "manifestPolicyGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] } }, "required": [ @@ -8750,6 +8807,7 @@ "mergeReadinessGateMode", "manifestPolicyGateMode", "firstTimeContributorGrace", + "reviewerRoutingMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", @@ -12150,46 +12208,6 @@ "maintainerNextSteps", "privateSummary" ] - }, - "PublicRepoStats": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "htmlUrl": { - "type": "string" - }, - "stargazers_count": { - "type": "number" - }, - "forks_count": { - "type": "number" - }, - "fetched_at": { - "type": "string" - }, - "source": { - "type": "string", - "enum": [ - "github", - "cache", - "stale_cache" - ] - }, - "stale": { - "type": "boolean" - } - }, - "required": [ - "repoFullName", - "htmlUrl", - "stargazers_count", - "forks_count", - "fetched_at", - "source", - "stale" - ] } }, "parameters": {}, @@ -12240,20 +12258,60 @@ } } }, - "/v1/registry/snapshot": { + "/v1/public/github/repos/{owner}/{repo}/stats": { "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], "responses": { "200": { - "description": "Latest Gittensor registry snapshot", + "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegistrySnapshot" + "$ref": "#/components/schemas/PublicRepoStats" } } } - } - }, + }, + "400": { + "description": "Invalid or non-allowlisted GitHub repository" + }, + "503": { + "description": "GitHub repository stats are unavailable" + } + } + } + }, + "/v1/registry/snapshot": { + "get": { + "responses": { + "200": { + "description": "Latest Gittensor registry snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrySnapshot" + } + } + } + } + }, "security": [ { "GittensoryBearer": [] @@ -12926,6 +12984,97 @@ ] } }, + "/v1/repos/{owner}/{repo}/focus-manifest": { + "get": { + "responses": { + "200": { + "description": "Repo focus manifest and compiled policy for maintainers", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + }, + "put": { + "responses": { + "200": { + "description": "Persist API-backed focus manifest for a repo", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Malformed JSON request body" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { + "post": { + "responses": { + "200": { + "description": "Refresh the persisted focus manifest cache from the repo file", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/app/self-dogfood/registration-pack": { "get": { "responses": { @@ -13019,6 +13168,39 @@ ] } }, + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { + "responses": { + "200": { + "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos/{owner}/{repo}/settings": { "get": { "responses": { @@ -14779,170 +14961,6 @@ } ] } - }, - "/v1/public/github/repos/{owner}/{repo}/stats": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicRepoStats" - } - } - } - }, - "400": { - "description": "Invalid or non-allowlisted GitHub repository" - }, - "503": { - "description": "GitHub repository stats are unavailable" - } - } - } - }, - "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { - "post": { - "responses": { - "200": { - "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest": { - "get": { - "responses": { - "200": { - "description": "Repo focus manifest and compiled policy for maintainers", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - }, - "put": { - "responses": { - "200": { - "description": "Persist API-backed focus manifest for a repo", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Malformed JSON request body" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, - "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { - "post": { - "responses": { - "200": { - "description": "Refresh the persisted focus manifest cache from the repo file", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } } }, "servers": [ diff --git a/migrations/0046_reviewer_routing_mode.sql b/migrations/0046_reviewer_routing_mode.sql new file mode 100644 index 0000000000..5e8e6bb7e6 --- /dev/null +++ b/migrations/0046_reviewer_routing_mode.sql @@ -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'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 848fab577a..2e9289ebe5 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -144,6 +144,7 @@ import type { RepoSyncStateRecord, RepositorySettings, RepositoryRecord, + ReviewerRoutingMode, ScorePreviewRecord, ScoringModelSnapshotRecord, SignalSnapshotRecord, @@ -429,6 +430,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise requireLinkedIssue: false, backfillEnabled: true, privateTrustEnabled: true, + reviewerRoutingMode: "off", badgeEnabled: false, agentPaused: false, agentDryRun: false, @@ -468,6 +470,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise requireLinkedIssue: row.requireLinkedIssue, backfillEnabled: row.backfillEnabled, privateTrustEnabled: row.privateTrustEnabled, + reviewerRoutingMode: parseReviewerRoutingMode(row.reviewerRoutingMode), badgeEnabled: row.badgeEnabled, agentPaused: row.agentPaused, agentDryRun: row.agentDryRun, @@ -511,6 +514,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial(value, null)).policy; } diff --git a/src/db/schema.ts b/src/db/schema.ts index 7296a6c595..5205e5030b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,6 +69,7 @@ export const repositorySettings = sqliteTable("repository_settings", { 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("{}"), diff --git a/src/github/reviewer-request.ts b/src/github/reviewer-request.ts new file mode 100644 index 0000000000..7f09c8153f --- /dev/null +++ b/src/github/reviewer-request.ts @@ -0,0 +1,92 @@ +import { Octokit } from "@octokit/core"; +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 { + 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> { + 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(); + 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 { + 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, + }); +} diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 448d3a90e3..34ac2ce05d 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -569,6 +569,7 @@ export const RepositorySettingsSchema = z 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(), @@ -614,6 +615,7 @@ export const RepoSettingsPreviewSchema = z 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(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6739305784..9316d7eada 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -81,6 +81,8 @@ import { 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"; @@ -1354,6 +1356,10 @@ async function maybePublishPrPublicSurface( let gateEvaluation: ReturnType | 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), @@ -1465,6 +1471,7 @@ async function maybePublishPrPublicSurface( 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; @@ -1525,6 +1532,48 @@ async function maybePublishPrPublicSurface( 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; @@ -1596,6 +1645,7 @@ async function maybePublishPrPublicSurface( await recordPublicSurfaceOutputFailure(env, "label", author, repoFullName, pr.number, webhook.deliveryId, message); } } + if (publishedOutputs.length === 0) { if (failedOutputs.length > 0) { await recordAuditEvent(env, { diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index aa9709d2a2..d3861c2982 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,5 +1,5 @@ import { parse as parseYaml } from "yaml"; -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"; @@ -67,6 +67,7 @@ export type FocusManifestSettings = Partial< | "requireLinkedIssue" | "backfillEnabled" | "privateTrustEnabled" + | "reviewerRoutingMode" | "autonomy" | "autoMaintain" | "agentPaused" @@ -430,6 +431,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) 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. diff --git a/src/signals/reviewer-routing.ts b/src/signals/reviewer-routing.ts new file mode 100644 index 0000000000..06267dcb2e --- /dev/null +++ b/src/signals/reviewer-routing.ts @@ -0,0 +1,135 @@ +/** + * Reviewer-routing signal (#540/#830): parse CODEOWNERS and rank reviewer suggestions for + * a pull request's changed files. Only user logins are returned — team entries (@org/team) + * are skipped at parse time because the GitHub request-reviewers API requires separate team + * handling and the issue spec says to skip teams. + */ + +/** A single ranked reviewer suggestion produced by buildReviewerRouting. */ +export type ReviewerSuggestion = { + /** GitHub login (without leading @). */ + login: string; + /** Number of changed files this reviewer owns. Higher = more relevant. */ + fileCount: number; +}; + +/** Result of buildReviewerRouting — null when CODEOWNERS is absent or has no user entries. */ +export type ReviewerRoutingResult = { + suggestions: ReviewerSuggestion[]; +}; + +/** + * Parse a raw CODEOWNERS file into (pattern → logins[]) pairs. Team entries (@org/team) + * are excluded because they cannot be passed directly to the request-reviewers users array. + * Later rules take precedence over earlier ones (same semantics as .gitignore / GitHub itself). + */ +export function parseCodeowners(content: string): Array<{ pattern: string; logins: string[] }> { + const rules: Array<{ pattern: string; logins: string[] }> = []; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const parts = line.split(/\s+/); + const pattern = parts[0]; + if (!pattern) continue; + const logins: string[] = []; + for (let i = 1; i < parts.length; i++) { + const entry = parts[i]; + if (!entry) continue; + if (!entry.startsWith("@")) continue; + const name = entry.slice(1); + // Skip team entries: @org/team contains a slash. + if (name.includes("/")) continue; + if (name) logins.push(name.toLowerCase()); + } + if (logins.length > 0) rules.push({ pattern, logins }); + } + return rules; +} + +/** + * Match a file path against a CODEOWNERS pattern. Implements the GitHub CODEOWNERS glob + * semantics used in practice: + * - A pattern without a leading `/` matches anywhere in the tree. + * - A pattern with a leading `/` is anchored to the repo root. + * - A pattern ending with `/` matches all files under that directory. + * - `*` matches any string within a path segment (not `/`). + * - `**` matches any number of path segments (including zero). + */ +function matchesCodeownersPattern(pattern: string, filePath: string): boolean { + // Normalise: drop leading slash for matching (we treat all paths as root-relative). + const anchored = pattern.startsWith("/"); + const normalised = anchored ? pattern.slice(1) : pattern; + + // Directory shorthand: `docs/` matches anything under `docs/`. + const dirMatch = normalised.endsWith("/"); + const effectivePattern = dirMatch ? normalised.slice(0, -1) : normalised; + + // Build a regex from the glob. + const regexBody = effectivePattern + .split("**") + .map((seg) => + seg + .split("*") + .map((s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&")) + .join("[^/]*"), + ) + .join(".*"); + + let regex: RegExp; + if (anchored) { + // Anchored: must match from the start. + if (dirMatch) { + regex = new RegExp(`^${regexBody}(/|$)`); + } else { + regex = new RegExp(`^${regexBody}(/.*)?$`); + } + } else { + // Unanchored: the pattern can match any segment boundary. + if (dirMatch) { + regex = new RegExp(`(^|/)${regexBody}(/|$)`); + } else { + regex = new RegExp(`(^|/)${regexBody}(/.*)?$`); + } + } + + return regex.test(filePath); +} + +/** + * Rank CODEOWNERS-derived reviewer suggestions for a set of changed files. + * + * Algorithm: + * 1. For each changed file, find the LAST matching CODEOWNERS rule (GitHub semantics). + * 2. Tally how many files each owner covers. + * 3. Return owners sorted descending by file count, deduped and normalised to lowercase. + * + * The caller is responsible for: + * - Filtering out the PR author. + * - Filtering out already-requested reviewers (idempotency). + */ +export function buildReviewerRouting(changedFilePaths: string[], codeownersContent: string): ReviewerRoutingResult { + const rules = parseCodeowners(codeownersContent); + if (rules.length === 0) return { suggestions: [] }; + + const tally = new Map(); + + for (const filePath of changedFilePaths) { + // GitHub: the LAST matching rule wins. + let matchedLogins: string[] | null = null; + for (const rule of rules) { + if (matchesCodeownersPattern(rule.pattern, filePath)) { + matchedLogins = rule.logins; + } + } + if (!matchedLogins) continue; + for (const login of matchedLogins) { + tally.set(login, (tally.get(login) ?? 0) + 1); + } + } + + const suggestions: ReviewerSuggestion[] = Array.from(tally.entries()) + .map(([login, fileCount]) => ({ login, fileCount })) + .sort((a, b) => b.fileCount - a.fileCount || a.login.localeCompare(b.login)); + + return { suggestions }; +} diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 91ab43a1b3..8e95ac667a 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -192,6 +192,7 @@ export type RepoSettingsPreview = { manifestPolicyGateMode: RepositorySettings["manifestPolicyGateMode"]; firstTimeContributorGrace: boolean; slopGateMinScore?: number | null | undefined; + reviewerRoutingMode: NonNullable; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; @@ -310,6 +311,7 @@ export function buildRepoSettingsPreview(args: { manifestPolicyGateMode: settings.manifestPolicyGateMode, firstTimeContributorGrace: settings.firstTimeContributorGrace, slopGateMinScore: settings.slopGateMinScore ?? null, + reviewerRoutingMode: settings.reviewerRoutingMode ?? "off", autoLabelEnabled: settings.autoLabelEnabled, gittensorLabel: settings.gittensorLabel, createMissingLabel: settings.createMissingLabel, diff --git a/src/types.ts b/src/types.ts index a27bac5832..c6dbc655cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -395,6 +395,11 @@ export type BountyRecord = { export type GateRuleMode = "off" | "advisory" | "block"; +/** Reviewer-routing mode (#540/#830). `off` = disabled; `advisory` = surface CODEOWNERS suggestions in + * the PR panel only, no GitHub API side-effects; `auto_request` = also call GitHub's request-reviewers + * API for the top-ranked suggestion (never for a first-time external contributor without explicit opt-in). */ +export type ReviewerRoutingMode = "off" | "advisory" | "auto_request"; + /** Which policy pack the gate runs under (#692). `gittensor` = the full Gittensor policy: only confirmed * Gittensor contributors are hard-blocked (registry/emissions-aware). `oss-anti-slop` = a general, repo- * agnostic pack: the same deterministic rules (slop/duplicate/linked-issue/readiness/AI-consensus) block @@ -461,6 +466,11 @@ export type RepositorySettings = { requireLinkedIssue: boolean; backfillEnabled: boolean; privateTrustEnabled: boolean; + /** Reviewer-routing mode (#540/#830). `off` = disabled; `advisory` = surface top CODEOWNERS suggestion in + * the PR panel only; `auto_request` = also call GitHub's request-reviewers API (never fires for a + * first-time external contributor — 0 merged PRs here — unless the maintainer has explicitly opted in). + * Default `off`. Optional so existing settings fixtures/callers need not be touched. */ + reviewerRoutingMode?: ReviewerRoutingMode | undefined; /** Opt-in for the public, unauthenticated README status badge (#541). Always populated by the DB layer * (default false); optional so existing settings fixtures/callers need not be touched. */ badgeEnabled?: boolean | undefined; diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 5e83bf0fa6..8fe7d9b706 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -345,3 +345,27 @@ describe("focus-manifest policy gate (#555)", () => { expect(result.blockers.map((finding) => finding.code)).toContain("manifest_blocked_path"); }); }); + +describe(".gittensory.yml settings.reviewerRoutingMode override (#830)", () => { + it("passes reviewerRoutingMode through resolveEffectiveSettings when set in manifest", () => { + const eff = resolveEffectiveSettings( + settings({ reviewerRoutingMode: "off" } as Partial), + parseFocusManifest({ settings: { reviewerRoutingMode: "auto_request" } }), + ); + expect(eff.reviewerRoutingMode).toBe("auto_request"); + }); + + it("leaves reviewerRoutingMode unchanged when manifest has no settings override", () => { + const eff = resolveEffectiveSettings( + settings({ reviewerRoutingMode: "advisory" } as Partial), + parseFocusManifest(null), + ); + expect(eff.reviewerRoutingMode).toBe("advisory"); + }); + + it("ignores an invalid reviewerRoutingMode value and accumulates a warning", () => { + const manifest = parseFocusManifest({ settings: { reviewerRoutingMode: "invalid_mode" } }); + expect(manifest.settings.reviewerRoutingMode).toBeUndefined(); + expect(manifest.warnings.some((w) => /reviewerRoutingMode/.test(w))).toBe(true); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0a8bd6beb1..900722f296 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -25,6 +25,7 @@ import { upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, + upsertPullRequestFile, upsertIssueWatchSubscription, upsertRepositorySettings, upsertRepositoryFromGitHub, @@ -4640,6 +4641,301 @@ function queueMinerSnapshot(login: string) { }; } + describe("reviewerRoutingMode: auto_request (#540/#830)", () => { + const REPO = "JSONbored/gittensory"; + const INSTALLATION_ID = 123; + const PR_NUMBER = 99; + const AUTHOR = "oktofeesh1"; + + async function setupAutoRequestBase(env: Env) { + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { [REPO]: { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertInstallation(env, { + installation: { + id: INSTALLATION_ID, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: REPO, private: false, owner: { login: "JSONbored" } }, INSTALLATION_ID); + await upsertRepositorySettings(env, { + repoFullName: REPO, + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + // Gate enabled so the function doesn't early-return before the auto_request block. + gateCheckMode: "enabled", + linkedIssueGateMode: "advisory", + reviewerRoutingMode: "auto_request", + }); + } + + // Minimal fetch handler for check-run calls triggered by the enabled gate. + function handleCheckRunCalls(url: string, method: string, init?: RequestInit): Response | null { + if (url.includes(`/commits/abc123/check-runs`)) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 900 }, { status: 201 }); + if (url.includes("/check-runs/900") && method === "PATCH") return Response.json({ id: 900 }); + return null; + } + + function prWebhook(action = "opened") { + return { + type: "github-webhook" as const, + deliveryId: `auto-request-${action}`, + eventName: "pull_request", + payload: { + action, + installation: { id: INSTALLATION_ID, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: REPO, private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: PR_NUMBER, + title: "Add reviewer-routing feature", + state: "open", + user: { login: AUTHOR }, + head: { sha: "abc123" }, + labels: [], + body: "Implements #540.", + }, + }, + }; + } + + it("requests the top CODEOWNERS reviewer for a non-newcomer author", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupAutoRequestBase(env); + // Seed a prior merged PR so the author is NOT a newcomer. + await upsertPullRequestFromGitHub(env, REPO, { + number: 50, + title: "Prior merged work", + state: "closed", + merged_at: "2026-05-01T00:00:00.000Z", + user: { login: AUTHOR }, + labels: [], + body: null, + }); + // Seed a PR file so CODEOWNERS routing has something to match. + await upsertPullRequestFile(env, { + repoFullName: REPO, + pullNumber: PR_NUMBER, + path: "src/signals/reviewer-routing.ts", + status: "modified", + additions: 10, + deletions: 2, + changes: 12, + previousFilename: null, + payload: {}, + }); + + const calls = { codeownersRequests: 0, reviewerRequests: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + const checkRunResponse = handleCheckRunCalls(url, method, init); + if (checkRunResponse) return checkRunResponse; + if (url.includes("raw.githubusercontent.com") && url.includes("CODEOWNERS")) { + calls.codeownersRequests++; + return new Response("* @reviewer1\n"); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "GET") { + return Response.json({ users: [] }); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "POST") { + calls.reviewerRequests++; + const body = JSON.parse(String(init?.body ?? "{}")) as { reviewers?: string[] }; + expect(body.reviewers).toEqual(["reviewer1"]); + return Response.json({ id: PR_NUMBER }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, prWebhook()); + expect(calls.codeownersRequests).toBeGreaterThan(0); + expect(calls.reviewerRequests).toBe(1); + }); + + it("skips the reviewer request for a newcomer author (0 merged PRs in repo)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupAutoRequestBase(env); + // No merged PR → author is a newcomer; auto_request must be skipped. + + const codeownersRequests: string[] = []; + const reviewerPostRequests: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + const checkRunResponse = handleCheckRunCalls(url, method, init); + if (checkRunResponse) return checkRunResponse; + if (url.includes("raw.githubusercontent.com") && url.includes("CODEOWNERS")) { + codeownersRequests.push(url); + return new Response("* @reviewer1\n"); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "POST") { + reviewerPostRequests.push(url); + return Response.json({ id: PR_NUMBER }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, prWebhook()); + expect(codeownersRequests).toHaveLength(0); + expect(reviewerPostRequests).toHaveLength(0); + }); + + it("absorbs reviewer-request failure and does not abort PR surface processing", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupAutoRequestBase(env); + // Seed merged PR so author is non-newcomer. + await upsertPullRequestFromGitHub(env, REPO, { + number: 51, + title: "Prior merged", + state: "closed", + merged_at: "2026-05-01T00:00:00.000Z", + user: { login: AUTHOR }, + labels: [], + body: null, + }); + await upsertPullRequestFile(env, { + repoFullName: REPO, + pullNumber: PR_NUMBER, + path: "src/index.ts", + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + previousFilename: null, + payload: {}, + }); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + const checkRunResponse = handleCheckRunCalls(url, method, init); + if (checkRunResponse) return checkRunResponse; + if (url.includes("raw.githubusercontent.com") && url.includes("CODEOWNERS")) return new Response("* @reviewer1\n"); + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "GET") return Response.json({ users: [] }); + // Simulate a GitHub API error when requesting the reviewer. + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "POST") return new Response("unprocessable", { status: 422 }); + return new Response("not found", { status: 404 }); + }); + + // Should complete without throwing even though the reviewer request failed. + await expect(processJob(env, prWebhook())).resolves.not.toThrow(); + }); + + it("skips the reviewer request when no CODEOWNERS file exists", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupAutoRequestBase(env); + // Prior merged PR so the author is not treated as a newcomer. + await upsertPullRequestFromGitHub(env, REPO, { + number: 52, + title: "Prior merged", + state: "closed", + merged_at: "2026-05-01T00:00:00.000Z", + user: { login: AUTHOR }, + labels: [], + body: null, + }); + await upsertPullRequestFile(env, { + repoFullName: REPO, + pullNumber: PR_NUMBER, + path: "src/index.ts", + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + previousFilename: null, + payload: {}, + }); + + const reviewerPostRequests: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + const checkRunResponse = handleCheckRunCalls(url, method, init); + if (checkRunResponse) return checkRunResponse; + // No CODEOWNERS anywhere → fetchCodeownersFile returns null → routing skipped entirely. + if (url.includes("raw.githubusercontent.com") && url.includes("CODEOWNERS")) { + return new Response("not found", { status: 404 }); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "POST") { + reviewerPostRequests.push(url); + return Response.json({ id: PR_NUMBER }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, prWebhook()); + expect(reviewerPostRequests).toHaveLength(0); + }); + + it("skips the reviewer request when the only CODEOWNERS owner is the PR author", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await setupAutoRequestBase(env); + await upsertPullRequestFromGitHub(env, REPO, { + number: 53, + title: "Prior merged", + state: "closed", + merged_at: "2026-05-01T00:00:00.000Z", + user: { login: AUTHOR }, + labels: [], + body: null, + }); + await upsertPullRequestFile(env, { + repoFullName: REPO, + pullNumber: PR_NUMBER, + path: "src/index.ts", + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + previousFilename: null, + payload: {}, + }); + + const reviewerPostRequests: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + const checkRunResponse = handleCheckRunCalls(url, method, init); + if (checkRunResponse) return checkRunResponse; + // The author is the sole CODEOWNERS owner → filtering the author out leaves no candidate. + if (url.includes("raw.githubusercontent.com") && url.includes("CODEOWNERS")) { + return new Response(`* @${AUTHOR}\n`); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "GET") { + return Response.json({ users: [] }); + } + if (url.includes(`/pulls/${PR_NUMBER}/requested_reviewers`) && method === "POST") { + reviewerPostRequests.push(url); + return Response.json({ id: PR_NUMBER }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, prWebhook()); + expect(reviewerPostRequests).toHaveLength(0); + }); + }); + function b64(value: string): string { return Buffer.from(value, "utf8").toString("base64"); } diff --git a/test/unit/reviewer-request.test.ts b/test/unit/reviewer-request.test.ts new file mode 100644 index 0000000000..3ccb0b2ec1 --- /dev/null +++ b/test/unit/reviewer-request.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchCodeownersFile, getRequestedReviewers, requestPullRequestReviewers } from "../../src/github/reviewer-request"; +import { createTestEnv } from "../helpers/d1"; + +async function generatePrivateKeyPem(): Promise { + const key = (await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); + const base64 = Buffer.from(exported as ArrayBuffer) + .toString("base64") + .replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; +} + +describe("fetchCodeownersFile", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns content when CODEOWNERS found at root path", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/HEAD/CODEOWNERS")) return new Response("* @owner\n"); + return new Response("not found", { status: 404 }); + }); + const result = await fetchCodeownersFile("owner/repo"); + expect(result).toBe("* @owner\n"); + }); + + it("falls through to .github/CODEOWNERS when root path returns 404", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/HEAD/CODEOWNERS")) return new Response("not found", { status: 404 }); + if (url.endsWith("/HEAD/.github/CODEOWNERS")) return new Response("* @github-owner\n"); + return new Response("not found", { status: 404 }); + }); + const result = await fetchCodeownersFile("owner/repo"); + expect(result).toBe("* @github-owner\n"); + }); + + it("falls through to docs/CODEOWNERS when first two paths fail", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/HEAD/docs/CODEOWNERS")) return new Response("* @docs-owner\n"); + return new Response("not found", { status: 404 }); + }); + const result = await fetchCodeownersFile("owner/repo"); + expect(result).toBe("* @docs-owner\n"); + }); + + it("returns null when all candidate paths return 404", async () => { + vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); + expect(await fetchCodeownersFile("owner/repo")).toBeNull(); + }); + + it("returns null for malformed repoFullName (no slash, leading slash, trailing slash)", async () => { + vi.stubGlobal("fetch", async () => new Response("should not be called", { status: 200 })); + expect(await fetchCodeownersFile("noslash")).toBeNull(); + expect(await fetchCodeownersFile("/leading")).toBeNull(); + expect(await fetchCodeownersFile("trailing/")).toBeNull(); + }); + + it("skips a path when content-length header exceeds the size limit", async () => { + let callCount = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + callCount++; + const url = input.toString(); + if (url.endsWith("/HEAD/CODEOWNERS")) { + return new Response("* @owner\n", { headers: { "content-length": String(512_001) } }); + } + if (url.endsWith("/HEAD/.github/CODEOWNERS")) return new Response("* @fallback\n"); + return new Response("not found", { status: 404 }); + }); + const result = await fetchCodeownersFile("owner/repo"); + expect(result).toBe("* @fallback\n"); + expect(callCount).toBeGreaterThan(1); + }); + + it("returns null when fetch throws (network error)", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network failure"); + }); + expect(await fetchCodeownersFile("owner/repo")).toBeNull(); + }); + + it("proceeds when content-length header is non-numeric (treated as unknown size)", async () => { + // Number.parseInt("junk") is NaN → not finite, so the size guard short-circuits and we read the body. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/HEAD/CODEOWNERS")) { + return new Response("* @owner\n", { headers: { "content-length": "junk" } }); + } + return new Response("not found", { status: 404 }); + }); + expect(await fetchCodeownersFile("owner/repo")).toBe("* @owner\n"); + }); + + it("reads the body and skips a path whose actual content exceeds the size limit", async () => { + // A content-length within the limit but an oversized actual body: the guard does not short-circuit + // (parsed <= limit), so the body is read and then rejected by the post-read size check. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/HEAD/CODEOWNERS")) { + return new Response("x".repeat(512_001), { headers: { "content-length": "1" } }); + } + return new Response("not found", { status: 404 }); + }); + expect(await fetchCodeownersFile("owner/repo")).toBeNull(); + }); +}); + +describe("getRequestedReviewers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns lowercase reviewer logins from the GitHub API", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "tok" }); + if (url.includes("/requested_reviewers")) return Response.json({ users: [{ login: "Alice" }, { login: "BOB" }] }); + return new Response("not found", { status: 404 }); + }); + const reviewers = await getRequestedReviewers(env, 1, "owner/repo", 42); + expect(reviewers).toEqual(new Set(["alice", "bob"])); + }); + + it("returns empty set when API request fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "tok" }); + return new Response("internal error", { status: 500 }); + }); + const reviewers = await getRequestedReviewers(env, 1, "owner/repo", 42); + expect(reviewers).toEqual(new Set()); + }); + + it("returns empty set for users with no login field", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "tok" }); + if (url.includes("/requested_reviewers")) return Response.json({ users: [{ login: null }, {}] }); + return new Response("not found", { status: 404 }); + }); + const reviewers = await getRequestedReviewers(env, 1, "owner/repo", 42); + expect(reviewers).toEqual(new Set()); + }); + + it("returns empty set on invalid repoFullName without making network calls", async () => { + const env = createTestEnv({}); + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + calls.push(input.toString()); + return new Response("unexpected", { status: 200 }); + }); + const reviewers = await getRequestedReviewers(env, 1, "noslash", 42); + expect(reviewers).toEqual(new Set()); + expect(calls).toHaveLength(0); + }); + + it("returns empty set when the response omits the users field", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "tok" }); + // No "users" key at all — the nullish coalescing falls back to an empty iterable. + if (url.includes("/requested_reviewers")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + const reviewers = await getRequestedReviewers(env, 1, "owner/repo", 42); + expect(reviewers).toEqual(new Set()); + }); +}); + +describe("requestPullRequestReviewers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts reviewer logins to GitHub API", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + let reviewersSent: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "tok" }); + if (url.includes("/requested_reviewers") && (init?.method ?? "GET") === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { reviewers?: string[] }; + reviewersSent = body.reviewers ?? []; + return Response.json({ id: 1 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + await requestPullRequestReviewers(env, 1, "owner/repo", 42, ["alice", "bob"]); + expect(reviewersSent).toEqual(["alice", "bob"]); + }); + + it("is a no-op when reviewerLogins is empty — makes no network calls", async () => { + const env = createTestEnv({}); + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + calls.push(input.toString()); + return new Response("unexpected", { status: 200 }); + }); + await requestPullRequestReviewers(env, 1, "owner/repo", 42, []); + expect(calls).toHaveLength(0); + }); + + it("throws on invalid repoFullName", async () => { + const env = createTestEnv({}); + await expect(requestPullRequestReviewers(env, 1, "noslash", 42, ["alice"])).rejects.toThrow(/Invalid repository/); + }); + + it("throws when the GitHub API returns a non-2xx response", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString().includes("/access_tokens")) return Response.json({ token: "tok" }); + return new Response("unprocessable", { status: 422 }); + }); + await expect(requestPullRequestReviewers(env, 1, "owner/repo", 42, ["alice"])).rejects.toThrow(); + }); +}); diff --git a/test/unit/reviewer-routing.test.ts b/test/unit/reviewer-routing.test.ts new file mode 100644 index 0000000000..e73293415d --- /dev/null +++ b/test/unit/reviewer-routing.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { buildReviewerRouting, parseCodeowners } from "../../src/signals/reviewer-routing"; + +const SIMPLE_CODEOWNERS = ` +# Global owner +* @globalowner + +# Source files +/src/ @srcowner + +# TypeScript specifically +*.ts @tsowner + +# Docs +/docs/ @docowner +`; + +describe("parseCodeowners", () => { + it("parses a simple CODEOWNERS file", () => { + const rules = parseCodeowners(SIMPLE_CODEOWNERS); + expect(rules.length).toBeGreaterThan(0); + // Comments and blank lines are excluded + expect(rules.every((r) => !r.pattern.startsWith("#"))).toBe(true); + }); + + it("excludes team entries (@org/team)", () => { + const content = ` +* @alice @org/team-a +/src/ @bob @my-org/developers +`; + const rules = parseCodeowners(content); + expect(rules[0]?.logins).toEqual(["alice"]); + expect(rules[1]?.logins).toEqual(["bob"]); + }); + + it("returns empty array for empty/comment-only CODEOWNERS", () => { + expect(parseCodeowners("")).toEqual([]); + expect(parseCodeowners("# Just a comment\n\n# Another")).toEqual([]); + }); + + it("normalises logins to lowercase", () => { + const content = "* @Alice @BOB"; + const [rule] = parseCodeowners(content); + expect(rule?.logins).toEqual(["alice", "bob"]); + }); + + it("skips entries that start with @ but have no name", () => { + const content = "* @ @valid"; + const [rule] = parseCodeowners(content); + expect(rule?.logins).toEqual(["valid"]); + }); + + it("skips owner tokens that are not @-prefixed handles", () => { + // Bare words and email-style addresses are not GitHub handles, so they must be ignored — + // only the @-prefixed user login is kept. + const content = "* alice@example.com @real bareword\n"; + const [rule] = parseCodeowners(content); + expect(rule?.logins).toEqual(["real"]); + }); + + it("skips rules with no user owners", () => { + const content = "* @org/team-only\n/src/ @real-user"; + const rules = parseCodeowners(content); + expect(rules.length).toBe(1); + expect(rules[0]?.pattern).toBe("/src/"); + }); +}); + +describe("buildReviewerRouting", () => { + it("returns empty suggestions when CODEOWNERS is blank", () => { + const result = buildReviewerRouting(["src/index.ts"], ""); + expect(result.suggestions).toEqual([]); + }); + + it("returns empty suggestions when no files are changed", () => { + const result = buildReviewerRouting([], SIMPLE_CODEOWNERS); + expect(result.suggestions).toEqual([]); + }); + + it("matches the global catch-all rule", () => { + const codeowners = "* @globalowner\n"; + const result = buildReviewerRouting(["src/index.ts", "README.md"], codeowners); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0]?.login).toBe("globalowner"); + expect(result.suggestions[0]?.fileCount).toBe(2); + }); + + it("later rules override earlier rules for the same file (GitHub semantics)", () => { + const codeowners = ` +* @globalowner +*.ts @tsowner +`; + const result = buildReviewerRouting(["src/main.ts"], codeowners); + // *.ts overrides * for .ts files + expect(result.suggestions[0]?.login).toBe("tsowner"); + expect(result.suggestions[0]?.fileCount).toBe(1); + }); + + it("ranks reviewers by number of files they own (descending)", () => { + const codeowners = ` +/src/ @srcowner +/docs/ @docowner +`; + const result = buildReviewerRouting(["src/a.ts", "src/b.ts", "docs/readme.md"], codeowners); + expect(result.suggestions[0]?.login).toBe("srcowner"); + expect(result.suggestions[0]?.fileCount).toBe(2); + expect(result.suggestions[1]?.login).toBe("docowner"); + expect(result.suggestions[1]?.fileCount).toBe(1); + }); + + it("matches anchored directory pattern for files in that directory", () => { + const codeowners = "/src/ @srcowner\n"; + expect(buildReviewerRouting(["src/index.ts"], codeowners).suggestions[0]?.login).toBe("srcowner"); + expect(buildReviewerRouting(["src/utils/helper.ts"], codeowners).suggestions[0]?.login).toBe("srcowner"); + expect(buildReviewerRouting(["test/src/index.ts"], codeowners).suggestions).toEqual([]); + }); + + it("matches unanchored glob across any directory depth", () => { + const codeowners = "*.ts @tsowner\n"; + expect(buildReviewerRouting(["src/foo.ts"], codeowners).suggestions[0]?.login).toBe("tsowner"); + expect(buildReviewerRouting(["deep/nested/dir/bar.ts"], codeowners).suggestions[0]?.login).toBe("tsowner"); + expect(buildReviewerRouting(["src/foo.js"], codeowners).suggestions).toEqual([]); + }); + + it("accumulates file count correctly when one reviewer owns multiple files", () => { + const codeowners = "* @owner\n"; + const result = buildReviewerRouting(["a.ts", "b.ts", "c.ts"], codeowners); + expect(result.suggestions[0]?.fileCount).toBe(3); + }); + + it("handles multiple owners on a single rule", () => { + const codeowners = "* @alice @bob\n"; + const result = buildReviewerRouting(["index.ts"], codeowners); + expect(result.suggestions.map((s) => s.login).sort()).toEqual(["alice", "bob"]); + expect(result.suggestions.every((s) => s.fileCount === 1)).toBe(true); + }); +}); + +describe("buildReviewerRouting — matchesCodeownersPattern branch coverage", () => { + it("anchored non-directory pattern matches only from repo root", () => { + const codeowners = "/Makefile @infra\n"; + expect(buildReviewerRouting(["Makefile"], codeowners).suggestions[0]?.login).toBe("infra"); + expect(buildReviewerRouting(["src/Makefile"], codeowners).suggestions).toEqual([]); + }); + + it("unanchored directory pattern matches at any depth", () => { + const codeowners = "docs/ @docowner\n"; + expect(buildReviewerRouting(["docs/guide.md"], codeowners).suggestions[0]?.login).toBe("docowner"); + expect(buildReviewerRouting(["nested/docs/guide.md"], codeowners).suggestions[0]?.login).toBe("docowner"); + expect(buildReviewerRouting(["src/main.ts"], codeowners).suggestions).toEqual([]); + }); + + it("double-star glob matches across any number of path segments", () => { + const codeowners = "src/**/*.ts @tsowner\n"; + expect(buildReviewerRouting(["src/signals/foo.ts"], codeowners).suggestions[0]?.login).toBe("tsowner"); + expect(buildReviewerRouting(["src/deep/nested/dir/bar.ts"], codeowners).suggestions[0]?.login).toBe("tsowner"); + expect(buildReviewerRouting(["src/main.js"], codeowners).suggestions).toEqual([]); + }); +}); + +describe("buildReviewerRouting — newcomer-guard integration invariant", () => { + it("returns suggestions that the caller can filter to exclude the PR author", () => { + const codeowners = "* @alice @bob\n"; + const result = buildReviewerRouting(["index.ts"], codeowners); + // Simulate: PR author is alice — caller should skip alice + const authorLogin = "alice"; + const alreadyRequested = new Set(); + const candidate = result.suggestions.find((s) => s.login !== authorLogin && !alreadyRequested.has(s.login)); + expect(candidate?.login).toBe("bob"); + }); + + it("returns no candidate when all suggestions are the author or already-requested", () => { + const codeowners = "* @alice\n"; + const result = buildReviewerRouting(["index.ts"], codeowners); + const candidate = result.suggestions.find((s) => s.login !== "alice" && !new Set().has(s.login)); + expect(candidate).toBeUndefined(); + }); + + it("respects already-requested set (idempotency guard)", () => { + const codeowners = "* @alice @bob\n"; + const result = buildReviewerRouting(["index.ts"], codeowners); + const alreadyRequested = new Set(["alice"]); + const candidate = result.suggestions.find((s) => s.login !== "carol" && !alreadyRequested.has(s.login)); + expect(candidate?.login).toBe("bob"); + }); +}); diff --git a/test/unit/settings-preview.test.ts b/test/unit/settings-preview.test.ts index 29b7580d31..b202d30cb6 100644 --- a/test/unit/settings-preview.test.ts +++ b/test/unit/settings-preview.test.ts @@ -360,6 +360,19 @@ describe("buildRepoSettingsPreview", () => { expect(preview.installPreview.checklist.find((item) => item.id === "public-outputs")?.summary).toMatch(/no public output action is enabled/i); }); + it("includes reviewerRoutingMode in the preview — defaults to off when not set", () => { + const preview = buildRepoSettingsPreview({ ...base, settings: settings(), installation: healthyInstall, sample: { authorLogin: "miner", minerStatus: "confirmed" } }); + expect(preview.settings.reviewerRoutingMode).toBe("off"); + }); + + it("passes reviewerRoutingMode advisory and auto_request through to settings", () => { + const advisory = buildRepoSettingsPreview({ ...base, settings: settings({ reviewerRoutingMode: "advisory" }), installation: healthyInstall, sample: {} }); + expect(advisory.settings.reviewerRoutingMode).toBe("advisory"); + + const autoRequest = buildRepoSettingsPreview({ ...base, settings: settings({ reviewerRoutingMode: "auto_request" }), installation: healthyInstall, sample: {} }); + expect(autoRequest.settings.reviewerRoutingMode).toBe("auto_request"); + }); + it("derives read-only base permissions from REQUIRED_INSTALLATION_PERMISSIONS so the preview stays in sync with the canonical constant", () => { // Regression: settings-preview previously hardcoded ["metadata: read", "pull_requests: read"] instead // of reading from REQUIRED_INSTALLATION_PERMISSIONS, so any change to the constant would leave the