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
7 changes: 5 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2605,6 +2605,7 @@ function commandPreviewMissingPermissions(request: z.infer<typeof commandPreview
const configured = new Set([...(installation?.missingPermissions ?? []), ...(request.sample?.missingPermissions ?? [])]);
const permissions = request.sample?.permissions ?? installation?.permissions;
if (permissions && permissions.issues !== "write") configured.add("issues");
if (permissions && permissions.pull_requests !== "write") configured.add("pull_requests");
return [...configured].sort();
}

Expand All @@ -2620,6 +2621,8 @@ function commandPreviewPermissionWarnings(missingPermissions: string[]) {
message:
permission === "issues"
? "Command responses require GitHub App permission Issues: write; preview will not post while it is missing."
: permission === "pull_requests"
? "Command responses require GitHub App permission Pull requests: write; preview will not post while it is missing."
: `GitHub App permission ${permission}: ${requiredAccess} is missing for this preview scenario.`,
};
});
Expand Down
39 changes: 25 additions & 14 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,30 +162,41 @@ const FRESHNESS_SIGNAL_TYPES = [
export async function upsertInstallation(env: Env, payload: GitHubWebhookPayload): Promise<void> {
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<string, string>)
: (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<string, string>),
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<string, string>),
eventsJson: jsonString(payload.installation.events ?? []),
suspendedAt: payload.installation.suspended_at ?? undefined,
accountLogin,
accountId,
targetType,
repositorySelection,
permissionsJson: jsonString(permissions),
eventsJson: jsonString(events),
suspendedAt,
updatedAt: nowIso(),
},
});
Expand Down
14 changes: 7 additions & 7 deletions src/github/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ export async function refreshContributorActivity(

export const REQUIRED_INSTALLATION_PERMISSIONS: Record<string, string> = {
metadata: "read",
pull_requests: "read",
pull_requests: "write",
issues: "write",
};
export const OPTIONAL_CHECK_RUN_PERMISSION: Record<string, string> = {
Expand Down Expand Up @@ -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",
Expand Down
108 changes: 89 additions & 19 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
countOpenIssues,
countOpenPullRequests,
getAgentCommandAnswer,
getInstallation,
getLatestRepoGithubTotalsSnapshot,
getFreshOfficialMinerDetection,
getPullRequest,
Expand Down Expand Up @@ -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) ?? [];
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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", {
Expand All @@ -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<void> {
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,
Expand Down
11 changes: 8 additions & 3 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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",
Expand Down
Loading