Skip to content

[Bug]: Merge and other write actions are hidden when GitHub is authenticated as a GitHub App installation #19095

Closed

Description

@alsofelix

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

  1. Run a T3 Code server whose GitHub credential is a GitHub App installation token (ghs_…), for example through GH_TOKEN. gh auth status shows the account as <app-slug>[bot]. The app is installed on the repository with permission to merge pull requests.
  2. From a client connected to that server, open an open pull request in that repository.
  3. 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:

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:

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.viewerPermission with GitHubStackPermissionError:

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:

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:

  1. Read a null permission as unknown and offer the write actions, letting GitHub refuse the ones the app cannot perform. This matches how a silent viewerCanUpdate is already handled, and the comment on the schema field, which says an unreported permission "is answered by granting".
  2. The same, but only when the viewer is an app (login ends in [bot]), keeping today's rule for any other null.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions