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
3 changes: 2 additions & 1 deletion packages/source-control-github/graphql.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from "@effect/graphql-generator/Config";

// Public schema: https://docs.github.com/public/fpt/schema.docs.graphql. Gate Enterprise-missing fields with @include.
// Public schema: https://docs.github.com/public/fpt/schema.docs.graphql. Enterprise Server rejects a
// document naming a field it lacks, even under `@include`, so such fields need github.com-only documents.
export default defineConfig({
schema: "./schema/github.graphql",
documents: ["src/**/*.graphql"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,8 @@ layer("GitHubPullRequestApi.layer", (it) => {
mergedAt: null,
closedAt: null,
commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
stack: number === 7 ? { number: 3, size: 2, baseRefName: "main" } : null,
stackEntry: number === 7 ? { position: 1 } : null,
});
mockedExecute.mockReturnValueOnce(
Effect.succeed(
Expand Down Expand Up @@ -1106,6 +1108,10 @@ layer("GitHubPullRequestApi.layer", (it) => {
},
);
assert.strictEqual(eight?.headBranch, "feat/8");
assert.deepStrictEqual(
[seven?.stack, eight?.stack],
[{ number: 3, size: 2, base: "main", position: 1 }, null],
);
expect(mockedExecute).toHaveBeenCalledOnce();
expect(queryAt(0)).toContain(
"_0_repository:repository(owner:$_0_owner name:$_0_name){pullRequest(number:$_0_number)",
Expand All @@ -1114,7 +1120,6 @@ layer("GitHubPullRequestApi.layer", (it) => {
_0_owner: "acme",
_0_name: "web",
_0_number: 7,
_0_stacks: true,
_1_number: 8,
});
}),
Expand Down Expand Up @@ -1572,7 +1577,11 @@ layer("GitHubPullRequestApi.layer", (it) => {
mockedExecute.mockReturnValue(
Effect.succeed(
searchPage([
searchItem(7, "acme/web", "2026-07-03T00:00:00Z"),
{
...searchItem(7, "acme/web", "2026-07-03T00:00:00Z"),
stack: { number: 3, size: 2, baseRefName: "main" },
stackEntry: { position: 2 },
},
searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"),
{},
]),
Expand Down Expand Up @@ -1604,6 +1613,10 @@ layer("GitHubPullRequestApi.layer", (it) => {
[0, 0],
],
);
assert.deepStrictEqual(
batch.items.map((item) => item.stack),
[{ number: 3, size: 2, base: "main", position: 2 }, undefined],
);
assert.isFalse(batch.truncated);
}),
);
Expand Down Expand Up @@ -2882,7 +2895,8 @@ layer("GitHubPullRequestApi.layer", (it) => {
}),
);

it.effect("names the host on every repository it addresses", () =>
// Enterprise Server rejects a document naming a field it lacks, even under `@include(if: false)`.
it.effect("names the host, and no stack field, in every Enterprise read", () =>
Effect.gen(function* () {
mockedExecute.mockImplementation((call) =>
Effect.succeed(
Expand All @@ -2892,21 +2906,36 @@ layer("GitHubPullRequestApi.layer", (it) => {
),
);
const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;

yield* cli.listPullRequests({
const listing = {
cwd: "/w",
repository: "acme/web",
host: "github.acme.dev",
state: "open",
involvement: "all",
viewer: "bilal",
limit: 10,
});
} as const;

assert.isAbove(mockedExecute.mock.calls.length, 0);
expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual(
new Set(["github.acme.dev"]),
);
yield* cli.listPullRequests({ ...listing, repository: "acme/web" });
yield* cli.searchPullRequests({ ...listing, repositories: ["acme/web"] });
const summary = yield* cli
.getPullRequestSummary({ ...listing, repository: "acme/web", number: 7 })
.pipe(Effect.ignore, Effect.forkChild);
yield* TestClock.adjust("10 millis");
yield* Fiber.join(summary);

const calls = mockedExecute.mock.calls.map(([call]) => call);
expect(
calls.map((call) => (call.kind === "graphql" ? call.operationName : call.kind)),
).toEqual([
"PullRequestSearch",
"PullRequestList",
"PullRequestSearch",
"PullRequestSummary",
]);
for (const call of calls) {
expect(call.host).toBe("github.acme.dev");
if (call.kind === "graphql") expect(call.query).not.toMatch(/\bstack/);
}
expect(searchOfCall(0)).toContain("repo:acme/web");
}),
);
Expand Down
71 changes: 41 additions & 30 deletions packages/source-control-github/src/server/GitHubPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
isSubjectOfPullRequest,
LenientPullRequestList,
LenientPullRequestSearch,
LenientPullRequestSearchWithStacks,
pageRows,
pullRequestLookup,
toLabelCandidates,
Expand Down Expand Up @@ -386,6 +387,8 @@ export interface GitHubPullRequestStat {
const STAT_ALIASES_PER_REQUEST = 25;
// Cache checks stagger reads across scheduler ticks; allow time to batch.
const SUMMARY_BATCH_WINDOW = "10 millis";
// Only github.com has pull request stacks; other hosts get documents that never name them.
const hasStacks = (host: string) => host.toLowerCase() === "github.com";

export interface GitHubPullRequestSearchBatch {
readonly items: ReadonlyArray<GitHubPullRequestSearchItem>;
Expand Down Expand Up @@ -895,7 +898,11 @@ const GRAPHQL_MERGE_METHODS = {

const PullRequestGraphQLGroup = GraphQLGroup.merge(
PullRequestBatchesGraphQLGroup,
GraphQLGroup.make(LenientPullRequestSearch, LenientPullRequestList),
GraphQLGroup.make(
LenientPullRequestSearch,
LenientPullRequestSearchWithStacks,
LenientPullRequestList,
),
PullRequestReadsGraphQLGroup,
PullRequestReviewsGraphQLGroup,
PullRequestMutationsGraphQLGroup,
Expand Down Expand Up @@ -1703,6 +1710,12 @@ export const make = Effect.gen(function* () {
RequestResolver.setDelay(SUMMARY_BATCH_WINDOW),
RequestResolver.batchN(STAT_ALIASES_PER_REQUEST),
);
const stackedSummaryResolver = GraphQLResolver.aliased(client.PullRequestSummaryWithStacks, {
batchKey: GitHubGraphQlClient.batchKey,
}).pipe(
RequestResolver.setDelay(SUMMARY_BATCH_WINDOW),
RequestResolver.batchN(STAT_ALIASES_PER_REQUEST),
);
const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = Effect.fn(
"GitHubPullRequestApi.getPullRequestSummary",
)(
Expand All @@ -1714,10 +1727,9 @@ export const make = Effect.gen(function* () {
operation: "getPullRequestSummary",
});
}
const result = yield* GraphQLResolver.request(
{ ...lookup, stacks: input.host.toLowerCase() === "github.com" },
summaryResolver,
);
const result = hasStacks(input.host)
? yield* GraphQLResolver.request(lookup, stackedSummaryResolver)
: yield* GraphQLResolver.request(lookup, summaryResolver);
const pullRequest = result.repository?.pullRequest;
if (pullRequest == null) {
return yield* new GitHubRequestPolicy.GitHubApiNotFoundError({
Expand Down Expand Up @@ -1830,7 +1842,8 @@ export const make = Effect.gen(function* () {
truncated,
};
});
const stacks = input.host.toLowerCase() === "github.com";
// Stack badges come from their own batched read below, for the rows that stay.
const stacks = hasStacks(input.host);
const read = (
continues: boolean,
requestedRows = input.limit + 1,
Expand All @@ -1842,8 +1855,6 @@ export const make = Effect.gen(function* () {
q: query,
first: pageRows(rows),
after,
// Stack badges come from their own batched read below, for the rows that stay.
stacks: false,
stats: true,
})
.pipe(Effect.map((result) => toPage(toPullRequestRows(result.search))))
Expand All @@ -1854,7 +1865,6 @@ export const make = Effect.gen(function* () {
states: LIST_STATES[input.state],
first: pageRows(rows),
after,
stacks: false,
stats: true,
})
.pipe(
Expand Down Expand Up @@ -1960,27 +1970,28 @@ export const make = Effect.gen(function* () {
operation: "searchPullRequests",
});
}
const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS);
return yield* client
.PullRequestSearch({
q: query,
first: rows,
stacks: input.host.toLowerCase() === "github.com",
stats: false,
})
.pipe(
graphql({ ...input, operation: "searchPullRequests" }),
Effect.map(({ search }) => {
const page = toPullRequestRows(search);
const items = page.rows.flatMap(({ item, repository }) =>
repository === null ? [] : [{ ...item, repository }],
);
return {
items: items.slice(0, input.limit),
truncated: page.rawCount > input.limit || page.hasNextPage,
};
}),
);
const variables = {
q: query,
first: Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS),
stats: false,
};
return yield* (
hasStacks(input.host)
? client.PullRequestSearchWithStacks(variables)
: client.PullRequestSearch(variables)
).pipe(
graphql({ ...input, operation: "searchPullRequests" }),
Effect.map(({ search }) => {
const page = toPullRequestRows(search);
const items = page.rows.flatMap(({ item, repository }) =>
repository === null ? [] : [{ ...item, repository }],
);
return {
items: items.slice(0, input.limit),
truncated: page.rawCount > input.limit || page.hasNextPage,
};
}),
);
}),

listPullRequestStats: Effect.fn("GitHubPullRequestApi.listPullRequestStats")(
Expand Down
23 changes: 18 additions & 5 deletions packages/source-control-github/src/server/gitHubPullRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChe
import type { ProviderChangeRequestSummary } from "@t3tools/source-control-core/server/PullRequestProvider";

import type * as GitHubSchema from "../../schema/github.graphql.ts";
import type {
PullRequestSummaryRepositoryPullRequestPullRequest as SummarizedPullRequest,
PullRequestWatchFingerprintRepositoryPullRequestPullRequest as WatchedPullRequest,
import {
PullRequestStackFields,
type PullRequestSummaryFields,
type PullRequestWatchFingerprintRepositoryPullRequestPullRequest as WatchedPullRequest,
} from "./pullRequestBatches.graphql.ts";
import {
PageInfo as ListingPageInfo,
PullRequestList,
PullRequestRow,
PullRequestSearch,
PullRequestSearchWithStacks,
} from "./pullRequestListings.graphql.ts";
import type { PullRequestActionSummary } from "./pullRequestMutations.graphql.ts";
import type {
Expand Down Expand Up @@ -790,6 +792,7 @@ const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
const decodeRow = Schema.decodeUnknownExit(PullRequestRow);
const decodeStack = Schema.decodeUnknownExit(PullRequestStackFields);

/** Decode rows individually so one invalid node cannot blank the entire listing. */
const LenientRows = Schema.Struct({
Expand All @@ -803,6 +806,12 @@ export const LenientPullRequestSearch = GraphQL.query(PullRequestSearch.name, {
result: Schema.Struct({ search: LenientRows }),
});

export const LenientPullRequestSearchWithStacks = GraphQL.query(PullRequestSearchWithStacks.name, {
document: PullRequestSearchWithStacks.document,
variables: PullRequestSearchWithStacks.variables,
result: Schema.Struct({ search: LenientRows }),
});

export const LenientPullRequestList = GraphQL.query(PullRequestList.name, {
document: PullRequestList.document,
variables: PullRequestList.variables,
Expand Down Expand Up @@ -843,7 +852,9 @@ function toRow(
const decodedNode = decodeRow(entry);
if (!Exit.isSuccess(decodedNode)) return null;
const node = decodedNode.value;
const stack = toStackMembership(node);
// Only rows from `PullRequestSearchWithStacks` carry a stack.
const decodedStack = decodeStack(entry);
const stack = Exit.isSuccess(decodedStack) ? toStackMembership(decodedStack.value) : undefined;
const reviewRequests = nodesOf(node.reviewRequests).flatMap(
(request): ReadonlyArray<ReviewRequestFields> => {
const reviewer = request.requestedReviewer;
Expand Down Expand Up @@ -940,7 +951,9 @@ export interface GitHubPullRequestSummary {
readonly stack?: PullRequestStackMembership | null;
}

export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullRequestSummary {
export function toPullRequestSummary(
pr: PullRequestSummaryFields & Partial<PullRequestStackFields>,
): GitHubPullRequestSummary {
return {
number: pr.number,
title: pr.title,
Expand Down
Loading
Loading