Skip to content
Source
Sign inSign up

measure-pr-preview.ts

152 lines6.3 KB

1// Run with: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo 123 456
2// Real GitHub reads share repository-permission caches, excluding browser and service caches.
3// HTTP client request counts are exact; GraphQL cost uses changes in `x-ratelimit-used`,
4// which are approximate when other clients spend the same token's quota.
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
8import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
9import * as Effect from "effect/Effect";
10import * as Console from "effect/Console";
11import * as Layer from "effect/Layer";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import { FetchHttpClient, HttpClient } from "effect/http";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16​
17import * as GitHubAppAccess from "../src/server/GitHubAppAccess.ts";
18import * as GitHubPullRequestApi from "../src/server/GitHubPullRequestApi.ts";
19import * as GitHubPullRequestProvider from "../src/server/GitHubPullRequestProvider.ts";
20import * as GitHubRest from "../src/server/GitHubRest.ts";
21​
22const [repository, ...numbers] = process.argv.slice(2);
23if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.test(number))) {
24 throw new Error(
25 "Usage: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo number...",
26 );
28​
29type Read = { graphqlRequests: number; restRequests: number; cost: number };
30const reads: Read[] = [];
31​
32let graphqlUsed: { used: number; reset: string } | null = null;
33const decodeRateLimit = Schema.decodeUnknownSync(
34 Schema.fromJsonString(
35 Schema.Struct({
36 resources: Schema.Struct({
37 graphql: Schema.Struct({ used: Schema.Number, reset: Schema.Number }),
38 }),
39 }),
40 ),
41);
42​
43// Count only increases in `used` so out-of-order answers do not double-count quota.
44function graphqlCost(headers: Readonly<Record<string, string | undefined>>): number {
45 const used = Number(headers["x-ratelimit-used"]);
46 const reset = headers["x-ratelimit-reset"] ?? "";
47 if (headers["x-ratelimit-resource"] !== "graphql" || !Number.isFinite(used)) return 0;
48 const previous = graphqlUsed?.reset === reset ? graphqlUsed.used : 0;
49 graphqlUsed = { used: Math.max(previous, used), reset };
50 return Math.max(0, used - previous);
52​
53const measuredHttp = Layer.effect(
54 HttpClient.HttpClient,
55 Effect.gen(function* () {
56 const client = yield* HttpClient.HttpClient;
57 return HttpClient.tap(client, (response) =>
58 Effect.sync(() => {
59 const graphql = new URL(response.request.url).pathname.endsWith("/graphql");
60 reads.push({
61 graphqlRequests: graphql ? 1 : 0,
62 restRequests: graphql ? 0 : 1,
63 cost: graphql ? graphqlCost(response.headers) : 0,
64 });
65 }),
66 );
67 }),
68).pipe(Layer.provide(FetchHttpClient.layer));
69​
70// Default settings, so `gh` picks the account and no saved token applies. Only the credential
71// lookup runs a process, and it needs no more than its output.
72const sourceControlHost = Layer.unwrap(
73 Effect.gen(function* () {
74 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
75 return TestSourceControlHost.layer({
76 process: {
77 run: (input) =>
78 Effect.scoped(
79 Effect.gen(function* () {
80 const handle = yield* spawner.spawn(
81 ChildProcess.make(input.command, [...input.args], {
82 cwd: input.spawnCwd ?? input.cwd,
83 env: input.env,
84 extendEnv: true,
85 }),
86 );
87 const [stdout, stderr, exitCode] = yield* Effect.all(
88 [
89 Stream.mkString(Stream.decodeText(handle.stdout)),
90 Stream.mkString(Stream.decodeText(handle.stderr)),
91 handle.exitCode,
92 ],
93 { concurrency: "unbounded" },
94 );
95 return { exitCode, stdout, stderr, stdoutTruncated: false, stderrTruncated: false };
96 }),
97 ).pipe(Effect.orDie),
98 },
99 });
100 }),
101).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
102​
103const services = GitHubPullRequestApi.layer.pipe(
104 Layer.provideMerge(GitHubAppAccess.layer),
105 Layer.provideMerge(GitHubRest.layerWithDependencies),
106 Layer.provideMerge(SourceControlRateLimit.layer),
107 Layer.provideMerge(measuredHttp),
108 Layer.provideMerge(sourceControlHost),
109);
110​
111const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
112​
113for (const mode of ["detail", "preview"] as const) {
114 const rows = await Effect.runPromise(
115 Effect.scoped(
116 Effect.gen(function* () {
117 const provider = yield* GitHubPullRequestProvider.make;
118 // `rate_limit` reads do not spend GraphQL quota.
119 const api = yield* GitHubRest.GitHubRest;
120 const limits = decodeRateLimit(
121 (yield* api.rest({ host: "github.com", operation: "rateLimit", path: "rate_limit" }))
122 .body,
123 ).resources.graphql;
124 graphqlUsed = { used: limits.used, reset: String(limits.reset) };
125 return yield* Effect.forEach(numbers.map(Number), (number) =>
126 Effect.gen(function* () {
127 const input = { cwd: process.cwd(), repository, host: "github.com", number };
128 reads.length = 0;
129 const start = performance.now();
130 const value = yield* mode === "detail"
131 ? provider.getChangeRequest(input)
132 : provider.getChangeRequestPreview!(input);
133 const elapsedMs = Math.round(performance.now() - start);
134 const measured = [...reads];
135 const points = measured.reduce((total, read) => total + read.cost, 0);
136 return {
137 repository,
138 number,
139 mode,
140 state: value.state,
141 elapsedMs,
142 points,
143 graphqlRequests: measured.reduce((total, read) => total + read.graphqlRequests, 0),
144 restRequests: measured.reduce((total, read) => total + read.restRequests, 0),
145 };
146 }),
147 );
148 }),
149 ).pipe(Effect.provide(services)),
150 );
151 for (const row of rows) await Effect.runPromise(Console.log(encodeJson(row)));