@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/GitHubClient.js
ADDED
|
@@ -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 };
|
package/GitHubCommit.js
ADDED
|
@@ -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 };
|
package/GitHubContent.js
ADDED
|
@@ -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 };
|