diff --git a/src/api/routes.ts b/src/api/routes.ts index 2d222be19c..aecd78c36f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2450,8 +2450,8 @@ function buildCommandPreview( }; } - if (missingPermissions.includes("issues")) { - const summary = "GitHub App permission Issues: write is required before a command response can be posted."; + if (missingPermissions.includes("issues") || missingPermissions.includes("pull_requests")) { + const summary = "GitHub App permissions Issues: write and Pull requests: write are required before a command response can be posted."; const body = sanitizePublicComment(`Gittensory preview is ready for ${target}, but ${summary}`); return { ...base, @@ -2605,6 +2605,7 @@ function commandPreviewMissingPermissions(request: z.infer { if (!payload.installation?.id) return; const account = payload.installation.account; + const existing = await getInstallation(env, payload.installation.id); + const permissions = + payload.installation.permissions && Object.keys(payload.installation.permissions).length > 0 + ? (payload.installation.permissions as Record) + : (existing?.permissions ?? {}); + const events = payload.installation.events && payload.installation.events.length > 0 ? payload.installation.events : (existing?.events ?? []); + const accountLogin = account?.login ?? existing?.accountLogin ?? "unknown"; + const accountId = account?.id ?? existing?.accountId ?? 0; + const targetType = payload.installation.target_type ?? account?.type ?? existing?.targetType ?? "unknown"; + const repositorySelection = payload.installation.repository_selection ?? existing?.repositorySelection; + const suspendedAt = payload.installation.suspended_at !== undefined ? payload.installation.suspended_at : (existing?.suspendedAt ?? undefined); const db = getDb(env.DB); await db .insert(installations) .values({ id: payload.installation.id, - accountLogin: account?.login ?? "unknown", - accountId: account?.id ?? 0, - targetType: payload.installation.target_type ?? account?.type ?? "unknown", - repositorySelection: payload.installation.repository_selection, - permissionsJson: jsonString((payload.installation.permissions ?? {}) as Record), - eventsJson: jsonString(payload.installation.events ?? []), - suspendedAt: payload.installation.suspended_at ?? undefined, + accountLogin, + accountId, + targetType, + repositorySelection, + permissionsJson: jsonString(permissions), + eventsJson: jsonString(events), + suspendedAt, updatedAt: nowIso(), }) .onConflictDoUpdate({ target: installations.id, set: { - accountLogin: account?.login ?? "unknown", - accountId: account?.id ?? 0, - targetType: payload.installation.target_type ?? account?.type ?? "unknown", - repositorySelection: payload.installation.repository_selection, - permissionsJson: jsonString((payload.installation.permissions ?? {}) as Record), - eventsJson: jsonString(payload.installation.events ?? []), - suspendedAt: payload.installation.suspended_at ?? undefined, + accountLogin, + accountId, + targetType, + repositorySelection, + permissionsJson: jsonString(permissions), + eventsJson: jsonString(events), + suspendedAt, updatedAt: nowIso(), }, }); diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 5a9c3e0170..8079b67b19 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -638,7 +638,7 @@ export async function refreshContributorActivity( export const REQUIRED_INSTALLATION_PERMISSIONS: Record = { metadata: "read", - pull_requests: "read", + pull_requests: "write", issues: "write", }; export const OPTIONAL_CHECK_RUN_PERMISSION: Record = { @@ -721,19 +721,19 @@ export async function buildInstallationRepairDiagnostics(env: Env, health: Insta mode: "comment", enabled: commentRepoCount > 0, affectedRepoCount: commentRepoCount, - permission: "issues", + permission: "pull_requests", requiredAccess: "write", - missing: missingPermissions.has("issues"), - summary: "PR comments use the GitHub Issues API, so comment mode requires Issues: write.", + missing: missingPermissions.has("pull_requests"), + summary: "PR comments are posted on pull requests, so comment mode requires Pull requests: write.", }), buildPermissionModeImpact({ mode: "label", enabled: labelRepoCount > 0, affectedRepoCount: labelRepoCount, - permission: "issues", + permission: "pull_requests", requiredAccess: "write", - missing: missingPermissions.has("issues"), - summary: "PR labels use the GitHub Issues API, so label mode requires Issues: write.", + missing: missingPermissions.has("pull_requests"), + summary: "PR labels are applied to pull requests, so label mode requires Pull requests: write.", }), buildPermissionModeImpact({ mode: "check_run", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index cf959d81e4..37516200d2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -2,6 +2,7 @@ import { countOpenIssues, countOpenPullRequests, getAgentCommandAnswer, + getInstallation, getLatestRepoGithubTotalsSnapshot, getFreshOfficialMinerDetection, getPullRequest, @@ -593,6 +594,9 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str } await upsertInstallation(env, payload); + const installationActor = + payload.installation?.account?.login ?? + (payload.installation?.id ? (await getInstallation(env, payload.installation.id))?.accountLogin : undefined); if (eventName === "installation_repositories" && payload.installation?.id) { const addedRepos = payload.repositories_added?.map((repo) => repo.full_name).filter(Boolean) ?? []; const removedRepos = payload.repositories_removed?.map((repo) => repo.full_name).filter(Boolean) ?? []; @@ -601,7 +605,7 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str await Promise.all([ ...addedRepos.slice(0, 50).map((repoFullName) => recordGithubProductUsage(env, "github_installation_repository_added", { - actor: payload.installation?.account?.login, + actor: installationActor, repoFullName, targetKey: payload.installation?.id ? `installation:${payload.installation.id}` : repoFullName, outcome: "completed", @@ -610,7 +614,7 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str ), ...removedRepos.slice(0, 50).map((repoFullName) => recordGithubProductUsage(env, "github_installation_repository_removed", { - actor: payload.installation?.account?.login, + actor: installationActor, repoFullName, targetKey: payload.installation?.id ? `installation:${payload.installation.id}` : repoFullName, outcome: "completed", @@ -625,7 +629,7 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str await Promise.all( installedRepos.slice(0, 50).map((repoFullName) => recordGithubProductUsage(env, "github_installation_created", { - actor: payload.installation?.account?.login, + actor: installationActor, repoFullName, targetKey: payload.installation?.id ? `installation:${payload.installation.id}` : repoFullName, outcome: "completed", @@ -727,6 +731,9 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str } } +type PublicSurfaceOutput = "comment" | "label" | "check_run"; +type PublicSurfaceOutputFailure = { output: PublicSurfaceOutput; error: string }; + async function maybePublishPrPublicSurface( env: Env, installationId: number, @@ -820,36 +827,76 @@ async function maybePublishPrPublicSurface( repoPullRequests, repoBounties, ); + const publishedOutputs: PublicSurfaceOutput[] = []; + const failedOutputs: PublicSurfaceOutputFailure[] = []; + + if (decision.willCheckRun && advisory.headSha) { + try { + const checkRunResult = await createOrUpdateCheckRun(env, installationId, repoFullName, advisory, settings.checkRunDetailLevel); + if (checkRunResult?.kind === "permission_missing") { + failedOutputs.push({ output: "check_run", error: checkRunResult.warning }); + await recordAuditEvent(env, { + eventType: "github_app.check_run_permission_missing", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: checkRunResult.warning, + metadata: { deliveryId: webhook.deliveryId, repoFullName }, + }); + } else if (checkRunResult?.kind === "published") { + publishedOutputs.push("check_run"); + } + } catch (error) { + const message = errorMessage(error); + failedOutputs.push({ output: "check_run", error: message }); + await recordPublicSurfaceOutputFailure(env, "check_run", author, repoFullName, pr.number, webhook.deliveryId, message); + } + } + if (decision.willComment) { const commentArgs = { repo, pr, profile, detection, queueHealth, collisions, preflight, settings }; const deterministicBody = buildPublicPrIntelligenceComment(commentArgs); // Optional AI rewrite (issue #151): disabled by default, source-free bundle only, quota-limited, // sanitizer-gated, and falls back to the deterministic body on any non-ok outcome. - const { body } = await rewritePublicPrIntelligenceComment(env, { - bundle: buildPublicCommentSignalBundle(commentArgs), - deterministicBody, - actor: author, - route: "github_app.pr_public_surface", - }); - await createOrUpdatePrIntelligenceComment(env, installationId, repoFullName, pr.number, body); + try { + const { body } = await rewritePublicPrIntelligenceComment(env, { + bundle: buildPublicCommentSignalBundle(commentArgs), + deterministicBody, + actor: author, + route: "github_app.pr_public_surface", + }); + await createOrUpdatePrIntelligenceComment(env, installationId, repoFullName, pr.number, body); + publishedOutputs.push("comment"); + } catch (error) { + const message = errorMessage(error); + failedOutputs.push({ output: "comment", error: message }); + await recordPublicSurfaceOutputFailure(env, "comment", author, repoFullName, pr.number, webhook.deliveryId, message); + } } if (decision.willLabel) { - await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, settings.gittensorLabel, { - createMissingLabel: settings.createMissingLabel, - }); + try { + await ensurePullRequestLabel(env, installationId, repoFullName, pr.number, settings.gittensorLabel, { + createMissingLabel: settings.createMissingLabel, + }); + publishedOutputs.push("label"); + } catch (error) { + const message = errorMessage(error); + failedOutputs.push({ output: "label", error: message }); + await recordPublicSurfaceOutputFailure(env, "label", author, repoFullName, pr.number, webhook.deliveryId, message); + } } - if (decision.willCheckRun && advisory.headSha) { - const checkRunResult = await createOrUpdateCheckRun(env, installationId, repoFullName, advisory, settings.checkRunDetailLevel); - if (checkRunResult?.kind === "permission_missing") { + if (publishedOutputs.length === 0) { + if (failedOutputs.length > 0) { await recordAuditEvent(env, { - eventType: "github_app.check_run_permission_missing", + eventType: "github_app.pr_public_surface_failed", actor: author, targetKey: `${repoFullName}#${pr.number}`, outcome: "error", - detail: checkRunResult.warning, - metadata: { deliveryId: webhook.deliveryId, repoFullName }, + detail: failedOutputs.map((failure) => failure.output).join(","), + metadata: { deliveryId: webhook.deliveryId, repoFullName, failedOutputs }, }); } + return; } await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", @@ -863,6 +910,8 @@ async function maybePublishPrPublicSurface( checkRunMode: settings.checkRunMode, gateCheckMode: settings.gateCheckMode, publicAudienceMode: settings.publicAudienceMode, + publishedOutputs, + failedOutputs, }, }); await recordGithubProductUsage(env, "pr_public_surface_published", { @@ -876,10 +925,31 @@ async function maybePublishPrPublicSurface( checkRunMode: settings.checkRunMode, gateCheckMode: settings.gateCheckMode, publicAudienceMode: settings.publicAudienceMode, + publishedOutputs, + failedOutputs, }, }); } +async function recordPublicSurfaceOutputFailure( + env: Env, + output: PublicSurfaceOutput, + actor: string | null, + repoFullName: string, + pullNumber: number, + deliveryId: string, + error: string, +): Promise { + await recordAuditEvent(env, { + eventType: `github_app.pr_${output}_publish_failed`, + actor, + targetKey: `${repoFullName}#${pullNumber}`, + outcome: "error", + detail: error, + metadata: { deliveryId, repoFullName, output }, + }); +} + async function recordGithubProductUsage( env: Env, eventName: string, diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 18f19c48bb..0912c3c124 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -315,8 +315,10 @@ function buildWarnings(settings: RepositorySettings, decision: PublicSurfaceDeci return warnings; } const missing = new Set(installation.missingPermissions); - if ((decision.willComment || decision.willLabel) && missing.has("issues")) { - warnings.push("Comments and labels require GitHub App permission Issues: write, which is currently missing. Set repository permission issues to write, then approve the change."); + if ((decision.willComment || decision.willLabel) && (missing.has("issues") || missing.has("pull_requests"))) { + warnings.push( + "Comments and labels require GitHub App permissions Issues: write and Pull requests: write. Set both repository permissions to write, then approve the change.", + ); } if (settings.checkRunMode === "enabled" && missing.has("checks")) { warnings.push("Check runs are enabled but GitHub App permission Checks: write is missing. Set repository permission checks to write, then approve the change."); @@ -378,7 +380,10 @@ function buildRepoInstallPreview(args: { status: commandAuthorizationStatus, label: "Command authorization", summary: "Public command responses require a maintainer or confirmed PR author; maintainer queue commands require owner, member, or collaborator context.", - action: commandAuthorizationStatus === "ready" ? "Use command previews to confirm actor and permission behavior before relying on repo commands." : "Restore Issues: write before enabling public command responses.", + action: + commandAuthorizationStatus === "ready" + ? "Use command previews to confirm actor and permission behavior before relying on repo commands." + : "Restore Issues: write and Pull requests: write before enabling public command responses.", }, { id: "audit-behavior", diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 5afa1d4195..8bb81e43b8 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1108,7 +1108,7 @@ describe("api routes", () => { expect(installationHealth.status).toBe(200); await expect(installationHealth.json()).resolves.toMatchObject({ installationId: 123, - requiredPermissions: { metadata: "read", pull_requests: "read", issues: "write" }, + requiredPermissions: { metadata: "read", pull_requests: "write", issues: "write" }, optionalPermissions: { checks: "write" }, permissionRemediation: expect.arrayContaining([expect.objectContaining({ permission: "issues", ok: true })]), repairSteps: ["No repair needed."], @@ -1294,7 +1294,7 @@ describe("api routes", () => { installedReposCount: 1, registeredInstalledCount: 0, status: "needs_attention", - missingPermissions: ["issues"], + missingPermissions: ["pull_requests", "issues"], missingEvents: ["issue_comment"], permissions: { metadata: "read", pull_requests: "read" }, events: ["issues", "pull_request", "repository"], @@ -1312,16 +1312,16 @@ describe("api routes", () => { 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" }, + installation: { status: "needs_attention", missingPermissions: ["pull_requests", "issues"], missingEvents: ["issue_comment"] }, + requiredPermissions: { metadata: "read", pull_requests: "write", 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: "comment", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "pull_requests", missing: true, optional: false })] }), + expect.objectContaining({ mode: "label", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "pull_requests", missing: true, optional: false })] }), expect.objectContaining({ mode: "check_run", enabled: false, affectedRepoCount: 0, requiredPermissions: [expect.objectContaining({ permission: "checks", missing: false, optional: true })] }), ]), ); @@ -1338,7 +1338,7 @@ describe("api routes", () => { status: "needs_attention", missingPermissions: ["checks"], missingEvents: [], - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], checkedAt: "2026-05-28T00:01:00.000Z", }); @@ -1357,7 +1357,7 @@ describe("api routes", () => { id: 777, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write", checks: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } @@ -1368,7 +1368,7 @@ describe("api routes", () => { await expect(refreshed.json()).resolves.toMatchObject({ refreshed: true, installation: { status: "healthy", missingPermissions: [], missingEvents: [] }, - requiredPermissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + requiredPermissions: { metadata: "read", pull_requests: "write", issues: "write", checks: "write" }, }); }); @@ -2095,7 +2095,7 @@ describe("api routes", () => { ); expect(permissionMapPreview.status).toBe(200); await expect(permissionMapPreview.json()).resolves.toMatchObject({ - preview: { decision: { status: "missing_permission", skipReason: "missing_permission" }, missingPermissions: ["issues"] }, + preview: { decision: { status: "missing_permission", skipReason: "missing_permission" }, missingPermissions: ["issues", "pull_requests"] }, }); const checksWarningPreview = await app.request( @@ -2336,7 +2336,7 @@ describe("api routes", () => { status: "needs_attention", missingPermissions: ["checks"], missingEvents: ["pull_request_review"], - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "pull_request", "repository"], checkedAt: "2026-05-31T11:00:00.000Z", }); @@ -5302,7 +5302,7 @@ async function seedSignalData(env: Env): Promise { id: 123, account: { login: "entrius", id: 1, type: "Organization" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "pull_request", "repository"], }, }); @@ -5315,7 +5315,7 @@ async function seedSignalData(env: Env): Promise { status: "healthy", missingPermissions: [], missingEvents: [], - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "pull_request", "repository"], checkedAt: freshAt, }); @@ -5496,7 +5496,7 @@ async function seedSignalData(env: Env): Promise { status: "healthy", missingPermissions: [], missingEvents: [], - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], checkedAt: freshAt, }); diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 90883c7bd6..4e143c6828 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -305,7 +305,7 @@ describe("GitHub backfill", () => { id: 124, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { checks: "write", metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } @@ -325,7 +325,7 @@ describe("GitHub backfill", () => { id: 124, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { checks: "write", metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }, }); @@ -341,7 +341,7 @@ describe("GitHub backfill", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }, }); @@ -357,7 +357,7 @@ describe("GitHub backfill", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } @@ -440,17 +440,17 @@ describe("GitHub backfill", () => { installedReposCount: 2, registeredInstalledCount: 0, status: "needs_attention", - missingPermissions: ["issues"], + missingPermissions: ["pull_requests"], missingEvents: [], - permissions: { metadata: "read", pull_requests: "read" }, + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], 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 })] }), + expect.objectContaining({ mode: "comment", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "pull_requests", missing: true })] }), + expect.objectContaining({ mode: "label", enabled: true, affectedRepoCount: 1, requiredPermissions: [expect.objectContaining({ permission: "pull_requests", missing: true })] }), ]), ); }); @@ -475,7 +475,7 @@ describe("GitHub backfill", () => { account: { login: "JSONbored", id: 1, type: "User" }, target_type: "User", repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { checks: "write", metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } @@ -503,7 +503,7 @@ describe("GitHub backfill", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 2070aa46d0..9b55f94fdc 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7,6 +7,7 @@ import { getContributorEvidence, getAgentRun, getContributorScoringProfile, + getInstallation, getLatestUpstreamRulesetSnapshot, getRepository, listUpstreamDriftReports, @@ -615,7 +616,7 @@ describe("queue processors", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }, repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }], @@ -628,7 +629,7 @@ describe("queue processors", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], }); } @@ -642,6 +643,14 @@ describe("queue processors", () => { it("syncs repositories added to and removed from an existing installation", async () => { const env = createTestEnv(); const installation = { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }; + await upsertInstallation(env, { + installation: { + ...installation, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }, + }); await processJob(env, { type: "github-webhook", @@ -649,12 +658,17 @@ describe("queue processors", () => { eventName: "installation_repositories", payload: { action: "added", - installation, + installation: { id: 123 }, repositories_added: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], }, }); expect(await getRepository(env, "JSONbored/gittensory")).toMatchObject({ isInstalled: true, installationId: 123 }); + expect(await getInstallation(env, 123)).toMatchObject({ + accountLogin: "JSONbored", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }); await processJob(env, { type: "github-webhook", @@ -662,7 +676,7 @@ describe("queue processors", () => { eventName: "installation_repositories", payload: { action: "removed", - installation, + installation: { id: 123 }, repositories_removed: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], }, }); @@ -1165,6 +1179,60 @@ describe("queue processors", () => { expect(audit?.detail).toMatch(/Checks: write permission is missing/i); }); + it("audits advisory context check publish failures without blocking webhook processing", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "enabled", + gateCheckMode: "off", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.endsWith("/users/contributor")) return Response.json({ login: "contributor" }); + if (url.includes("/users/contributor/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/context500/check-runs")) return new Response("GitHub check API failed", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "context-check-failure", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, + pull_request: { number: 25, title: "Context check", state: "open", user: { login: "contributor" }, head: { sha: "context500" }, labels: [], body: "No issue needed." }, + }, + }), + ).resolves.toBeUndefined(); + + const outputFailure = await env.DB.prepare("select event_type, detail from audit_events where event_type = ?") + .bind("github_app.pr_check_run_publish_failed") + .first<{ event_type: string; detail: string }>(); + expect(outputFailure).toMatchObject({ event_type: "github_app.pr_check_run_publish_failed" }); + expect(outputFailure?.detail).toMatch(/GitHub check API failed|failed/i); + const aggregate = await env.DB.prepare("select detail, metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_failed") + .first<{ detail: string; metadata_json: string }>(); + expect(aggregate).toMatchObject({ detail: "check_run" }); + expect(aggregate?.metadata_json).toContain('"output":"check_run"'); + }); + it("audits disabled public-surface skips without miner lookup", async () => { const env = createTestEnv(); await upsertRepositorySettings(env, { @@ -1209,7 +1277,7 @@ describe("queue processors", () => { expect(JSON.stringify(skipped.results)).not.toMatch(/wallet|hotkey|raw trust|installation-token/i); }); - it("records webhook processing when public comment publishing fails after miner confirmation", async () => { + it("records public comment failure without blocking the context check", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( env, @@ -1225,9 +1293,10 @@ describe("queue processors", () => { publicSurface: "comment_only", autoLabelEnabled: true, createMissingLabel: true, - checkRunMode: "off", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", }); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const calls = { checks: 0 }; vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); const method = init?.method ?? "GET"; @@ -1238,6 +1307,11 @@ describe("queue processors", () => { if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1" }); if (url.includes("/users/oktofeesh1/repos")) return Response.json([]); if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/abc123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") { + calls.checks += 1; + return Response.json({ id: 42, html_url: "https://github.com/checks/42" }, { status: 201 }); + } if (url.includes("/issues/30/comments") && method === "GET") return Response.json([]); if (url.includes("/issues/30/comments") && method === "POST") return new Response("comment failed", { status: 503 }); return new Response("not found", { status: 404 }); @@ -1252,17 +1326,80 @@ describe("queue processors", () => { action: "opened", installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, - pull_request: { number: 30, title: "Miner work", state: "open", user: { login: "oktofeesh1" }, labels: [], body: "Fixes #1" }, + pull_request: { number: 30, title: "Miner work", state: "open", head: { sha: "abc123", ref: "feature" }, user: { login: "oktofeesh1" }, labels: [], body: "Fixes #1" }, }, }), ).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("pr_public_surface_failed")); + expect(calls.checks).toBe(1); const webhook = await env.DB.prepare("select status from webhook_events where delivery_id = ?").bind("comment-failure").first<{ status: string }>(); expect(webhook?.status).toBe("processed"); + const outputFailures = await env.DB.prepare("select event_type, detail from audit_events where target_key = ? and outcome = ? order by event_type") + .bind("JSONbored/gittensory#30", "error") + .all<{ event_type: string; detail: string }>(); + expect(outputFailures.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event_type: "github_app.pr_comment_publish_failed", + detail: "comment failed", + }), + ]), + ); + const published = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("github_app.pr_public_surface_published").first<{ metadata_json: string }>(); + expect(published?.metadata_json).toContain('"publishedOutputs":["check_run"]'); + expect(published?.metadata_json).toContain('"output":"comment"'); + }); + + it("records an aggregate public-surface failure when no configured output publishes", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "detected_contributors_only", + publicSurface: "comment_only", + checkRunMode: "off", + }); + 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([{ githubUsername: "oktofeesh1", githubId: "123", totalPrs: 2, totalMergedPrs: 2, isEligible: true, credibility: 1 }]); + if (url === "https://api.gittensor.io/miners/123") return Response.json({ repositories: [] }); + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1" }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/31/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/31/comments") && method === "POST") return new Response("comment failed", { status: 503 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "all-public-outputs-failed", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, + pull_request: { number: 31, title: "Miner work", state: "open", user: { login: "oktofeesh1" }, labels: [], body: "Fixes #1" }, + }, + }); + + const aggregate = await env.DB.prepare("select detail, metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_failed") + .first<{ detail: string; metadata_json: string }>(); + expect(aggregate).toMatchObject({ detail: "comment" }); + expect(aggregate?.metadata_json).toContain('"output":"comment"'); const published = await env.DB.prepare("select event_type from audit_events where event_type = ?").bind("github_app.pr_public_surface_published").all(); expect(published.results).toEqual([]); - errorSpy.mockRestore(); }); it("keeps repository and PR webhook processing internal when installation context is absent", async () => { @@ -1380,6 +1517,75 @@ describe("queue processors", () => { expect(snapshot?.snapshot_json).not.toContain("must-not-cache"); }); + it("records label-only public-surface failures without creating duplicate comments", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "detected_contributors_only", + publicSurface: "label_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + }); + const calls = { comments: 0, labels: 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([{ githubUsername: "oktofeesh1", githubId: "123", totalPrs: 1, totalMergedPrs: 1, isEligible: true, credibility: 1 }]); + if (url === "https://api.gittensor.io/miners/123") return Response.json({ repositories: [] }); + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1" }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/comments")) { + calls.comments += 1; + return Response.json([]); + } + if (url.includes("/labels") && method === "GET") return Response.json([]); + if (url.includes("/labels") && method === "POST") { + calls.labels += 1; + return new Response("label failed", { status: 503 }); + } + return new Response("not found", { status: 404 }); + }); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "label-failure", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, + pull_request: { number: 50, title: "Miner label work", state: "open", user: { login: "oktofeesh1" }, labels: [], body: "Fixes #1" }, + }, + }), + ).resolves.toBeUndefined(); + + expect(calls).toEqual({ comments: 0, labels: 1 }); + const outputFailure = await env.DB.prepare("select event_type, detail from audit_events where event_type = ?") + .bind("github_app.pr_label_publish_failed") + .first<{ event_type: string; detail: string }>(); + expect(outputFailure).toMatchObject({ event_type: "github_app.pr_label_publish_failed", detail: "label failed" }); + const aggregate = await env.DB.prepare("select detail, metadata_json from audit_events where event_type = ?") + .bind("github_app.pr_public_surface_failed") + .first<{ detail: string; metadata_json: string }>(); + expect(aggregate).toMatchObject({ detail: "label" }); + expect(aggregate?.metadata_json).toContain('"output":"label"'); + const published = await env.DB.prepare("select event_type from audit_events where event_type = ?").bind("github_app.pr_public_surface_published").all(); + expect(published.results).toEqual([]); + }); + it("keeps GitHub-history-only contributors quiet through not_found cache hits and expiry", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { @@ -1804,6 +2010,7 @@ describe("queue processors", () => { it("posts maintainer-only queue digest commands from cached public-safe metadata", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + delete (env as Partial).PUBLIC_SITE_ORIGIN; for (const issue of [ { number: 1, title: "Ready linked fix" }, { number: 2, title: "Overlap issue" }, @@ -1898,6 +2105,53 @@ describe("queue processors", () => { ); }); + it("omits the maintainer queue digest control-panel link when the public site origin is invalid", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), PUBLIC_SITE_ORIGIN: "not a url" }); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 94, + title: "Ready linked fix", + state: "open", + author_association: "NONE", + user: { login: "alice" }, + labels: [], + body: "Fixes #1", + }); + let commentBody = ""; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/94/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/94/comments") && method === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string }; + commentBody = body.body ?? ""; + return Response.json({ id: 1002 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "maintainer-queue-summary-invalid-origin", + eventName: "issue_comment", + payload: { + action: "created", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + issue: { number: 94, title: "Ready linked fix", state: "open", pull_request: {}, user: { login: "alice" }, author_association: "NONE" }, + comment: { + id: 9002, + body: "@gittensory queue-summary", + user: { login: "maintainer", type: "User" }, + author_association: "OWNER", + }, + }, + }); + + expect(commentBody).toContain("**Gittensory maintainer queue summary**"); + expect(commentBody).not.toContain("Authenticated control panel:"); + }); + it("applies repo command authorization policy overrides during issue_comment handling", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertRepositorySettings(env, {