Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, assert, expect, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
Expand Down Expand Up @@ -61,6 +62,34 @@ afterEach(() => {
});

layer("AzureDevOpsPullRequestCli.layer", (it) => {
it.effect("bounds dependency discovery when full raw pages contain no usable rows", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(
Effect.succeed(
output(
yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(
Array.from({ length: 201 }, () => ({ pullRequestId: "invalid" })),
),
),
),
);
const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "web",
state: "open",
involvement: "all",
viewer: "",
limit: 200,
relationshipOnly: true,
});
expect(mockedExecute).toHaveBeenCalledTimes(4);
expect(batch.items).toHaveLength(0);
expect(batch.truncated).toBe(true);
expect(batch.cursorAdvance).toBe(804);
}),
);

it.effect("asks for one row more than the page, to probe for a next page", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class AzureDevOpsPullRequestCli extends Context.Service<
}) => Effect.Effect<string, AzureDevOpsPullRequestCliError>;

readonly listPullRequests: (input: {
readonly relationshipOnly?: boolean | undefined;
readonly cwd: string;
readonly repository: string;
readonly state: PullRequestListState;
Expand Down Expand Up @@ -294,6 +295,8 @@ export const make = Effect.gen(function* () {
readonly skip: number;
readonly cursorAdvance: number;
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
readonly relationshipOnly?: boolean | undefined;
readonly page: number;
}): Effect.Effect<
{
readonly items: ReadonlyArray<AzureDevOpsPullRequest>;
Expand Down Expand Up @@ -363,8 +366,16 @@ export const make = Effect.gen(function* () {
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
});
}
if (input.relationshipOnly === true && input.page >= 4) {
return Effect.succeed({
items,
truncated: true,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
});
}
return listPullRequestPage({
...input,
page: input.page + 1,
skip: input.skip + decoded.success.rawCount,
cursorAdvance: input.cursorAdvance + decoded.success.rawCount,
items,
Expand Down Expand Up @@ -411,6 +422,8 @@ export const make = Effect.gen(function* () {
skip: input.cursor?.delivered ?? 0,
cursorAdvance: 0,
items: [],
page: 1,
relationshipOnly: input.relationshipOnly,
}),

getPullRequest: (input) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const make = Effect.gen(function* () {
viewer: input.viewer,
limit: input.limit,
cursor: input.cursor,
relationshipOnly: input.relationshipOnly,
})
.pipe(
Effect.mapError(fail("listChangeRequests")),
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ afterEach(() => {
});

layer("BitbucketPullRequestApi.layer", (it) => {
it.effect("bounds dependency discovery and retains omissions from earlier pages", () =>
Effect.gen(function* () {
const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
mockedRequest.mockReturnValueOnce(
Effect.succeed(response(valuePage([{ id: "invalid" }], next))),
);
mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(1, 7))));
const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
const input = {
repository: "acme/web",
state: "open" as const,
limit: 200,
relationshipOnly: true,
};
const batch = yield* api.listPullRequests(input);
expect(batch.items).toHaveLength(1);
expect(batch.truncated).toBe(true);
expect(mockedRequest).toHaveBeenCalledTimes(2);
mockedRequest.mockClear();
mockedRequest.mockReturnValue(Effect.succeed(response(valuePage([{ id: "invalid" }], next))));
const bounded = yield* api.listPullRequests(input);
expect(mockedRequest).toHaveBeenCalledTimes(4);
expect(bounded.truncated).toBe(true);
}),
);

it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1))));
Expand Down Expand Up @@ -144,6 +170,41 @@ layer("BitbucketPullRequestApi.layer", (it) => {
}),
);

it.effect("marks a page with a skipped row incomplete even without a next cursor", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response(
valuePage([
{ id: "not a number" },
{
id: 7,
title: "Usable row",
state: "OPEN",
created_on: "2026-06-16T05:04:32+00:00",
updated_on: "2026-06-16T05:04:33+00:00",
source: { branch: { name: "feat/page" } },
destination: { branch: { name: "master" } },
links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } },
},
]),
),
),
);
const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;

const batch = yield* api.listPullRequests({
repository: "acme/web",
state: "open",
limit: 50,
relationshipOnly: true,
});

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

it.effect("counts the rows it walked past as more to come", () =>
Effect.gen(function* () {
// Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a
Expand Down
20 changes: 17 additions & 3 deletions apps/server/src/pullRequest/BitbucketPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export class BitbucketPullRequestApi extends Context.Service<
readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>;

readonly listPullRequests: (input: {
readonly relationshipOnly?: boolean | undefined;
readonly repository: string;
readonly state: PullRequestListState;
readonly limit: number;
Expand Down Expand Up @@ -405,6 +406,8 @@ export const make = Effect.gen(function* () {
readonly limit: number;
readonly page: number;
readonly collected: ReadonlyArray<BitbucketPullRequest>;
readonly relationshipOnly?: boolean | undefined;
readonly incomplete: boolean;
}): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> =>
bitbucket.request({ method: "GET", url: input.url }).pipe(
Effect.flatMap((response) => {
Expand All @@ -419,16 +422,25 @@ export const make = Effect.gen(function* () {
}
const collected = [...input.collected, ...decoded.success.items];
const next = decoded.success.next;
if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) {
const incomplete =
input.incomplete || decoded.success.rawCount !== decoded.success.items.length;
if (
next === null ||
collected.length >= input.limit ||
input.page >= (input.relationshipOnly === true ? 4 : MAX_LIST_PAGES)
) {
return Effect.succeed({
items: collected.slice(0, input.limit),
// Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the
// count rather than on the last page is holding rows it is about to drop. Those are
// more results just as surely as another page would be.
truncated: next !== null || collected.length > input.limit,
truncated:
next !== null ||
collected.length > input.limit ||
(input.relationshipOnly === true && incomplete),
});
}
return listPage({ ...input, url: next, page: input.page + 1, collected });
return listPage({ ...input, url: next, page: input.page + 1, collected, incomplete });
}),
);

Expand Down Expand Up @@ -562,6 +574,8 @@ export const make = Effect.gen(function* () {
limit: input.limit,
page: 1,
collected: [],
incomplete: false,
relationshipOnly: input.relationshipOnly,
});
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export const make = Effect.gen(function* () {
limit: input.limit,
query: input.query,
cursor: input.cursor,
relationshipOnly: input.relationshipOnly,
})
.pipe(
Effect.mapError(fail("listChangeRequests")),
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,74 @@ afterEach(() => {
});

layer("GitHubPullRequestCli.layer", (it) => {
it.effect(
"relationship discovery uses one bounded repository listing without search fallback",
() =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "acme/web",
host: "github.com",
state: "open",
involvement: "all",
viewer: "reader",
limit: 200,
relationshipOnly: true,
});
expect(batch).toEqual({ items: [], truncated: false, continues: false });
expect(mockedExecute).toHaveBeenCalledTimes(1);
expect(callAt(0).args).not.toContain("--search");
expect(callAt(0).args).toContain("201");
}),
);

it.effect("a skipped relationship row keeps coverage partial without fetching replacements", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(
Effect.succeed(output(pullRequests(201, 1, () => ({ number: "invalid" })))),
);
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "acme/web",
host: "github.com",
state: "open",
involvement: "all",
viewer: "reader",
limit: 200,
relationshipOnly: true,
});
expect(batch.items).toHaveLength(0);
expect(batch.truncated).toBe(true);
expect(mockedExecute).toHaveBeenCalledTimes(1);
}),
);

it.effect("caps a larger relationship request and reports the unread remainder", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(201, 1))));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
const batch = yield* cli.listPullRequests({
cwd: "/w",
repository: "acme/web",
host: "github.com",
state: "open",
involvement: "all",
viewer: "reader",
limit: 1_000,
relationshipOnly: true,
});

expect(batch.items).toHaveLength(201);
expect(batch.truncated).toBe(true);
expect(mockedExecute).toHaveBeenCalledTimes(1);
const args = callAt(0).args;
expect(args[args.indexOf("--limit") + 1]).toBe("201");
}),
);

it.effect("reads linked pull request status through one narrow request", () =>
Effect.gen(function* () {
mockedGetPullRequest.mockReturnValueOnce(
Expand Down
18 changes: 15 additions & 3 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;

/** A search-free fallback may scan older rows for local filters, but never the whole repository. */
const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
const RELATIONSHIP_ONLY_MAX_ROWS = 201;

/** What the files API serves at most in one response, which is what one slice is made of. */
const DIFF_FILES_PAGE_SIZE = 100;
Expand Down Expand Up @@ -411,6 +412,7 @@ export class GitHubPullRequestCli extends Context.Service<
}) => Effect.Effect<string, GitHubPullRequestCliError>;

readonly listPullRequests: (input: {
readonly relationshipOnly?: boolean | undefined;
readonly cwd: string;
readonly repository: string;
readonly host: string;
Expand Down Expand Up @@ -1504,6 +1506,7 @@ export const make = Effect.gen(function* () {
: decoded.success.items.filter((item) => matchesUnsortedListing(item, input));
if (
!continues &&
input.relationshipOnly !== true &&
items.length < input.limit &&
decoded.success.rawCount >= requestedRows &&
requestedRows < fallbackMaxRows
Expand All @@ -1515,9 +1518,13 @@ export const make = Effect.gen(function* () {
items: items.slice(0, input.limit),
// One row over the page size is the probe for a next page, and it is
// counted before decoding: a skipped malformed row must not end paging.
truncated: continues
? decoded.success.rawCount > input.limit
: items.length > input.limit || decoded.success.rawCount >= requestedRows,
truncated:
input.relationshipOnly === true
? decoded.success.rawCount >= requestedRows ||
decoded.success.rawCount !== items.length
: continues
? decoded.success.rawCount > input.limit
: items.length > input.limit || decoded.success.rawCount >= requestedRows,
continues,
});
}
Expand Down Expand Up @@ -1548,6 +1555,11 @@ export const make = Effect.gen(function* () {
// the same read rather than a wider one. Free text is the one thing the fallback cannot
// judge locally — it lists rows, it does not search their text — so a query still rules
// the fallback out: an empty answer under one is already the answer.
// Relationship discovery needs the repository collection, not its eventually indexed search.
// A single bounded CLI listing uses at most three 100-row underlying pages.
if (input.relationshipOnly === true) {
return read(false, Math.min(input.limit + 1, RELATIONSHIP_ONLY_MAX_ROWS));
}
const hasQuery = (input.query?.trim().length ?? 0) > 0;
return read(true).pipe(
Effect.flatMap((batch) =>
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";

it.effect("dependency listings do not spend host requests loading actor avatars", () =>
Effect.gen(function* () {
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
listPullRequests: () => Effect.succeed({ items: [], truncated: false, continues: false }),
listActorAvatars: () => Effect.die("relationship read requested avatars"),
}),
),
);
const page = yield* provider.listChangeRequests({
cwd: "/w",
host: "github.com",
repository: "acme/web",
state: "open",
involvement: "all",
viewer: "reader",
limit: 200,
relationshipOnly: true,
});
expect(page).toEqual({ items: [], truncated: false, continues: false });
}),
);

it.effect("uses one narrow read for a linked pull request summary", () =>
Effect.gen(function* () {
let summaryReads = 0;
Expand Down
Loading