1import * as Github from "@distilled.cloud/github";
2import * as Actions from "@distilled.cloud/github/actions";
3import * as Apps from "@distilled.cloud/github/apps";
4import * as Checks from "@distilled.cloud/github/checks";
5import * as Issues from "@distilled.cloud/github/issues";
6import * as Pulls from "@distilled.cloud/github/pulls";
7import * as Arr from "effect/Array";
8import * as Clock from "effect/Clock";
9import * as Context from "effect/Context";
10import * as Data from "effect/Data";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import { Base64Url } from "effect/encoding";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Redacted from "effect/Redacted";
18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
20import * as HttpClient from "effect/http/HttpClient";
21import * as crypto from "node:crypto";
22import { COMMENT_MARKER, RegistryConfig } from "./Bindings.ts";
23import * as KVCache from "./KVCache.ts";
24
25export class CryptoError extends Data.TaggedError("CryptoError")<{
26 readonly message: string;
27}> {}
28
29export class AppNotInstalled extends Data.TaggedError("AppNotInstalled")<{
30 readonly repo: string;
31}> {
32 override get message() {
33 return `The GitHub App is not installed on ${this.repo}`;
34 }
35}
36
37/** Import an RSA private key PEM (PKCS#1 or PKCS#8) for RS256 signing. */
38export const importPrivateKey = (pem: string) =>
39 Effect.try({
40 try: () => crypto.createPrivateKey({ key: pem }),
41 catch: (cause) =>
42 new CryptoError({ message: `invalid private key: ${cause}` }),
43 });
44
45/** Sign a compact RS256 JWT, used to authenticate as the GitHub App. */
46export const signJwt = (
47 claims: Record<string, unknown>,
48 key: crypto.KeyObject,
49) =>
50 Effect.try({
51 try: () => {
52 const header = Base64Url.encode(
53 JSON.stringify({ alg: "RS256", typ: "JWT" }),
54 );
55 const payload = Base64Url.encode(JSON.stringify(claims));
56 const input = `${header}.${payload}`;
57 const signature = crypto.sign("sha256", Buffer.from(input), key);
58 return `${input}.${Base64Url.encode(new Uint8Array(signature))}`;
59 },
60 catch: (cause) => new CryptoError({ message: `signing failed: ${cause}` }),
61 });
62
63/**
64 * Install commands per package, grouped. A group marked `collapsed`
65 * renders as a closed `<details>` block so a long list of secondary
66 * packages stays out of the way.
67 */
68export const renderInstalls = (
69 packages: ReadonlyArray<{ name: string; group: string; url: string }>,
70 groups: ReadonlyArray<{ name: string; collapsed: boolean }>,
71) => {
72 const collapsed = new Set(
73 groups.filter((g) => g.collapsed).map((g) => g.name),
74 );
75 // One code block per package so each command has its own copy button, in
76 // the order the manifest lists them, which is the order they were given
77 // to `pkg pack`. Blank lines around the markdown inside `<details>` are
78 // what make GitHub render it.
79 return Object.entries(Arr.groupBy(packages, (pkg) => pkg.group))
80 .flatMap(([group, members]) => {
81 const installs = members.flatMap(({ url }) => [
82 "```sh",
83 `pnpm install ${url}`,
84 "```",
85 "",
86 ]);
87 return collapsed.has(group)
88 ? [
89 "<details>",
90 `<summary><b>${group}</b> (${members.length})</summary>`,
91 "",
92 ...installs,
93 "</details>",
94 "",
95 ]
96 : [`### ${group}`, "", ...installs];
97 })
98 .join("\n");
99};
100
101/**
102 * GitHub's `<relative-time>` element, rendered as a live relative time in
103 * comments, with a plain UTC fallback like `Sep 7, 2026 at 2:42 PM UTC`.
104 */
105const relativeTime = (millis: number) => {
106 const date = DateTime.makeUnsafe(millis);
107 const label = DateTime.formatUtc(date, {
108 locale: "en-US",
109 dateStyle: "medium",
110 timeStyle: "short",
111 });
112 return `<relative-time datetime="${DateTime.formatIso(date)}">${label} UTC</relative-time>`;
113};
114
115export const renderComment = (
116 packages: ReadonlyArray<{ name: string; group: string; url: string }>,
117 groups: ReadonlyArray<{ name: string; collapsed: boolean }>,
118 times: { readonly publishedAt: number; readonly expiresAt: number },
119) =>
120 [
121 COMMENT_MARKER,
122 "",
123 "Install the packages built from this commit:",
124 "",
125 renderInstalls(packages, groups),
126 `Published ${relativeTime(times.publishedAt)}. Expires ${relativeTime(times.expiresAt)}, extended while this pull request is open.`,
127 ].join("\n");
128
129/** Render any GitHub operation failure for logs and error responses. */
130export const describe = (error: {
131 readonly _tag: string;
132 readonly message?: string | undefined;
133}) => (error.message ? `${error._tag}: ${error.message}` : error._tag);
134
135const USER_AGENT = "alchemy-pkg";
136
137const split = (repo: string) => {
138 const [owner, name] = repo.split("/");
139 return { owner: owner ?? "", repo: name ?? "" };
140};
141
142/**
143 * Installation tokens are refreshed two minutes before GitHub expires them:
144 * one for clock skew, one for the minute KV's edge cache may keep serving an
145 * expired entry.
146 */
147const TOKEN_MARGIN = Duration.minutes(2);
148
149/**
150 * The registry's GitHub App: every operation runs as the App's installation
151 * on the repository in question. Installation tokens live in the KV cache,
152 * so one mint per repository serves every isolate for about an hour.
153 */
154const make = Effect.gen(function* () {
155 const config = yield* RegistryConfig;
156 const http = yield* HttpClient.HttpClient;
157
158 /** Run a distilled GitHub operation as the given bearer token. */
159 const as = <A, E, R>(token: string, operation: Effect.Effect<A, E, R>) =>
160 operation.pipe(
161 Effect.provideService(
162 Github.Credentials,
163 Effect.succeed({
164 token: Redacted.make(token),
165 apiBaseUrl: config.github.apiUrl,
166 userAgent: USER_AGENT,
167 }),
168 ),
169 Effect.provideService(HttpClient.HttpClient, http),
170 );
171
172 // The credentials are read here, when a token is minted, never during the
173 // Worker's Init: `Registry.ts` binds them explicitly, so plan time never
174 // resolves them. Importing the key is a sub-millisecond step next to the
175 // two GitHub calls a mint makes, so nothing is kept between mints.
176 const appJwt = Effect.gen(function* () {
177 const pem = yield* config.github.privateKey;
178 const key = yield* importPrivateKey(Redacted.value(pem));
179 const now = Math.floor((yield* Clock.currentTimeMillis) / 1000);
180 return yield* signJwt(
181 { iat: now - 60, exp: now + 540, iss: yield* config.github.appId },
182 key,
183 );
184 });
185
186 // Failures are not kept: the next call retries the mint.
187 const tokens = KVCache.make(
188 (repo: string) => `token:${repo}`,
189 Schema.String,
190 (repo) =>
191 Effect.gen(function* () {
192 const jwt = yield* appJwt;
193 const installation = yield* as(
194 jwt,
195 Apps.getRepoInstallation(split(repo)),
196 ).pipe(
197 Effect.catchTag("NotFound", () => new AppNotInstalled({ repo })),
198 );
199 const access = yield* as(
200 jwt,
201 Apps.createInstallationAccessToken({
202 installation_id: installation.id,
203 }),
204 );
205 const now = yield* Clock.currentTimeMillis;
206 return {
207 value: access.token,
208 ttl: Duration.subtract(
209 Duration.millis(Math.max(0, Date.parse(access.expires_at) - now)),
210 TOKEN_MARGIN,
211 ),
212 };
213 }),
214 );
215
216 /** A GitHub operation run as the App's installation on `repo`. */
217 const asInstallation = <A, E, R>(
218 repo: string,
219 operation: Effect.Effect<A, E, R>,
220 ) => Effect.flatMap(tokens(repo), (token) => as(token, operation));
221
222 return {
223 getRun: (repo: string, runId: number) =>
224 asInstallation(
225 repo,
226 Actions.getWorkflowRun({ ...split(repo), run_id: runId }),
227 ),
228
229 /**
230 * Artifacts named `name` uploaded to a run so far, including by jobs
231 * still in progress.
232 */
233 listRunArtifacts: (repo: string, runId: number, name: string) =>
234 asInstallation(
235 repo,
236 Actions.listWorkflowRunArtifacts({
237 ...split(repo),
238 run_id: runId,
239 name,
240 per_page: 100,
241 }),
242 ).pipe(Effect.map((page) => page.artifacts)),
243
244 /**
245 * Open pull requests against `repo` whose head is `sha` in `headRepo`, the
246 * repository the commit was pushed to. Commit-associated PR lookups can
247 * return no results for forks, so discover candidates by owner and branch.
248 */
249 pullRequestsForCommit: (
250 repo: string,
251 headRepo: string,
252 headBranch: string,
253 sha: string,
254 ) =>
255 asInstallation(
256 repo,
257 Pulls.list({
258 ...split(repo),
259 head: `${split(headRepo).owner}:${headBranch}`,
260 state: "open",
261 per_page: 100,
262 }),
263 ).pipe(
264 Effect.map((pulls) =>
265 pulls.filter(
266 (pr) =>
267 pr.head.sha === sha &&
268 pr.head.ref === headBranch &&
269 pr.head.repo?.full_name === headRepo &&
270 pr.base.repo.full_name === repo,
271 ),
272 ),
273 ),
274
275 getPullRequest: (repo: string, number: number) =>
276 asInstallation(repo, Pulls.get({ ...split(repo), pull_number: number })),
277
278 /**
279 * Publish a completed check run on `headSha`. GitHub shows the newest run
280 * per name and App, so re-publishing the same commit simply supersedes it.
281 */
282 createCheckRun: (
283 repo: string,
284 input: {
285 readonly headSha: string;
286 readonly name: string;
287 readonly title: string;
288 readonly summary: string;
289 readonly detailsUrl: string;
290 },
291 ) =>
292 Effect.flatMap(DateTime.now, (now) =>
293 asInstallation(
294 repo,
295 Checks.create({
296 ...split(repo),
297 name: input.name,
298 head_sha: input.headSha,
299 status: "completed",
300 conclusion: "success",
301 completed_at: DateTime.formatIso(now),
302 details_url: input.detailsUrl,
303 output: { title: input.title, summary: input.summary },
304 }),
305 ),
306 ),
307
308 /**
309 * Create or update the comment on `issue` whose body starts with
310 * `marker`, looking through the first five pages of comments.
311 */
312 upsertComment: (
313 repo: string,
314 issue: number,
315 marker: string,
316 body: string,
317 ) =>
318 Effect.gen(function* () {
319 const comments = Stream.paginate(1, (page) =>
320 asInstallation(
321 repo,
322 Issues.listComments({
323 ...split(repo),
324 issue_number: issue,
325 per_page: 100,
326 page,
327 }),
328 ).pipe(
329 Effect.map((comments) => [
330 comments,
331 comments.length < 100 || page >= 5
332 ? Option.none()
333 : Option.some(page + 1),
334 ]),
335 ),
336 );
337 const existing = yield* comments.pipe(
338 Stream.filter((comment) => comment.body?.startsWith(marker) === true),
339 Stream.take(1),
340 Stream.runCollect,
341 Effect.map(Arr.head),
342 );
343 yield* Option.match(existing, {
344 onNone: () =>
345 asInstallation(
346 repo,
347 Issues.createComment({
348 ...split(repo),
349 issue_number: issue,
350 body,
351 }),
352 ),
353 onSome: (comment) =>
354 asInstallation(
355 repo,
356 Issues.updateComment({
357 ...split(repo),
358 comment_id: comment.id,
359 body,
360 }),
361 ),
362 });
363 }),
364 };
365});
366
367export class GitHubApp extends Context.Service<
368 GitHubApp,
369 Effect.Success<typeof make>
370>()("@alchemy.run/pkg/GitHubApp") {}
371
372export const GitHubAppLive = Layer.effect(GitHubApp, make);