Skip to content
Merged
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDiff]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
});
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/cloud/pinnedRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
});
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/cloud/selfUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}
order.push("preflight");
Expand All @@ -64,6 +66,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
};
}),
});
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/environment/ServerEnvironmentLabel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ describe("resolveServerEnvironmentLabel", () => {
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);

Expand Down Expand Up @@ -120,6 +122,8 @@ describe("resolveServerEnvironmentLabel", () => {
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);

Expand Down Expand Up @@ -223,6 +227,8 @@ describe("resolveServerEnvironmentLabel", () => {
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);

Expand Down Expand Up @@ -264,6 +270,8 @@ describe("resolveServerEnvironmentLabel", () => {
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);

Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/processRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import {
collectUint8StreamText,
decodeUtf8,
type CollectedUint8StreamText,
} from "./stream/collectUint8StreamText.ts";

Expand Down Expand Up @@ -41,6 +42,8 @@ export interface ProcessRunOutput {
readonly timedOut: boolean;
readonly stdoutTruncated: boolean;
readonly stderrTruncated: boolean;
readonly stdoutInvalidUtf8: boolean;
readonly stderrInvalidUtf8: boolean;
}

const ProcessInvocationFields = {
Expand Down Expand Up @@ -238,7 +241,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: {
),
Effect.map(
(state): CollectedUint8StreamText => ({
text: Buffer.concat(state.chunks, state.bytes).toString("utf8"),
...decodeUtf8(Buffer.concat(state.chunks, state.bytes)),
bytes: state.bytes,
truncated: false,
}),
Expand Down Expand Up @@ -268,6 +271,8 @@ function finalizeRunProcess<R>(
timedOut: true,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
} satisfies ProcessRunOutput);
}
return Effect.fail(
Expand Down Expand Up @@ -394,6 +399,8 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
timedOut: false,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
stdoutInvalidUtf8: stdout.invalidUtf8,
stderrInvalidUtf8: stderr.invalidUtf8,
} satisfies ProcessRunOutput;
});

Expand Down
67 changes: 55 additions & 12 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,24 @@ function output(stdout: string) {
};
}

function pullRequestRows(
count: number,
firstNumber: number,
): ReadonlyArray<Record<string, unknown>> {
return Array.from({ length: count }, (_, index) => ({
pullRequestId: firstNumber + index,
title: `Pull request ${firstNumber + index}`,
status: "active",
sourceRefName: "refs/heads/feat/page",
targetRefName: "refs/heads/main",
creationDate: "2026-07-01T00:00:00Z",
repository: { name: "web", project: { name: "platform" } },
url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
}));
}

function pullRequests(count: number, firstNumber: number): string {
return JSON.stringify(
Array.from({ length: count }, (_, index) => ({
pullRequestId: firstNumber + index,
title: `Pull request ${firstNumber + index}`,
status: "active",
sourceRefName: "refs/heads/feat/page",
targetRefName: "refs/heads/main",
creationDate: "2026-07-01T00:00:00Z",
repository: { name: "web", project: { name: "platform" } },
url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
})),
);
return JSON.stringify(pullRequestRows(count, firstNumber));
}

/** The arguments of the nth az invocation. */
Expand Down Expand Up @@ -172,6 +177,44 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {

assert.strictEqual(batch.items.length, 10);
assert.isTrue(batch.truncated);
assert.strictEqual(batch.cursorAdvance, 10);
}),
);

it.effect("advances by malformed raw rows and keeps reading until the page is full", () =>
Effect.gen(function* () {
mockedExecute
.mockReturnValueOnce(
Effect.succeed(
output(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{ pullRequestId: "malformed" },
pullRequestRows(1, 1)[0],
{ pullRequestId: "also malformed" },
]),
),
),
)
.mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2))));
const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;

const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "web",
state: "open",
involvement: "all",
viewer: "bilal@acme.dev",
limit: 2,
});

expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
assert.isTrue(batch.truncated);
// Three raw rows from the first request and one from the second produced this page.
assert.strictEqual(batch.cursorAdvance, 4);
const secondArgs = argsOfCall(1);
assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3");
assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2");
}),
);

Expand Down
157 changes: 112 additions & 45 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
*/
readonly cursor?: ProviderListCursor | undefined;
}) => Effect.Effect<
{ readonly items: ReadonlyArray<AzureDevOpsPullRequest>; readonly truncated: boolean },
{
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
readonly truncated: boolean;
/** Raw Azure rows consumed to produce this page, including malformed rows. */
readonly cursorAdvance: number;
},
AzureDevOpsPullRequestCliError
>;

Expand Down Expand Up @@ -242,6 +247,98 @@ export const make = Effect.gen(function* () {
args: [...input.args, "--only-show-errors", "--output", "json"],
});

/**
* Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and
* retain the raw count so the next public cursor skips every row this walk consumed.
*/
const listPullRequestPage = (input: {
readonly cwd: string;
readonly repository: string;
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
readonly limit: number;
readonly skip: number;
readonly cursorAdvance: number;
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
}): Effect.Effect<
{
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
readonly truncated: boolean;
readonly cursorAdvance: number;
},
AzureDevOpsPullRequestCliError
> => {
const remaining = input.limit - input.items.length;
const top = remaining + 1;
return executeJson({
cwd: input.cwd,
args: [
"repos",
"pr",
"list",
...detectArgs,
"--repository",
input.repository,
...statusArgs(input.state),
...involvementArgs(input),
// A web link per row, which is the only url that needs no assembling.
"--include-links",
...(input.skip === 0 ? [] : ["--skip", String(input.skip)]),
"--top",
String(top),
],
}).pipe(
Effect.flatMap((result) => {
const raw = result.stdout.trim();
if (raw.length === 0) {
return Effect.succeed({
items: input.items,
truncated: false,
cursorAdvance: input.cursorAdvance,
});
}
const decoded = decodePullRequestListJson(raw);
if (!Result.isSuccess(decoded)) {
return Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "listPullRequests",
cause: decoded.failure,
}),
);
}

const lastItemIndex = decoded.success.rawIndexes[remaining - 1];
if (lastItemIndex !== undefined) {
const consumed = lastItemIndex + 1;
return Effect.succeed({
items: [...input.items, ...decoded.success.items.slice(0, remaining)],
// A full raw response may have more rows even when malformed entries used the probe.
truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top,
cursorAdvance: input.cursorAdvance + consumed,
});
}

const items = [...input.items, ...decoded.success.items];
if (decoded.success.rawCount < top) {
return Effect.succeed({
items,
truncated: false,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
});
}
return listPullRequestPage({
...input,
skip: input.skip + decoded.success.rawCount,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
items,
});
}),
);
};

return AzureDevOpsPullRequestCli.of({
getViewer: (input) =>
executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe(
Expand All @@ -266,51 +363,21 @@ export const make = Effect.gen(function* () {
),

listPullRequests: (input) =>
executeJson({
listPullRequestPage({
cwd: input.cwd,
args: [
"repos",
"pr",
"list",
...detectArgs,
"--repository",
input.repository,
...statusArgs(input.state),
...involvementArgs(input),
// A web link per row, which is the only url that needs no assembling.
"--include-links",
// Azure counts rather than filters, so a slice carries on by stepping over what has
// already been handed over. That is an offset into a list that can shift underneath
// it: a pull request opened between two slices moves everything down one, and the row
// on the seam is the one that pays for it.
...(input.cursor === undefined ? [] : ["--skip", String(input.cursor.delivered)]),
"--top",
// One row over the page reveals that the repository has more than the page shows.
String(input.limit + 1),
],
}).pipe(
Effect.flatMap((result) => {
const raw = result.stdout.trim();
if (raw.length === 0) {
return Effect.succeed({ items: [], truncated: false });
}
const decoded = decodePullRequestListJson(raw);
return Result.isSuccess(decoded)
? Effect.succeed({
items: decoded.success.items.slice(0, input.limit),
// Counted before decoding, so a skipped malformed row cannot end paging early.
truncated: decoded.success.rawCount > input.limit,
})
: Effect.fail(
new AzureDevOpsPullRequestReadError({
command: "az",
cwd: input.cwd,
operation: "listPullRequests",
cause: decoded.failure,
}),
);
}),
),
repository: input.repository,
state: input.state,
involvement: input.involvement,
viewer: input.viewer,
limit: input.limit,
// Azure counts rather than filters, so a slice carries on by stepping over every raw row
// the prior slice consumed. That is an offset into a list that can shift underneath it:
// a pull request opened between two slices moves everything down one, and the row on the
// seam is the one that pays for it.
skip: input.cursor?.delivered ?? 0,
cursorAdvance: 0,
items: [],
}),

getPullRequest: (input) =>
executeJson({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const make = Effect.gen(function* () {
Effect.map((batch) => ({
items: batch.items.map(toChangeRequest),
truncated: batch.truncated,
cursorAdvance: batch.cursorAdvance,
// Azure answers in one order whether or not it is being carried on from, so a slice
// can always be stepped past — by counting, which is all Azure offers.
continues: true,
Expand Down
Loading