diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 3a6d53f181..d0dd7a8550 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7253,6 +7253,382 @@ "privateTrustEnabled" ] }, + "InstallationRepair": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "installation": { + "$ref": "#/components/schemas/InstallationHealth" + }, + "installedRepos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "isRegistered": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "publicSurface": { + "type": "string", + "enum": [ + "off", + "comment_and_label", + "comment_only", + "label_only" + ] + }, + "commentMode": { + "type": "string", + "enum": [ + "off", + "detected_contributors_only", + "all_prs" + ] + }, + "checkRunMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, + "autoLabelEnabled": { + "type": "boolean" + } + }, + "required": [ + "publicSurface", + "commentMode", + "checkRunMode", + "autoLabelEnabled" + ] + } + }, + "required": [ + "repoFullName", + "isRegistered", + "settings" + ] + } + }, + "requiredPermissions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "optionalPermissions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "requiredEvents": { + "type": "array", + "items": { + "type": "string" + } + }, + "optionalEvents": { + "type": "array", + "items": { + "type": "string" + } + }, + "modeImpacts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "comment", + "label", + "check_run" + ] + }, + "enabled": { + "type": "boolean" + }, + "affectedRepoCount": { + "type": "number" + }, + "requiredPermissions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "requiredAccess": { + "type": "string" + }, + "missing": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + } + }, + "required": [ + "permission", + "requiredAccess", + "missing", + "optional" + ] + } + }, + "summary": { + "type": "string" + }, + "action": { + "type": "string" + } + }, + "required": [ + "mode", + "enabled", + "affectedRepoCount", + "requiredPermissions", + "summary", + "action" + ] + } + }, + "eventDiagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event": { + "type": "string" + }, + "missing": { + "type": "boolean" + }, + "optional": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "action": { + "type": "string" + } + }, + "required": [ + "event", + "missing", + "optional", + "summary", + "action" + ] + } + }, + "repairSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "refresh": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "POST" + ] + }, + "path": { + "type": "string" + }, + "lastCheckedAt": { + "type": "string" + } + }, + "required": [ + "method", + "path", + "lastCheckedAt" + ] + }, + "refreshed": { + "type": "boolean" + } + }, + "required": [ + "generatedAt", + "installation", + "installedRepos", + "requiredPermissions", + "optionalPermissions", + "requiredEvents", + "optionalEvents", + "modeImpacts", + "eventDiagnostics", + "repairSteps", + "refresh" + ] + }, + "InstallationHealth": { + "type": "object", + "properties": { + "installationId": { + "type": "number" + }, + "accountLogin": { + "type": "string" + }, + "repositorySelection": { + "type": "string", + "nullable": true + }, + "installedReposCount": { + "type": "number" + }, + "registeredInstalledCount": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "needs_attention", + "broken" + ] + }, + "missingPermissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "missingEvents": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "checkedAt": { + "type": "string" + }, + "errorSummary": { + "type": "string", + "nullable": true + }, + "requiredPermissions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "requiredEvents": { + "type": "array", + "items": { + "type": "string" + } + }, + "optionalVisibleEvents": { + "type": "array", + "items": { + "type": "string" + } + }, + "permissionRemediation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "permission": { + "type": "string" + }, + "requiredAccess": { + "type": "string" + }, + "currentAccess": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + } + }, + "required": [ + "permission", + "requiredAccess", + "currentAccess", + "ok", + "action" + ] + } + }, + "eventRemediation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + } + }, + "required": [ + "event", + "ok", + "action" + ] + } + }, + "repairSteps": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "installationId", + "accountLogin", + "installedReposCount", + "registeredInstalledCount", + "status", + "missingPermissions", + "missingEvents", + "permissions", + "events", + "checkedAt" + ] + }, "RepoSettingsPreview": { "type": "object", "properties": { @@ -8419,154 +8795,6 @@ "rateLimitedRepos" ] }, - "InstallationHealth": { - "type": "object", - "properties": { - "installationId": { - "type": "number" - }, - "accountLogin": { - "type": "string" - }, - "repositorySelection": { - "type": "string", - "nullable": true - }, - "installedReposCount": { - "type": "number" - }, - "registeredInstalledCount": { - "type": "number" - }, - "status": { - "type": "string", - "enum": [ - "healthy", - "needs_attention", - "broken" - ] - }, - "missingPermissions": { - "type": "array", - "items": { - "type": "string" - } - }, - "missingEvents": { - "type": "array", - "items": { - "type": "string" - } - }, - "permissions": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "events": { - "type": "array", - "items": { - "type": "string" - } - }, - "checkedAt": { - "type": "string" - }, - "errorSummary": { - "type": "string", - "nullable": true - }, - "requiredPermissions": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "requiredEvents": { - "type": "array", - "items": { - "type": "string" - } - }, - "optionalVisibleEvents": { - "type": "array", - "items": { - "type": "string" - } - }, - "permissionRemediation": { - "type": "array", - "items": { - "type": "object", - "properties": { - "permission": { - "type": "string" - }, - "requiredAccess": { - "type": "string" - }, - "currentAccess": { - "type": "string" - }, - "ok": { - "type": "boolean" - }, - "action": { - "type": "string" - } - }, - "required": [ - "permission", - "requiredAccess", - "currentAccess", - "ok", - "action" - ] - } - }, - "eventRemediation": { - "type": "array", - "items": { - "type": "object", - "properties": { - "event": { - "type": "string" - }, - "ok": { - "type": "boolean" - }, - "action": { - "type": "string" - } - }, - "required": [ - "event", - "ok", - "action" - ] - } - }, - "repairSteps": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "installationId", - "accountLogin", - "installedReposCount", - "registeredInstalledCount", - "status", - "missingPermissions", - "missingEvents", - "permissions", - "events", - "checkedAt" - ] - }, "SyncStatus": { "type": "object", "properties": { @@ -10701,6 +10929,60 @@ ] } }, + "/v1/installations/{id}/repair": { + "get": { + "responses": { + "200": { + "description": "GitHub App installation repair diagnostics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallationRepair" + } + } + } + }, + "404": { + "description": "Installation health not found" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, + "/v1/installations/{id}/repair/refresh": { + "post": { + "responses": { + "200": { + "description": "Refreshed GitHub App installation repair diagnostics", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallationRepair" + } + } + } + }, + "404": { + "description": "Installation not found" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/app/notification-model": { "get": { "responses": { diff --git a/src/api/routes.ts b/src/api/routes.ts index 9c4b6694ad..62ac03f239 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -89,6 +89,7 @@ import { backfillOpenPullRequestDetails, backfillRegisteredRepositories, backfillRepositorySegment, + buildInstallationRepairDiagnostics, enrichInstallationHealth, refreshContributorActivity, refreshInstallationHealth, @@ -1432,6 +1433,26 @@ export function createApp() { return c.json(enrichInstallationHealth(health)); }); + app.get("/v1/installations/:id/repair", async (c) => { + const installationId = Number(c.req.param("id")); + if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400); + const health = await getInstallationHealth(c.env, installationId); + if (!health) return c.json({ error: "installation_health_not_found" }, 404); + return c.json(await buildInstallationRepairDiagnostics(c.env, health)); + }); + + app.post("/v1/installations/:id/repair/refresh", async (c) => { + const installationId = Number(c.req.param("id")); + if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400); + const refreshed = await refreshInstallationHealth(c.env); + if (!refreshed.installations.some((installation) => installation.installationId === installationId)) { + return c.json({ error: "installation_not_found" }, 404); + } + const health = await getInstallationHealth(c.env, installationId); + if (!health) return c.json({ error: "installation_health_not_found" }, 404); + return c.json({ ...(await buildInstallationRepairDiagnostics(c.env, health)), refreshed: true }); + }); + app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env))); app.get("/v1/repos/:owner/:repo", async (c) => { diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 31ed3a76e6..82f2de9c2c 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -50,6 +50,7 @@ import type { RepoSyncSegmentRecord, RepoSyncStateRecord, RepositoryRecord, + RepositorySettings, } from "../types"; import { errorMessage, nowIso, repoParts, strippedErrorMessage } from "../utils/json"; import { createInstallationToken, getAppInstallation } from "./app"; @@ -646,6 +647,23 @@ export const OPTIONAL_CHECK_RUN_PERMISSION: Record = { export const REQUIRED_INSTALLATION_EVENTS = ["issues", "issue_comment", "pull_request", "repository"] as const; export const OPTIONAL_VISIBLE_INSTALLATION_EVENTS = ["installation_target"] as const; +type InstallationModeImpact = { + mode: "comment" | "label" | "check_run"; + enabled: boolean; + affectedRepoCount: number; + requiredPermissions: Array<{ permission: string; requiredAccess: string; missing: boolean; optional: boolean }>; + summary: string; + action: string; +}; + +type InstallationEventDiagnostic = { + event: string; + missing: boolean; + optional: boolean; + summary: string; + action: string; +}; + export function enrichInstallationHealth(health: InstallationHealthRecord) { const missingPermissions = new Set(health.missingPermissions); const missingEvents = new Set(health.missingEvents); @@ -683,6 +701,129 @@ export function enrichInstallationHealth(health: InstallationHealthRecord) { }; } +export async function buildInstallationRepairDiagnostics(env: Env, health: InstallationHealthRecord) { + const installedRepos = (await listRepositories(env)).filter((repo) => repo.installationId === health.installationId && repo.isInstalled); + const installedSettings = await Promise.all(installedRepos.map((repo) => getRepositorySettings(env, repo.fullName))); + const commentRepoCount = installedSettings.filter(usesCommentMode).length; + const labelRepoCount = installedSettings.filter(usesLabelMode).length; + const checkRunRepoCount = installedSettings.filter((settings) => settings.checkRunMode === "enabled").length; + const missingPermissions = new Set(health.missingPermissions); + const missingEvents = new Set(health.missingEvents); + const requiredPermissions = { + ...REQUIRED_INSTALLATION_PERMISSIONS, + ...(checkRunRepoCount > 0 ? OPTIONAL_CHECK_RUN_PERMISSION : {}), + }; + const optionalPermissions = checkRunRepoCount > 0 ? {} : OPTIONAL_CHECK_RUN_PERMISSION; + const modeImpacts: InstallationModeImpact[] = [ + buildPermissionModeImpact({ + mode: "comment", + enabled: commentRepoCount > 0, + affectedRepoCount: commentRepoCount, + permission: "issues", + requiredAccess: "write", + missing: missingPermissions.has("issues"), + summary: "PR comments use the GitHub Issues API, so comment mode requires Issues: write.", + }), + buildPermissionModeImpact({ + mode: "label", + enabled: labelRepoCount > 0, + affectedRepoCount: labelRepoCount, + permission: "issues", + requiredAccess: "write", + missing: missingPermissions.has("issues"), + summary: "PR labels use the GitHub Issues API, so label mode requires Issues: write.", + }), + buildPermissionModeImpact({ + mode: "check_run", + enabled: checkRunRepoCount > 0, + affectedRepoCount: checkRunRepoCount, + permission: "checks", + requiredAccess: "write", + missing: checkRunRepoCount > 0 && missingPermissions.has("checks"), + optional: checkRunRepoCount === 0, + summary: + checkRunRepoCount > 0 + ? "Check run mode is enabled for at least one installed repo, so Checks: write is required." + : "Checks: write is optional unless check run mode is enabled for an installed repo.", + }), + ]; + const eventDiagnostics: InstallationEventDiagnostic[] = REQUIRED_INSTALLATION_EVENTS.map((event) => ({ + event, + missing: missingEvents.has(event), + optional: false, + summary: `Gittensory expects the ${event} webhook event for installation health and GitHub App automation.`, + action: missingEvents.has(event) ? `Subscribe to the ${event} webhook event, then approve or reinstall the app.` : "No change needed.", + })); + return { + generatedAt: nowIso(), + installation: enrichInstallationHealth(health), + installedRepos: installedRepos.map((repo, index) => ({ + repoFullName: repo.fullName, + isRegistered: repo.isRegistered, + settings: summarizeRepairSettings(installedSettings[index] as RepositorySettings), + })), + requiredPermissions, + optionalPermissions, + requiredEvents: [...REQUIRED_INSTALLATION_EVENTS], + optionalEvents: [...OPTIONAL_VISIBLE_INSTALLATION_EVENTS], + modeImpacts, + eventDiagnostics, + repairSteps: + health.status === "healthy" + ? ["No repair needed."] + : [ + "Update the GitHub App permissions and subscribed events listed in diagnostics.", + "Approve the changed permissions or reinstall the app on the target account.", + `Run POST /v1/installations/${health.installationId}/repair/refresh after GitHub applies the changes.`, + `Recheck GET /v1/installations/${health.installationId}/repair.`, + ], + refresh: { + method: "POST", + path: `/v1/installations/${health.installationId}/repair/refresh`, + lastCheckedAt: health.checkedAt, + }, + }; +} + +function buildPermissionModeImpact(args: { + mode: InstallationModeImpact["mode"]; + enabled: boolean; + affectedRepoCount: number; + permission: string; + requiredAccess: string; + missing: boolean; + summary: string; + optional?: boolean; +}): InstallationModeImpact { + const optional = args.optional ?? false; + return { + mode: args.mode, + enabled: args.enabled, + affectedRepoCount: args.affectedRepoCount, + requiredPermissions: [{ permission: args.permission, requiredAccess: args.requiredAccess, missing: args.missing, optional }], + summary: args.summary, + action: args.missing ? `Set repository permission ${args.permission} to ${args.requiredAccess}, then approve or reinstall the app.` : "No change needed.", + }; +} + +function usesCommentMode(settings: RepositorySettings): boolean { + if (settings.commentMode === "off") return false; + return settings.publicSurface === "comment_and_label" || settings.publicSurface === "comment_only"; +} + +function usesLabelMode(settings: RepositorySettings): boolean { + return settings.autoLabelEnabled && (settings.publicSurface === "comment_and_label" || settings.publicSurface === "label_only"); +} + +function summarizeRepairSettings(settings: RepositorySettings) { + return { + publicSurface: settings.publicSurface, + commentMode: settings.commentMode, + checkRunMode: settings.checkRunMode, + autoLabelEnabled: settings.autoLabelEnabled, + }; +} + export async function refreshInstallationHealth(env: Env) { const [installations, repositories] = await Promise.all([listInstallations(env), listRepositories(env)]); const health = []; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 65a159ed3a..7fc10391fe 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -808,6 +808,43 @@ export const InstallationHealthSchema = z }) .openapi("InstallationHealth"); +export const InstallationRepairSchema = z + .object({ + generatedAt: z.string(), + installation: InstallationHealthSchema, + installedRepos: z.array( + z.object({ + repoFullName: z.string(), + isRegistered: z.boolean(), + settings: z.object({ + publicSurface: z.enum(["off", "comment_and_label", "comment_only", "label_only"]), + commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), + checkRunMode: z.enum(["off", "enabled"]), + autoLabelEnabled: z.boolean(), + }), + }), + ), + requiredPermissions: z.record(z.string(), z.string()), + optionalPermissions: z.record(z.string(), z.string()), + requiredEvents: z.array(z.string()), + optionalEvents: z.array(z.string()), + modeImpacts: z.array( + z.object({ + mode: z.enum(["comment", "label", "check_run"]), + enabled: z.boolean(), + affectedRepoCount: z.number(), + requiredPermissions: z.array(z.object({ permission: z.string(), requiredAccess: z.string(), missing: z.boolean(), optional: z.boolean() })), + summary: z.string(), + action: z.string(), + }), + ), + eventDiagnostics: z.array(z.object({ event: z.string(), missing: z.boolean(), optional: z.boolean(), summary: z.string(), action: z.string() })), + repairSteps: z.array(z.string()), + refresh: z.object({ method: z.literal("POST"), path: z.string(), lastCheckedAt: z.string() }), + refreshed: z.boolean().optional(), + }) + .openapi("InstallationRepair"); + export const UpstreamDriftReportSchema = z .object({ id: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 97142c8896..19b890060a 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -28,6 +28,7 @@ import { ContributorStrategySchema, HealthSchema, InstallationHealthSchema, + InstallationRepairSchema, IssueQualityReportSchema, IssueQualityResponseSchema, LabelAuditSchema, @@ -116,6 +117,7 @@ export function buildOpenApiSpec() { registry.register("BountyAdvisory", BountyAdvisorySchema); registry.register("BountyLifecycleEvents", BountyLifecycleEventsSchema); registry.register("RepositorySettings", RepositorySettingsSchema); + registry.register("InstallationRepair", InstallationRepairSchema); registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema); registry.register("CommandPreviewResponse", CommandPreviewResponseSchema); registry.register("AgentRun", AgentRunSchema); @@ -262,6 +264,22 @@ export function buildOpenApiSpec() { 404: { description: "Installation health not found" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/installations/{id}/repair", + responses: { + 200: { description: "GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, + 404: { description: "Installation health not found" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/installations/{id}/repair/refresh", + responses: { + 200: { description: "Refreshed GitHub App installation repair diagnostics", content: { "application/json": { schema: InstallationRepairSchema } } }, + 404: { description: "Installation not found" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/app/notification-model", diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index b6e96a7c65..a0668b56d7 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1194,6 +1194,113 @@ describe("api routes", () => { } }); + it("serves installation repair diagnostics and refreshes installation health", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + const repoPayload = { name: "gittensory", full_name: "JSONbored/gittensory", private: true, default_branch: "main", owner: { login: "JSONbored" } }; + await upsertInstallation(env, { + installation: { + id: 777, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + repositories: [repoPayload], + }); + await upsertRepositoryFromGitHub(env, repoPayload, 777); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSurface: "comment_and_label", + autoLabelEnabled: true, + checkRunMode: "off", + }); + await upsertInstallationHealth(env, { + installationId: 777, + accountLogin: "JSONbored", + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 0, + status: "needs_attention", + missingPermissions: ["issues"], + missingEvents: ["issue_comment"], + permissions: { metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + checkedAt: "2026-05-28T00:00:00.000Z", + }); + + const repair = await app.request("/v1/installations/777/repair", { headers: apiHeaders(env) }, env); + expect(repair.status).toBe(200); + const repairBody = (await repair.json()) as { + installation: { status: string; missingPermissions: string[]; missingEvents: string[] }; + requiredPermissions: Record; + optionalPermissions: Record; + modeImpacts: Array<{ mode: string; enabled: boolean; affectedRepoCount: number; requiredPermissions: Array<{ permission: string; missing: boolean; optional: boolean }> }>; + eventDiagnostics: Array<{ event: string; missing: boolean }>; + refresh: { method: string; path: string }; + }; + expect(repairBody).toMatchObject({ + installation: { status: "needs_attention", missingPermissions: ["issues"], missingEvents: ["issue_comment"] }, + requiredPermissions: { metadata: "read", pull_requests: "read", issues: "write" }, + optionalPermissions: { checks: "write" }, + refresh: { method: "POST", path: "/v1/installations/777/repair/refresh" }, + }); + expect(repairBody.requiredPermissions).not.toHaveProperty("checks"); + expect(repairBody.modeImpacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ mode: "comment", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "issues", missing: true, optional: false })] }), + expect.objectContaining({ mode: "label", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "issues", missing: true, optional: false })] }), + expect.objectContaining({ mode: "check_run", enabled: false, affectedRepoCount: 0, requiredPermissions: [expect.objectContaining({ permission: "checks", missing: false, optional: true })] }), + ]), + ); + expect(repairBody.eventDiagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ event: "issue_comment", missing: true })])); + expect(JSON.stringify(repairBody)).not.toMatch(/wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate|github_pat|private key/i); + + await upsertRepositorySettings(env, { repoFullName: "JSONbored/gittensory", checkRunMode: "enabled" }); + await upsertInstallationHealth(env, { + installationId: 777, + accountLogin: "JSONbored", + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 0, + status: "needs_attention", + missingPermissions: ["checks"], + missingEvents: [], + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository"], + checkedAt: "2026-05-28T00:01:00.000Z", + }); + const repairWithChecks = await app.request("/v1/installations/777/repair", { headers: apiHeaders(env) }, env); + const repairWithChecksBody = (await repairWithChecks.json()) as typeof repairBody; + expect(repairWithChecksBody.requiredPermissions).toMatchObject({ checks: "write" }); + expect(repairWithChecksBody.optionalPermissions).toEqual({}); + expect(repairWithChecksBody.modeImpacts).toEqual( + expect.arrayContaining([expect.objectContaining({ mode: "check_run", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "checks", missing: true, optional: false })] })]), + ); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/777")) { + return Response.json({ + id: 777, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); + const refreshed = await app.request("/v1/installations/777/repair/refresh", { method: "POST", headers: apiHeaders(env) }, env); + expect(refreshed.status).toBe(200); + await expect(refreshed.json()).resolves.toMatchObject({ + refreshed: true, + installation: { status: "healthy", missingPermissions: [], missingEvents: [] }, + requiredPermissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + }); + }); + it("serves live app dashboards, digest subscriptions, commands, and extension context", async () => { const app = createApp(); const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1,other", PRODUCT_USAGE_HASH_SALT: "usage-adoption-test-salt" }); @@ -4594,6 +4701,22 @@ function apiHeaders(env: Env): Record { }; } +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-----`; +} + function upstreamContractFetch() { const files: Record = { "gittensor/constants.py": "SRC_TOK_SATURATION_SCALE = 58\nMAX_CODE_DENSITY_MULTIPLIER = 1.15\n", diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index e3c95321b3..dc9be3e136 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -432,6 +432,10 @@ describe("api route guards and error branches", () => { expect((await app.request("/v1/repos/nope/missing", { headers: apiHeaders(env) }, env)).status).toBe(404); expect((await app.request("/v1/installations/not-a-number/health", { headers: apiHeaders(env) }, env)).status).toBe(400); expect((await app.request("/v1/installations/999/health", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/installations/not-a-number/repair", { headers: apiHeaders(env) }, env)).status).toBe(400); + expect((await app.request("/v1/installations/999/repair", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/installations/not-a-number/repair/refresh", { method: "POST", headers: apiHeaders(env) }, env)).status).toBe(400); + expect((await app.request("/v1/installations/999/repair/refresh", { method: "POST", headers: apiHeaders(env) }, env)).status).toBe(404); const emptyReadiness = await app.request("/v1/readiness", { headers: apiHeaders(env) }, env); expect(emptyReadiness.status).toBe(200); await expect(emptyReadiness.json()).resolves.toMatchObject({ registry: null, scoringModel: null, readyForPublicReview: false }); diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 670454f0cc..750a136857 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -26,6 +26,7 @@ import { backfillOpenPullRequestDetails, backfillRegisteredRepositories, backfillRepositorySegment, + buildInstallationRepairDiagnostics, enqueueRepositoryOpenDataBackfill, refreshContributorActivity, refreshInstallationHealth, @@ -376,6 +377,83 @@ describe("GitHub backfill", () => { ); }); + it("marks comment, label, and check repair impacts disabled by repo settings", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + }); + + const repair = await buildInstallationRepairDiagnostics(env, { + installationId: 123, + accountLogin: "JSONbored", + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 0, + status: "healthy", + missingPermissions: [], + missingEvents: [], + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository"], + checkedAt: "2026-05-28T00:00:00.000Z", + }); + + expect(repair.repairSteps).toEqual(["No repair needed."]); + expect(repair.requiredPermissions).not.toHaveProperty("checks"); + expect(repair.modeImpacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ mode: "comment", enabled: false, affectedRepoCount: 0, action: "No change needed." }), + expect.objectContaining({ mode: "label", enabled: false, affectedRepoCount: 0, action: "No change needed." }), + expect.objectContaining({ mode: "check_run", enabled: false, affectedRepoCount: 0, requiredPermissions: [expect.objectContaining({ optional: true })] }), + ]), + ); + }); + + it("counts comment-only and label-only repair surfaces separately", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "comments", full_name: "JSONbored/comments", private: true, owner: { login: "JSONbored" } }, 124); + await upsertRepositoryFromGitHub(env, { name: "labels", full_name: "JSONbored/labels", private: true, owner: { login: "JSONbored" } }, 124); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/comments", + commentMode: "detected_contributors_only", + publicSurface: "comment_only", + autoLabelEnabled: false, + checkRunMode: "off", + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/labels", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + checkRunMode: "off", + }); + + const repair = await buildInstallationRepairDiagnostics(env, { + installationId: 124, + accountLogin: "JSONbored", + repositorySelection: "selected", + installedReposCount: 2, + registeredInstalledCount: 0, + status: "needs_attention", + missingPermissions: ["issues"], + missingEvents: [], + permissions: { metadata: "read", pull_requests: "read" }, + events: ["issues", "issue_comment", "pull_request", "repository"], + checkedAt: "2026-05-28T00:00:00.000Z", + }); + + expect(repair.modeImpacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ mode: "comment", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "issues", missing: true })] }), + expect.objectContaining({ mode: "label", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "issues", missing: true })] }), + ]), + ); + }); + it("refreshes installation health from live GitHub App metadata", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await seedRegisteredRepo(env); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index b8d86ef496..04babc60d8 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -55,6 +55,8 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/auth/github/device/start"]).toBeDefined(); expect(spec.paths["/v1/auth/session"]).toBeDefined(); expect(spec.paths["/v1/internal/jobs/repair-data-fidelity"]).toBeDefined(); + expect(spec.paths["/v1/installations/{id}/repair"]).toBeDefined(); + expect(spec.paths["/v1/installations/{id}/repair/refresh"]).toBeDefined(); for (const removedPath of [ "/v1/contributors/{login}/opportunities", @@ -93,6 +95,7 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); expect(spec.components?.schemas?.RepoSettingsPreview).toBeDefined(); + expect(spec.components?.schemas?.InstallationRepair).toBeDefined(); expect(spec.components?.schemas?.CommandPreviewResponse).toBeDefined(); expect(spec.components?.schemas?.AgentRunBundle).toBeDefined(); expect(spec.components?.schemas?.AgentAction).toBeDefined();