@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/Repo.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Config, Context, Effect, Layer, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Repo.ts
|
|
4
|
+
/**
|
|
5
|
+
* A repository slug was not `owner/repo`.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
var InvalidRepoRefError = class extends Schema.TaggedErrorClass()("InvalidRepoRefError", {
|
|
10
|
+
/** What was handed in. */
|
|
11
|
+
input: Schema.String }) {
|
|
12
|
+
get message() {
|
|
13
|
+
return `not an owner/repo slug: ${JSON.stringify(this.input)}`;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Which repository an operation acts on.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var RepoRef = class RepoRef extends Schema.Class("RepoRef")({
|
|
22
|
+
/** The user or organization. */
|
|
23
|
+
owner: Schema.NonEmptyString,
|
|
24
|
+
/** The repository name, without the owner. */
|
|
25
|
+
repo: Schema.NonEmptyString
|
|
26
|
+
}) {
|
|
27
|
+
/**
|
|
28
|
+
* Parse `"owner/repo"`, synchronously.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* The sync `Result` primitive; {@link RepoRef.parse} is the `Effect` form over
|
|
32
|
+
* it. `make` is reserved by the class factory for the validated field
|
|
33
|
+
* constructor, which is why string parsing is named rather than overloaded.
|
|
34
|
+
*/
|
|
35
|
+
static parseResult(slug) {
|
|
36
|
+
const parts = slug.split("/");
|
|
37
|
+
const [owner, repo] = parts;
|
|
38
|
+
if (parts.length !== 2 || owner === void 0 || repo === void 0 || owner === "" || repo === "") return Result.fail(new InvalidRepoRefError({ input: slug }));
|
|
39
|
+
return Result.succeed(RepoRef.make({
|
|
40
|
+
owner,
|
|
41
|
+
repo
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/** Parse `"owner/repo"`. */
|
|
45
|
+
static parse = Effect.fn("RepoRef.parse")((slug) => Effect.fromResult(RepoRef.parseResult(slug)));
|
|
46
|
+
/** `"owner/repo"`. */
|
|
47
|
+
get slug() {
|
|
48
|
+
return `${this.owner}/${this.repo}`;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The repository the surrounding program acts on.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Every resource service takes this in its `R` and **no method takes an
|
|
56
|
+
* `{ owner, repo }` argument**, which is what makes a read like
|
|
57
|
+
* `GitHubRepository.defaultBranch` a single expression instead of a preamble.
|
|
58
|
+
*
|
|
59
|
+
* The package this replaces read `process.env.GITHUB_REPOSITORY` in three
|
|
60
|
+
* places — inside the client, inside the App layer, and transitively in every
|
|
61
|
+
* caller of `client.repo` — which is most of what coupled a GitHub API client to
|
|
62
|
+
* the GitHub Actions runtime. Here the coordinate is a value, the env-driven way
|
|
63
|
+
* to get one is a layer variant **named for being env-driven**, and a program
|
|
64
|
+
* that acts on several repositories uses {@link Repo.provide}:
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* import { Repo } from "@effected/github";
|
|
69
|
+
* import { Effect } from "effect";
|
|
70
|
+
*
|
|
71
|
+
* declare const syncOne: Effect.Effect<void, never, Repo>;
|
|
72
|
+
* declare const targets: ReadonlyArray<Repo["Service"]>;
|
|
73
|
+
*
|
|
74
|
+
* const syncAll = Effect.forEach(targets, (target) => syncOne.pipe(Repo.provide(target)), {
|
|
75
|
+
* concurrency: 4,
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* **A deliberate exception to "no non-effectful members on a service shape."**
|
|
80
|
+
* This shape is entirely one immutable value: there is nothing to leave
|
|
81
|
+
* unimplemented, so `Layer.mock` has nothing to degrade and `Layer.succeed` is
|
|
82
|
+
* the correct double. The rule exists to stop a sync member from quietly
|
|
83
|
+
* degrading a *mixed* shape's partial mock. The boundary to hold: the moment a
|
|
84
|
+
* method appears here, this is a service again and the rule applies.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
var Repo = class Repo extends Context.Service()("@effected/github/Repo") {
|
|
89
|
+
/** The repository, as a value you already have. */
|
|
90
|
+
static layer = (ref) => Layer.succeed(Repo, ref);
|
|
91
|
+
/** The repository, from an `"owner/repo"` slug. */
|
|
92
|
+
static layerFromSlug = (slug) => Layer.effect(Repo, RepoRef.parse(slug));
|
|
93
|
+
/**
|
|
94
|
+
* The repository from configuration, `GITHUB_REPOSITORY` by default.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* The one env-driven variant, read through the ambient `ConfigProvider` rather
|
|
98
|
+
* than `process.env` — so a test provides a provider instead of mutating the
|
|
99
|
+
* environment, and a consumer outside Actions can source it however it likes.
|
|
100
|
+
*/
|
|
101
|
+
static layerFromConfig = (options = {}) => Layer.effect(Repo, Effect.flatMap(Config.string(options.name ?? "GITHUB_REPOSITORY"), (slug) => RepoRef.parse(slug)));
|
|
102
|
+
/** Run `effect` against a different repository. */
|
|
103
|
+
static provide = (ref) => (effect) => Effect.provideService(effect, Repo, ref);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
export { InvalidRepoRefError, Repo, RepoRef };
|
package/Resilience.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Duration, Effect, Random, Schedule, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Resilience.ts
|
|
4
|
+
/**
|
|
5
|
+
* What GitHub's rate-limit headers said on the most recent REST response.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Every REST response carries `x-ratelimit-remaining`, `x-ratelimit-limit` and
|
|
9
|
+
* `x-ratelimit-reset`; the client parses them into this and keeps the latest
|
|
10
|
+
* one. Read it through the client's `rateLimit` member when you want to pace
|
|
11
|
+
* yourself — **nothing in this package throttles on your behalf.**
|
|
12
|
+
*
|
|
13
|
+
* The package this replaces coupled the writer and the reader through an
|
|
14
|
+
* optional shared `Ref` service that both resolved with `Effect.serviceOption`,
|
|
15
|
+
* so an application that forgot to provide it got two private cells and a
|
|
16
|
+
* silently dead feature. Here the cell lives inside the client layer that writes
|
|
17
|
+
* it, and this is the only way to read it.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var RateLimitSnapshot = class extends Schema.Class("RateLimitSnapshot")({
|
|
22
|
+
/** Requests left in the current window. */
|
|
23
|
+
remaining: Schema.Int,
|
|
24
|
+
/** The window's ceiling. */
|
|
25
|
+
limit: Schema.Int,
|
|
26
|
+
/** When the window resets, as epoch **seconds** — GitHub's own unit. */
|
|
27
|
+
resetEpochSeconds: Schema.Int
|
|
28
|
+
}) {
|
|
29
|
+
/** Milliseconds until the window resets, relative to `nowMillis`, floored at zero. */
|
|
30
|
+
millisUntilReset(nowMillis) {
|
|
31
|
+
return Math.max(0, this.resetEpochSeconds * 1e3 - nowMillis);
|
|
32
|
+
}
|
|
33
|
+
/** True when the budget is spent. */
|
|
34
|
+
get isExhausted() {
|
|
35
|
+
return this.remaining <= 0;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* How the client retries a failed request.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* There is exactly **one** retry policy in this package, and it is wired into
|
|
43
|
+
* the client so every resource inherits it and no resource carries its own. The
|
|
44
|
+
* package this replaces shipped four mutually inconsistent policies — a
|
|
45
|
+
* hand-rolled recursive loop, a second exported `Schedule` that ignored
|
|
46
|
+
* server-advised delays, a per-operation branch retry layered on top of the
|
|
47
|
+
* client's, and a rate-limiter retry with no predicate at all, which cheerfully
|
|
48
|
+
* retried permission denials — and consumers added two more on top.
|
|
49
|
+
*
|
|
50
|
+
* Only failures that report `retryable` are retried, which for `GitHubError` means a transport failure or a rate limit. A 404, a
|
|
51
|
+
* validation rejection and an authorization failure fail on the first attempt.
|
|
52
|
+
*
|
|
53
|
+
* @public
|
|
54
|
+
*/
|
|
55
|
+
var RetryPolicy = class RetryPolicy extends Schema.Class("RetryPolicy")({
|
|
56
|
+
/** Retries after the first attempt. `0` disables retrying. */
|
|
57
|
+
maxRetries: Schema.Int.check(Schema.isBetween({
|
|
58
|
+
minimum: 0,
|
|
59
|
+
maximum: 10
|
|
60
|
+
})),
|
|
61
|
+
/** The first backoff step; doubles per attempt. */
|
|
62
|
+
baseDelay: Schema.DurationFromMillis,
|
|
63
|
+
/** The computed backoff never exceeds this. */
|
|
64
|
+
maxDelay: Schema.DurationFromMillis,
|
|
65
|
+
/** Prefer GitHub's `retry-after` / rate-limit reset over the computed backoff. */
|
|
66
|
+
respectRetryAfter: Schema.Boolean,
|
|
67
|
+
/**
|
|
68
|
+
* Refuse to wait longer than this for a server-advised delay.
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* A primary rate-limit window can be three quarters of an hour out. Sleeping
|
|
72
|
+
* through it converts a failure into a hang, so past this ceiling the error
|
|
73
|
+
* is re-failed immediately and the caller decides what to do.
|
|
74
|
+
*/
|
|
75
|
+
maxServerAdvisedDelay: Schema.DurationFromMillis
|
|
76
|
+
}) {
|
|
77
|
+
/** Four retries, 1s base, 30s cap, honoring server-advised delays up to a minute. */
|
|
78
|
+
static default = RetryPolicy.make({
|
|
79
|
+
maxRetries: 4,
|
|
80
|
+
baseDelay: Duration.seconds(1),
|
|
81
|
+
maxDelay: Duration.seconds(30),
|
|
82
|
+
respectRetryAfter: true,
|
|
83
|
+
maxServerAdvisedDelay: Duration.seconds(60)
|
|
84
|
+
});
|
|
85
|
+
/** Retries nothing; every failure surfaces on the first attempt. */
|
|
86
|
+
static none = RetryPolicy.make({
|
|
87
|
+
maxRetries: 0,
|
|
88
|
+
baseDelay: Duration.zero,
|
|
89
|
+
maxDelay: Duration.zero,
|
|
90
|
+
respectRetryAfter: false,
|
|
91
|
+
maxServerAdvisedDelay: Duration.zero
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Whether this policy would retry `failure` at all, ignoring attempt counts.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* Pure and total, so the classification is testable without a clock, a
|
|
98
|
+
* runtime or a schedule.
|
|
99
|
+
*/
|
|
100
|
+
retries(failure) {
|
|
101
|
+
if (this.maxRetries === 0 || !failure.retryable) return false;
|
|
102
|
+
const advised = this.advisedMillis(failure);
|
|
103
|
+
return advised === void 0 || advised <= Duration.toMillis(this.maxServerAdvisedDelay);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The delay before the given attempt, for a failure and a `[0, 1)` draw.
|
|
107
|
+
*
|
|
108
|
+
* @remarks
|
|
109
|
+
* A server-advised delay wins outright when `respectRetryAfter`
|
|
110
|
+
* is set: GitHub knows when its window reopens and a computed backoff can only
|
|
111
|
+
* guess. Otherwise this is **full jitter** — a uniform draw from
|
|
112
|
+
* `[0, min(baseDelay * 2^(attempt-1), maxDelay)]` — which spreads a fleet of
|
|
113
|
+
* retrying callers rather than synchronizing them into a second herd.
|
|
114
|
+
*
|
|
115
|
+
* `random` is a parameter so the arithmetic is checkable without stubbing a
|
|
116
|
+
* generator.
|
|
117
|
+
*/
|
|
118
|
+
delayFor(failure, attempt, random) {
|
|
119
|
+
const advised = this.advisedMillis(failure);
|
|
120
|
+
if (advised !== void 0) return Duration.millis(advised);
|
|
121
|
+
const uncapped = Duration.toMillis(this.baseDelay) * 2 ** Math.max(0, attempt - 1);
|
|
122
|
+
const capped = Math.min(uncapped, Duration.toMillis(this.maxDelay));
|
|
123
|
+
return Duration.millis(Math.floor(capped * random));
|
|
124
|
+
}
|
|
125
|
+
/** The server-advised delay, when there is one and this policy honors it. */
|
|
126
|
+
advisedMillis(failure) {
|
|
127
|
+
return this.respectRetryAfter ? failure.retryAfterMillis : void 0;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The `Schedule` this policy compiles to, for `Effect.retry`.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* Built on `Schedule.modifyDelay`, whose callback receives the schedule's
|
|
134
|
+
* `Metadata` — including the **input that failed**. That is what makes a
|
|
135
|
+
* header-driven policy expressible as a `Schedule` at all: the delay is a
|
|
136
|
+
* function of the error, not only of the attempt number. Not knowing this was
|
|
137
|
+
* available is why the package this replaces hand-rolled a recursive retry
|
|
138
|
+
* loop instead of using `Effect.retry`.
|
|
139
|
+
*/
|
|
140
|
+
schedule() {
|
|
141
|
+
return Schedule.forever.pipe(Schedule.modifyDelay(({ input, attempt }) => Effect.map(Random.next, (random) => this.delayFor(input, attempt, random))), Schedule.while(({ input, attempt }) => attempt <= this.maxRetries && this.retries(input)));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { RateLimitSnapshot, RetryPolicy };
|
package/Rest.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Rest.ts
|
|
4
|
+
/**
|
|
5
|
+
* How far a paginated read should go.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Both fields are honored by every paginating method in this package. The
|
|
9
|
+
* package it replaces accepted them on the client and then passed `{}` at six
|
|
10
|
+
* of its eight call sites, so callers silently got 100-item pages and an
|
|
11
|
+
* unbounded walk with no way to say otherwise.
|
|
12
|
+
*
|
|
13
|
+
* `perPage` is **validated, not clamped**: GitHub caps a page at 100 and
|
|
14
|
+
* silently ignores anything larger, so a caller asking for 250 has a bug whose
|
|
15
|
+
* arithmetic is already wrong. Failing at the boundary is cheaper than
|
|
16
|
+
* discovering it in production.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var PageOptions = class PageOptions extends Schema.Class("PageOptions")({
|
|
21
|
+
/** Items requested per page. GitHub's ceiling is 100. */
|
|
22
|
+
perPage: Schema.optionalKey(Schema.Int.check(Schema.isBetween({
|
|
23
|
+
minimum: 1,
|
|
24
|
+
maximum: 100
|
|
25
|
+
}))),
|
|
26
|
+
/** Stop after this many pages. Absent means "until GitHub stops". */
|
|
27
|
+
maxPages: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0)))
|
|
28
|
+
}) {
|
|
29
|
+
/** Reads every page, 100 at a time — GitHub's maximum page size. */
|
|
30
|
+
static all = PageOptions.make({ perPage: 100 });
|
|
31
|
+
/**
|
|
32
|
+
* Reads at most one page of `perPage` items.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* The shape a "is there any?" or "give me the newest few" read wants, where
|
|
36
|
+
* walking every page is waste.
|
|
37
|
+
*/
|
|
38
|
+
static first(perPage) {
|
|
39
|
+
return PageOptions.make({
|
|
40
|
+
perPage,
|
|
41
|
+
maxPages: 1
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { PageOptions };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/TokenPermissions.ts
|
|
4
|
+
/**
|
|
5
|
+
* How much access a permission grants.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
const PermissionLevel = Schema.Literals([
|
|
10
|
+
"read",
|
|
11
|
+
"write",
|
|
12
|
+
"admin"
|
|
13
|
+
]);
|
|
14
|
+
/** `read` < `write` < `admin`. */
|
|
15
|
+
const RANK = {
|
|
16
|
+
read: 1,
|
|
17
|
+
write: 2,
|
|
18
|
+
admin: 3
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A permission the token does not have enough of.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
var PermissionGap = class extends Schema.Class("PermissionGap")({
|
|
26
|
+
/** The permission's name, e.g. `"contents"`. */
|
|
27
|
+
permission: Schema.String,
|
|
28
|
+
/** What was asked for. */
|
|
29
|
+
required: PermissionLevel,
|
|
30
|
+
/** What the token has, when it has any at all. */
|
|
31
|
+
granted: Schema.optionalKey(PermissionLevel)
|
|
32
|
+
}) {};
|
|
33
|
+
/**
|
|
34
|
+
* A permission the token has and did not need.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
var ExtraPermission = class extends Schema.Class("ExtraPermission")({
|
|
39
|
+
permission: Schema.String,
|
|
40
|
+
granted: PermissionLevel,
|
|
41
|
+
/** What was asked for, when anything was. */
|
|
42
|
+
required: Schema.optionalKey(PermissionLevel)
|
|
43
|
+
}) {};
|
|
44
|
+
/**
|
|
45
|
+
* What comparing a token's permissions against a requirement found.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
var PermissionResult = class extends Schema.Class("PermissionResult")({
|
|
50
|
+
/** Permissions that are missing or too weak. */
|
|
51
|
+
missing: Schema.Array(PermissionGap),
|
|
52
|
+
/** Permissions granted beyond what was asked for. */
|
|
53
|
+
extra: Schema.Array(ExtraPermission)
|
|
54
|
+
}) {
|
|
55
|
+
/** Nothing missing. */
|
|
56
|
+
get satisfied() {
|
|
57
|
+
return this.missing.length === 0;
|
|
58
|
+
}
|
|
59
|
+
/** Nothing missing and nothing spare. */
|
|
60
|
+
get exact() {
|
|
61
|
+
return this.satisfied && this.extra.length === 0;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* A token asked for access it does not have, or has access it did not ask for.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
var TokenPermissionError = class extends Schema.TaggedErrorClass()("TokenPermissionError", {
|
|
70
|
+
/** Which assertion failed. */
|
|
71
|
+
kind: Schema.Literals(["insufficient", "excess"]),
|
|
72
|
+
/** The comparison that produced it. */
|
|
73
|
+
result: PermissionResult
|
|
74
|
+
}) {
|
|
75
|
+
get message() {
|
|
76
|
+
return this.kind === "insufficient" ? `token is missing ${this.result.missing.map((gap) => `${gap.permission}:${gap.required}`).join(", ")}` : `token has unrequested ${this.result.extra.map((extra) => `${extra.permission}:${extra.granted}`).join(", ")}`;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The permissions a token was granted, and what they satisfy.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* **A pure class, not a service.** The version this replaces was a
|
|
84
|
+
* `Context.Service` whose live layer was a `Layer.succeed` with zero octokit
|
|
85
|
+
* calls and an empty `R` — a `read < write < admin` comparator behind a service
|
|
86
|
+
* boundary that bought nothing. It cost something, though: its test double
|
|
87
|
+
* reimplemented the entire ranking and every assertion branch, making it the
|
|
88
|
+
* heaviest of the thirty-eight doubles in the package.
|
|
89
|
+
*
|
|
90
|
+
* Here there is no service, no layer and no double: a caller holds the
|
|
91
|
+
* permissions GitHub already gave it (`InstallationToken.permissions`) and
|
|
92
|
+
* compares them. The only `Effect`s are the two assertions, and they exist only
|
|
93
|
+
* because failing typed is more useful than returning a boolean.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* import { TokenPermissions } from "@effected/github";
|
|
98
|
+
* import { Effect } from "effect";
|
|
99
|
+
*
|
|
100
|
+
* declare const permissions: Record<string, string>;
|
|
101
|
+
*
|
|
102
|
+
* const check = Effect.gen(function* () {
|
|
103
|
+
* const granted = TokenPermissions.fromGitHub(permissions);
|
|
104
|
+
* yield* granted.assertSufficient({ contents: "write", pull_requests: "write" });
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @public
|
|
109
|
+
*/
|
|
110
|
+
var TokenPermissions = class TokenPermissions extends Schema.Class("TokenPermissions")({
|
|
111
|
+
/** Permission name to level. */
|
|
112
|
+
granted: Schema.Record(Schema.String, PermissionLevel) }) {
|
|
113
|
+
/**
|
|
114
|
+
* Read GitHub's permission map, ignoring anything unrecognized.
|
|
115
|
+
*
|
|
116
|
+
* @remarks
|
|
117
|
+
* GitHub adds permission levels over time; a token carrying one this package
|
|
118
|
+
* does not know about is not a reason to fail a comparison about a different
|
|
119
|
+
* permission entirely.
|
|
120
|
+
*/
|
|
121
|
+
static fromGitHub(permissions) {
|
|
122
|
+
const granted = {};
|
|
123
|
+
for (const [name, level] of Object.entries(permissions)) if (level === "read" || level === "write" || level === "admin") granted[name] = level;
|
|
124
|
+
return TokenPermissions.make({ granted });
|
|
125
|
+
}
|
|
126
|
+
/** Compare against a requirement. Pure and total. */
|
|
127
|
+
compare(required) {
|
|
128
|
+
const missing = [];
|
|
129
|
+
const extra = [];
|
|
130
|
+
for (const [permission, want] of Object.entries(required)) {
|
|
131
|
+
const have = this.granted[permission];
|
|
132
|
+
if (have === void 0) missing.push(PermissionGap.make({
|
|
133
|
+
permission,
|
|
134
|
+
required: want
|
|
135
|
+
}));
|
|
136
|
+
else if (RANK[have] < RANK[want]) missing.push(PermissionGap.make({
|
|
137
|
+
permission,
|
|
138
|
+
required: want,
|
|
139
|
+
granted: have
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
for (const [permission, have] of Object.entries(this.granted)) {
|
|
143
|
+
const want = required[permission];
|
|
144
|
+
if (want === void 0) extra.push(ExtraPermission.make({
|
|
145
|
+
permission,
|
|
146
|
+
granted: have
|
|
147
|
+
}));
|
|
148
|
+
else if (RANK[have] > RANK[want]) extra.push(ExtraPermission.make({
|
|
149
|
+
permission,
|
|
150
|
+
granted: have,
|
|
151
|
+
required: want
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
return PermissionResult.make({
|
|
155
|
+
missing,
|
|
156
|
+
extra
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Fail unless every required permission is held at least at the level asked for. */
|
|
160
|
+
assertSufficient(required) {
|
|
161
|
+
const result = this.compare(required);
|
|
162
|
+
return result.satisfied ? Effect.void : Effect.fail(new TokenPermissionError({
|
|
163
|
+
kind: "insufficient",
|
|
164
|
+
result
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Fail unless the token holds exactly what was asked for.
|
|
169
|
+
*
|
|
170
|
+
* @remarks
|
|
171
|
+
* For the workflow that wants a least-privilege token and treats a broader
|
|
172
|
+
* one as a misconfiguration worth stopping for.
|
|
173
|
+
*/
|
|
174
|
+
assertExact(required) {
|
|
175
|
+
const result = this.compare(required);
|
|
176
|
+
if (!result.satisfied) return Effect.fail(new TokenPermissionError({
|
|
177
|
+
kind: "insufficient",
|
|
178
|
+
result
|
|
179
|
+
}));
|
|
180
|
+
return result.extra.length === 0 ? Effect.void : Effect.fail(new TokenPermissionError({
|
|
181
|
+
kind: "excess",
|
|
182
|
+
result
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
export { ExtraPermission, PermissionGap, PermissionLevel, PermissionResult, TokenPermissionError, TokenPermissions };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
3
|
+
import { Repo } from "./Repo.js";
|
|
4
|
+
import { PageOptions } from "./Rest.js";
|
|
5
|
+
import { Clock, Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/WorkflowDispatch.ts
|
|
8
|
+
/**
|
|
9
|
+
* Where a workflow run has got to.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
var WorkflowRunStatus = class extends Schema.Class("WorkflowRunStatus")({
|
|
14
|
+
id: Schema.Int,
|
|
15
|
+
/** `queued`, `in_progress`, `completed`, … */
|
|
16
|
+
status: Schema.String,
|
|
17
|
+
/** Set once `status` is `completed`. */
|
|
18
|
+
conclusion: Schema.optionalKey(Schema.String),
|
|
19
|
+
url: Schema.String
|
|
20
|
+
}) {
|
|
21
|
+
/** Has the run finished, whatever the outcome? */
|
|
22
|
+
get isDone() {
|
|
23
|
+
return this.status === "completed";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const DEFAULT_INTERVAL = Duration.seconds(10);
|
|
27
|
+
const DEFAULT_TIMEOUT = Duration.minutes(5);
|
|
28
|
+
/**
|
|
29
|
+
* Workflow dispatch.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
var WorkflowDispatch = class WorkflowDispatch extends Context.Service()("@effected/github/WorkflowDispatch") {
|
|
34
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
35
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
36
|
+
static makeTest = (overrides = {}) => ({
|
|
37
|
+
dispatch: overrides.dispatch ?? (() => unstubbed("dispatch")),
|
|
38
|
+
runStatus: overrides.runStatus ?? (() => unstubbed("runStatus")),
|
|
39
|
+
dispatchAndWait: overrides.dispatchAndWait ?? (() => unstubbed("dispatchAndWait"))
|
|
40
|
+
});
|
|
41
|
+
/** {@link WorkflowDispatch.makeTest} behind a `Layer`. */
|
|
42
|
+
static layerTest = (overrides = {}) => Layer.succeed(WorkflowDispatch, WorkflowDispatch.makeTest(overrides));
|
|
43
|
+
};
|
|
44
|
+
const unstubbed = (member) => {
|
|
45
|
+
throw new Error(`WorkflowDispatch.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
46
|
+
};
|
|
47
|
+
const statusOf = (raw) => WorkflowRunStatus.make({
|
|
48
|
+
id: raw.id,
|
|
49
|
+
status: raw.status ?? "unknown",
|
|
50
|
+
...raw.conclusion != null ? { conclusion: raw.conclusion } : {},
|
|
51
|
+
url: raw.html_url
|
|
52
|
+
});
|
|
53
|
+
const make = (client) => {
|
|
54
|
+
const dispatch = Effect.fn("WorkflowDispatch.dispatch")(function* (workflow, ref, inputs) {
|
|
55
|
+
const { owner, repo } = yield* Repo;
|
|
56
|
+
yield* Effect.annotateCurrentSpan({
|
|
57
|
+
owner,
|
|
58
|
+
repo,
|
|
59
|
+
workflow,
|
|
60
|
+
ref
|
|
61
|
+
});
|
|
62
|
+
yield* client.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
|
|
63
|
+
owner,
|
|
64
|
+
repo,
|
|
65
|
+
workflow_id: workflow,
|
|
66
|
+
ref,
|
|
67
|
+
...inputs !== void 0 ? { inputs } : {}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
dispatch,
|
|
72
|
+
runStatus: Effect.fn("WorkflowDispatch.runStatus")(function* (runId) {
|
|
73
|
+
const { owner, repo } = yield* Repo;
|
|
74
|
+
yield* Effect.annotateCurrentSpan({
|
|
75
|
+
owner,
|
|
76
|
+
repo,
|
|
77
|
+
runId
|
|
78
|
+
});
|
|
79
|
+
const raw = yield* client.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
|
|
80
|
+
owner,
|
|
81
|
+
repo,
|
|
82
|
+
run_id: runId
|
|
83
|
+
});
|
|
84
|
+
return statusOf(raw);
|
|
85
|
+
}),
|
|
86
|
+
dispatchAndWait: Effect.fn("WorkflowDispatch.dispatchAndWait")(function* (workflow, ref, options) {
|
|
87
|
+
const { owner, repo } = yield* Repo;
|
|
88
|
+
const interval = options?.poll?.interval ?? DEFAULT_INTERVAL;
|
|
89
|
+
const timeout = options?.poll?.timeout ?? DEFAULT_TIMEOUT;
|
|
90
|
+
const attempts = Math.max(1, Math.ceil(Duration.toMillis(timeout) / Duration.toMillis(interval)));
|
|
91
|
+
yield* Effect.annotateCurrentSpan({
|
|
92
|
+
owner,
|
|
93
|
+
repo,
|
|
94
|
+
workflow,
|
|
95
|
+
ref,
|
|
96
|
+
attempts
|
|
97
|
+
});
|
|
98
|
+
const dispatchedAt = new Date(yield* Clock.currentTimeMillis).toISOString();
|
|
99
|
+
yield* dispatch(workflow, ref, options?.inputs);
|
|
100
|
+
const findRun = Effect.gen(function* () {
|
|
101
|
+
const match = (yield* client.paginate("GET /repos/{owner}/{repo}/actions/runs", {
|
|
102
|
+
owner,
|
|
103
|
+
repo,
|
|
104
|
+
created: `>=${dispatchedAt}`,
|
|
105
|
+
branch: ref
|
|
106
|
+
}, PageOptions.make({
|
|
107
|
+
perPage: 10,
|
|
108
|
+
maxPages: 1
|
|
109
|
+
}))).find((run) => run.path?.endsWith(workflow) ?? true);
|
|
110
|
+
return match === void 0 ? Option.none() : Option.some(statusOf(match));
|
|
111
|
+
});
|
|
112
|
+
const settled = yield* Effect.repeat(findRun, {
|
|
113
|
+
while: (found) => Option.isNone(found) || !found.value.isDone,
|
|
114
|
+
schedule: Schedule.spaced(interval),
|
|
115
|
+
times: attempts
|
|
116
|
+
});
|
|
117
|
+
if (Option.isNone(settled) || !settled.value.isDone) return yield* Effect.fail(GitHubError.rejected("WorkflowDispatch.dispatchAndWait", 408, `workflow ${workflow} did not finish within ${Duration.format(timeout)}`));
|
|
118
|
+
return settled.value;
|
|
119
|
+
})
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
export { WorkflowDispatch, WorkflowRunStatus };
|