| 1 | import * as Github from "@distilled.cloud/github"; |
| 2 | import * as Actions from "@distilled.cloud/github/actions"; |
| 3 | import * as Apps from "@distilled.cloud/github/apps"; |
| 4 | import * as Checks from "@distilled.cloud/github/checks"; |
| 5 | import * as Issues from "@distilled.cloud/github/issues"; |
| 6 | import * as Pulls from "@distilled.cloud/github/pulls"; |
| 7 | import * as Arr from "effect/Array"; |
| 8 | import * as Clock from "effect/Clock"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Data from "effect/Data"; |
| 11 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import { Base64Url } from "effect/encoding"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Redacted from "effect/Redacted"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import * as Stream from "effect/Stream"; |
| 20 | import * as HttpClient from "effect/http/HttpClient"; |
| 21 | import * as crypto from "node:crypto"; |
| 22 | import { COMMENT_MARKER, RegistryConfig } from "./Bindings.ts"; |
| 23 | import * as KVCache from "./KVCache.ts"; |
| 24 | |
| 25 | export class CryptoError extends Data.TaggedError("CryptoError")<{ |
| 26 | readonly message: string; |
| 27 | }> {} |
| 28 | |
| 29 | export 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. */ |
| 38 | export 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. */ |
| 46 | export 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 | */ |
| 68 | export 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 | */ |
| 105 | const 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 | |
| 115 | export 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. */ |
| 130 | export const describe = (error: { |
| 131 | readonly _tag: string; |
| 132 | readonly message?: string | undefined; |
| 133 | }) => (error.message ? `${error._tag}: ${error.message}` : error._tag); |
| 134 | |
| 135 | const USER_AGENT = "alchemy-pkg"; |
| 136 | |
| 137 | const 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 | */ |
| 147 | const 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 | */ |
| 154 | const 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 | |
| 367 | export class GitHubApp extends Context.Service< |
| 368 | GitHubApp, |
| 369 | Effect.Success<typeof make> |
| 370 | >()("@alchemy.run/pkg/GitHubApp") {} |
| 371 | |
| 372 | export const GitHubAppLive = Layer.effect(GitHubApp, make); |