@effected/github 0.1.0
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.
- package/ArtifactMetadata.js +62 -0
- package/Attestation.js +106 -0
- package/CheckRun.js +286 -0
- package/GitBranch.js +171 -0
- package/GitCommit.js +183 -0
- package/GitHubApp.js +363 -0
- package/GitHubClient.js +192 -0
- package/GitHubCommit.js +198 -0
- package/GitHubContent.js +54 -0
- package/GitHubError.js +254 -0
- package/GitHubIssue.js +216 -0
- package/GitHubRelease.js +188 -0
- package/GitHubRepository.js +59 -0
- package/GitTag.js +191 -0
- package/GraphQL.js +183 -0
- package/LICENSE +21 -0
- package/PullRequest.js +300 -0
- package/PullRequestComment.js +132 -0
- package/Repo.js +107 -0
- package/Resilience.js +146 -0
- package/Rest.js +47 -0
- package/TokenPermissions.js +188 -0
- package/WorkflowDispatch.js +124 -0
- package/index.d.ts +2427 -0
- package/index.js +24 -0
- package/internal/headers.js +52 -0
- package/internal/octokit.js +115 -0
- package/internal/paginate.js +54 -0
- package/package.json +53 -0
- package/tsdoc-metadata.json +11 -0
package/GitCommit.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/GitCommit.ts
|
|
6
|
+
/**
|
|
7
|
+
* A blob's file mode, as the Git Database API spells it.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
const FileMode = Schema.Literals([
|
|
12
|
+
"100644",
|
|
13
|
+
"100755",
|
|
14
|
+
"120000"
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* A file to write in a commit.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var FileContent = class extends Schema.TaggedClass()("FileContent", {
|
|
22
|
+
/** Repository-relative path. */
|
|
23
|
+
path: Schema.NonEmptyString,
|
|
24
|
+
/** The file's new contents. */
|
|
25
|
+
content: Schema.String,
|
|
26
|
+
/** Defaults to a regular file. */
|
|
27
|
+
mode: Schema.optionalKey(FileMode)
|
|
28
|
+
}) {};
|
|
29
|
+
/**
|
|
30
|
+
* A file to remove in a commit.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var FileDeletion = class extends Schema.TaggedClass()("FileDeletion", {
|
|
35
|
+
/** Repository-relative path. */
|
|
36
|
+
path: Schema.NonEmptyString }) {};
|
|
37
|
+
/**
|
|
38
|
+
* One change in a commit.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const FileChange = Schema.Union([FileContent, FileDeletion]);
|
|
43
|
+
/**
|
|
44
|
+
* A commit, projected to the three fields callers actually use.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* `treeSha` is here because two surveyed call sites dropped to a raw octokit
|
|
48
|
+
* cast for it alone, both with the comment *"the Git Data API's `base_tree`
|
|
49
|
+
* wants a tree SHA, not a commit SHA"* — the same eight lines written twice, in
|
|
50
|
+
* two files, for one string.
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
var CommitRef = class extends Schema.Class("CommitRef")({
|
|
55
|
+
/** The commit's own sha. */
|
|
56
|
+
sha: Schema.String,
|
|
57
|
+
/** The tree the commit points at — what `baseTree` wants. */
|
|
58
|
+
treeSha: Schema.String,
|
|
59
|
+
/** Parent commit shas, in order. */
|
|
60
|
+
parents: Schema.Array(Schema.String)
|
|
61
|
+
}) {};
|
|
62
|
+
/**
|
|
63
|
+
* Commits, trees and blobs.
|
|
64
|
+
*
|
|
65
|
+
* @public
|
|
66
|
+
*/
|
|
67
|
+
var GitCommit = class GitCommit extends Context.Service()("@effected/github/GitCommit") {
|
|
68
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
69
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
70
|
+
static makeTest = (overrides = {}) => ({
|
|
71
|
+
get: overrides.get ?? (() => unstubbed("get")),
|
|
72
|
+
createTree: overrides.createTree ?? (() => unstubbed("createTree")),
|
|
73
|
+
createCommit: overrides.createCommit ?? (() => unstubbed("createCommit")),
|
|
74
|
+
commitFiles: overrides.commitFiles ?? (() => unstubbed("commitFiles"))
|
|
75
|
+
});
|
|
76
|
+
/** {@link GitCommit.makeTest} behind a `Layer`. */
|
|
77
|
+
static layerTest = (overrides = {}) => Layer.succeed(GitCommit, GitCommit.makeTest(overrides));
|
|
78
|
+
};
|
|
79
|
+
const unstubbed = (member) => {
|
|
80
|
+
throw new Error(`GitCommit.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
81
|
+
};
|
|
82
|
+
/** The Git Database tree entry for one change. */
|
|
83
|
+
const treeEntry = (change) => change._tag === "FileContent" ? {
|
|
84
|
+
path: change.path,
|
|
85
|
+
mode: change.mode ?? "100644",
|
|
86
|
+
type: "blob",
|
|
87
|
+
content: change.content
|
|
88
|
+
} : {
|
|
89
|
+
path: change.path,
|
|
90
|
+
mode: "100644",
|
|
91
|
+
type: "blob",
|
|
92
|
+
sha: null
|
|
93
|
+
};
|
|
94
|
+
const make = (client) => {
|
|
95
|
+
const get = Effect.fn("GitCommit.get")(function* (sha) {
|
|
96
|
+
const { owner, repo } = yield* Repo;
|
|
97
|
+
yield* Effect.annotateCurrentSpan({
|
|
98
|
+
owner,
|
|
99
|
+
repo,
|
|
100
|
+
sha
|
|
101
|
+
});
|
|
102
|
+
const commit = yield* client.request("GET /repos/{owner}/{repo}/git/commits/{commit_sha}", {
|
|
103
|
+
owner,
|
|
104
|
+
repo,
|
|
105
|
+
commit_sha: sha
|
|
106
|
+
});
|
|
107
|
+
return CommitRef.make({
|
|
108
|
+
sha: commit.sha,
|
|
109
|
+
treeSha: commit.tree.sha,
|
|
110
|
+
parents: commit.parents.map((parent) => parent.sha)
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
const createTree = Effect.fn("GitCommit.createTree")(function* (options) {
|
|
114
|
+
const { owner, repo } = yield* Repo;
|
|
115
|
+
yield* Effect.annotateCurrentSpan({
|
|
116
|
+
owner,
|
|
117
|
+
repo,
|
|
118
|
+
changes: options.changes.length
|
|
119
|
+
});
|
|
120
|
+
return (yield* client.request("POST /repos/{owner}/{repo}/git/trees", {
|
|
121
|
+
owner,
|
|
122
|
+
repo,
|
|
123
|
+
tree: options.changes.map(treeEntry),
|
|
124
|
+
...options.baseTree !== void 0 ? { base_tree: options.baseTree } : {}
|
|
125
|
+
})).sha;
|
|
126
|
+
});
|
|
127
|
+
const createCommit = Effect.fn("GitCommit.createCommit")(function* (options) {
|
|
128
|
+
const { owner, repo } = yield* Repo;
|
|
129
|
+
yield* Effect.annotateCurrentSpan({
|
|
130
|
+
owner,
|
|
131
|
+
repo,
|
|
132
|
+
tree: options.tree
|
|
133
|
+
});
|
|
134
|
+
return (yield* client.request("POST /repos/{owner}/{repo}/git/commits", {
|
|
135
|
+
owner,
|
|
136
|
+
repo,
|
|
137
|
+
message: options.message,
|
|
138
|
+
tree: options.tree,
|
|
139
|
+
parents: [...options.parents]
|
|
140
|
+
})).sha;
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
get,
|
|
144
|
+
createTree,
|
|
145
|
+
createCommit,
|
|
146
|
+
commitFiles: Effect.fn("GitCommit.commitFiles")(function* (options) {
|
|
147
|
+
const { owner, repo } = yield* Repo;
|
|
148
|
+
const short = options.branch.replace(/^refs\/heads\//, "").replace(/^heads\//, "");
|
|
149
|
+
yield* Effect.annotateCurrentSpan({
|
|
150
|
+
owner,
|
|
151
|
+
repo,
|
|
152
|
+
branch: short,
|
|
153
|
+
changes: options.changes.length
|
|
154
|
+
});
|
|
155
|
+
const ref = yield* client.request("GET /repos/{owner}/{repo}/git/ref/{ref}", {
|
|
156
|
+
owner,
|
|
157
|
+
repo,
|
|
158
|
+
ref: `heads/${short}`
|
|
159
|
+
});
|
|
160
|
+
const head = yield* get(ref.object.sha);
|
|
161
|
+
const tree = yield* createTree({
|
|
162
|
+
changes: options.changes,
|
|
163
|
+
baseTree: head.treeSha
|
|
164
|
+
});
|
|
165
|
+
const commit = yield* createCommit({
|
|
166
|
+
message: options.message,
|
|
167
|
+
tree,
|
|
168
|
+
parents: [head.sha]
|
|
169
|
+
});
|
|
170
|
+
yield* client.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
|
|
171
|
+
owner,
|
|
172
|
+
repo,
|
|
173
|
+
ref: `heads/${short}`,
|
|
174
|
+
sha: commit,
|
|
175
|
+
force: false
|
|
176
|
+
});
|
|
177
|
+
return commit;
|
|
178
|
+
})
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
export { CommitRef, FileChange, FileContent, FileDeletion, FileMode, GitCommit };
|
package/GitHubApp.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { GitHubGraphQLError } from "./GraphQL.js";
|
|
3
|
+
import { GitHubClient, makeClientShape } from "./GitHubClient.js";
|
|
4
|
+
import { Clock, Context, DateTime, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Stream } from "effect";
|
|
5
|
+
import githubAppJwt from "universal-github-app-jwt";
|
|
6
|
+
|
|
7
|
+
//#region src/GitHubApp.ts
|
|
8
|
+
/**
|
|
9
|
+
* A GitHub App call failed.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Distinct from `GitHubError` because "I could not obtain credentials" and "the
|
|
13
|
+
* API call failed" are different problems with different fixes: the first is a
|
|
14
|
+
* misconfigured app, a wrong private key or a missing installation; the second
|
|
15
|
+
* is the request that used the credentials.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
var GitHubAppError = class GitHubAppError extends Schema.TaggedErrorClass()("GitHubAppError", {
|
|
20
|
+
/** Which step failed. */
|
|
21
|
+
kind: Schema.Literals([
|
|
22
|
+
"jwt",
|
|
23
|
+
"token",
|
|
24
|
+
"revoke",
|
|
25
|
+
"identity",
|
|
26
|
+
"installation"
|
|
27
|
+
]),
|
|
28
|
+
/** Human-readable cause. */
|
|
29
|
+
reason: Schema.String,
|
|
30
|
+
/** The underlying failure, when there is one. */
|
|
31
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
32
|
+
}) {
|
|
33
|
+
get message() {
|
|
34
|
+
return `GitHub App ${this.kind} failed: ${this.reason}`;
|
|
35
|
+
}
|
|
36
|
+
/** @internal */
|
|
37
|
+
static of(kind, reason, cause) {
|
|
38
|
+
return new GitHubAppError({
|
|
39
|
+
kind,
|
|
40
|
+
reason,
|
|
41
|
+
...cause !== void 0 ? { cause } : {}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* An installation access token and what GitHub said about it.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* Encodable on purpose. `@effected/github-actions` persists one across the
|
|
50
|
+
* `pre`/`main`/`post` process boundary through `GITHUB_STATE`, and
|
|
51
|
+
* `Schema.encodeUnknownEffect` produces JSON with the token as a plain string
|
|
52
|
+
* and `expiresAt` as an ISO instant. A `Redacted` cannot survive serialization
|
|
53
|
+
* by design, so masking the encoded value is the caller's job — Actions calls
|
|
54
|
+
* `::add-mask::`.
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
var InstallationToken = class extends Schema.Class("InstallationToken")({
|
|
59
|
+
/** The token. Decodes to `Redacted`, encodes back to the raw string. */
|
|
60
|
+
token: Schema.RedactedFromValue(Schema.String),
|
|
61
|
+
/** When GitHub will stop accepting it — about an hour out. */
|
|
62
|
+
expiresAt: Schema.DateTimeUtcFromString,
|
|
63
|
+
/** The installation it is scoped to. */
|
|
64
|
+
installationId: Schema.Int,
|
|
65
|
+
/** The permissions GitHub actually granted, which may be narrower than requested. */
|
|
66
|
+
permissions: Schema.Record(Schema.String, Schema.String),
|
|
67
|
+
/** The app's slug, when identity was resolved. */
|
|
68
|
+
appSlug: Schema.optionalKey(Schema.String),
|
|
69
|
+
/** The app's bot user id, when identity was resolved. */
|
|
70
|
+
appUserId: Schema.optionalKey(Schema.Int),
|
|
71
|
+
/** The app's display name, when identity was resolved. */
|
|
72
|
+
appName: Schema.optionalKey(Schema.String)
|
|
73
|
+
}) {
|
|
74
|
+
/**
|
|
75
|
+
* Whether this token is spent, `skew` before its stated expiry.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* Modelled **and enforced**. The package this replaces persisted `expiresAt`
|
|
79
|
+
* and read it nowhere, so a long `main` phase that outlived the hour simply
|
|
80
|
+
* started answering 401 with no explanation.
|
|
81
|
+
*/
|
|
82
|
+
isExpired(nowMillis, skew = DEFAULT_SKEW) {
|
|
83
|
+
return DateTime.toEpochMillis(this.expiresAt) - Duration.toMillis(skew) <= nowMillis;
|
|
84
|
+
}
|
|
85
|
+
/** The committer identity a commit made with this token should carry. */
|
|
86
|
+
botIdentity() {
|
|
87
|
+
return this.appSlug === void 0 ? BotIdentity.githubActions : BotIdentity.forApp({
|
|
88
|
+
appSlug: this.appSlug,
|
|
89
|
+
...this.appUserId !== void 0 ? { appUserId: this.appUserId } : {}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** Re-mint a minute before GitHub would start refusing the token. */
|
|
94
|
+
const DEFAULT_SKEW = Duration.seconds(60);
|
|
95
|
+
/**
|
|
96
|
+
* Who a bot commits as.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* A **pure class**, not a service member. The package this replaces put
|
|
100
|
+
* `botIdentity(source?)` on the `GitHubApp` service shape as a plain synchronous
|
|
101
|
+
* method, which makes it required in every `Layer.mock` and silently degrades
|
|
102
|
+
* every partial double to a full implementation.
|
|
103
|
+
*
|
|
104
|
+
* @public
|
|
105
|
+
*/
|
|
106
|
+
var BotIdentity = class BotIdentity extends Schema.Class("BotIdentity")({
|
|
107
|
+
/** The git author/committer name, e.g. `"my-app[bot]"`. */
|
|
108
|
+
name: Schema.String,
|
|
109
|
+
/** The no-reply address GitHub attributes to that account. */
|
|
110
|
+
email: Schema.String
|
|
111
|
+
}) {
|
|
112
|
+
/** The identity for an app, given whatever of its identity is known. */
|
|
113
|
+
static forApp(source) {
|
|
114
|
+
const name = `${source.appSlug}[bot]`;
|
|
115
|
+
return BotIdentity.make({
|
|
116
|
+
name,
|
|
117
|
+
email: source.appUserId === void 0 ? `${name}@users.noreply.github.com` : `${source.appUserId}+${name}@users.noreply.github.com`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** The well-known identity of the `github-actions` bot. */
|
|
121
|
+
static githubActions = BotIdentity.make({
|
|
122
|
+
name: "github-actions[bot]",
|
|
123
|
+
email: "41898282+github-actions[bot]@users.noreply.github.com"
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* What GitHub knows about the app itself.
|
|
128
|
+
*
|
|
129
|
+
* @public
|
|
130
|
+
*/
|
|
131
|
+
var AppIdentity = class extends Schema.Class("AppIdentity")({
|
|
132
|
+
/** The URL slug, e.g. `"my-app"`. */
|
|
133
|
+
slug: Schema.String,
|
|
134
|
+
/** The display name. */
|
|
135
|
+
name: Schema.String,
|
|
136
|
+
/** The bot user's numeric id, when it could be resolved. */
|
|
137
|
+
userId: Schema.optionalKey(Schema.Int)
|
|
138
|
+
}) {
|
|
139
|
+
/** The committer identity for this app. */
|
|
140
|
+
botIdentity() {
|
|
141
|
+
return BotIdentity.forApp({
|
|
142
|
+
appSlug: this.slug,
|
|
143
|
+
...this.userId !== void 0 ? { appUserId: this.userId } : {}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* One installation of the app.
|
|
149
|
+
*
|
|
150
|
+
* @public
|
|
151
|
+
*/
|
|
152
|
+
var Installation = class extends Schema.Class("Installation")({
|
|
153
|
+
/** The installation id, which is what a token is minted against. */
|
|
154
|
+
id: Schema.Int,
|
|
155
|
+
/** The account the app is installed on, when GitHub reported one. */
|
|
156
|
+
account: Schema.optionalKey(Schema.String)
|
|
157
|
+
}) {};
|
|
158
|
+
/**
|
|
159
|
+
* GitHub App authentication: mint, revoke and identify.
|
|
160
|
+
*
|
|
161
|
+
* @remarks
|
|
162
|
+
* **This is the only module in the package that imports a JWT signer**, which is
|
|
163
|
+
* what makes the tree-shaking invariant structural rather than aspirational: a
|
|
164
|
+
* consumer that authenticates with a token it already holds imports
|
|
165
|
+
* `GitHubClient` and never reaches this module or its dependency.
|
|
166
|
+
*
|
|
167
|
+
* That constraint is also why the App-authenticated **client** layer lives here
|
|
168
|
+
* as {@link GitHubApp.clientLayer} rather than as a third static on
|
|
169
|
+
* `GitHubClient`: statics on one class share one module, and putting it there
|
|
170
|
+
* would make every token-only consumer link the signer. The kit has this shape
|
|
171
|
+
* already — `@effected/workspaces` ships `localExecLayer`, which builds
|
|
172
|
+
* `@effected/commands`' service, for the same reason.
|
|
173
|
+
*
|
|
174
|
+
* The JWT signer is `universal-github-app-jwt` — zero dependencies, and
|
|
175
|
+
* `@octokit/auth-app`'s own JWT dependency. Taking it directly rather than
|
|
176
|
+
* taking `auth-app` leaves behind roughly half a megabyte of OAuth app, user and
|
|
177
|
+
* device-flow machinery that this package never calls.
|
|
178
|
+
*
|
|
179
|
+
* @public
|
|
180
|
+
*/
|
|
181
|
+
var GitHubApp = class GitHubApp extends Context.Service()("@effected/github/GitHubApp") {
|
|
182
|
+
/** The default transport. Bind it once; layers are memoized by reference. */
|
|
183
|
+
static layer = Layer.effect(this, makeApp({}));
|
|
184
|
+
/**
|
|
185
|
+
* A transport with custom settings.
|
|
186
|
+
*
|
|
187
|
+
* @remarks
|
|
188
|
+
* Parameterized, so **bind the result to a `const`** and reuse it. Calling
|
|
189
|
+
* this at two provide sites builds two instances, because layers are
|
|
190
|
+
* memoized by reference.
|
|
191
|
+
*/
|
|
192
|
+
static layerWith = (options) => Layer.effect(GitHubApp, makeApp(options));
|
|
193
|
+
/**
|
|
194
|
+
* A {@link GitHubClient} authenticated as an app installation.
|
|
195
|
+
*
|
|
196
|
+
* @remarks
|
|
197
|
+
* The token's lifetime is the layer's scope: it is minted on build and
|
|
198
|
+
* **revoked on release**, best-effort, so a workflow does not leave live
|
|
199
|
+
* credentials behind. It is also **re-minted automatically** a minute before
|
|
200
|
+
* it expires, which the package this replaces did not do — it persisted
|
|
201
|
+
* `expiresAt` and read it nowhere, so a `main` phase outliving the hour
|
|
202
|
+
* started answering 401 with nothing explaining why.
|
|
203
|
+
*
|
|
204
|
+
* A failure to obtain credentials surfaces to the caller as a
|
|
205
|
+
* `GitHubError { kind: "unauthorized" }` carrying the `GitHubAppError` as its
|
|
206
|
+
* cause: from a request's point of view, "could not authenticate" is an
|
|
207
|
+
* authorization failure, and widening every method's error channel to say so
|
|
208
|
+
* would tax every caller for a case only this layer can produce.
|
|
209
|
+
*/
|
|
210
|
+
static clientLayer = (request, options = {}) => Layer.effect(GitHubClient, Effect.flatMap(GitHubApp, (app) => makeRotatingClient(app, request, options))).pipe(Layer.provide(GitHubApp.layerWith(options)));
|
|
211
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
212
|
+
static makeTest = (overrides = {}) => ({
|
|
213
|
+
token: overrides.token ?? (() => unstubbed("token")),
|
|
214
|
+
scopedToken: overrides.scopedToken ?? (() => unstubbed("scopedToken")),
|
|
215
|
+
revoke: overrides.revoke ?? (() => unstubbed("revoke")),
|
|
216
|
+
identity: overrides.identity ?? (() => unstubbed("identity")),
|
|
217
|
+
installations: overrides.installations ?? (() => unstubbed("installations"))
|
|
218
|
+
});
|
|
219
|
+
/** {@link GitHubApp.makeTest} behind a `Layer`. */
|
|
220
|
+
static layerTest = (overrides = {}) => Layer.succeed(GitHubApp, GitHubApp.makeTest(overrides));
|
|
221
|
+
};
|
|
222
|
+
const unstubbed = (member) => {
|
|
223
|
+
throw new Error(`GitHubApp.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
224
|
+
};
|
|
225
|
+
/** Mint an app JWT. The only cryptography in this package, and it is a leaf call. */
|
|
226
|
+
const mintJwt = (credentials) => Effect.tryPromise({
|
|
227
|
+
try: () => githubAppJwt({
|
|
228
|
+
id: credentials.appId,
|
|
229
|
+
privateKey: Redacted.value(credentials.privateKey)
|
|
230
|
+
}),
|
|
231
|
+
catch: (error) => GitHubAppError.of("jwt", error instanceof Error ? error.message : "could not sign the app JWT", error)
|
|
232
|
+
}).pipe(Effect.map((result) => Redacted.make(result.token)));
|
|
233
|
+
/** A client speaking as the app itself. */
|
|
234
|
+
const asApp = (credentials, options) => Effect.flatMap(mintJwt(credentials), (jwt) => makeClientShape({
|
|
235
|
+
...options,
|
|
236
|
+
token: jwt
|
|
237
|
+
}));
|
|
238
|
+
/** A client speaking as a holder of `token`, or as nobody when there is none. */
|
|
239
|
+
const asBearer = (token, options) => makeClientShape({
|
|
240
|
+
...options,
|
|
241
|
+
token: token ?? Redacted.make("")
|
|
242
|
+
});
|
|
243
|
+
const appFailure = (kind) => (error) => Effect.fail(GitHubAppError.of(kind, error.reason, error));
|
|
244
|
+
function makeApp(options) {
|
|
245
|
+
return Effect.sync(() => {
|
|
246
|
+
const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
|
|
247
|
+
return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => Installation.make({
|
|
248
|
+
id: entry.id,
|
|
249
|
+
...entry.account !== null && entry.account !== void 0 && "login" in entry.account ? { account: entry.account.login } : {}
|
|
250
|
+
}));
|
|
251
|
+
});
|
|
252
|
+
const resolveInstallationId = (request) => request.installationId !== void 0 ? Effect.succeed(request.installationId) : Effect.gen(function* () {
|
|
253
|
+
const all = yield* installations(request);
|
|
254
|
+
if (request.owner !== void 0) {
|
|
255
|
+
const wanted = request.owner.toLowerCase();
|
|
256
|
+
const match = all.find((entry) => entry.account?.toLowerCase() === wanted);
|
|
257
|
+
if (match !== void 0) return match.id;
|
|
258
|
+
return yield* Effect.fail(GitHubAppError.of("installation", `the app is not installed on ${request.owner} (installed on: ${all.map((entry) => entry.account ?? entry.id).join(", ") || "nothing"})`));
|
|
259
|
+
}
|
|
260
|
+
const only = all[0];
|
|
261
|
+
if (all.length === 1 && only !== void 0) return only.id;
|
|
262
|
+
return yield* Effect.fail(GitHubAppError.of("installation", all.length === 0 ? "the app has no installations" : `the app has ${all.length} installations; pass installationId or owner`));
|
|
263
|
+
});
|
|
264
|
+
const token = Effect.fn("GitHubApp.token")(function* (request) {
|
|
265
|
+
const installationId = yield* resolveInstallationId(request);
|
|
266
|
+
const minted = yield* (yield* asApp(request, options)).request("POST /app/installations/{installation_id}/access_tokens", { installation_id: installationId }).pipe(Effect.catch(appFailure("token")));
|
|
267
|
+
return yield* Schema.decodeUnknownEffect(InstallationToken)({
|
|
268
|
+
token: minted.token,
|
|
269
|
+
expiresAt: minted.expires_at,
|
|
270
|
+
installationId,
|
|
271
|
+
permissions: normalizePermissions(minted.permissions)
|
|
272
|
+
}).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubAppError.of("token", "GitHub returned an unexpected token payload", error))));
|
|
273
|
+
});
|
|
274
|
+
const revoke = Effect.fn("GitHubApp.revoke")(function* (value) {
|
|
275
|
+
yield* (yield* asBearer(value, options)).request("DELETE /installation/token", {}).pipe(Effect.catch(appFailure("revoke")));
|
|
276
|
+
});
|
|
277
|
+
const scopedToken = (request) => Effect.acquireRelease(token(request), (minted) => Effect.ignore(revoke(minted.token)));
|
|
278
|
+
return {
|
|
279
|
+
token,
|
|
280
|
+
scopedToken,
|
|
281
|
+
revoke,
|
|
282
|
+
identity: Effect.fn("GitHubApp.identity")(function* (request) {
|
|
283
|
+
const app = yield* (yield* asApp(request, options)).request("GET /app", {}).pipe(Effect.catch(appFailure("identity")));
|
|
284
|
+
if (app === null) return yield* Effect.fail(GitHubAppError.of("identity", "GET /app returned no app"));
|
|
285
|
+
const slug = app.slug ?? "";
|
|
286
|
+
const name = app.name;
|
|
287
|
+
const user = yield* (yield* asBearer(request.installationToken, options)).request("GET /users/{username}", { username: `${slug}[bot]` }).pipe(Effect.option);
|
|
288
|
+
return AppIdentity.make({
|
|
289
|
+
slug,
|
|
290
|
+
name,
|
|
291
|
+
...Option.isSome(user) ? { userId: user.value.id } : {}
|
|
292
|
+
});
|
|
293
|
+
}),
|
|
294
|
+
installations
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/** GitHub's permission values are strings; anything else is not a permission. */
|
|
299
|
+
const normalizePermissions = (raw) => {
|
|
300
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
301
|
+
const out = {};
|
|
302
|
+
for (const [key, value] of Object.entries(raw)) if (typeof value === "string") out[key] = value;
|
|
303
|
+
return out;
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* A client shape that re-mints its installation token before it expires.
|
|
307
|
+
*
|
|
308
|
+
* @remarks
|
|
309
|
+
* The rotation is invisible to a caller: each member resolves the current
|
|
310
|
+
* client first, and "current" means "minted, and not within a minute of
|
|
311
|
+
* expiry". Rotating revokes the token it replaces, so at most one live token
|
|
312
|
+
* exists at a time and the scope's release revokes the last of them.
|
|
313
|
+
*/
|
|
314
|
+
const makeRotatingClient = (app, request, options) => Effect.gen(function* () {
|
|
315
|
+
const held = yield* Ref.make(Option.none());
|
|
316
|
+
const revokeHeld = Effect.flatMap(Ref.get(held), (current) => Option.isSome(current) ? Effect.ignore(app.revoke(current.value.token.token)) : Effect.void);
|
|
317
|
+
const rotate = Effect.gen(function* () {
|
|
318
|
+
yield* revokeHeld;
|
|
319
|
+
const minted = yield* app.token(request);
|
|
320
|
+
const client = yield* makeClientShape({
|
|
321
|
+
...options,
|
|
322
|
+
token: minted.token
|
|
323
|
+
});
|
|
324
|
+
yield* Ref.set(held, Option.some({
|
|
325
|
+
token: minted,
|
|
326
|
+
client
|
|
327
|
+
}));
|
|
328
|
+
return client;
|
|
329
|
+
});
|
|
330
|
+
yield* rotate;
|
|
331
|
+
yield* Effect.addFinalizer(() => revokeHeld);
|
|
332
|
+
/** The live client, re-minting first if the held token is spent. */
|
|
333
|
+
const fresh = Effect.gen(function* () {
|
|
334
|
+
const now = yield* Clock.currentTimeMillis;
|
|
335
|
+
const state = yield* Ref.get(held);
|
|
336
|
+
if (Option.isSome(state) && !state.value.token.isExpired(now)) return state.value.client;
|
|
337
|
+
return yield* rotate;
|
|
338
|
+
});
|
|
339
|
+
const current = fresh.pipe(Effect.catchTag("GitHubAppError", (error) => Effect.fail(new GitHubError({
|
|
340
|
+
kind: "unauthorized",
|
|
341
|
+
operation: "GitHubApp.clientLayer",
|
|
342
|
+
reason: error.reason,
|
|
343
|
+
cause: error
|
|
344
|
+
}))));
|
|
345
|
+
const currentForGraphQL = fresh.pipe(Effect.catchTag("GitHubAppError", (error) => Effect.fail(new GitHubGraphQLError({
|
|
346
|
+
kind: "unauthorized",
|
|
347
|
+
operation: "GitHubApp.clientLayer",
|
|
348
|
+
reason: error.reason,
|
|
349
|
+
errors: [],
|
|
350
|
+
cause: error
|
|
351
|
+
}))));
|
|
352
|
+
return {
|
|
353
|
+
request: (route, params) => Effect.flatMap(current, (client) => client.request(route, params)),
|
|
354
|
+
requestDecoded: (route, params, schema) => Effect.flatMap(current, (client) => client.requestDecoded(route, params, schema)),
|
|
355
|
+
paginate: (route, params, pageOptions) => Effect.flatMap(current, (client) => client.paginate(route, params, pageOptions)),
|
|
356
|
+
paginateStream: (route, params, pageOptions) => Stream.unwrap(Effect.map(current, (client) => client.paginateStream(route, params, pageOptions))),
|
|
357
|
+
graphql: (document, variables) => Effect.flatMap(currentForGraphQL, (client) => client.graphql(document, variables)),
|
|
358
|
+
rateLimit: Effect.flatMap(Ref.get(held), (state) => Option.isSome(state) ? state.value.client.rateLimit : Effect.succeed(Option.none()))
|
|
359
|
+
};
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
export { AppIdentity, BotIdentity, GitHubApp, GitHubAppError, Installation, InstallationToken };
|