@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/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 };