Repository navigation
[Bug]: Merge and other write actions are hidden when GitHub is authenticated as a GitHub App installation #19095
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server (packages/source-control-github)
Steps to reproduce
- Run a T3 Code server whose GitHub credential is a GitHub App installation token (
ghs_…), for example throughGH_TOKEN.gh auth statusshows the account as<app-slug>[bot]. The app is installed on the repository with permission to merge pull requests. - From a client connected to that server, open an open pull request in that repository.
- Look for the merge control.
Expected behavior
Merge (and squash and merge) is offered, because the app is allowed to merge the pull request.
Actual behavior
There is no merge control. T3 Code treats the account as read-only on every repository, whatever the app is actually permitted to do.
I saw this in the mobile app. The answer is computed on the server, so web and desktop get the same one.
Cause. Write access is decided from GraphQL repository.viewerPermission, and only ADMIN, MAINTAIN and WRITE count:
t3code/packages/source-control-github/src/server/gitHubPullRequestJson.ts
Lines 2889 to 2898 in 42160f2
| function toCanWrite(viewerPermission: string | null | undefined): boolean { | |
| switch (viewerPermission?.trim().toUpperCase()) { | |
| case "ADMIN": | |
| case "MAINTAIN": | |
| case "WRITE": | |
| return true; | |
| default: | |
| return false; | |
| } | |
| } |
GitHub never reports that field for an app. Its schema describes it as:
The users permission level on the repository. Will return null if authenticated as an GitHub App.
On the affected server the same query returns the bot as viewer and viewerPermission: null for a repository the app can merge in. null falls through to false, so canWrite and canTriage are both false.
What that hides. Everything gitHubViewerPermissions gates on those two:
t3code/packages/source-control-github/src/server/GitHubPullRequestProvider.ts
Lines 64 to 95 in 42160f2
| export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { | |
| return { | |
| ...(access.canWrite ? { stackRebase: true } : {}), | |
| actions: [ | |
| // Arming a merge and taking the arming back are the merge, deferred: whoever may not | |
| // merge here may not leave an instruction to merge later either. | |
| ...(access.canWrite | |
| ? ([ | |
| "merge", | |
| "enable-auto-merge", | |
| "disable-auto-merge", | |
| "revert", | |
| "approve-workflows", | |
| ] as const) | |
| : []), | |
| ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), | |
| // Whether this viewer may update the branch is GitHub's own answer, read with the | |
| // comparison; without it the action is offered to nobody rather than to everybody. | |
| ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), | |
| ], | |
| comment: true, | |
| resolve: access.canWrite || access.didAuthor, | |
| // Anyone may review a pull request they can see, except their own: GitHub refuses an author's | |
| // approval and their request for changes ("Can not approve your own pull request"), and | |
| // leaves them commenting, which is what an author has to say about their own change anyway. | |
| verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, | |
| requestReviewers: access.canWrite, | |
| ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), | |
| // Triage is the one role that labels without writing, which is what triage is for. | |
| labels: access.canTriage, | |
| }; | |
| } |
- merge, enable and disable auto-merge, revert, approve workflows
- requesting reviewers and editing labels
- resolving conversations on pull requests the bot did not author
- stack rebase, which also fails its own precondition on
headRepository.viewerPermissionwithGitHubStackPermissionError:
t3code/packages/source-control-github/src/server/githubStackActions.ts
Lines 214 to 224 in 42160f2
| if ( | |
| open.some((layer) => { | |
| const pr = access.data.repository?.[`pr${layer.number}`]; | |
| return ( | |
| !pr?.headRepository || | |
| (!pr.maintainerCanModify && | |
| !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) | |
| ); | |
| }) | |
| ) | |
| return yield* new GitHubStackPermissionError({ ...identity }); |
The decision this needs. Withholding write on a missing permission is deliberate today. There is a test for it, with the reasoning that a merge button a reader cannot use wastes the press:
t3code/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts
Lines 987 to 992 in 42160f2
| it("withholds write where gh names no permission, which is not a standing it gave", () => { | |
| // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes | |
| // the press, where a missing one still leaves the pull request open on its host. | |
| expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false); | |
| expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false); | |
| }); |
For an app, though, null is not "no standing". It is GitHub declining to answer, and as far as I can tell there is no other API that says what an installation token may do: the REST permissions object is not usable for installations either (semantic-release documents the same limitation). So the options I can see are:
- Read a
nullpermission as unknown and offer the write actions, letting GitHub refuse the ones the app cannot perform. This matches how a silentviewerCanUpdateis already handled, and the comment on the schema field, which says an unreported permission "is answered by granting". - The same, but only when the viewer is an app (login ends in
[bot]), keeping today's rule for any othernull.
Either way an app with read-only permissions would be shown a merge control that GitHub then refuses. I'm happy to send a PR for whichever reading you prefer.
Impact
Major degradation or frequent failure
Every write action on every pull request is hidden for these credentials, every time. Merging has to be done on GitHub instead.
Version or commit
Confirmed in source on main @ 42160f2925. I did not capture the exact version of the affected server.
Environment
T3 Code server authenticated to github.com with a GitHub App installation token (ghs_…) through GH_TOKEN; T3 Code mobile app as the client.
Workaround
Authenticate the server with a user token instead of an app installation token, or merge on GitHub.
Related
#11247 is the other half of app-token support: the Pull Requests list fails for the same credentials because REST GET /user is refused, with a fix proposed in #18780. Both come from assuming the account is a human user, but the mechanisms are separate, and fixing that one leaves this one in place.
Investigated and written with Claude Code (claude-opus-5-5).
Activity