From a56b0cd7178dfef299aa49b26773a80a34abe205 Mon Sep 17 00:00:00 2001 From: Simone Date: Wed, 2 Sep 2026 09:43:10 +0200 Subject: [PATCH] fix(server): allow large Azure DevOps PR lists (#8572) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- .../AzureDevOpsPullRequestCli.test.ts | 40 +++++++++++++++++++ .../pullRequest/AzureDevOpsPullRequestCli.ts | 9 ++++- .../src/sourceControl/AzureDevOpsCli.test.ts | 22 ++++++++++ .../src/sourceControl/AzureDevOpsCli.ts | 2 + 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index 5baf18a1ff6ae1ad92c05339972159bb1c9c4962..98e21d75bc586f116f91f1c3e59bbc86a8350ca3 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -97,6 +97,46 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("reads an Azure pull request page larger than the VCS default output limit", () => + Effect.gen(function* () { + const rows = pullRequestRows(100, 1).map((row) => ({ + ...row, + description: "x".repeat(10_000), + })); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const response = JSON.stringify(rows); + expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000); + + mockedExecute.mockImplementationOnce((input) => { + const maxOutputBytes = + "maxOutputBytes" in input && typeof input.maxOutputBytes === "number" + ? input.maxOutputBytes + : 1_000_000; + return Effect.succeed( + maxOutputBytes >= Buffer.byteLength(response) + ? output(response) + : { + ...output(response.slice(0, maxOutputBytes)), + stdoutTruncated: true, + }, + ); + }); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "web", + state: "merged", + involvement: "all", + viewer: "bilal@acme.dev", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 549a172b36464c7c20209859d7e060cc3f7ddaa1..96dc4ea1d30e9354d5407f2467255e0d9cab7d69 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError = /** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; +const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; export class AzureDevOpsPullRequestCli extends Context.Service< AzureDevOpsPullRequestCli, @@ -258,10 +259,15 @@ export const make = Effect.gen(function* () { // how to read all of them. const detectArgs = ["--detect", "true"] as const; - const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray }) => + const executeJson = (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly maxOutputBytes?: number; + }) => azure.execute({ cwd: input.cwd, args: [...input.args, "--only-show-errors", "--output", "json"], + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }); /** @@ -290,6 +296,7 @@ export const make = Effect.gen(function* () { const top = remaining + 1; return executeJson({ cwd: input.cwd, + maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES, args: [ "repos", "pr", diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index a5bb9a9e30af49d00367d8ed09b0e249dd0fd383..f0cb52003029de06ad66bd7d30df7d4cc8f1baaa 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -332,6 +332,28 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("forwards explicit output limits to the process boundary", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.execute({ + cwd: "/repo", + args: ["repos", "pr", "list"], + maxOutputBytes: 16 * 1024 * 1024, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: ["repos", "pr", "list"], + cwd: "/repo", + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + }); + }).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/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 556dc4bf213d92cd476d55d1803f1c94410e4c6d..f05f4a7588c469244451f81a55e53ef7980a4af4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -218,6 +218,7 @@ export class AzureDevOpsCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; + readonly maxOutputBytes?: number; }) => Effect.Effect; readonly listPullRequests: (input: { @@ -362,6 +363,7 @@ export const make = Effect.gen(function* () { args: input.args, cwd: input.cwd, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }) .pipe( Effect.mapError((error) => -- 2.54.0