@usine/github 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +117 -2
  2. package/dist/access.d.ts +48 -0
  3. package/dist/access.d.ts.map +1 -0
  4. package/dist/access.js +53 -0
  5. package/dist/access.js.map +1 -0
  6. package/dist/credentials.d.ts +28 -0
  7. package/dist/credentials.d.ts.map +1 -0
  8. package/dist/credentials.js +22 -0
  9. package/dist/credentials.js.map +1 -0
  10. package/dist/error.d.ts +36 -0
  11. package/dist/error.d.ts.map +1 -0
  12. package/dist/error.js +48 -0
  13. package/dist/error.js.map +1 -0
  14. package/dist/github-app.d.ts +30 -0
  15. package/dist/github-app.d.ts.map +1 -0
  16. package/dist/github-app.js +184 -0
  17. package/dist/github-app.js.map +1 -0
  18. package/dist/http.d.ts +18 -0
  19. package/dist/http.d.ts.map +1 -0
  20. package/dist/http.js +68 -0
  21. package/dist/http.js.map +1 -0
  22. package/dist/installation.d.ts +48 -0
  23. package/dist/installation.d.ts.map +1 -0
  24. package/dist/installation.js +40 -0
  25. package/dist/installation.js.map +1 -0
  26. package/dist/item-writer.d.ts +23 -0
  27. package/dist/item-writer.d.ts.map +1 -0
  28. package/dist/item-writer.js +67 -0
  29. package/dist/item-writer.js.map +1 -0
  30. package/dist/jwt.d.ts +11 -0
  31. package/dist/jwt.d.ts.map +1 -0
  32. package/dist/jwt.js +72 -0
  33. package/dist/jwt.js.map +1 -0
  34. package/dist/label-writer.d.ts +23 -0
  35. package/dist/label-writer.d.ts.map +1 -0
  36. package/dist/label-writer.js +54 -0
  37. package/dist/label-writer.js.map +1 -0
  38. package/dist/local-secrets.d.ts +28 -0
  39. package/dist/local-secrets.d.ts.map +1 -0
  40. package/dist/local-secrets.js +56 -0
  41. package/dist/local-secrets.js.map +1 -0
  42. package/dist/polling/content.d.ts +3 -0
  43. package/dist/polling/content.d.ts.map +1 -0
  44. package/dist/polling/content.js +24 -0
  45. package/dist/polling/content.js.map +1 -0
  46. package/dist/polling/detection.d.ts +55 -0
  47. package/dist/polling/detection.d.ts.map +1 -0
  48. package/dist/polling/detection.js +49 -0
  49. package/dist/polling/detection.js.map +1 -0
  50. package/dist/polling/labels.d.ts +31 -0
  51. package/dist/polling/labels.d.ts.map +1 -0
  52. package/dist/polling/labels.js +29 -0
  53. package/dist/polling/labels.js.map +1 -0
  54. package/dist/polling/payload.d.ts +565 -0
  55. package/dist/polling/payload.d.ts.map +1 -0
  56. package/dist/polling/payload.js +128 -0
  57. package/dist/polling/payload.js.map +1 -0
  58. package/dist/polling/polling.d.ts +24 -0
  59. package/dist/polling/polling.d.ts.map +1 -0
  60. package/dist/polling/polling.js +325 -0
  61. package/dist/polling/polling.js.map +1 -0
  62. package/dist/polling/queries.d.ts +3 -0
  63. package/dist/polling/queries.d.ts.map +1 -0
  64. package/dist/polling/queries.js +125 -0
  65. package/dist/polling/queries.js.map +1 -0
  66. package/package.json +32 -6
  67. package/dist/index.d.ts +0 -8
  68. package/dist/index.d.ts.map +0 -1
  69. package/dist/index.js +0 -8
  70. package/dist/index.js.map +0 -1
package/README.md CHANGED
@@ -1,5 +1,120 @@
1
1
  # @usine/github
2
2
 
3
- GitHub integration for usine: polling change detection, per-stream GraphQL slice fetchers, App auth and token minting.
3
+ `@usine/github` gives an usine factory one GitHub App identity. It signs App JWTs, finds the App's installation, reports registry repositories that the installation does not cover, and mints two kinds of installation token:
4
4
 
5
- Part of [usine](https://github.com/basaltbytes/usine), an agent-agnostic software factory. This block is not implemented yet — its 0.0.x releases reserve the seam while the engine grows. Everything here is 0.x: APIs change without notice.
5
+ - an installation-wide token for orchestrator polling and writes;
6
+ - a token restricted to one repository for a worker attempt.
7
+
8
+ Both token requests use a one-hour maximum lifetime. Expiry validation tolerates up to three minutes of clock skew while capping the local lease at one hour; expired tokens are rejected. Token values and the App private key stay inside Effect `Redacted` values.
9
+
10
+ Import `GitHubApp`, credentials, access results, and typed App errors from `@usine/github/github-app`. `@usine/github/polling` provides `GitHubPolling` with its cursors, change-detection results, and payload snapshots. Item and label writes use `/item-writer` and `/label-writer`; local credential loading uses `/local-secrets`.
11
+
12
+ `/polling` also exports `RepositoryLabel`, `RepositoryLabelCatalog`, and
13
+ `RepositoryLabelsNotModified` for reading label catalogs and handling conditional
14
+ responses. These contracts share the same entry point as `GitHubPolling`.
15
+
16
+ ## Create the App manually
17
+
18
+ Open your GitHub account or organization settings, select **Developer settings → GitHub Apps**, then select **New GitHub App**. Use a name that identifies the factory and set **Where can this GitHub App be installed?** to **Only on this account**. User authorization isn't required. You can turn off active webhooks for a polling-only development factory.
19
+
20
+ Set these repository permissions:
21
+
22
+ | Permission | Access |
23
+ | --- | --- |
24
+ | Metadata | Read-only |
25
+ | Contents | Read and write |
26
+ | Issues | Read and write |
27
+ | Pull requests | Read and write |
28
+ | Checks | Read-only |
29
+
30
+ Leave Workflows, Administration, every organization permission, and every account permission at **No access**. GitHub grants Metadata read access automatically. The Contents permission lets installation tokens authenticate Git over HTTPS; Workflows remains off, so worker tokens cannot edit files under `.github/workflows/`.
31
+
32
+ Create the App, generate a private key, and note the numeric App ID. On the App's **Install App** page, install it on the account that owns the factory's managed repositories. Select every repository listed in `usine.yaml`. A factory expects one App installation, so don't install the same App on a second account.
33
+
34
+ GitHub's current setup references are [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app) and [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app).
35
+
36
+ ## Store the credentials
37
+
38
+ The local runtime file is `<instance>/.usine/github-app.json`. Keep `.usine/` in the instance repo's `.gitignore`, and set the file mode to `0600`.
39
+
40
+ ```json
41
+ {
42
+ "app_id": 123456,
43
+ "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
44
+ }
45
+ ```
46
+
47
+ This command creates the JSON without copying PEM newlines by hand:
48
+
49
+ ```sh
50
+ mkdir -p .usine
51
+ install -m 600 /dev/null .usine/github-app.json
52
+ jq -n --argjson app_id "$APP_ID" --rawfile private_key factory.private-key.pem \
53
+ '{app_id: $app_id, private_key: $private_key}' > .usine/github-app.json
54
+ ```
55
+
56
+ `USINE_GITHUB_APP_ID` and `USINE_GITHUB_PRIVATE_KEY` override their matching file fields. When both variables exist, the loader does not read the file.
57
+
58
+ ## Compose the services
59
+
60
+ The package root has no Node dependency. Supply an HTTP client plus platform file and path layers at the application edge:
61
+
62
+ ```ts
63
+ import { NodeFileSystem, NodePath } from "@effect/platform-node"
64
+ import { InstanceDirectory } from "@usine/core/instance"
65
+ import { GitHubApp } from "@usine/github/github-app"
66
+ import { localSecretsLayer } from "@usine/github/local-secrets"
67
+ import { Layer } from "effect"
68
+ import { FetchHttpClient } from "effect/unstable/http"
69
+
70
+ const instance = InstanceDirectory.make("/path/to/instance")
71
+ const platformLayer = Layer.merge(NodeFileSystem.layer, NodePath.layer)
72
+ const credentialsLayer = localSecretsLayer(instance).pipe(
73
+ Layer.provide(platformLayer),
74
+ )
75
+
76
+ export const githubLayer = GitHubApp.layer.pipe(
77
+ Layer.provide(credentialsLayer),
78
+ Layer.provide(FetchHttpClient.layer),
79
+ )
80
+ ```
81
+
82
+ `GitHubApp.layer` imports the private key once, when the layer is built: a key WebCrypto cannot import fails composition with `AppAuthenticationError` instead of failing the first request. Another credential source provides `GitHubCredentials` with an `AppCredentials` whose key went through the `RedactedPrivateKey` schema.
83
+
84
+ `installation()`, `installationToken()`, and `projectAccess()` share one lease: a single installation discovery and installation-wide mint, reused until five minutes before the token expires. Polling therefore costs no App requests between renewals. `workerToken()` mints on every call, so each attempt gets its own token.
85
+
86
+ Call `GitHubApp.projectAccess(registry)` before scheduling work. Each result is either a `CoveredProject`, which carries the repository ID needed for scoped minting, or a `ProjectNotInstalledError`. A missing repository does not block covered projects.
87
+
88
+ Pass a `CoveredProject` to `GitHubApp.workerToken`. GitHub must confirm in the token response that the token covers exactly that repository ID; otherwise the call fails with `InvalidGitHubResponseError`.
89
+
90
+ ## Poll repository changes
91
+
92
+ `GitHubPollingLayer` supplies the operations the orchestrator needs:
93
+
94
+ - `changes(repository, cursor)` makes one conditional Issues REST request. It returns changed issue and pull-request numbers, the next ETag and watermark, plus any rate-limit delay GitHub requested.
95
+ - `labels(repository)` reads every page of the repository label catalog for Board lane proposals.
96
+
97
+ Change-detection results distinguish `nextPollAt`, the earliest next conditional poll, from `backoffUntil`, a request delay caused by `Retry-After` or rate-limit pressure. Self-write Hints can fetch an Item snapshot during the normal poll interval while respecting request backoff.
98
+ - `snapshot(repository, item)` fetches the issue or pull-request payload through GraphQL and computes its SHA-256 content hash. The stored payload retains timestamps, while the hash excludes timestamp fields at every nesting level.
99
+
100
+ The REST cursor overlaps its watermark by five seconds. Repeated items are expected; the engine's permanent Event ledger owns deduplication. Provide `GitHubPollingLayer` with `GitHubApp`, an Effect HTTP client, and `Crypto.Crypto`. The package root remains platform-free.
101
+
102
+ `GitHubItemWriter.updateLabels(repository, item, mutation)` adds destination lane labels and removes only explicitly lane-owned labels from an issue or pull request. It never replaces the complete applied-label set or deletes repository label definitions, so unrelated and concurrently added labels survive stale or truncated Item projections. The service requires `GitHubApp` and an Effect HTTP client.
103
+
104
+ `GitHubLabelWriter.create(repository, label)` creates one repository label through `POST /repos/{owner}/{repo}/labels` using the installation-wide App token. Provide a name; color and description are optional. Reconciliation policy belongs to the orchestrator, so this service exposes creation only and never edits or deletes label definitions.
105
+
106
+ ## Real-App integration check
107
+
108
+ Run the opt-in integration test against a disposable issue in a repository covered by the App:
109
+
110
+ ```sh
111
+ export USINE_GITHUB_INTEGRATION_INSTANCE_DIRECTORY=/path/to/instance
112
+ export USINE_GITHUB_INTEGRATION_REPO=owner/repo
113
+ export USINE_GITHUB_INTEGRATION_ISSUE_NUMBER=123
114
+ export 'USINE_GITHUB_INTEGRATION_BOT_LOGIN=your-app-slug[bot]'
115
+ pnpm --filter @usine/github test:integration
116
+ ```
117
+
118
+ The test mints both token shapes, posts one issue comment with the repository-scoped token, checks the comment author against the expected `<app>[bot]` login, and deletes the comment during cleanup. `@usine/orchestrator` has a separate live polling test that temporarily creates and applies a repository label.
119
+
120
+ All APIs remain 0.x and can change between minor releases.
@@ -0,0 +1,48 @@
1
+ import { RepositoryName } from "@usine/core/repository";
2
+ import { Schema } from "effect";
3
+ import { RepositoryId, Installation } from "./installation.ts";
4
+ /** Repository grants required for polling, label updates, and agent-authored changes. */
5
+ export declare const REQUIRED_REPOSITORY_PERMISSIONS: {
6
+ readonly checks: "read";
7
+ readonly contents: "write";
8
+ readonly issues: "write";
9
+ readonly metadata: "read";
10
+ readonly pull_requests: "write";
11
+ };
12
+ export type RequiredRepositoryPermission = keyof typeof REQUIRED_REPOSITORY_PERMISSIONS;
13
+ /** The required repository permissions an installation grant does not satisfy, as `name:level` strings. */
14
+ export declare const missingPermissions: (granted: Readonly<Record<string, string>>) => ReadonlyArray<string>;
15
+ declare const CoveredProject_base: Schema.Class<CoveredProject, Schema.TaggedStruct<"CoveredProject", {
16
+ readonly installationId: Schema.brand<Schema.Int, "GitHubInstallationId">;
17
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
18
+ readonly repositoryId: Schema.brand<Schema.Int, "GitHubRepositoryId">;
19
+ }>, {}>;
20
+ /** A registered repository covered by the discovered GitHub App installation. */
21
+ export declare class CoveredProject extends CoveredProject_base {
22
+ }
23
+ declare const ProjectNotInstalledError_base: Schema.Class<ProjectNotInstalledError, Schema.TaggedStruct<"ProjectNotInstalledError", {
24
+ readonly repository: Schema.brand<Schema.String, "RepositoryName">;
25
+ readonly message: Schema.NonEmptyString;
26
+ }>, import("effect/Cause").YieldableError>;
27
+ /** A registry repository the installation does not cover. Raised per project, so covered siblings still load. */
28
+ export declare class ProjectNotInstalledError extends ProjectNotInstalledError_base {
29
+ }
30
+ /** The installation coverage result for one registered repository. */
31
+ export declare const ProjectAccess: Schema.Union<readonly [typeof CoveredProject, typeof ProjectNotInstalledError]>;
32
+ export type ProjectAccess = typeof ProjectAccess.Type;
33
+ /** A repository reported by GitHub for an installation, identified by its numeric id and full name. */
34
+ export interface InstalledRepository {
35
+ readonly id: RepositoryId;
36
+ readonly name: RepositoryName;
37
+ }
38
+ /** Checks each registry repository against the installation's repositories and returns one `ProjectAccess` per project. */
39
+ export declare const deriveProjectAccess: (installation: Installation, registry: ReadonlyArray<RepositoryName>, installed: ReadonlyArray<InstalledRepository>) => ReadonlyArray<ProjectAccess>;
40
+ declare const AppPermissionError_base: Schema.Class<AppPermissionError, Schema.TaggedStruct<"AppPermissionError", {
41
+ readonly missing: Schema.$Array<Schema.NonEmptyString>;
42
+ readonly message: Schema.NonEmptyString;
43
+ }>, import("effect/Cause").YieldableError>;
44
+ /** The installation lacks required repository grants; missing entries use name:level form. */
45
+ export declare class AppPermissionError extends AppPermissionError_base {
46
+ }
47
+ export {};
48
+ //# sourceMappingURL=access.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAkB,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE/E,yFAAyF;AACzF,eAAO,MAAM,+BAA+B;aAC1C,MAAM,EAAE,MAAM;aACd,QAAQ,EAAE,OAAO;aACjB,MAAM,EAAE,OAAO;aACf,QAAQ,EAAE,MAAM;aAChB,aAAa,EAAE,OAAO;CACd,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAAG,MAAM,OAAO,+BAA+B,CAAC;AAKxF,2GAA2G;AAC3G,eAAO,MAAM,kBAAkB,YACpB,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KACxC,aAAa,CAAC,MAAM,CAGpB,CAAC;;;;;;AAEJ,iFAAiF;AACjF,qBAAa,cAAe,SAAQ,mBAMlC;CAAG;;;;;AAEL,iHAAiH;AACjH,qBAAa,wBAAyB,SAAQ,6BAK5C;CAAG;AAEL,sEAAsE;AACtE,eAAO,MAAM,aAAa,iFAA2D,CAAC;AACtF,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC,IAAI,CAAC;AAEtD,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,YAAY,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;CAC/B;AAED,2HAA2H;AAC3H,eAAO,MAAM,mBAAmB,iBAChB,YAAY,YAChB,aAAa,CAAC,cAAc,CAAC,aAC5B,aAAa,CAAC,mBAAmB,CAAC,KAC5C,aAAa,CAAC,aAAa,CAe7B,CAAC;;;;;AAEF,8FAA8F;AAC9F,qBAAa,kBAAmB,SAAQ,uBAKtC;CAAG"}
package/dist/access.js ADDED
@@ -0,0 +1,53 @@
1
+ import { RepositoryName } from "@usine/core/repository";
2
+ import { Schema } from "effect";
3
+ import { InstallationId, RepositoryId, Installation } from "./installation.js";
4
+ /** Repository grants required for polling, label updates, and agent-authored changes. */
5
+ export const REQUIRED_REPOSITORY_PERMISSIONS = {
6
+ checks: "read",
7
+ contents: "write",
8
+ issues: "write",
9
+ metadata: "read",
10
+ pull_requests: "write",
11
+ };
12
+ const grants = (actual, required) => actual === required || (required === "read" && actual === "write");
13
+ /** The required repository permissions an installation grant does not satisfy, as `name:level` strings. */
14
+ export const missingPermissions = (granted) => Object.entries(REQUIRED_REPOSITORY_PERMISSIONS).flatMap(([permission, required]) => grants(granted[permission], required) ? [] : [`${permission}:${required}`]);
15
+ /** A registered repository covered by the discovered GitHub App installation. */
16
+ export class CoveredProject extends Schema.TaggedClass("@usine/github/ProjectAccess/CoveredProject")("CoveredProject", {
17
+ installationId: InstallationId,
18
+ repository: RepositoryName,
19
+ repositoryId: RepositoryId,
20
+ }) {
21
+ }
22
+ /** A registry repository the installation does not cover. Raised per project, so covered siblings still load. */
23
+ export class ProjectNotInstalledError extends Schema.TaggedError("@usine/github/ProjectNotInstalledError")("ProjectNotInstalledError", {
24
+ repository: RepositoryName,
25
+ message: Schema.NonEmptyString,
26
+ }) {
27
+ }
28
+ /** The installation coverage result for one registered repository. */
29
+ export const ProjectAccess = Schema.Union([CoveredProject, ProjectNotInstalledError]);
30
+ /** Checks each registry repository against the installation's repositories and returns one `ProjectAccess` per project. */
31
+ export const deriveProjectAccess = (installation, registry, installed) => {
32
+ const byName = new Map(installed.map((repo) => [repo.name.toLowerCase(), repo]));
33
+ return registry.map((repository) => {
34
+ const match = byName.get(repository.toLowerCase());
35
+ return match === undefined
36
+ ? new ProjectNotInstalledError({
37
+ repository,
38
+ message: `The GitHub App installation does not cover ${repository}`,
39
+ })
40
+ : new CoveredProject({
41
+ installationId: installation.id,
42
+ repository,
43
+ repositoryId: match.id,
44
+ });
45
+ });
46
+ };
47
+ /** The installation lacks required repository grants; missing entries use name:level form. */
48
+ export class AppPermissionError extends Schema.TaggedError("@usine/github/AppPermissionError")("AppPermissionError", {
49
+ missing: Schema.Array(Schema.NonEmptyString).check(Schema.isNonEmpty()),
50
+ message: Schema.NonEmptyString,
51
+ }) {
52
+ }
53
+ //# sourceMappingURL=access.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access.js","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ import { Context, Schema } from "effect";
2
+ /** The numeric GitHub App identity included in signed App JWTs. */
3
+ export declare const AppId: Schema.brand<Schema.Int, "GitHubAppId">;
4
+ export type AppId = typeof AppId.Type;
5
+ /** A PEM-encoded RSA private key used to authenticate the GitHub App. */
6
+ export declare const PrivateKey: Schema.brand<Schema.NonEmptyString, "GitHubAppPrivateKey">;
7
+ export type PrivateKey = typeof PrivateKey.Type;
8
+ /** An App signing key that stays redacted in diagnostics and cannot be encoded. */
9
+ export declare const RedactedPrivateKey: Schema.RedactedFromValue<Schema.brand<Schema.NonEmptyString, "GitHubAppPrivateKey">>;
10
+ declare const AppCredentials_base: Schema.Class<AppCredentials, Schema.Struct<{
11
+ readonly appId: Schema.brand<Schema.Int, "GitHubAppId">;
12
+ readonly privateKey: Schema.RedactedFromValue<Schema.brand<Schema.NonEmptyString, "GitHubAppPrivateKey">>;
13
+ }>, {}>;
14
+ /** The App credentials the orchestrator keeps and never passes to a worker. */
15
+ export declare class AppCredentials extends AppCredentials_base {
16
+ }
17
+ declare const GitHubCredentials_base: Context.ServiceClass<GitHubCredentials, "@usine/github/GitHubCredentials", AppCredentials>;
18
+ /** Supplies `AppCredentials`, so where credentials come from stays separate from how the App uses them. */
19
+ export declare class GitHubCredentials extends GitHubCredentials_base {
20
+ }
21
+ declare const AppAuthenticationError_base: Schema.Class<AppAuthenticationError, Schema.TaggedStruct<"AppAuthenticationError", {
22
+ readonly message: Schema.NonEmptyString;
23
+ }>, import("effect/Cause").YieldableError>;
24
+ /** The App private key cannot sign. Raised once, when the App layer is built. */
25
+ export declare class AppAuthenticationError extends AppAuthenticationError_base {
26
+ }
27
+ export {};
28
+ //# sourceMappingURL=credentials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,mEAAmE;AACnE,eAAO,MAAM,KAAK,yCAEjB,CAAC;AACF,MAAM,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,IAAI,CAAC;AAEtC,yEAAyE;AACzE,eAAO,MAAM,UAAU,4DAKoB,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAC,IAAI,CAAC;AAEhD,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,sFAAiE,CAAC;;;;;AAEjG,+EAA+E;AAC/E,qBAAa,cAAe,SAAQ,mBAGlC;CAAG;;AAEL,2GAA2G;AAC3G,qBAAa,iBAAkB,SAAQ,sBAEtC;CAAG;;;;AAEJ,iFAAiF;AACjF,qBAAa,sBAAuB,SAAQ,2BAI1C;CAAG"}
@@ -0,0 +1,22 @@
1
+ import { Context, Schema } from "effect";
2
+ /** The numeric GitHub App identity included in signed App JWTs. */
3
+ export const AppId = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).pipe(Schema.brand("GitHubAppId"));
4
+ /** A PEM-encoded RSA private key used to authenticate the GitHub App. */
5
+ export const PrivateKey = Schema.NonEmptyString.check(Schema.isPattern(/^-----BEGIN (?:RSA )?PRIVATE KEY-----[\s\S]+-----END (?:RSA )?PRIVATE KEY-----\s*$/u, { message: "Expected a PEM-encoded RSA private key" })).pipe(Schema.brand("GitHubAppPrivateKey"));
6
+ /** An App signing key that stays redacted in diagnostics and cannot be encoded. */
7
+ export const RedactedPrivateKey = Schema.RedactedFromValue(PrivateKey, { disallowEncode: true });
8
+ /** The App credentials the orchestrator keeps and never passes to a worker. */
9
+ export class AppCredentials extends Schema.Class("@usine/github/AppCredentials")({
10
+ appId: AppId,
11
+ privateKey: RedactedPrivateKey,
12
+ }) {
13
+ }
14
+ /** Supplies `AppCredentials`, so where credentials come from stays separate from how the App uses them. */
15
+ export class GitHubCredentials extends Context.Service()("@usine/github/GitHubCredentials") {
16
+ }
17
+ /** The App private key cannot sign. Raised once, when the App layer is built. */
18
+ export class AppAuthenticationError extends Schema.TaggedError("@usine/github/AppAuthenticationError")("AppAuthenticationError", {
19
+ message: Schema.NonEmptyString,
20
+ }) {
21
+ }
22
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEzC,mEAAmE;AACnE,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAC1E,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,CAC5B,CAAC;AAGF,yEAAyE;AACzE,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,cAAc,CAAC,KAAK,CACnD,MAAM,CAAC,SAAS,CACd,qFAAqF,EACrF,EAAE,OAAO,EAAE,wCAAwC,EAAE,CACtD,CACF,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC;AAG5C,mFAAmF;AACnF,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC,iBAAiB,CAAC,UAAU,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;AAEjG,+EAA+E;AAC/E,MAAM,OAAO,cAAe,SAAQ,MAAM,CAAC,KAAK,CAAiB,8BAA8B,CAAC,CAAC;IAC/F,KAAK,EAAE,KAAK;IACZ,UAAU,EAAE,kBAAkB;CAC/B,CAAC;CAAG;AAEL,2GAA2G;AAC3G,MAAM,OAAO,iBAAkB,SAAQ,OAAO,CAAC,OAAO,EAAqC,CACzF,iCAAiC,CAClC;CAAG;AAEJ,iFAAiF;AACjF,MAAM,OAAO,sBAAuB,SAAQ,MAAM,CAAC,WAAW,CAC5D,sCAAsC,CACvC,CAAC,wBAAwB,EAAE;IAC1B,OAAO,EAAE,MAAM,CAAC,cAAc;CAC/B,CAAC;CAAG"}
@@ -0,0 +1,36 @@
1
+ import { Schema } from "effect";
2
+ import { InstallationDiscoveryError } from "./installation.ts";
3
+ import { AppPermissionError } from "./access.ts";
4
+ /** The GitHub request that failed, shared by API, rate-limit, and response errors. */
5
+ export declare const GitHubOperation: Schema.Literals<readonly ["create-repository-label", "discover-installation", "fetch-item-labels", "fetch-issue-snapshot", "fetch-pull-request-snapshot", "list-installation-repositories", "list-repository-labels", "mint-installation-token", "mint-worker-token", "poll-repository-changes", "update-item-labels"]>;
6
+ export type GitHubOperation = typeof GitHubOperation.Type;
7
+ declare const GitHubApiError_base: Schema.Class<GitHubApiError, Schema.TaggedStruct<"GitHubApiError", {
8
+ readonly operation: Schema.Literals<readonly ["create-repository-label", "discover-installation", "fetch-item-labels", "fetch-issue-snapshot", "fetch-pull-request-snapshot", "list-installation-repositories", "list-repository-labels", "mint-installation-token", "mint-worker-token", "poll-repository-changes", "update-item-labels"]>;
9
+ readonly status: Schema.Option<Schema.Int>;
10
+ readonly message: Schema.NonEmptyString;
11
+ }>, import("effect/Cause").YieldableError>;
12
+ /** A failed GitHub request, with an HTTP status when a response was received. */
13
+ export declare class GitHubApiError extends GitHubApiError_base {
14
+ }
15
+ declare const GitHubRateLimitError_base: Schema.Class<GitHubRateLimitError, Schema.TaggedStruct<"GitHubRateLimitError", {
16
+ readonly operation: Schema.Literals<readonly ["create-repository-label", "discover-installation", "fetch-item-labels", "fetch-issue-snapshot", "fetch-pull-request-snapshot", "list-installation-repositories", "list-repository-labels", "mint-installation-token", "mint-worker-token", "poll-repository-changes", "update-item-labels"]>;
17
+ readonly retryAt: Schema.DateTimeUtc;
18
+ readonly message: Schema.NonEmptyString;
19
+ }>, import("effect/Cause").YieldableError>;
20
+ /** GitHub deferred the request until retryAt; callers can schedule a later attempt. */
21
+ export declare class GitHubRateLimitError extends GitHubRateLimitError_base {
22
+ }
23
+ declare const InvalidGitHubResponseError_base: Schema.Class<InvalidGitHubResponseError, Schema.TaggedStruct<"InvalidGitHubResponseError", {
24
+ readonly operation: Schema.Literals<readonly ["create-repository-label", "discover-installation", "fetch-item-labels", "fetch-issue-snapshot", "fetch-pull-request-snapshot", "list-installation-repositories", "list-repository-labels", "mint-installation-token", "mint-worker-token", "poll-repository-changes", "update-item-labels"]>;
25
+ readonly message: Schema.NonEmptyString;
26
+ /** What made the response invalid: the decode path, the missing field, or the GraphQL errors. */
27
+ readonly detail: Schema.NonEmptyString;
28
+ }>, import("effect/Cause").YieldableError>;
29
+ /** GitHub returned a response that cannot be decoded or is missing required content. */
30
+ export declare class InvalidGitHubResponseError extends InvalidGitHubResponseError_base {
31
+ }
32
+ /** Failures from discovering installation access or making an authenticated GitHub request. */
33
+ export declare const GitHubAppError: Schema.Union<readonly [typeof InstallationDiscoveryError, typeof AppPermissionError, typeof GitHubApiError, typeof GitHubRateLimitError, typeof InvalidGitHubResponseError]>;
34
+ export type GitHubAppError = typeof GitHubAppError.Type;
35
+ export {};
36
+ //# sourceMappingURL=error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,sFAAsF;AACtF,eAAO,MAAM,eAAe,yTAY1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,OAAO,eAAe,CAAC,IAAI,CAAC;;;;;;AAE1D,iFAAiF;AACjF,qBAAa,cAAe,SAAQ,mBAMlC;CAAG;;;;;;AAEL,uFAAuF;AACvF,qBAAa,oBAAqB,SAAQ,yBAMxC;CAAG;;;;IAQH,iGAAiG;;;AANnG,wFAAwF;AACxF,qBAAa,0BAA2B,SAAQ,+BAO9C;CAAG;AAEL,+FAA+F;AAC/F,eAAO,MAAM,cAAc,8KAMzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,OAAO,cAAc,CAAC,IAAI,CAAC"}
package/dist/error.js ADDED
@@ -0,0 +1,48 @@
1
+ import { Schema } from "effect";
2
+ import { InstallationDiscoveryError } from "./installation.js";
3
+ import { AppPermissionError } from "./access.js";
4
+ /** The GitHub request that failed, shared by API, rate-limit, and response errors. */
5
+ export const GitHubOperation = Schema.Literals([
6
+ "create-repository-label",
7
+ "discover-installation",
8
+ "fetch-item-labels",
9
+ "fetch-issue-snapshot",
10
+ "fetch-pull-request-snapshot",
11
+ "list-installation-repositories",
12
+ "list-repository-labels",
13
+ "mint-installation-token",
14
+ "mint-worker-token",
15
+ "poll-repository-changes",
16
+ "update-item-labels",
17
+ ]);
18
+ /** A failed GitHub request, with an HTTP status when a response was received. */
19
+ export class GitHubApiError extends Schema.TaggedError("@usine/github/GitHubApiError")("GitHubApiError", {
20
+ operation: GitHubOperation,
21
+ status: Schema.Option(Schema.Int),
22
+ message: Schema.NonEmptyString,
23
+ }) {
24
+ }
25
+ /** GitHub deferred the request until retryAt; callers can schedule a later attempt. */
26
+ export class GitHubRateLimitError extends Schema.TaggedError("@usine/github/GitHubRateLimitError")("GitHubRateLimitError", {
27
+ operation: GitHubOperation,
28
+ retryAt: Schema.DateTimeUtc,
29
+ message: Schema.NonEmptyString,
30
+ }) {
31
+ }
32
+ /** GitHub returned a response that cannot be decoded or is missing required content. */
33
+ export class InvalidGitHubResponseError extends Schema.TaggedError("@usine/github/InvalidGitHubResponseError")("InvalidGitHubResponseError", {
34
+ operation: GitHubOperation,
35
+ message: Schema.NonEmptyString,
36
+ /** What made the response invalid: the decode path, the missing field, or the GraphQL errors. */
37
+ detail: Schema.NonEmptyString,
38
+ }) {
39
+ }
40
+ /** Failures from discovering installation access or making an authenticated GitHub request. */
41
+ export const GitHubAppError = Schema.Union([
42
+ InstallationDiscoveryError,
43
+ AppPermissionError,
44
+ GitHubApiError,
45
+ GitHubRateLimitError,
46
+ InvalidGitHubResponseError,
47
+ ]);
48
+ //# sourceMappingURL=error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../src/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEjD,sFAAsF;AACtF,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC7C,yBAAyB;IACzB,uBAAuB;IACvB,mBAAmB;IACnB,sBAAsB;IACtB,6BAA6B;IAC7B,gCAAgC;IAChC,wBAAwB;IACxB,yBAAyB;IACzB,mBAAmB;IACnB,yBAAyB;IACzB,oBAAoB;CACrB,CAAC,CAAC;AAGH,iFAAiF;AACjF,MAAM,OAAO,cAAe,SAAQ,MAAM,CAAC,WAAW,CACpD,8BAA8B,CAC/B,CAAC,gBAAgB,EAAE;IAClB,SAAS,EAAE,eAAe;IAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC,cAAc;CAC/B,CAAC;CAAG;AAEL,uFAAuF;AACvF,MAAM,OAAO,oBAAqB,SAAQ,MAAM,CAAC,WAAW,CAC1D,oCAAoC,CACrC,CAAC,sBAAsB,EAAE;IACxB,SAAS,EAAE,eAAe;IAC1B,OAAO,EAAE,MAAM,CAAC,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC,cAAc;CAC/B,CAAC;CAAG;AAEL,wFAAwF;AACxF,MAAM,OAAO,0BAA2B,SAAQ,MAAM,CAAC,WAAW,CAChE,0CAA0C,CAC3C,CAAC,4BAA4B,EAAE;IAC9B,SAAS,EAAE,eAAe;IAC1B,OAAO,EAAE,MAAM,CAAC,cAAc;IAC9B,iGAAiG;IACjG,MAAM,EAAE,MAAM,CAAC,cAAc;CAC9B,CAAC;CAAG;AAEL,+FAA+F;AAC/F,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,KAAK,CAAC;IACzC,0BAA0B;IAC1B,kBAAkB;IAClB,cAAc;IACd,oBAAoB;IACpB,0BAA0B;CAC3B,CAAC,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { RepositoryName } from "@usine/core/repository";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import { HttpClient } from "effect/unstable/http";
4
+ import { type AppAuthenticationError, GitHubCredentials } from "./credentials.ts";
5
+ import { type CoveredProject, type ProjectAccess } from "./access.ts";
6
+ import { type GitHubAppError } from "./error.ts";
7
+ import { Installation, InstallationToken } from "./installation.ts";
8
+ declare const GitHubApp_base: Context.ServiceClass<GitHubApp, "@usine/github/GitHubApp", {
9
+ /** Returns the App installation after checking its required permissions. */
10
+ readonly installation: () => Effect.Effect<Installation, GitHubAppError>;
11
+ /** Returns the shared installation-wide token for orchestrator operations, renewing it near expiry. */
12
+ readonly installationToken: () => Effect.Effect<InstallationToken, GitHubAppError>;
13
+ /** Reports access for every registry repository, retaining uncovered Projects as individual errors. */
14
+ readonly projectAccess: (registry: ReadonlyArray<RepositoryName>) => Effect.Effect<ReadonlyArray<ProjectAccess>, GitHubAppError>;
15
+ /** Mints a fresh token restricted to the covered Project's repository for a Worker. */
16
+ readonly workerToken: (project: CoveredProject) => Effect.Effect<InstallationToken, GitHubAppError>;
17
+ }>;
18
+ /**
19
+ * The factory's GitHub identity and repository access.
20
+ * Orchestrator operations share installation-wide access; each Worker token is
21
+ * newly minted for the repository assigned to its Attempt.
22
+ */
23
+ export declare class GitHubApp extends GitHubApp_base {
24
+ static readonly layer: Layer.Layer<GitHubApp, AppAuthenticationError, GitHubCredentials | HttpClient.HttpClient>;
25
+ }
26
+ export { REQUIRED_REPOSITORY_PERMISSIONS, deriveProjectAccess, missingPermissions, type InstalledRepository, type RequiredRepositoryPermission, CoveredProject, ProjectNotInstalledError, ProjectAccess, AppPermissionError, } from "./access.ts";
27
+ export { AppId, PrivateKey, RedactedPrivateKey, AppCredentials, GitHubCredentials, AppAuthenticationError, } from "./credentials.ts";
28
+ export { InstallationId, RepositoryId, Installation, InstallationWide, SingleRepository, TokenScope, InstallationToken, InstallationDiscoveryReason, InstallationDiscoveryError, } from "./installation.ts";
29
+ export { GitHubOperation, GitHubApiError, GitHubRateLimitError, InvalidGitHubResponseError, GitHubAppError, } from "./error.ts";
30
+ //# sourceMappingURL=github-app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-app.d.ts","sourceRoot":"","sources":["../src/github-app.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAEL,OAAO,EAEP,MAAM,EACN,KAAK,EAKN,MAAM,QAAQ,CAAC;AAChB,OAAO,EAAE,UAAU,EAAyC,MAAM,sBAAsB,CAAC;AAGzF,OAAO,EAAE,KAAK,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAClF,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,aAAa,EAInB,MAAM,aAAa,CAAC;AACrB,OAAO,EAEL,KAAK,cAAc,EAIpB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,YAAY,EAGZ,iBAAiB,EAKlB,MAAM,mBAAmB,CAAC;;IA8DvB,4EAA4E;2BACrD,MAAM,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,cAAc,CAAC;IACxE,uGAAuG;gCAC3E,MAAM,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,cAAc,CAAC;IAClF,uGAAuG;4BAC/E,CACtB,QAAQ,EAAE,aAAa,CAAC,cAAc,CAAC,KACpC,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,aAAa,CAAC,EAAE,cAAc,CAAC;IAChE,uFAAuF;0BACjE,CACpB,OAAO,EAAE,cAAc,KACpB,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,cAAc,CAAC;;AAnBzD;;;;GAIG;AACH,qBAAa,SAAU,SAAQ,cAgBD;IAC5B,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAChC,SAAS,EACT,sBAAsB,EACtB,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAC1C,CAuLC;CACH;AAED,OAAO,EACL,+BAA+B,EAC/B,mBAAmB,EACnB,kBAAkB,EAClB,KAAK,mBAAmB,EACxB,KAAK,4BAA4B,EACjC,cAAc,EACd,wBAAwB,EACxB,aAAa,EACb,kBAAkB,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,KAAK,EACL,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,iBAAiB,EACjB,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,eAAe,EACf,cAAc,EACd,oBAAoB,EACpB,0BAA0B,EAC1B,cAAc,GACf,MAAM,YAAY,CAAC"}
@@ -0,0 +1,184 @@
1
+ import { RepositoryName } from "@usine/core/repository";
2
+ import { Clock, Context, DateTime, Effect, Layer, Option, Redacted, Schema, SynchronizedRef, } from "effect";
3
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
+ import { appJwtSigner } from "./jwt.js";
5
+ import { API_ROOT, authorized, invalidResponse, statusError, transportError } from "./http.js";
6
+ import { GitHubCredentials } from "./credentials.js";
7
+ import { AppPermissionError, REQUIRED_REPOSITORY_PERMISSIONS, deriveProjectAccess, missingPermissions, } from "./access.js";
8
+ import { InvalidGitHubResponseError, } from "./error.js";
9
+ import { Installation, InstallationDiscoveryError, InstallationId, InstallationToken, InstallationWide, RepositoryId, SingleRepository, } from "./installation.js";
10
+ const TOKEN_MAX_LIFETIME_MS = 60 * 60 * 1_000;
11
+ /** GitHub's expiry uses its own clock; ordinary worker/orchestrator skew is not a bad token. */
12
+ const TOKEN_CLOCK_SKEW_MARGIN_MS = 3 * 60 * 1_000;
13
+ /** Renew the shared lease when its token has less than this long left. */
14
+ const LEASE_RENEWAL_MARGIN_MS = 5 * 60 * 1_000;
15
+ const InstallationResponse = Schema.Struct({
16
+ id: InstallationId,
17
+ account: Schema.Struct({ login: Schema.NonEmptyString }),
18
+ permissions: Schema.Record(Schema.String, Schema.String),
19
+ suspendedAt: Schema.NullOr(Schema.String),
20
+ }).pipe(Schema.encodeKeys({ suspendedAt: "suspended_at" }));
21
+ const InstallationsResponse = Schema.Array(InstallationResponse);
22
+ const RepositoryResponse = Schema.Struct({
23
+ id: RepositoryId,
24
+ name: RepositoryName,
25
+ }).pipe(Schema.encodeKeys({ name: "full_name" }));
26
+ const RepositoriesResponse = Schema.Struct({
27
+ totalCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
28
+ repositories: Schema.Array(RepositoryResponse),
29
+ }).pipe(Schema.encodeKeys({ totalCount: "total_count" }));
30
+ const TokenResponse = Schema.Struct({
31
+ token: Schema.NonEmptyString,
32
+ expiresAt: Schema.DateTimeUtcFromString,
33
+ repositories: Schema.OptionFromOptionalKey(Schema.Array(RepositoryResponse)),
34
+ }).pipe(Schema.encodeKeys({ expiresAt: "expires_at" }));
35
+ /** The operation name and request body for minting a token of this scope. */
36
+ const mintPlan = (scope) => scope._tag === "InstallationWide"
37
+ ? {
38
+ operation: "mint-installation-token",
39
+ body: { permissions: REQUIRED_REPOSITORY_PERMISSIONS },
40
+ }
41
+ : {
42
+ operation: "mint-worker-token",
43
+ body: {
44
+ permissions: REQUIRED_REPOSITORY_PERMISSIONS,
45
+ repository_ids: [scope.repositoryId],
46
+ },
47
+ };
48
+ /**
49
+ * The factory's GitHub identity and repository access.
50
+ * Orchestrator operations share installation-wide access; each Worker token is
51
+ * newly minted for the repository assigned to its Attempt.
52
+ */
53
+ export class GitHubApp extends Context.Service()("@usine/github/GitHubApp") {
54
+ static layer = Layer.effect(GitHubApp, Effect.gen(function* () {
55
+ const credentials = yield* GitHubCredentials;
56
+ const sign = yield* appJwtSigner(credentials);
57
+ const http = yield* HttpClient.HttpClient;
58
+ /** Sends one request and decodes its JSON body. Any other outcome is a typed GitHub error. */
59
+ const fetchJson = (operation, request, schema) => Effect.gen(function* () {
60
+ const response = yield* http
61
+ .execute(request)
62
+ .pipe(Effect.mapError((error) => transportError(operation, error)));
63
+ if (response.status < 200 || response.status >= 300) {
64
+ return yield* statusError(operation, response, yield* Clock.currentTimeMillis);
65
+ }
66
+ return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(Effect.mapError((error) => invalidResponse(operation, error.message)));
67
+ });
68
+ const discoverInstallation = Effect.fn("GitHubApp.discoverInstallation")(function* () {
69
+ const jwt = yield* sign();
70
+ const [found, ...others] = yield* fetchJson("discover-installation", HttpClientRequest.get(`${API_ROOT}/app/installations?per_page=2`).pipe(authorized(jwt)), InstallationsResponse);
71
+ if (found === undefined) {
72
+ return yield* new InstallationDiscoveryError({
73
+ reason: "missing",
74
+ message: "The GitHub App is not installed on an account",
75
+ });
76
+ }
77
+ if (others.length > 0) {
78
+ return yield* new InstallationDiscoveryError({
79
+ reason: "multiple",
80
+ message: "The GitHub App must have exactly one installation for this factory",
81
+ });
82
+ }
83
+ if (found.suspendedAt !== null) {
84
+ return yield* new InstallationDiscoveryError({
85
+ reason: "suspended",
86
+ message: "The GitHub App installation is suspended",
87
+ });
88
+ }
89
+ const missing = missingPermissions(found.permissions);
90
+ if (missing.length > 0) {
91
+ return yield* new AppPermissionError({
92
+ missing,
93
+ message: `The GitHub App installation is missing permissions: ${missing.join(", ")}`,
94
+ });
95
+ }
96
+ return new Installation({ id: found.id, accountLogin: found.account.login });
97
+ });
98
+ const mintToken = Effect.fn("GitHubApp.mintToken")(function* (installationId, scope) {
99
+ const { operation, body } = mintPlan(scope);
100
+ yield* Effect.annotateCurrentSpan({ installationId, operation, scope: scope._tag });
101
+ const jwt = yield* sign();
102
+ const response = yield* fetchJson(operation, HttpClientRequest.post(`${API_ROOT}/app/installations/${installationId}/access_tokens`).pipe(HttpClientRequest.bodyJsonUnsafe(body), authorized(jwt)), TokenResponse);
103
+ const now = yield* Clock.currentTimeMillis;
104
+ const lifetime = DateTime.toEpochMillis(response.expiresAt) - now;
105
+ if (lifetime <= 0 || lifetime > TOKEN_MAX_LIFETIME_MS + TOKEN_CLOCK_SKEW_MARGIN_MS) {
106
+ return yield* new InvalidGitHubResponseError({
107
+ operation,
108
+ message: "GitHub returned an installation token outside the one-hour lifetime bound",
109
+ detail: `expires_at ${DateTime.formatIso(response.expiresAt)} leaves ${lifetime}ms to live`,
110
+ });
111
+ }
112
+ if (lifetime > TOKEN_MAX_LIFETIME_MS) {
113
+ yield* Effect.logInfo("Accepted GitHub token clock skew; capped the local lease", {
114
+ installationId,
115
+ operation,
116
+ skewMillis: lifetime - TOKEN_MAX_LIFETIME_MS,
117
+ });
118
+ }
119
+ const confirmed = Option.getOrElse(response.repositories, () => []);
120
+ if (scope._tag === "SingleRepository" &&
121
+ !(confirmed.length === 1 && confirmed.every((repo) => repo.id === scope.repositoryId))) {
122
+ return yield* new InvalidGitHubResponseError({
123
+ operation,
124
+ message: "GitHub did not confirm that the worker token is scoped to exactly one repo",
125
+ detail: `expected repository id ${scope.repositoryId}, confirmed [${confirmed.map((repo) => repo.id).join(", ")}]`,
126
+ });
127
+ }
128
+ return new InstallationToken({
129
+ value: Redacted.make(response.token),
130
+ expiresAt: DateTime.makeUnsafe(now + Math.min(lifetime, TOKEN_MAX_LIFETIME_MS)),
131
+ scope,
132
+ });
133
+ });
134
+ const installedRepositories = Effect.fn("GitHubApp.installedRepositories")(function* (token) {
135
+ const installed = [];
136
+ for (let page = 1;; page += 1) {
137
+ const response = yield* fetchJson("list-installation-repositories", HttpClientRequest.get(`${API_ROOT}/installation/repositories?per_page=100&page=${page}`).pipe(authorized(token.value)), RepositoriesResponse);
138
+ installed.push(...response.repositories);
139
+ if (installed.length >= response.totalCount)
140
+ return installed;
141
+ if (response.repositories.length === 0) {
142
+ return yield* new InvalidGitHubResponseError({
143
+ operation: "list-installation-repositories",
144
+ message: "GitHub pagination ended before total_count repositories were returned",
145
+ detail: `page ${page} was empty after ${installed.length} of ${response.totalCount} repositories`,
146
+ });
147
+ }
148
+ }
149
+ });
150
+ const leaseRef = yield* SynchronizedRef.make(Option.none());
151
+ /** Returns the current lease, renewing it inside the ref when its token is under the margin. */
152
+ const lease = Effect.fn("GitHubApp.lease")(() => SynchronizedRef.modifyEffect(leaseRef, (current) => Effect.gen(function* () {
153
+ const now = yield* Clock.currentTimeMillis;
154
+ if (Option.isSome(current) &&
155
+ DateTime.toEpochMillis(current.value.token.expiresAt) - now > LEASE_RENEWAL_MARGIN_MS) {
156
+ const kept = [current.value, current];
157
+ return kept;
158
+ }
159
+ const installation = yield* discoverInstallation();
160
+ const token = yield* mintToken(installation.id, new InstallationWide());
161
+ const renewed = { installation, token };
162
+ const stored = [renewed, Option.some(renewed)];
163
+ return stored;
164
+ })));
165
+ return {
166
+ installation: Effect.fn("GitHubApp.installation")(() => Effect.map(lease(), (current) => current.installation)),
167
+ installationToken: Effect.fn("GitHubApp.installationToken")(() => Effect.map(lease(), (current) => current.token)),
168
+ projectAccess: Effect.fn("GitHubApp.projectAccess")(function* (registry) {
169
+ const { installation, token } = yield* lease();
170
+ const installed = yield* installedRepositories(token);
171
+ return deriveProjectAccess(installation, registry, installed);
172
+ }),
173
+ workerToken: Effect.fn("GitHubApp.workerToken")((project) => mintToken(project.installationId, new SingleRepository({
174
+ repository: project.repository,
175
+ repositoryId: project.repositoryId,
176
+ }))),
177
+ };
178
+ }));
179
+ }
180
+ export { REQUIRED_REPOSITORY_PERMISSIONS, deriveProjectAccess, missingPermissions, CoveredProject, ProjectNotInstalledError, ProjectAccess, AppPermissionError, } from "./access.js";
181
+ export { AppId, PrivateKey, RedactedPrivateKey, AppCredentials, GitHubCredentials, AppAuthenticationError, } from "./credentials.js";
182
+ export { InstallationId, RepositoryId, Installation, InstallationWide, SingleRepository, TokenScope, InstallationToken, InstallationDiscoveryReason, InstallationDiscoveryError, } from "./installation.js";
183
+ export { GitHubOperation, GitHubApiError, GitHubRateLimitError, InvalidGitHubResponseError, GitHubAppError, } from "./error.js";
184
+ //# sourceMappingURL=github-app.js.map