1// Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456
2// Numbers form a session with shared repository-permission caches. Browser and
3// service caches are excluded. Uses real GitHub reads, without a server or database.
4// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer.
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Effect from "effect/Effect";
7import * as Console from "effect/Console";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { FetchHttpClient } from "effect/http";
12
13import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi";
14import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider";
15import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
16import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
17import * as ServerConfig from "../src/config.ts";
18import * as ServerSettings from "../src/serverSettings.ts";
19import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota";
20import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
21import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
22import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
23import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
24import * as VcsProcess from "../src/vcs/VcsProcess.ts";
25import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";
26
27const [repository, ...numbers] = process.argv.slice(2);
28if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) {
29 throw new Error("Usage: node apps/server/scripts/measure-pr-preview.ts owner/repo number...");
30}
31
32type Read = { graphqlRequests: number; restRequests: number; cost: number };
33const reads: Read[] = [];
34const decodeCost = Schema.decodeUnknownOption(
35 Schema.fromJsonString(
36 Schema.Struct({
37 data: Schema.Struct({ rateLimit: Schema.Struct({ cost: Schema.Number }) }),
38 }),
39 ),
40);
41/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */
42function withRateLimit(query: string): string {
43 const end = query.lastIndexOf("}");
44 return query.trimStart().startsWith("mutation") || end === -1
45 ? query
46 : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`;
47}
48
49// The server's host port over default settings: gh's own account choice, no saved token.
50const sourceControlHost = ServerSourceControlHost.layer.pipe(
51 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
52 Layer.provide(GitVcsDriver.layer),
53 Layer.provide(ServerSettings.layerTest()),
54 // A scratch state directory, so nothing here touches a real T3 home.
55 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-measure-pr-preview-" })),
56 Layer.provideMerge(VcsProcess.layer),
57 Layer.provideMerge(NodeServices.layer),
58);
59
60const measuredApi = Layer.effect(
61 GitHubApi.GitHubApi,
62 Effect.gen(function* () {
63 const api = yield* GitHubApi.make;
64 return GitHubApi.GitHubApi.of({
65 ...api,
66 graphql: (input) =>
67 api.graphql({ ...input, query: withRateLimit(input.query) }).pipe(
68 Effect.tap((body) =>
69 Effect.sync(() =>
70 reads.push({
71 graphqlRequests: 1,
72 restRequests: 0,
73 cost: Option.match(decodeCost(body), {
74 onNone: () => 0,
75 onSome: (decoded) => decoded.data.rateLimit.cost,
76 }),
77 }),
78 ),
79 ),
80 ),
81 rest: (input) =>
82 api
83 .rest(input)
84 .pipe(
85 Effect.tap(() =>
86 Effect.sync(() => reads.push({ graphqlRequests: 0, restRequests: 1, cost: 0 })),
87 ),
88 ),
89 });
90 }),
91).pipe(
92 Layer.provide(GitHubCredentials.layer),
93 Layer.provide(GitHubQuota.layer),
94 Layer.provide(SourceControlRateLimit.layer),
95 Layer.provide(FetchHttpClient.layer),
96);
97
98const services = GitHubPullRequestApi.layer.pipe(
99 Layer.provideMerge(measuredApi),
100 Layer.provideMerge(sourceControlHost),
101);
102
103const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
104
105for (const mode of ["detail", "preview"] as const) {
106 // Start each side cold, then retain the caches shared across PRs in one session.
107 const rows = await Effect.runPromise(
108 Effect.scoped(
109 Effect.gen(function* () {
110 const provider = yield* GitHubPullRequestProvider.make;
111 return yield* Effect.forEach(numbers.map(Number), (number) =>
112 Effect.gen(function* () {
113 const input = { cwd: process.cwd(), repository, host: "github.com", number };
114 reads.length = 0;
115 const start = performance.now();
116 const value = yield* mode === "detail"
117 ? provider.getChangeRequest(input)
118 : provider.getChangeRequestPreview!(input);
119 const elapsedMs = Math.round(performance.now() - start);
120 const measured = [...reads];
121 const points = measured.reduce((total, read) => total + read.cost, 0);
122 return {
123 repository,
124 number,
125 mode,
126 state: value.state,
127 elapsedMs,
128 points,
129 graphqlRequests: measured.reduce((total, read) => total + read.graphqlRequests, 0),
130 restRequests: measured.reduce((total, read) => total + read.restRequests, 0),
131 };
132 }),
133 );
134 }),
135 ).pipe(Effect.provide(services)),
136 );
137 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));
138}