authorgravatar for 73218979+RealDoc06@users.noreply.github.comSebastian Pugliese <73218979+RealDoc06@users.noreply.github.com> 2026-10-15 03:15:53+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 18:15:53-07:00
logec1313c8f8333292506f05a355bb72d308a455d8
treea28518d652a039da21c461a27d8bca695986d8db
parentab3a3cd162d90fd9f01ac62efdb9d4ad55c33639
signature Signed by PGP key B5690EEEBB952194

fix(server): use workspace-scoped Bitbucket permissions API (#10984)

Co-authored-by: RealDoc06 <73218979+RealDoc06@users.noreply.github.com> Co-authored-by: Theo Browne <me@t3.gg> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

3 files changed, 62 insertions(+), 52 deletions(-)

packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts+37-26
......@@ -998,11 +998,29 @@ layer("BitbucketPullRequestApi.layer", (it) => {
998998
999999 assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" }));
10001000
1001 expect(callAt(0).url).toContain("/user/permissions/repositories");
1001 expect(new URL(callAt(0).url, "https://api.bitbucket.org").pathname).toBe(
1002 "/user/workspaces/acme/permissions/repositories",
1003 );
10021004 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"');
10031005 }),
10041006 );
10051007
1008 it.effect("matches repository permissions after normalizing whitespace around segments", () =>
1009 Effect.gen(function* () {
1010 mockedRequest.mockImplementation((input) => {
1011 const url = new URL(input.url, "https://api.bitbucket.org");
1012 const matches =
1013 url.pathname === "/user/workspaces/acme/permissions/repositories" &&
1014 url.searchParams.get("q") === 'repository.full_name="acme/web"';
1015 return Effect.succeed(response(valuePage(matches ? [{ permission: "read" }] : [])));
1016 });
1017 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1018
1019 // An unmatched, empty permission page is treated as unknown and would allow this attempt.
1020 assert.isFalse(yield* api.getRepositoryPermission({ repository: " acme / web " }));
1021 }),
1022 );
1023
10061024 it.effect("escapes a repository name before it goes inside a filter literal", () =>
10071025 Effect.gen(function* () {
10081026 mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] }))));
......@@ -1015,43 +1033,36 @@ layer("BitbucketPullRequestApi.layer", (it) => {
10151033 }),
10161034 );
10171035
1018 it.effect(
1019 "reads a removed permissions endpoint as granted rather than failing the merge on it",
1020 () =>
1036 const permissionFailure = (status: number) =>
1037 Effect.fail(
1038 new BitbucketApi.BitbucketResponseError({
1039 operation: "request",
1040 status,
1041 responseBodyLength: 0,
1042 }),
1043 );
1044
1045 it.effect.each([403, 404, 410])(
1046 "reads HTTP %s from the permissions endpoint as an unknown permission",
1047 (status) =>
10211048 Effect.gen(function* () {
1022 // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now
1023 // gets HTTP 410 here, whatever it may do.
1024 mockedRequest.mockReturnValue(
1025 Effect.fail(
1026 new BitbucketApi.BitbucketResponseError({
1027 operation: "request",
1028 status: 410,
1029 responseBodyLength: 0,
1030 }),
1031 ),
1032 );
1049 mockedRequest.mockReturnValue(permissionFailure(status));
10331050 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
10341051
10351052 assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" }));
10361053 }),
10371054 );
10381055
1039 it.effect("still fails the permission read on a failure that is not the removed endpoint", () =>
1056 it.effect.each([401, 500])("still fails the permission read on HTTP %s", (status) =>
10401057 Effect.gen(function* () {
1041 mockedRequest.mockReturnValue(
1042 Effect.fail(
1043 new BitbucketApi.BitbucketResponseError({
1044 operation: "request",
1045 status: 401,
1046 responseBodyLength: 0,
1047 }),
1048 ),
1049 );
1058 mockedRequest.mockReturnValue(permissionFailure(status));
10501059 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
10511060
10521061 const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" }));
1053
10541062 assert.strictEqual(error._tag, "BitbucketResponseError");
1063 if (error._tag === "BitbucketResponseError") {
1064 assert.strictEqual(error.status, status);
1065 }
10551066 }),
10561067 );
10571068
packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts+23-23
......@@ -112,14 +112,14 @@ export type BitbucketPullRequestApiError =
112112 | BitbucketRepositoryUnsupportedError
113113 | BitbucketDiffCommitError;
114114
115/**
116 * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a
117 * permission — Bitbucket sends this for every account now, not only ones it would have refused.
118 */
119function isRepositoryPermissionRemovedError(
115/** The permissions endpoint refusing or missing for these credentials, rather than answering. */
116function isRepositoryPermissionUnavailableError(
120117 error: BitbucketPullRequestApiError,
121118): error is BitbucketApi.BitbucketResponseError {
122 return error._tag === "BitbucketResponseError" && error.status === 410;
119 return (
120 error._tag === "BitbucketResponseError" &&
121 (error.status === 403 || error.status === 404 || error.status === 410)
122 );
123123}
124124
125125/**
......@@ -412,16 +412,19 @@ export const make = Effect.gen(function* () {
412412 const bitbucket = yield* BitbucketApi.BitbucketApi;
413413
414414 /**
415 * The repository's own path, and the workspace above it — which the people who may review are
416 * kept on rather than on the repository, so both are handed over at once.
415 * The repository's API path and normalized segments, used for workspace reads and filters.
417416 */
418417 const withRepository = <A>(
419418 repository: string,
420 use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>,
419 use: (
420 path: string,
421 workspace: string,
422 slug: string,
423 ) => Effect.Effect<A, BitbucketPullRequestApiError>,
421424 ): Effect.Effect<A, BitbucketPullRequestApiError> => {
422425 const segments = repositorySegments(repository);
423426 return Result.isSuccess(segments)
424 ? use(repositoryPathOf(segments.success), segments.success.workspace)
427 ? use(repositoryPathOf(segments.success), segments.success.workspace, segments.success.slug)
425428 : Effect.fail(segments.failure);
426429 };
427430
......@@ -656,26 +659,23 @@ export const make = Effect.gen(function* () {
656659 }),
657660 ),
658661
659 // Nothing on the repository, the pull request or the workspace states what the credentials
660 // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked
661 // alongside the reads the detail was already making, so it costs no round trip of its own.
662 // Ask for the caller's effective permission within this workspace. The full name identifies
663 // the repository by its slug, even when its display name differs.
662664 //
663 // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410
664 // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a
665 // permission being refused, so it is read the same way an unreachable read already is
666 // elsewhere — as a permission that could not be learned, which grants rather than blocks, and
667 // leaves the actual merge or write to say why if the account may not do it. Any other failure
668 // (a bad token, a network fault, an unreadable body) still fails as it did before.
665 // Bitbucket has retired permission endpoints before (CHANGE-2770), and some credentials may
666 // not reach `/user` endpoints at all. A 403, 404 or 410 here is read as a permission that
667 // could not be learned, the same as an empty page: it grants, and the write itself says why
668 // if the account may not do it. Other failures (a bad token, a network fault) still fail.
669669 getRepositoryPermission: (input) =>
670 withRepository(input.repository, () =>
670 withRepository(input.repository, (_path, workspace, slug) =>
671671 readPage({
672672 operation: "getRepositoryPermission",
673 url: `/user/permissions/repositories?q=${encodeURIComponent(
674 `repository.full_name="${filterLiteral(input.repository.trim())}"`,
673 url: `/user/workspaces/${encodeURIComponent(workspace)}/permissions/repositories?q=${encodeURIComponent(
674 `repository.full_name="${filterLiteral(`${workspace}/${slug}`)}"`,
675675 )}`,
676676 decode: decodeRepositoryPermissionJson,
677677 }),
678 ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))),
678 ).pipe(Effect.catchIf(isRepositoryPermissionUnavailableError, () => Effect.succeed(true))),
679679
680680 getPullRequestDiff: pullRequestDiff,
681681
packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts+2-3
......@@ -163,9 +163,8 @@ const RawViewerSchema = Schema.Struct({
163163});
164164
165165/**
166 * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket
167 * states what the credentials may do with it: nothing on the repository, the pull request or the
168 * workspace carries it. One row, or none where Bitbucket names no permission for this account.
166 * The caller's workspace-scoped repository permissions, filtered to one repository.
167 * One row, or none where Bitbucket names no permission for this account.
169168 */
170169const RawRepositoryPermissionsSchema = Schema.Struct({
171170 values: Schema.optional(