@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.
@@ -0,0 +1,192 @@
1
+ import { GitHubError } from "./GitHubError.js";
2
+ import { GitHubGraphQLError } from "./GraphQL.js";
3
+ import { RetryPolicy } from "./Resilience.js";
4
+ import { makeTransport } from "./internal/octokit.js";
5
+ import { fromArray, paginate } from "./internal/paginate.js";
6
+ import { Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
7
+
8
+ //#region src/GitHubClient.ts
9
+ /** GitHub's own maximum page size, and the default this package requests. */
10
+ const DEFAULT_PER_PAGE = 100;
11
+ const resolvePolicy = (retry) => retry === void 0 ? RetryPolicy.default : retry === "off" ? RetryPolicy.none : retry;
12
+ const perPageOf = (options) => options?.perPage ?? DEFAULT_PER_PAGE;
13
+ const unstubbed = (member) => {
14
+ throw new Error(`GitHubClient.makeTest: ${member}() was called but not stubbed — pass an override, or use GitHubClient.layerFixture for recorded responses.`);
15
+ };
16
+ /**
17
+ * The typed GitHub API client.
18
+ *
19
+ * @remarks
20
+ * The route is the key. `@octokit/types` generates a map from GitHub's OpenAPI
21
+ * description that carries every endpoint's parameter and response shapes, and
22
+ * `@octokit/core`'s `request` already consumes it — so a caller writes a route
23
+ * literal and gets both sides typed, with no callback, no type parameter to
24
+ * invent, and no cast.
25
+ *
26
+ * That is the whole point of this package. The surface it replaces was
27
+ * `rest<T>(operation: string, fn: (octokit: any) => Promise<{ data: T }>)`,
28
+ * where `T` was whatever the caller wrote and nothing connected it to the
29
+ * endpoint. Four consumer repos paid for that with sixteen cast sites and three
30
+ * hand-written octokit interfaces, one of which gave up and typed its methods as
31
+ * `Record<string, (p: unknown) => Promise<{ data: unknown }>>`.
32
+ *
33
+ * @public
34
+ */
35
+ var GitHubClient = class GitHubClient extends Context.Service()("@effected/github/GitHubClient") {
36
+ /**
37
+ * A client authenticated with a token you already hold.
38
+ *
39
+ * @remarks
40
+ * This module imports `@octokit/core` and nothing heavier. A consumer that
41
+ * only ever authenticates with a token never links the GitHub App JWT signer,
42
+ * because the App-authenticated layer lives in `GitHubApp` — a different
43
+ * module — rather than as a third static here.
44
+ */
45
+ static layerFromToken = (options) => Layer.effect(this, makeClientShape(options));
46
+ /**
47
+ * A client authenticated from configuration, `GITHUB_TOKEN` by default.
48
+ *
49
+ * @remarks
50
+ * Reads through the ambient `ConfigProvider`, not `process.env`, so a test
51
+ * provides a provider instead of mutating the environment and a non-Actions
52
+ * consumer can source the token however it likes.
53
+ *
54
+ * Construction fails with core's `ConfigError` — an honest "no token is
55
+ * configured". The layer this replaces failed with a **wire-failure** error
56
+ * type instead, which is why one consumer wrapped it in `Layer.orDie` under a
57
+ * five-line comment explaining that the error did not mean what it said.
58
+ */
59
+ static layerFromConfig = (options = {}) => Layer.effect(this, Effect.gen(function* () {
60
+ const token = yield* Config.redacted(options.name ?? "GITHUB_TOKEN");
61
+ return yield* makeClientShape({
62
+ ...options,
63
+ token
64
+ });
65
+ }));
66
+ /**
67
+ * An in-memory double: stub the members a test exercises, and every other
68
+ * member **dies** naming itself.
69
+ *
70
+ * @remarks
71
+ * No member has an honest default. A fabricated response — an empty list, a
72
+ * made-up sha — would leak into the code under test as fact, so the double
73
+ * fails loudly instead, which also makes it proof that a test touches nothing
74
+ * but what it stubbed.
75
+ *
76
+ * For recorded responses that page for real, use
77
+ * {@link GitHubClient.layerFixture}.
78
+ */
79
+ static makeTest = (overrides = {}) => ({
80
+ request: overrides.request ?? (() => unstubbed("request")),
81
+ requestDecoded: overrides.requestDecoded ?? (() => unstubbed("requestDecoded")),
82
+ paginate: overrides.paginate ?? (() => unstubbed("paginate")),
83
+ paginateStream: overrides.paginateStream ?? (() => unstubbed("paginateStream")),
84
+ graphql: overrides.graphql ?? (() => unstubbed("graphql")),
85
+ rateLimit: overrides.rateLimit ?? Effect.succeed(Option.none())
86
+ });
87
+ /** {@link GitHubClient.makeTest} behind a `Layer`. */
88
+ static layerTest = (overrides = {}) => Layer.succeed(GitHubClient, GitHubClient.makeTest(overrides));
89
+ /**
90
+ * A double over recorded responses that **pages them for real**.
91
+ *
92
+ * @remarks
93
+ * The one recorded-response double in this package, and the single narrow
94
+ * exception to the no-behavior-reimplementing-doubles rule — safe precisely
95
+ * because it reimplements nothing: it builds a `PageSource` over the recorded
96
+ * array and hands it to the same `paginate` engine the live client uses, so
97
+ * `perPage` and `maxPages` cannot behave differently here than in production.
98
+ *
99
+ * The double it replaces named its pagination parameters `_options` and
100
+ * ignored them, returning every recorded page regardless of what the caller
101
+ * asked for — which made every truncation path in every consumer
102
+ * structurally untestable.
103
+ *
104
+ * `fixtures.requested` is appended to as the test runs, so a suite can assert
105
+ * which routes were walked and at what page size.
106
+ */
107
+ static layerFixture = (fixtures) => Layer.succeed(GitHubClient, makeFixture(fixtures));
108
+ };
109
+ /**
110
+ * Builds the live shape over a constructed transport.
111
+ *
112
+ * @remarks
113
+ * Exported for `GitHubApp`, which builds clients of its own — one speaking as
114
+ * the app (JWT), one as an installation, and one unauthenticated for the bot-user
115
+ * lookup that rejects an app JWT. Not part of the public surface.
116
+ *
117
+ * @internal
118
+ */
119
+ const makeClientShape = (options) => Effect.gen(function* () {
120
+ const transport = yield* makeTransport({
121
+ token: options.token,
122
+ retry: resolvePolicy(options.retry),
123
+ baseUrl: options.baseUrl,
124
+ userAgent: options.userAgent,
125
+ fetch: options.fetch
126
+ });
127
+ const request = Effect.fn("GitHubClient.request")(function* (route, params) {
128
+ yield* Effect.annotateCurrentSpan({ route });
129
+ return (yield* transport.request(route, route, params)).data;
130
+ });
131
+ const requestDecoded = Effect.fn("GitHubClient.requestDecoded")(function* (route, params, schema) {
132
+ yield* Effect.annotateCurrentSpan({ route });
133
+ const response = yield* transport.request(route, route, params);
134
+ return yield* Schema.decodeUnknownEffect(schema)(response.data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "response did not match its schema", error))));
135
+ });
136
+ const paginateStream = (route, params, options) => paginate(() => transport.pageSource(route, route, {
137
+ per_page: perPageOf(options),
138
+ ...params
139
+ }), options?.maxPages);
140
+ return {
141
+ request,
142
+ requestDecoded,
143
+ paginate: Effect.fn("GitHubClient.paginate")(function* (route, params, options) {
144
+ yield* Effect.annotateCurrentSpan({
145
+ route,
146
+ perPage: perPageOf(options),
147
+ maxPages: options?.maxPages ?? -1
148
+ });
149
+ return yield* Stream.runCollect(paginateStream(route, params, options));
150
+ }),
151
+ paginateStream,
152
+ graphql: Effect.fn("GitHubClient.graphql")(function* (document, variables) {
153
+ yield* Effect.annotateCurrentSpan({ document: document.name });
154
+ const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
155
+ return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
156
+ }),
157
+ rateLimit: transport.rateLimit
158
+ };
159
+ });
160
+ const makeFixture = (fixtures) => {
161
+ const requested = fixtures.requested;
162
+ const paginateStream = (route, _params, options) => {
163
+ const items = fixtures.paginate?.[route];
164
+ if (items === void 0) return Stream.fail(GitHubError.notFound("GitHubClient.paginate", `fixture for ${route}`));
165
+ const perPage = perPageOf(options);
166
+ requested?.push({
167
+ route,
168
+ perPage
169
+ });
170
+ return paginate(() => fromArray(items, perPage), options?.maxPages);
171
+ };
172
+ return {
173
+ request: (route, _params) => {
174
+ const data = fixtures.request?.[route];
175
+ return data === void 0 ? Effect.fail(GitHubError.notFound("GitHubClient.request", `fixture for ${route}`)) : Effect.succeed(data);
176
+ },
177
+ requestDecoded: (route, _params, schema) => {
178
+ const data = fixtures.request?.[route];
179
+ return data === void 0 ? Effect.fail(GitHubError.notFound("GitHubClient.requestDecoded", `fixture for ${route}`)) : Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "fixture did not match its schema", error))));
180
+ },
181
+ paginate: (route, params, options) => Stream.runCollect(paginateStream(route, params, options)),
182
+ paginateStream,
183
+ graphql: (document, _variables) => {
184
+ const raw = fixtures.graphql?.[document.name];
185
+ return raw === void 0 ? Effect.die(/* @__PURE__ */ new Error(`GitHubClient.layerFixture: no graphql fixture for ${document.name}`)) : document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "fixture did not match its schema", error))));
186
+ },
187
+ rateLimit: Effect.succeed(Option.fromUndefinedOr(fixtures.rateLimit))
188
+ };
189
+ };
190
+
191
+ //#endregion
192
+ export { GitHubClient, makeClientShape };
@@ -0,0 +1,198 @@
1
+ import { paginate } from "./internal/paginate.js";
2
+ import { GitHubClient } from "./GitHubClient.js";
3
+ import { Repo } from "./Repo.js";
4
+ import { Context, Effect, Layer, Option, Schema, Stream } from "effect";
5
+
6
+ //#region src/GitHubCommit.ts
7
+ /**
8
+ * A commit, projected to what callers read.
9
+ *
10
+ * @public
11
+ */
12
+ var CommitSummary = class extends Schema.Class("CommitSummary")({
13
+ /** The commit sha. */
14
+ sha: Schema.String,
15
+ /** The full commit message, untrimmed — this package does not decide what "the message" means. */
16
+ message: Schema.String,
17
+ /** The author's name as git recorded it, or `"Unknown"` when GitHub reports none. */
18
+ author: Schema.String,
19
+ /** The GitHub login of the authoring account, when GitHub could attribute one. */
20
+ authorLogin: Schema.optionalKey(Schema.String),
21
+ /** The web URL for the commit. */
22
+ url: Schema.String
23
+ }) {
24
+ /** The message's first line. */
25
+ get subject() {
26
+ return this.message.split("\n", 1)[0] ?? "";
27
+ }
28
+ };
29
+ /**
30
+ * How a file changed in a commit or a comparison.
31
+ *
32
+ * @public
33
+ */
34
+ const FileStatus = Schema.Literals([
35
+ "added",
36
+ "removed",
37
+ "modified",
38
+ "renamed",
39
+ "copied",
40
+ "changed",
41
+ "unchanged"
42
+ ]);
43
+ /**
44
+ * One changed file.
45
+ *
46
+ * @public
47
+ */
48
+ var CommitFile = class extends Schema.Class("CommitFile")({
49
+ /** Repository-relative path, after any rename. */
50
+ path: Schema.String,
51
+ /** What happened to it. */
52
+ status: FileStatus,
53
+ /** Lines added. */
54
+ additions: Schema.Int,
55
+ /** Lines removed. */
56
+ deletions: Schema.Int,
57
+ /** The path before a rename or copy. */
58
+ previousPath: Schema.optionalKey(Schema.String)
59
+ }) {};
60
+ /**
61
+ * The result of comparing two refs.
62
+ *
63
+ * @public
64
+ */
65
+ var CommitComparison = class extends Schema.Class("CommitComparison")({
66
+ /** How head relates to base. */
67
+ status: Schema.Literals([
68
+ "diverged",
69
+ "ahead",
70
+ "behind",
71
+ "identical"
72
+ ]),
73
+ /** Commits head has that base does not. */
74
+ aheadBy: Schema.Int,
75
+ /** Commits base has that head does not. */
76
+ behindBy: Schema.Int,
77
+ /** The commits in the range. */
78
+ commits: Schema.Array(CommitSummary),
79
+ /** The files that differ, subject to GitHub's own 300-file cap on this endpoint. */
80
+ files: Schema.Array(CommitFile)
81
+ }) {};
82
+ /**
83
+ * Commits, as GitHub reports them.
84
+ *
85
+ * @public
86
+ */
87
+ var GitHubCommit = class GitHubCommit extends Context.Service()("@effected/github/GitHubCommit") {
88
+ static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
89
+ /** An in-memory double; unstubbed members die naming themselves. */
90
+ static makeTest = (overrides = {}) => ({
91
+ get: overrides.get ?? (() => unstubbed("get")),
92
+ list: overrides.list ?? (() => unstubbed("list")),
93
+ compare: overrides.compare ?? (() => unstubbed("compare")),
94
+ changedFiles: overrides.changedFiles ?? (() => unstubbed("changedFiles"))
95
+ });
96
+ /** {@link GitHubCommit.makeTest} behind a `Layer`. */
97
+ static layerTest = (overrides = {}) => Layer.succeed(GitHubCommit, GitHubCommit.makeTest(overrides));
98
+ };
99
+ const unstubbed = (member) => {
100
+ throw new Error(`GitHubCommit.makeTest: ${member}() was called but not stubbed — pass an override.`);
101
+ };
102
+ const summarize = (raw) => CommitSummary.make({
103
+ sha: raw.sha,
104
+ message: raw.commit.message,
105
+ author: raw.commit.author?.name ?? "Unknown",
106
+ ...raw.author?.login !== void 0 ? { authorLogin: raw.author.login } : {},
107
+ url: raw.html_url
108
+ });
109
+ const fileOf = (raw) => CommitFile.make({
110
+ path: raw.filename,
111
+ status: raw.status,
112
+ additions: raw.additions,
113
+ deletions: raw.deletions,
114
+ ...raw.previous_filename !== void 0 ? { previousPath: raw.previous_filename } : {}
115
+ });
116
+ const make = (client) => ({
117
+ get: Effect.fn("GitHubCommit.get")(function* (ref) {
118
+ const { owner, repo } = yield* Repo;
119
+ yield* Effect.annotateCurrentSpan({
120
+ owner,
121
+ repo,
122
+ ref
123
+ });
124
+ const commit = yield* client.request("GET /repos/{owner}/{repo}/commits/{ref}", {
125
+ owner,
126
+ repo,
127
+ ref
128
+ });
129
+ return summarize(commit);
130
+ }),
131
+ list: Effect.fn("GitHubCommit.list")(function* (options) {
132
+ const { owner, repo } = yield* Repo;
133
+ yield* Effect.annotateCurrentSpan({
134
+ owner,
135
+ repo,
136
+ ref: options?.ref ?? ""
137
+ });
138
+ return (yield* client.paginate("GET /repos/{owner}/{repo}/commits", {
139
+ owner,
140
+ repo,
141
+ ...options?.ref !== void 0 ? { sha: options.ref } : {},
142
+ ...options?.path !== void 0 ? { path: options.path } : {}
143
+ }, options?.page)).map(summarize);
144
+ }),
145
+ compare: Effect.fn("GitHubCommit.compare")(function* (base, head) {
146
+ const { owner, repo } = yield* Repo;
147
+ yield* Effect.annotateCurrentSpan({
148
+ owner,
149
+ repo,
150
+ base,
151
+ head
152
+ });
153
+ const comparison = yield* client.request("GET /repos/{owner}/{repo}/compare/{basehead}", {
154
+ owner,
155
+ repo,
156
+ basehead: `${base}...${head}`
157
+ });
158
+ return CommitComparison.make({
159
+ status: comparison.status,
160
+ aheadBy: comparison.ahead_by,
161
+ behindBy: comparison.behind_by,
162
+ commits: comparison.commits.map(summarize),
163
+ files: (comparison.files ?? []).map(fileOf)
164
+ });
165
+ }),
166
+ changedFiles: Effect.fn("GitHubCommit.changedFiles")(function* (ref, options) {
167
+ const { owner, repo } = yield* Repo;
168
+ yield* Effect.annotateCurrentSpan({
169
+ owner,
170
+ repo,
171
+ ref
172
+ });
173
+ const perPage = options?.page?.perPage ?? 100;
174
+ const source = () => {
175
+ let page = 0;
176
+ let finished = false;
177
+ return { next: Effect.suspend(() => {
178
+ if (finished) return Effect.succeed(Option.none());
179
+ page += 1;
180
+ return client.request("GET /repos/{owner}/{repo}/commits/{ref}", {
181
+ owner,
182
+ repo,
183
+ ref,
184
+ page,
185
+ per_page: perPage
186
+ }).pipe(Effect.map((commit) => {
187
+ const files = commit.files ?? [];
188
+ if (files.length < perPage) finished = true;
189
+ return files.length === 0 ? Option.none() : Option.some(files.map(fileOf));
190
+ }));
191
+ }) };
192
+ };
193
+ return yield* Stream.runCollect(paginate(source, options?.page?.maxPages));
194
+ })
195
+ });
196
+
197
+ //#endregion
198
+ export { CommitComparison, CommitFile, CommitSummary, FileStatus, GitHubCommit };
@@ -0,0 +1,54 @@
1
+ import { GitHubError } from "./GitHubError.js";
2
+ import { GitHubClient } from "./GitHubClient.js";
3
+ import { Repo } from "./Repo.js";
4
+ import { Context, Effect, Layer, Option } from "effect";
5
+
6
+ //#region src/GitHubContent.ts
7
+ /**
8
+ * Repository file contents.
9
+ *
10
+ * @public
11
+ */
12
+ var GitHubContent = class GitHubContent extends Context.Service()("@effected/github/GitHubContent") {
13
+ static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
14
+ /** An in-memory double; unstubbed members die naming themselves. */
15
+ static makeTest = (overrides = {}) => ({
16
+ getFile: overrides.getFile ?? (() => unstubbed("getFile")),
17
+ getFileOption: overrides.getFileOption ?? (() => unstubbed("getFileOption"))
18
+ });
19
+ /** {@link GitHubContent.makeTest} behind a `Layer`. */
20
+ static layerTest = (overrides = {}) => Layer.succeed(GitHubContent, GitHubContent.makeTest(overrides));
21
+ };
22
+ const unstubbed = (member) => {
23
+ throw new Error(`GitHubContent.makeTest: ${member}() was called but not stubbed — pass an override.`);
24
+ };
25
+ const make = (client) => {
26
+ const getFile = Effect.fn("GitHubContent.getFile")(function* (path, options) {
27
+ const { owner, repo } = yield* Repo;
28
+ yield* Effect.annotateCurrentSpan({
29
+ owner,
30
+ repo,
31
+ path,
32
+ ref: options?.ref ?? ""
33
+ });
34
+ const content = yield* client.request("GET /repos/{owner}/{repo}/contents/{path}", {
35
+ owner,
36
+ repo,
37
+ path,
38
+ ...options?.ref !== void 0 ? { ref: options.ref } : {}
39
+ });
40
+ if (Array.isArray(content)) return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} is a directory`));
41
+ if (content.type !== "file") return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} is a ${content.type}, not a file`));
42
+ if (content.encoding !== "base64") return yield* Effect.fail(GitHubError.rejected("GitHubContent.getFile", 422, `${path} came back with encoding ${JSON.stringify(content.encoding)} — it is probably too large for the contents API`));
43
+ return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8");
44
+ });
45
+ return {
46
+ getFile,
47
+ getFileOption: Effect.fn("GitHubContent.getFileOption")(function* (path, options) {
48
+ return yield* getFile(path, options).pipe(Effect.map(Option.some), Effect.catchIf(GitHubError.hasKind("notFound"), () => Effect.succeed(Option.none())));
49
+ })
50
+ };
51
+ };
52
+
53
+ //#endregion
54
+ export { GitHubContent };
package/GitHubError.js ADDED
@@ -0,0 +1,254 @@
1
+ import { headerNumber, retryAfterMillisFrom } from "./internal/headers.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/GitHubError.ts
5
+ /**
6
+ * Why a GitHub call failed, as a value you can branch on.
7
+ *
8
+ * @remarks
9
+ * This discriminant is what replaces string-matching an error message. The
10
+ * package this replaces carried a free-form `reason` string and nothing else
11
+ * structural, so consumers grepped it: one repo lowercased a GraphQL message and
12
+ * tested `includes("already") || includes("exists")`, and another re-issued an
13
+ * existence check after every failed branch creation because it could not tell
14
+ * "someone else created it" from "that failed".
15
+ *
16
+ * @public
17
+ */
18
+ const GitHubErrorKind = Schema.Literals([
19
+ "notFound",
20
+ "alreadyExists",
21
+ "rejected",
22
+ "unauthorized",
23
+ "rateLimited",
24
+ "transport",
25
+ "decode"
26
+ ]);
27
+ /**
28
+ * Every REST failure this package produces, from every resource.
29
+ *
30
+ * @remarks
31
+ * One error class, not one per resource. Across the six repos surveyed for this
32
+ * port, consumers read `reason` about forty times, `status` twice, `operation`
33
+ * twice, and matched a resource-specific `_tag` exactly **once** — at a call
34
+ * site that disappears entirely now that `GitBranch.upsert` exists. Eighteen
35
+ * near-identical error classes and thirteen near-identical mapper closures
36
+ * bought that one match.
37
+ *
38
+ * What replaces them is {@link GitHubErrorKind} for routing and `operation` for
39
+ * identification. This mirrors `@effected/git`, where the rule is that no
40
+ * consumer ever string-matches stderr because classification happens once.
41
+ *
42
+ * @public
43
+ */
44
+ var GitHubError = class GitHubError extends Schema.TaggedErrorClass()("GitHubError", {
45
+ /** Structural routing. Branch on this, never on the rendered message. */
46
+ kind: GitHubErrorKind,
47
+ /** What was attempted: a resource method (`"GitBranch.upsert"`) or a raw route. */
48
+ operation: Schema.String,
49
+ /** Human-readable cause, for logs and messages. Never a routing surface. */
50
+ reason: Schema.String,
51
+ /** GitHub's HTTP status, when the request reached GitHub at all. */
52
+ status: Schema.optionalKey(Schema.Int),
53
+ /**
54
+ * A server-advised delay before retrying, in milliseconds.
55
+ *
56
+ * @remarks
57
+ * Written by the client from `retry-after` or the rate-limit reset, and read
58
+ * by exactly one thing: the retry `Schedule`. It is a policy input, not
59
+ * information for a caller — which is why it is optional and why the
60
+ * `retryable` boolean it used to travel with is now a derived getter.
61
+ */
62
+ retryAfterMillis: Schema.optionalKey(Schema.Int),
63
+ /** The underlying throwable, when one exists. */
64
+ cause: Schema.optionalKey(Schema.Defect())
65
+ }) {
66
+ /** `"GitBranch.upsert failed (422): Reference already exists"`. */
67
+ get message() {
68
+ return this.status === void 0 ? `${this.operation} failed: ${this.reason}` : `${this.operation} failed (${this.status}): ${this.reason}`;
69
+ }
70
+ /**
71
+ * Whether retrying could plausibly succeed.
72
+ *
73
+ * @remarks
74
+ * Derived from `kind` rather than stored. A 404 or a
75
+ * validation rejection will fail identically on every attempt; only a
76
+ * transport failure or a rate limit can change its mind.
77
+ */
78
+ get retryable() {
79
+ return this.kind === "transport" || this.kind === "rateLimited";
80
+ }
81
+ /** The requested thing is not there. */
82
+ static notFound(operation, subject) {
83
+ return new GitHubError({
84
+ kind: "notFound",
85
+ operation,
86
+ reason: `${subject} not found`,
87
+ status: 404
88
+ });
89
+ }
90
+ /** The thing you asked to create is already there. */
91
+ static alreadyExists(operation, subject) {
92
+ return new GitHubError({
93
+ kind: "alreadyExists",
94
+ operation,
95
+ reason: `${subject} already exists`,
96
+ status: 422
97
+ });
98
+ }
99
+ /** GitHub understood the request and refused it. */
100
+ static rejected(operation, status, reason) {
101
+ return new GitHubError({
102
+ kind: "rejected",
103
+ operation,
104
+ reason,
105
+ status
106
+ });
107
+ }
108
+ /** A response did not match the schema it was decoded against. */
109
+ static decode(operation, reason, cause) {
110
+ return new GitHubError({
111
+ kind: "decode",
112
+ operation,
113
+ reason,
114
+ ...cause !== void 0 ? { cause } : {}
115
+ });
116
+ }
117
+ /**
118
+ * Classify anything octokit threw.
119
+ *
120
+ * @remarks
121
+ * The single classification step for the whole package — every resource
122
+ * method's failures come through here, so the taxonomy cannot drift between
123
+ * resources the way thirteen hand-written mapper closures did.
124
+ *
125
+ * `nowMillis` is passed in rather than read from the wall clock so the
126
+ * function stays pure and total: the rate-limit reset header is an absolute
127
+ * epoch second, and turning it into a delay needs a "now" the caller controls.
128
+ * The client supplies `Clock.currentTimeMillis`, which is the `TestClock`
129
+ * under test.
130
+ */
131
+ static fromOctokit(operation, error, nowMillis) {
132
+ const facts = readThrowable(error);
133
+ const retryAfterMillis = retryAfterMillisFrom(facts.headers, nowMillis);
134
+ const kind = classify(facts, retryAfterMillis);
135
+ return new GitHubError({
136
+ kind,
137
+ operation,
138
+ reason: facts.reason,
139
+ ...facts.status !== void 0 ? { status: facts.status } : {},
140
+ ...retryAfterMillis !== void 0 ? { retryAfterMillis } : {},
141
+ cause: error
142
+ });
143
+ }
144
+ /**
145
+ * A predicate over one or more kinds, for `Effect.catchIf`.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import { GitHubError } from "@effected/github";
150
+ * import { Effect } from "effect";
151
+ *
152
+ * declare const read: Effect.Effect<string, GitHubError>;
153
+ *
154
+ * const orDefault = read.pipe(
155
+ * Effect.catchIf(GitHubError.hasKind("notFound"), () => Effect.succeed("")),
156
+ * );
157
+ * ```
158
+ */
159
+ static hasKind(...kinds) {
160
+ const set = new Set(kinds);
161
+ return (error) => set.has(error.kind);
162
+ }
163
+ };
164
+ /**
165
+ * Read an unknown throwable structurally.
166
+ *
167
+ * @remarks
168
+ * Deliberately structural rather than `instanceof RequestError`:
169
+ * `@octokit/request-error` is a transitive dependency this package does not
170
+ * declare, and importing a package you did not declare is how a peer closure
171
+ * rots. The shape it throws — `{ status, response: { headers, data } }` — is
172
+ * stable and public.
173
+ */
174
+ const readThrowable = (error) => {
175
+ if (typeof error !== "object" || error === null) return {
176
+ status: void 0,
177
+ headers: void 0,
178
+ reason: String(error),
179
+ detailMessages: []
180
+ };
181
+ const record = error;
182
+ const response = asRecord(record.response);
183
+ const headers = asRecord(response?.headers);
184
+ const data = asRecord(response?.data);
185
+ return {
186
+ status: typeof record.status === "number" ? record.status : void 0,
187
+ headers,
188
+ reason: sanitizeReason(typeof record.message === "string" ? record.message : String(error)),
189
+ detailMessages: readDetailMessages(data)
190
+ };
191
+ };
192
+ const asRecord = (value) => typeof value === "object" && value !== null ? value : void 0;
193
+ /**
194
+ * GitHub answers some requests with an HTML error page whose body becomes a
195
+ * multi-kilobyte "message".
196
+ *
197
+ * @remarks
198
+ * The package this replaces detected the "Unicorn" page specifically; this is
199
+ * the general form — an HTML body is never a useful reason string, and letting
200
+ * one through means a log line with a whole web page in it.
201
+ */
202
+ const sanitizeReason = (message) => {
203
+ const trimmed = message.trim();
204
+ if (trimmed.startsWith("<") || trimmed.length > MAX_REASON_LENGTH) return trimmed.startsWith("<") ? "GitHub returned an HTML error page instead of a JSON response" : `${trimmed.slice(0, MAX_REASON_LENGTH)}…`;
205
+ return trimmed;
206
+ };
207
+ const MAX_REASON_LENGTH = 500;
208
+ /** GitHub's validation failures arrive as `data.errors[].message`. */
209
+ const readDetailMessages = (data) => {
210
+ const errors = data?.errors;
211
+ if (!Array.isArray(errors)) return [];
212
+ const messages = [];
213
+ for (const entry of errors) {
214
+ const message = asRecord(entry)?.message;
215
+ if (typeof message === "string") messages.push(message);
216
+ }
217
+ return messages;
218
+ };
219
+ const ALREADY_EXISTS = "already exists";
220
+ const classify = (facts, retryAfterMillis) => {
221
+ const { status } = facts;
222
+ if (status === void 0) return "transport";
223
+ if (status === 404) return "notFound";
224
+ if (status >= 500) return "transport";
225
+ if (status === 429) return "rateLimited";
226
+ if (status === 403) return retryAfterMillis !== void 0 ? "rateLimited" : "unauthorized";
227
+ if (status === 401) return "unauthorized";
228
+ if (status === 422 || status === 409) return saysAlreadyExists(facts) ? "alreadyExists" : "rejected";
229
+ return "rejected";
230
+ };
231
+ const saysAlreadyExists = (facts) => {
232
+ if (facts.reason.toLowerCase().includes(ALREADY_EXISTS)) return true;
233
+ return facts.detailMessages.some((message) => message.toLowerCase().includes(ALREADY_EXISTS));
234
+ };
235
+ /**
236
+ * Re-exported for the client, which reads the same headers on the **success**
237
+ * path to keep its rate-limit snapshot current.
238
+ *
239
+ * @internal
240
+ */
241
+ const readRateLimitHeaders = (headers) => {
242
+ const remaining = headerNumber(headers, "x-ratelimit-remaining");
243
+ const limit = headerNumber(headers, "x-ratelimit-limit");
244
+ const reset = headerNumber(headers, "x-ratelimit-reset");
245
+ if (remaining === void 0 || limit === void 0 || reset === void 0) return void 0;
246
+ return {
247
+ remaining,
248
+ limit,
249
+ resetEpochSeconds: reset
250
+ };
251
+ };
252
+
253
+ //#endregion
254
+ export { GitHubError, GitHubErrorKind, readRateLimitHeaders };