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..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,6 +233,38 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("percent-encodes the source branch while preserving recognized ref prefixes", () => + 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"); + + 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)), + ); + 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..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: [ @@ -410,7 +415,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. + refPrefix + encodeURIComponent(branch.slice(refPrefix.length)), "--status", toAzureStatus(input.state), "--top", 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",