Summary
The maintainer-facing repo settings preview computes its required/missing permission guidance in src/signals/settings-preview.ts, but that guidance disagrees with the GitHub App's own declared baseline in src/github/backfill.ts:
// src/github/backfill.ts:639-643 — the app's required install permissions
export const REQUIRED_INSTALLATION_PERMISSIONS: Record<string, string> = {
metadata: "read",
pull_requests: "write", // <- the app needs WRITE (PR conversation comments + PR labels go through the Pull requests permission)
issues: "write",
};
// src/signals/settings-preview.ts:469-483
function requiredInstallPermissions(settings: RepositorySettings, decision: PublicSurfaceDecision): string[] {
const permissions = new Set(["metadata: read", "pull_requests: read"]); // <- lists READ, never upgraded to write
if (decision.willComment || decision.willLabel || shouldPublishPrComment(settings) || shouldApplyPrLabel(settings, "confirmed")) permissions.add("issues: write");
if (decision.willCheckRun || settings.checkRunMode === "enabled" || settings.gateCheckMode === "enabled") permissions.add("checks: write");
return [...permissions];
}
function activeMissingPermissions(settings: RepositorySettings, decision: PublicSurfaceDecision, installation: InstallationHealthSummary | null): string[] {
if (!installation) return [];
const missing = new Set(installation.missingPermissions);
const active: string[] = [];
if ((decision.willComment || decision.willLabel || shouldPublishPrComment(settings) || shouldApplyPrLabel(settings, "confirmed")) && missing.has("issues")) active.push("issues");
if ((decision.willCheckRun || settings.checkRunMode === "enabled" || settings.gateCheckMode === "enabled") && missing.has("checks")) active.push("checks");
return active; // <- never checks missing.has("pull_requests")
}
So the preview:
- lists the required
pull_requests level as read even when the previewed behavior is to comment on / label PRs (which needs pull_requests: write), and
- never adds
pull_requests to the active-missing list, so a missing pull_requests permission is omitted from the itemized remediation entirely.
Why this is wrong
PR conversation comments (POST /repos/{owner}/{repo}/issues/{n}/comments against a PR) and PR labels (.../issues/{n}/labels against a PR) are gated by GitHub on the Pull requests permission, not Issues — which is exactly why REQUIRED_INSTALLATION_PERMISSIONS declares pull_requests: "write". The install-health surface and its tests already treat it as required:
test/integration/api.test.ts:1316 / :1111 assert requiredPermissions: { metadata: "read", pull_requests: "write", issues: "write" }.
test/integration/api.test.ts:1297-1299 exercise a real state where an install granting only pull_requests: "read" reports missingPermissions: ["pull_requests", "issues"] — i.e. pull_requests: read is treated as missing because write is required.
So pull_requests is a first-class required (write) permission for the comment/label outputs, but the settings-preview's requiredInstallPermissions understates it as read and activeMissingPermissions doesn't check it at all. The companion issues/checks permissions are handled correctly; pull_requests is the gap.
Downstream impact
buildRepoInstallPreview (settings-preview.ts:338-359) uses these two functions to build the maintainer-facing permission checklist:
const required = requiredInstallPermissions(args.settings, args.decision);
const missing = activeMissingPermissions(args.settings, args.decision, args.installation);
// permissionStatus is still correct here because it also ORs in args.installation.status === "needs_attention":
const permissionStatus = ... missing.length > 0 || missingEvents.length > 0 || args.installation.status === "needs_attention" ? "needs_attention" : "ready";
// ...but the itemized guidance shown to the maintainer is built from `required` and `missing`:
summary: permissionSummary(args.installation, missing, missingEvents),
The overall permissionStatus is rescued by the installation.status === "needs_attention" clause, so it does not falsely flip to "ready". But the itemized guidance the maintainer actually reads is wrong:
installPreview.permissions.required shows pull_requests: read (the maintainer is told to grant read).
- When the install is missing
pull_requests: write, the permissionSummary text (built from missing, which omits pull_requests) lists only issues/checks as missing — never pull_requests.
So a maintainer whose install lacks pull_requests: write sees "needs attention", grants the named permissions (issues, checks), and is still blocked — with no surfaced reason, because pull_requests was never named as required-at-write or as missing. Comment/label publishing then fails at runtime with GitHub's "Resource not accessible by integration".
Failure mode (concrete example)
Repo previewed with comment output enabled; installation granted metadata: read, pull_requests: read, issues: write, checks: write (so missingPermissions: ["pull_requests"]).
- Current:
required lists pull_requests: read; activeMissingPermissions returns [] (it only checks issues/checks, both granted). The remediation summary names no missing permission, while installation.status keeps the checklist at "needs_attention" with no actionable item. The maintainer cannot tell that pull_requests: write is the blocker.
- Correct:
required lists pull_requests: write; activeMissingPermissions returns ["pull_requests"], and the summary tells the maintainer to grant pull_requests: write.
Steps to reproduce
- Build a repo settings preview (
buildRepoInstallPreview) for a repo with comment or label output enabled and an installation whose missingPermissions includes pull_requests.
- Inspect
installPreview.permissions.required and the permissions checklist item's summary.
- Observe
pull_requests is listed at read (not write) and is absent from the missing-permission remediation, even though the app requires pull_requests: write for those outputs.
Expected behavior
The settings-preview's required/missing permission guidance matches REQUIRED_INSTALLATION_PERMISSIONS: PR comment/label outputs require pull_requests: write, and a missing pull_requests permission is surfaced in the active-missing list (and its remediation text), consistent with how issues and checks are already handled.
Actual behavior
requiredInstallPermissions lists pull_requests: read and activeMissingPermissions never checks pull_requests, so the maintainer-facing permission guidance understates the required pull_requests level and omits it from the missing list.
Suggested fix
- In
requiredInstallPermissions, require pull_requests: write (not read) for the comment/label outputs (and keep read as the baseline otherwise), so the listed level matches the app's actual need.
- In
activeMissingPermissions, add pull_requests to the active-missing check for the comment/label outputs:
if ((decision.willComment || decision.willLabel || shouldPublishPrComment(settings) || shouldApplyPrLabel(settings, "confirmed")) && missing.has("pull_requests")) active.push("pull_requests");
- Best: source the baseline from the shared
REQUIRED_INSTALLATION_PERMISSIONS constant so the two surfaces cannot drift again.
- Add fail-on-revert coverage: a preview with comment/label output enabled and an installation missing
pull_requests must list pull_requests (at write) in both the required set and the active-missing remediation.
Summary
The maintainer-facing repo settings preview computes its required/missing permission guidance in
src/signals/settings-preview.ts, but that guidance disagrees with the GitHub App's own declared baseline insrc/github/backfill.ts:So the preview:
pull_requestslevel asreadeven when the previewed behavior is to comment on / label PRs (which needspull_requests: write), andpull_requeststo the active-missing list, so a missingpull_requestspermission is omitted from the itemized remediation entirely.Why this is wrong
PR conversation comments (
POST /repos/{owner}/{repo}/issues/{n}/commentsagainst a PR) and PR labels (.../issues/{n}/labelsagainst a PR) are gated by GitHub on the Pull requests permission, not Issues — which is exactly whyREQUIRED_INSTALLATION_PERMISSIONSdeclarespull_requests: "write". The install-health surface and its tests already treat it as required:test/integration/api.test.ts:1316/:1111assertrequiredPermissions: { metadata: "read", pull_requests: "write", issues: "write" }.test/integration/api.test.ts:1297-1299exercise a real state where an install granting onlypull_requests: "read"reportsmissingPermissions: ["pull_requests", "issues"]— i.e.pull_requests: readis treated as missing because write is required.So
pull_requestsis a first-class required (write) permission for the comment/label outputs, but the settings-preview'srequiredInstallPermissionsunderstates it asreadandactiveMissingPermissionsdoesn't check it at all. The companionissues/checkspermissions are handled correctly;pull_requestsis the gap.Downstream impact
buildRepoInstallPreview(settings-preview.ts:338-359) uses these two functions to build the maintainer-facing permission checklist:The overall
permissionStatusis rescued by theinstallation.status === "needs_attention"clause, so it does not falsely flip to "ready". But the itemized guidance the maintainer actually reads is wrong:installPreview.permissions.requiredshowspull_requests: read(the maintainer is told to grant read).pull_requests: write, thepermissionSummarytext (built frommissing, which omitspull_requests) lists onlyissues/checksas missing — neverpull_requests.So a maintainer whose install lacks
pull_requests: writesees "needs attention", grants the named permissions (issues,checks), and is still blocked — with no surfaced reason, becausepull_requestswas never named as required-at-write or as missing. Comment/label publishing then fails at runtime with GitHub's "Resource not accessible by integration".Failure mode (concrete example)
Repo previewed with comment output enabled; installation granted
metadata: read,pull_requests: read,issues: write,checks: write(somissingPermissions: ["pull_requests"]).requiredlistspull_requests: read;activeMissingPermissionsreturns[](it only checksissues/checks, both granted). The remediation summary names no missing permission, whileinstallation.statuskeeps the checklist at "needs_attention" with no actionable item. The maintainer cannot tell thatpull_requests: writeis the blocker.requiredlistspull_requests: write;activeMissingPermissionsreturns["pull_requests"], and the summary tells the maintainer to grantpull_requests: write.Steps to reproduce
buildRepoInstallPreview) for a repo with comment or label output enabled and an installation whosemissingPermissionsincludespull_requests.installPreview.permissions.requiredand the permissions checklist item's summary.pull_requestsis listed atread(notwrite) and is absent from the missing-permission remediation, even though the app requirespull_requests: writefor those outputs.Expected behavior
The settings-preview's required/missing permission guidance matches
REQUIRED_INSTALLATION_PERMISSIONS: PR comment/label outputs requirepull_requests: write, and a missingpull_requestspermission is surfaced in the active-missing list (and its remediation text), consistent with howissuesandchecksare already handled.Actual behavior
requiredInstallPermissionslistspull_requests: readandactiveMissingPermissionsnever checkspull_requests, so the maintainer-facing permission guidance understates the requiredpull_requestslevel and omits it from the missing list.Suggested fix
requiredInstallPermissions, requirepull_requests: write(notread) for the comment/label outputs (and keepreadas the baseline otherwise), so the listed level matches the app's actual need.activeMissingPermissions, addpull_requeststo the active-missing check for the comment/label outputs:REQUIRED_INSTALLATION_PERMISSIONSconstant so the two surfaces cannot drift again.pull_requestsmust listpull_requests(atwrite) in both the required set and the active-missing remediation.