authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-09-02 09:43:10+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 00:43:10-07:00
loga56b0cd7178dfef299aa49b26773a80a34abe205
tree3fbef95adeaebc644a38fa388ecba66432676bc6
parent4116db9807cb37b49d3db35c7a45301f5e851b01
signature Signed by PGP key B5690EEEBB952194

fix(server): allow large Azure DevOps PR lists (#8572)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

4 files changed, 72 insertions(+), 1 deletions(-)

apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+40
......@@ -97,6 +97,46 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
9797 }),
9898 );
9999
100 it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
101 Effect.gen(function* () {
102 const rows = pullRequestRows(100, 1).map((row) => ({
103 ...row,
104 description: "x".repeat(10_000),
105 }));
106 // @effect-diagnostics-next-line preferSchemaOverJson:off
107 const response = JSON.stringify(rows);
108 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
109
110 mockedExecute.mockImplementationOnce((input) => {
111 const maxOutputBytes =
112 "maxOutputBytes" in input && typeof input.maxOutputBytes === "number"
113 ? input.maxOutputBytes
114 : 1_000_000;
115 return Effect.succeed(
116 maxOutputBytes >= Buffer.byteLength(response)
117 ? output(response)
118 : {
119 ...output(response.slice(0, maxOutputBytes)),
120 stdoutTruncated: true,
121 },
122 );
123 });
124 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
125
126 const batch = yield* cli.listPullRequests({
127 cwd: "/w",
128 repository: "web",
129 state: "merged",
130 involvement: "all",
131 viewer: "bilal@acme.dev",
132 limit: 99,
133 });
134
135 assert.strictEqual(batch.items.length, 99);
136 assert.isTrue(batch.truncated);
137 }),
138 );
139
100140 it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () =>
101141 Effect.gen(function* () {
102142 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+8-1
......@@ -111,6 +111,7 @@ export type AzureDevOpsPullRequestCliError =
111111
112112/** The version every REST call below is pinned to, so a new default cannot reshape a response. */
113113const REST_API_VERSION = "7.1";
114const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
114115
115116export class AzureDevOpsPullRequestCli extends Context.Service<
116117 AzureDevOpsPullRequestCli,
......@@ -258,10 +259,15 @@ export const make = Effect.gen(function* () {
258259 // how to read all of them.
259260 const detectArgs = ["--detect", "true"] as const;
260261
261 const executeJson = (input: { readonly cwd: string; readonly args: ReadonlyArray<string> }) =>
262 const executeJson = (input: {
263 readonly cwd: string;
264 readonly args: ReadonlyArray<string>;
265 readonly maxOutputBytes?: number;
266 }) =>
262267 azure.execute({
263268 cwd: input.cwd,
264269 args: [...input.args, "--only-show-errors", "--output", "json"],
270 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
265271 });
266272
267273 /**
......@@ -290,6 +296,7 @@ export const make = Effect.gen(function* () {
290296 const top = remaining + 1;
291297 return executeJson({
292298 cwd: input.cwd,
299 maxOutputBytes: PULL_REQUEST_LIST_MAX_OUTPUT_BYTES,
293300 args: [
294301 "repos",
295302 "pr",
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+22
......@@ -332,6 +332,28 @@ describe("AzureDevOpsCli.layer", () => {
332332 }).pipe(Effect.provide(layer)),
333333 );
334334
335 it.effect("forwards explicit output limits to the process boundary", () =>
336 Effect.gen(function* () {
337 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
338
339 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
340 yield* az.execute({
341 cwd: "/repo",
342 args: ["repos", "pr", "list"],
343 maxOutputBytes: 16 * 1024 * 1024,
344 });
345
346 expect(mockRun).toHaveBeenCalledWith({
347 operation: "AzureDevOpsCli.execute",
348 command: "az",
349 args: ["repos", "pr", "list"],
350 cwd: "/repo",
351 timeoutMs: 30_000,
352 maxOutputBytes: 16 * 1024 * 1024,
353 });
354 }).pipe(Effect.provide(layer)),
355 );
356
335357 it.effect("preserves VCS causes without copying upstream details into messages", () =>
336358 Effect.gen(function* () {
337359 const cause = new VcsProcessExitError({
apps/server/src/sourceControl/AzureDevOpsCli.ts+2
......@@ -218,6 +218,7 @@ export class AzureDevOpsCli extends Context.Service<
218218 readonly cwd: string;
219219 readonly args: ReadonlyArray<string>;
220220 readonly timeoutMs?: number;
221 readonly maxOutputBytes?: number;
221222 }) => Effect.Effect<VcsProcess.VcsProcessOutput, AzureDevOpsCliError>;
222223
223224 readonly listPullRequests: (input: {
......@@ -362,6 +363,7 @@ export const make = Effect.gen(function* () {
362363 args: input.args,
363364 cwd: input.cwd,
364365 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
366 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
365367 })
366368 .pipe(
367369 Effect.mapError((error) =>