From b7b9c8c93641966f41926582da05ad365690e05f Mon Sep 17 00:00:00 2001 From: Adam Otradovec Date: Fri, 6 Nov 2026 08:52:52 +0100 Subject: [PATCH 1/3] fix(azure-devops): existing pull requests are found for branches containing # --- .../src/server/AzureDevOpsCli.test.ts | 16 ++++++++++++++++ .../src/server/AzureDevOpsCli.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index 3cb69661997b6..f7301b7549f49 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -233,6 +233,22 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("percent-encodes the source branch so `#` is not read as a URL fragment", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.listPullRequests({ + cwd: "/somewhere", + headSelector: "user/feat/1-(#2)-slug", + state: "open", + }); + + const args = mockRun.mock.calls.at(-1)?.[0].args ?? []; + assert.strictEqual(args[args.indexOf("--source-branch") + 1], "user%2Ffeat%2F1-(%232)-slug"); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves VCS causes without copying upstream details into messages", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index 57b9f4086cdaa..c9eb3413e013e 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -410,7 +410,8 @@ export const make = Effect.gen(function* () { "--detect", "true", "--source-branch", - SourceControlProvider.sourceBranch(input), + // az puts this in the query string unescaped; a `#` would cut the request. + encodeURIComponent(SourceControlProvider.sourceBranch(input)), "--status", toAzureStatus(input.state), "--top", From 638f2f1a31f2385f00cefaceb9b4b4c98c773a55 Mon Sep 17 00:00:00 2001 From: Adam Otradovec Date: Fri, 6 Nov 2026 11:05:05 +0100 Subject: [PATCH 2/3] fix(azure-devops): changed files with # or & in their path can be read --- .../src/server/AzureDevOpsPullRequestCli.test.ts | 16 ++++++++++++++++ .../src/server/AzureDevOpsPullRequestCli.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index 4b2e46b5a1e93..9bbe1468f8f54 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -850,6 +850,22 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("escapes characters in a file path that would cut the query string", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "x" })))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.readItemContent({ + cwd: "/w", + location: { project: "platform", repository: "web" }, + path: "docs/C#/a&b.md", + commit: "a".repeat(40), + }); + + expect(mockedExecute.mock.calls.at(-1)?.[0].args).toContain("path=%2Fdocs%2FC%23%2Fa%26b.md"); + }), + ); + it.effect("reports a pull request it cannot place as its own outcome", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 07d9c2a2057e7..81c9b2e68fb6e 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -603,7 +603,8 @@ export const make = Effect.gen(function* () { resource: "items", routeParameters: repositoryRoute(input.location), queryParameters: [ - `path=${toItemPath(input.path)}`, + // `az devops invoke` does not escape query values, so `#` or `&` in a path would truncate it. + `path=${encodeURIComponent(toItemPath(input.path))}`, "versionDescriptor.versionType=commit", `versionDescriptor.version=${input.commit}`, "includeContent=true", From 3180dd3fb5c59574278c55c2584476cf893b4a2c Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 6 Nov 2026 13:49:39 -0600 Subject: [PATCH 3/3] fix(azure-devops): preserve ref prefixes when encoding branches --- .../src/server/AzureDevOpsCli.test.ts | 18 +++++++++++++++++- .../src/server/AzureDevOpsCli.ts | 7 ++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index f7301b7549f49..9b40406093155 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -233,7 +233,7 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); - it.effect("percent-encodes the source branch so `#` is not read as a URL fragment", () => + it.effect("percent-encodes the source branch while preserving recognized ref prefixes", () => Effect.gen(function* () { mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); @@ -246,6 +246,22 @@ describe("AzureDevOpsCli.layer", () => { const args = mockRun.mock.calls.at(-1)?.[0].args ?? []; assert.strictEqual(args[args.indexOf("--source-branch") + 1], "user%2Ffeat%2F1-(%232)-slug"); + + for (const prefix of ["refs/heads/", "refs/pull/", "refs/tags/"]) { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + yield* az.listPullRequests({ + cwd: "/somewhere", + headSelector: "fallback", + source: { refName: `${prefix}user/feat/1-(#2)-slug` }, + state: "open", + }); + + const refArgs = mockRun.mock.calls.at(-1)?.[0].args ?? []; + assert.strictEqual( + refArgs[refArgs.indexOf("--source-branch") + 1], + `${prefix}user%2Ffeat%2F1-(%232)-slug`, + ); + } }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index c9eb3413e013e..eb6151d336263 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -401,6 +401,11 @@ export const make = Effect.gen(function* () { return AzureDevOpsCli.of({ execute, listPullRequests: Effect.fn("AzureDevOpsCli.listPullRequests")(function (input) { + const branch = SourceControlProvider.sourceBranch(input); + // az checks literal ref prefixes before constructing the request URL. + const refPrefix = + ["refs/heads/", "refs/pull/", "refs/tags/"].find((prefix) => branch.startsWith(prefix)) ?? + ""; return executeJson({ cwd: input.cwd, args: [ @@ -411,7 +416,7 @@ export const make = Effect.gen(function* () { "true", "--source-branch", // az puts this in the query string unescaped; a `#` would cut the request. - encodeURIComponent(SourceControlProvider.sourceBranch(input)), + refPrefix + encodeURIComponent(branch.slice(refPrefix.length)), "--status", toAzureStatus(input.state), "--top",