authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-15 05:04:13+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 20:34:13-03:00
log742c229957b69a0cc5a1379dff6c66b595854057
treea72e7f71e5e52610a4752c8311aa30b52b27d5f1
parent40a7908df54d5069c93835032c9e955b56b55f1d
signature Signed by PGP key B5690EEEBB952194

fix(server): GitHub App tokens can merge and use other write actions (#19103)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 37 insertions(+), 8 deletions(-)

packages/source-control-github/src/server/gitHubPullRequestJson.test.ts+6-1
......@@ -1017,7 +1017,12 @@ describe("repository access decoding", () => {
10171017 // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes
10181018 // the press, where a missing one still leaves the pull request open on its host.
10191019 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson())).canWrite).toBe(false);
1020 expect(expectSuccess(decodeViewerPermissionsJson(repositoryJson(null))).canWrite).toBe(false);
1020 });
1021
1022 it("grants write and triage to a GitHub App, which GitHub answers with a null permission", () => {
1023 const access = expectSuccess(decodeViewerPermissionsJson(repositoryJson(null)));
1024 expect(access.canWrite).toBe(true);
1025 expect(access.canTriage).toBe(true);
10211026 });
10221027});
10231028
packages/source-control-github/src/server/gitHubPullRequestJson.ts+9-4
......@@ -708,9 +708,9 @@ const RawRepositoryAccessSchema = Schema.Struct({
708708 squashMergeAllowed: Schema.Boolean,
709709 rebaseMergeAllowed: Schema.Boolean,
710710 /**
711 * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE. Optional rather than required, unlike the
712 * three above: an install that does not report it leaves the viewer's standing unknown, which
713 * is answered by granting rather than by failing the whole detail read.
711 * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE, or null for a GitHub App. Optional rather than
712 * required, unlike the three above: an install that does not report it still decodes rather
713 * than failing the whole detail read, and `toCanWrite` decides what that standing allows.
714714 */
715715 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
716716});
......@@ -2919,8 +2919,13 @@ export interface GitHubRepositoryAccess {
29192919 * need, and offering those to a reader who cannot use them wastes the press and reads as the app
29202920 * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default,
29212921 * because being unable to say something is the worse failure there.
2922 *
2923 * A null permission is not that. GitHub answers null only to a GitHub App installation, and no
2924 * API says what an installation may do, so null is granted and GitHub refuses whatever the app
2925 * was not given. A person always gets a role, READ at the least.
29222926 */
2923function toCanWrite(viewerPermission: string | null | undefined): boolean {
2927export function toCanWrite(viewerPermission: string | null | undefined): boolean {
2928 if (viewerPermission === null) return true;
29242929 switch (viewerPermission?.trim().toUpperCase()) {
29252930 case "ADMIN":
29262931 case "MAINTAIN":
packages/source-control-github/src/server/githubStackActions.test.ts+20
......@@ -325,6 +325,26 @@ it.effect("allows a fork that explicitly permits maintainer updates", () =>
325325 }),
326326);
327327
328it.effect("rebases for a GitHub App, which GitHub gives no permission to read", () =>
329 Effect.gen(function* () {
330 gitCalls = [];
331 const api = fake([
332 stack,
333 {
334 data: {
335 repository: {
336 pr2: { headRepository: { viewerPermission: null }, maintainerCanModify: false },
337 pr3: { headRepository: { viewerPermission: null }, maintainerCanModify: false },
338 },
339 },
340 },
341 ...rebaseResponses,
342 ]);
343 yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
344 expect(gitCalls.filter((args) => args[0] === "push")).toHaveLength(2);
345 }),
346);
347
328348it.effect("bounds polling and reports a still-running merge without claiming success", () =>
329349 Effect.gen(function* () {
330350 const api = fake([
packages/source-control-github/src/server/githubStackActions.ts+2-3
......@@ -10,7 +10,7 @@ import * as Schema from "effect/Schema";
1010
1111import * as GitHubApi from "./GitHubApi.ts";
1212import { aliasedGraphQlDocument } from "./githubGraphQl.ts";
13import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
13import { decodePullRequestStacksJson, toCanWrite } from "./gitHubPullRequestJson.ts";
1414import { cascadeRebaseStack } from "./githubStackRebase.ts";
1515
1616const stackErrorIdentity = {
......@@ -216,8 +216,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function*
216216 const pr = access.data.repository?.[`pr${layer.number}`];
217217 return (
218218 !pr?.headRepository ||
219 (!pr.maintainerCanModify &&
220 !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? ""))
219 (!pr.maintainerCanModify && !toCanWrite(pr.headRepository.viewerPermission))
221220 );
222221 })
223222 )