Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
578 changes: 430 additions & 148 deletions apps/gittensory-ui/public/openapi.json

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
backfillOpenPullRequestDetails,
backfillRegisteredRepositories,
backfillRepositorySegment,
buildInstallationRepairDiagnostics,
enrichInstallationHealth,
refreshContributorActivity,
refreshInstallationHealth,
Expand Down Expand Up @@ -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) => {
Expand Down
141 changes: 141 additions & 0 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -646,6 +647,23 @@ export const OPTIONAL_CHECK_RUN_PERMISSION: Record<string, string> = {
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);
Expand Down Expand Up @@ -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 = [];
Expand Down
37 changes: 37 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
18 changes: 18 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ContributorStrategySchema,
HealthSchema,
InstallationHealthSchema,
InstallationRepairSchema,
IssueQualityReportSchema,
IssueQualityResponseSchema,
LabelAuditSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
Loading