@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/GraphQL.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { retryAfterMillisFrom } from "./internal/headers.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/GraphQL.ts
|
|
5
|
+
/**
|
|
6
|
+
* One entry from a GraphQL response's `errors` array.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
var GraphQLErrorEntry = class extends Schema.Class("GraphQLErrorEntry")({
|
|
11
|
+
/** GitHub's prose. */
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
/** GitHub's own classification, e.g. `"NOT_FOUND"` or `"FORBIDDEN"`. */
|
|
14
|
+
type: Schema.optionalKey(Schema.String)
|
|
15
|
+
}) {};
|
|
16
|
+
/**
|
|
17
|
+
* A GraphQL call failed.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Separate from `GitHubError` because GraphQL genuinely answers differently:
|
|
21
|
+
* a 200 response can still carry failures, and it carries a **list** of them.
|
|
22
|
+
* `errors` is the one structured field a surveyed consumer actually read.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
var GitHubGraphQLError = class GitHubGraphQLError extends Schema.TaggedErrorClass()("GitHubGraphQLError", {
|
|
27
|
+
/**
|
|
28
|
+
* Structural routing, mirroring `GitHubError`'s.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* `"alreadyExists"` exists here for the same reason it exists on the REST
|
|
32
|
+
* error: without it a consumer lowercases the message and greps it for
|
|
33
|
+
* `"already"` and `"exists"`, which is exactly what one surveyed repo did to
|
|
34
|
+
* make project creation idempotent.
|
|
35
|
+
*/
|
|
36
|
+
kind: Schema.Literals([
|
|
37
|
+
"alreadyExists",
|
|
38
|
+
"notFound",
|
|
39
|
+
"rejected",
|
|
40
|
+
"unauthorized",
|
|
41
|
+
"rateLimited",
|
|
42
|
+
"transport",
|
|
43
|
+
"decode"
|
|
44
|
+
]),
|
|
45
|
+
/** The document's name, e.g. `"linkedIssues"` — never the literal `"graphql"`. */
|
|
46
|
+
operation: Schema.String,
|
|
47
|
+
/** Human-readable cause, for logs. */
|
|
48
|
+
reason: Schema.String,
|
|
49
|
+
/** Everything GitHub reported, in order. */
|
|
50
|
+
errors: Schema.Array(GraphQLErrorEntry),
|
|
51
|
+
/** A server-advised delay in milliseconds, read only by the retry schedule. */
|
|
52
|
+
retryAfterMillis: Schema.optionalKey(Schema.Int),
|
|
53
|
+
/** The underlying throwable, when one exists. */
|
|
54
|
+
cause: Schema.optionalKey(Schema.Defect())
|
|
55
|
+
}) {
|
|
56
|
+
get message() {
|
|
57
|
+
return `${this.operation} failed: ${this.reason}`;
|
|
58
|
+
}
|
|
59
|
+
/** Whether retrying could plausibly succeed. Derived, like the REST error's. */
|
|
60
|
+
get retryable() {
|
|
61
|
+
return this.kind === "transport" || this.kind === "rateLimited";
|
|
62
|
+
}
|
|
63
|
+
/** A response arrived but did not match the document's declared schema. */
|
|
64
|
+
static decode(operation, reason, cause) {
|
|
65
|
+
return new GitHubGraphQLError({
|
|
66
|
+
kind: "decode",
|
|
67
|
+
operation,
|
|
68
|
+
reason,
|
|
69
|
+
errors: [],
|
|
70
|
+
...cause !== void 0 ? { cause } : {}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Classify anything the GraphQL transport threw.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* octokit surfaces two different failures here: a `GraphqlResponseError`,
|
|
78
|
+
* which is an HTTP 200 whose body carries `errors`, and an ordinary HTTP
|
|
79
|
+
* failure with a `status`. Both arrive as throwables and both are read
|
|
80
|
+
* structurally, for the same reason the REST classifier does it — the error
|
|
81
|
+
* classes live in packages this one does not declare.
|
|
82
|
+
*/
|
|
83
|
+
static fromThrowable(operation, error, nowMillis) {
|
|
84
|
+
const record = typeof error === "object" && error !== null ? error : void 0;
|
|
85
|
+
const status = typeof record?.status === "number" ? record.status : void 0;
|
|
86
|
+
const headers = asRecord(record?.headers) ?? asRecord(asRecord(record?.response)?.headers);
|
|
87
|
+
const entries = readEntries(record?.errors);
|
|
88
|
+
const reason = entries.length > 0 ? entries.map((entry) => entry.message).join("; ") : typeof record?.message === "string" ? record.message : String(error);
|
|
89
|
+
const retryAfterMillis = retryAfterMillisFrom(headers, nowMillis);
|
|
90
|
+
return new GitHubGraphQLError({
|
|
91
|
+
kind: classify(status, entries, reason, retryAfterMillis),
|
|
92
|
+
operation,
|
|
93
|
+
reason,
|
|
94
|
+
errors: entries,
|
|
95
|
+
...retryAfterMillis !== void 0 ? { retryAfterMillis } : {},
|
|
96
|
+
cause: error
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const asRecord = (value) => typeof value === "object" && value !== null ? value : void 0;
|
|
101
|
+
const readEntries = (value) => {
|
|
102
|
+
if (!Array.isArray(value)) return [];
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const raw of value) {
|
|
105
|
+
const record = asRecord(raw);
|
|
106
|
+
if (record === void 0) continue;
|
|
107
|
+
const message = typeof record.message === "string" ? record.message : String(raw);
|
|
108
|
+
const type = typeof record.type === "string" ? record.type : void 0;
|
|
109
|
+
entries.push(GraphQLErrorEntry.make({
|
|
110
|
+
message,
|
|
111
|
+
...type !== void 0 ? { type } : {}
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
};
|
|
116
|
+
const classify = (status, entries, reason, retryAfterMillis) => {
|
|
117
|
+
if (reason.toLowerCase().includes("already exists") || entries.some((entry) => entry.message.toLowerCase().includes("already exists"))) return "alreadyExists";
|
|
118
|
+
if (entries.some((entry) => entry.type === "NOT_FOUND")) return "notFound";
|
|
119
|
+
if (entries.some((entry) => entry.type === "FORBIDDEN" || entry.type === "UNAUTHORIZED")) return "unauthorized";
|
|
120
|
+
if (entries.some((entry) => entry.type === "RATE_LIMITED")) return "rateLimited";
|
|
121
|
+
if (status === void 0) return entries.length > 0 ? "rejected" : "transport";
|
|
122
|
+
if (status === 404) return "notFound";
|
|
123
|
+
if (status === 401) return "unauthorized";
|
|
124
|
+
if (status === 429) return "rateLimited";
|
|
125
|
+
if (status === 403) return retryAfterMillis !== void 0 ? "rateLimited" : "unauthorized";
|
|
126
|
+
if (status >= 500) return "transport";
|
|
127
|
+
return "rejected";
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* A named GraphQL document, its variables, and how to read its answer.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* This is the mechanism that makes `client.graphql` return a **domain value**
|
|
134
|
+
* rather than an `unknown` the caller casts. The package this replaces took a
|
|
135
|
+
* query string and a caller-chosen type parameter with nothing connecting them,
|
|
136
|
+
* so every consumer wrote its own response interface and hoped.
|
|
137
|
+
*
|
|
138
|
+
* The kit owns the documents its resources need; a consumer with a domain of
|
|
139
|
+
* its own — silk-sync-action's ProjectV2 work, for instance — builds its own
|
|
140
|
+
* `GraphQLDocument` and gets the same typing and the same error taxonomy
|
|
141
|
+
* without this package having to know about its schema.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* import { GraphQLDocument } from "@effected/github";
|
|
146
|
+
* import { Schema } from "effect";
|
|
147
|
+
*
|
|
148
|
+
* const ViewerLogin = GraphQLDocument.make({
|
|
149
|
+
* name: "viewerLogin",
|
|
150
|
+
* document: `query { viewer { login } }`,
|
|
151
|
+
* response: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) }),
|
|
152
|
+
* })<{ readonly login: string }>();
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @public
|
|
156
|
+
*/
|
|
157
|
+
var GraphQLDocument = class GraphQLDocument {
|
|
158
|
+
name;
|
|
159
|
+
document;
|
|
160
|
+
decode;
|
|
161
|
+
encodeVariables;
|
|
162
|
+
constructor(name, document, decode, encodeVariables) {
|
|
163
|
+
this.name = name;
|
|
164
|
+
this.document = document;
|
|
165
|
+
this.decode = decode;
|
|
166
|
+
this.encodeVariables = encodeVariables;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build a document from a response schema.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* Curried, because `A` is inferred from `response` while `V` is stated:
|
|
173
|
+
* TypeScript takes explicit type arguments all-or-nothing, so a single call
|
|
174
|
+
* would force the caller to spell out the decoded type as well.
|
|
175
|
+
*/
|
|
176
|
+
static make(options) {
|
|
177
|
+
const decode = Schema.decodeUnknownEffect(options.response);
|
|
178
|
+
return (encodeVariables) => new GraphQLDocument(options.name, options.document, (raw) => decode(raw), encodeVariables ?? ((variables) => variables));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
export { GitHubGraphQLError, GraphQLDocument, GraphQLErrorEntry };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PullRequest.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { GraphQLDocument } from "./GraphQL.js";
|
|
3
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
4
|
+
import { Repo } from "./Repo.js";
|
|
5
|
+
import { PageOptions } from "./Rest.js";
|
|
6
|
+
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
//#region src/PullRequest.ts
|
|
9
|
+
/** How a pull request is merged. @public */
|
|
10
|
+
const MergeMethod = Schema.Literals([
|
|
11
|
+
"merge",
|
|
12
|
+
"squash",
|
|
13
|
+
"rebase"
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* A pull request, projected to what callers read.
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
var PullRequestInfo = class extends Schema.Class("PullRequestInfo")({
|
|
21
|
+
/** The number in `#123`. */
|
|
22
|
+
number: Schema.Int,
|
|
23
|
+
/** The GraphQL node id, which the auto-merge mutations need. */
|
|
24
|
+
nodeId: Schema.String,
|
|
25
|
+
/** The web URL. */
|
|
26
|
+
url: Schema.String,
|
|
27
|
+
title: Schema.String,
|
|
28
|
+
state: Schema.Literals(["open", "closed"]),
|
|
29
|
+
/** The source branch name. */
|
|
30
|
+
head: Schema.String,
|
|
31
|
+
/** The target branch name. */
|
|
32
|
+
base: Schema.String,
|
|
33
|
+
draft: Schema.Boolean,
|
|
34
|
+
merged: Schema.Boolean,
|
|
35
|
+
/**
|
|
36
|
+
* When it merged, if it did.
|
|
37
|
+
*
|
|
38
|
+
* @remarks
|
|
39
|
+
* An `Option`, not an optional field. Whether a pull request has merged is a
|
|
40
|
+
* fact GitHub always reports, so modelling it as "maybe absent" would be
|
|
41
|
+
* modelling a gap in our fixtures rather than a gap in the domain.
|
|
42
|
+
*/
|
|
43
|
+
mergedAt: Schema.Option(Schema.DateTimeUtcFromString),
|
|
44
|
+
/** The description, when GitHub sent one. */
|
|
45
|
+
body: Schema.optionalKey(Schema.String),
|
|
46
|
+
/** The merge commit, once there is one. */
|
|
47
|
+
mergeCommitSha: Schema.optionalKey(Schema.String)
|
|
48
|
+
}) {};
|
|
49
|
+
const AutoMergeResponse = Schema.Struct({});
|
|
50
|
+
const EnableAutoMerge = GraphQLDocument.make({
|
|
51
|
+
name: "enablePullRequestAutoMerge",
|
|
52
|
+
document: `mutation ($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) {
|
|
53
|
+
enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod }) {
|
|
54
|
+
clientMutationId
|
|
55
|
+
}
|
|
56
|
+
}`,
|
|
57
|
+
response: AutoMergeResponse
|
|
58
|
+
})();
|
|
59
|
+
const DisableAutoMerge = GraphQLDocument.make({
|
|
60
|
+
name: "disablePullRequestAutoMerge",
|
|
61
|
+
document: `mutation ($pullRequestId: ID!) {
|
|
62
|
+
disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
|
|
63
|
+
}`,
|
|
64
|
+
response: AutoMergeResponse
|
|
65
|
+
})();
|
|
66
|
+
/** GraphQL spells the merge methods in capitals. */
|
|
67
|
+
const GRAPHQL_MERGE_METHOD = {
|
|
68
|
+
merge: "MERGE",
|
|
69
|
+
squash: "SQUASH",
|
|
70
|
+
rebase: "REBASE"
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Pull requests.
|
|
74
|
+
*
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
var PullRequest = class PullRequest extends Context.Service()("@effected/github/PullRequest") {
|
|
78
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
79
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
80
|
+
static makeTest = (overrides = {}) => ({
|
|
81
|
+
get: overrides.get ?? (() => unstubbed("get")),
|
|
82
|
+
list: overrides.list ?? (() => unstubbed("list")),
|
|
83
|
+
listFiles: overrides.listFiles ?? (() => unstubbed("listFiles")),
|
|
84
|
+
listAssociatedWithCommit: overrides.listAssociatedWithCommit ?? (() => unstubbed("listAssociatedWithCommit")),
|
|
85
|
+
create: overrides.create ?? (() => unstubbed("create")),
|
|
86
|
+
update: overrides.update ?? (() => unstubbed("update")),
|
|
87
|
+
upsert: overrides.upsert ?? (() => unstubbed("upsert")),
|
|
88
|
+
merge: overrides.merge ?? (() => unstubbed("merge")),
|
|
89
|
+
addLabels: overrides.addLabels ?? (() => unstubbed("addLabels")),
|
|
90
|
+
requestReviewers: overrides.requestReviewers ?? (() => unstubbed("requestReviewers")),
|
|
91
|
+
setAutoMerge: overrides.setAutoMerge ?? (() => unstubbed("setAutoMerge"))
|
|
92
|
+
});
|
|
93
|
+
/** {@link PullRequest.makeTest} behind a `Layer`. */
|
|
94
|
+
static layerTest = (overrides = {}) => Layer.succeed(PullRequest, PullRequest.makeTest(overrides));
|
|
95
|
+
};
|
|
96
|
+
const unstubbed = (member) => {
|
|
97
|
+
throw new Error(`PullRequest.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
98
|
+
};
|
|
99
|
+
const project = (raw) => Effect.try({
|
|
100
|
+
try: () => PullRequestInfo.make({
|
|
101
|
+
number: raw.number,
|
|
102
|
+
nodeId: raw.node_id,
|
|
103
|
+
url: raw.html_url,
|
|
104
|
+
title: raw.title,
|
|
105
|
+
state: raw.state === "closed" ? "closed" : "open",
|
|
106
|
+
head: raw.head.ref,
|
|
107
|
+
base: raw.base.ref,
|
|
108
|
+
draft: raw.draft ?? false,
|
|
109
|
+
merged: raw.merged ?? raw.merged_at != null,
|
|
110
|
+
mergedAt: raw.merged_at == null ? Option.none() : Option.some(DateTime.makeUnsafe(raw.merged_at)),
|
|
111
|
+
...raw.body != null ? { body: raw.body } : {},
|
|
112
|
+
...raw.merge_commit_sha != null ? { mergeCommitSha: raw.merge_commit_sha } : {}
|
|
113
|
+
}),
|
|
114
|
+
catch: (error) => GitHubError.decode("PullRequest", "GitHub returned an unexpected pull request", error)
|
|
115
|
+
});
|
|
116
|
+
/** `owner:branch` is what GitHub's `head` filter wants for a cross-fork search. */
|
|
117
|
+
const qualifyHead = (owner, head) => head.includes(":") ? head : `${owner}:${head}`;
|
|
118
|
+
const make = (client) => {
|
|
119
|
+
const list = Effect.fn("PullRequest.list")(function* (options) {
|
|
120
|
+
const { owner, repo } = yield* Repo;
|
|
121
|
+
const raw = yield* client.paginate("GET /repos/{owner}/{repo}/pulls", {
|
|
122
|
+
owner,
|
|
123
|
+
repo,
|
|
124
|
+
...options?.state !== void 0 ? { state: options.state } : {},
|
|
125
|
+
...options?.head !== void 0 ? { head: qualifyHead(owner, options.head) } : {},
|
|
126
|
+
...options?.base !== void 0 ? { base: options.base } : {}
|
|
127
|
+
}, options?.page);
|
|
128
|
+
return yield* Effect.forEach(raw, project);
|
|
129
|
+
});
|
|
130
|
+
const create = Effect.fn("PullRequest.create")(function* (input) {
|
|
131
|
+
const { owner, repo } = yield* Repo;
|
|
132
|
+
yield* Effect.annotateCurrentSpan({
|
|
133
|
+
owner,
|
|
134
|
+
repo,
|
|
135
|
+
head: input.head,
|
|
136
|
+
base: input.base
|
|
137
|
+
});
|
|
138
|
+
const created = yield* client.request("POST /repos/{owner}/{repo}/pulls", {
|
|
139
|
+
owner,
|
|
140
|
+
repo,
|
|
141
|
+
title: input.title,
|
|
142
|
+
head: input.head,
|
|
143
|
+
base: input.base,
|
|
144
|
+
...input.body !== void 0 ? { body: input.body } : {},
|
|
145
|
+
...input.draft !== void 0 ? { draft: input.draft } : {}
|
|
146
|
+
});
|
|
147
|
+
return yield* project(created);
|
|
148
|
+
});
|
|
149
|
+
const update = Effect.fn("PullRequest.update")(function* (number, patch) {
|
|
150
|
+
const { owner, repo } = yield* Repo;
|
|
151
|
+
yield* Effect.annotateCurrentSpan({
|
|
152
|
+
owner,
|
|
153
|
+
repo,
|
|
154
|
+
number
|
|
155
|
+
});
|
|
156
|
+
const updated = yield* client.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", {
|
|
157
|
+
owner,
|
|
158
|
+
repo,
|
|
159
|
+
pull_number: number,
|
|
160
|
+
...patch.title !== void 0 ? { title: patch.title } : {},
|
|
161
|
+
...patch.body !== void 0 ? { body: patch.body } : {},
|
|
162
|
+
...patch.state !== void 0 ? { state: patch.state } : {},
|
|
163
|
+
...patch.base !== void 0 ? { base: patch.base } : {}
|
|
164
|
+
});
|
|
165
|
+
return yield* project(updated);
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
list,
|
|
169
|
+
create,
|
|
170
|
+
update,
|
|
171
|
+
get: Effect.fn("PullRequest.get")(function* (number) {
|
|
172
|
+
const { owner, repo } = yield* Repo;
|
|
173
|
+
yield* Effect.annotateCurrentSpan({
|
|
174
|
+
owner,
|
|
175
|
+
repo,
|
|
176
|
+
number
|
|
177
|
+
});
|
|
178
|
+
const raw = yield* client.request("GET /repos/{owner}/{repo}/pulls/{pull_number}", {
|
|
179
|
+
owner,
|
|
180
|
+
repo,
|
|
181
|
+
pull_number: number
|
|
182
|
+
});
|
|
183
|
+
return yield* project(raw);
|
|
184
|
+
}),
|
|
185
|
+
listFiles: Effect.fn("PullRequest.listFiles")(function* (number, options) {
|
|
186
|
+
const { owner, repo } = yield* Repo;
|
|
187
|
+
yield* Effect.annotateCurrentSpan({
|
|
188
|
+
owner,
|
|
189
|
+
repo,
|
|
190
|
+
number
|
|
191
|
+
});
|
|
192
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/pulls/{pull_number}/files", {
|
|
193
|
+
owner,
|
|
194
|
+
repo,
|
|
195
|
+
pull_number: number
|
|
196
|
+
}, options?.page)).map((file) => file.filename);
|
|
197
|
+
}),
|
|
198
|
+
listAssociatedWithCommit: Effect.fn("PullRequest.listAssociatedWithCommit")(function* (sha, options) {
|
|
199
|
+
const { owner, repo } = yield* Repo;
|
|
200
|
+
yield* Effect.annotateCurrentSpan({
|
|
201
|
+
owner,
|
|
202
|
+
repo,
|
|
203
|
+
sha
|
|
204
|
+
});
|
|
205
|
+
const raw = yield* client.paginate("GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls", {
|
|
206
|
+
owner,
|
|
207
|
+
repo,
|
|
208
|
+
commit_sha: sha
|
|
209
|
+
}, options?.page);
|
|
210
|
+
return yield* Effect.forEach(raw, project);
|
|
211
|
+
}),
|
|
212
|
+
upsert: Effect.fn("PullRequest.upsert")(function* (input) {
|
|
213
|
+
const found = (yield* list({
|
|
214
|
+
head: input.head,
|
|
215
|
+
base: input.base,
|
|
216
|
+
state: "open",
|
|
217
|
+
page: PageOptions.make({
|
|
218
|
+
perPage: 1,
|
|
219
|
+
maxPages: 1
|
|
220
|
+
})
|
|
221
|
+
}))[0];
|
|
222
|
+
if (found === void 0) return {
|
|
223
|
+
pullRequest: yield* create(input),
|
|
224
|
+
created: true
|
|
225
|
+
};
|
|
226
|
+
const patch = {
|
|
227
|
+
title: input.title,
|
|
228
|
+
...input.body !== void 0 ? { body: input.body } : {}
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
pullRequest: yield* update(found.number, patch),
|
|
232
|
+
created: false
|
|
233
|
+
};
|
|
234
|
+
}),
|
|
235
|
+
merge: Effect.fn("PullRequest.merge")(function* (number, options) {
|
|
236
|
+
const { owner, repo } = yield* Repo;
|
|
237
|
+
yield* Effect.annotateCurrentSpan({
|
|
238
|
+
owner,
|
|
239
|
+
repo,
|
|
240
|
+
number,
|
|
241
|
+
method: options?.method ?? "merge"
|
|
242
|
+
});
|
|
243
|
+
return (yield* client.request("PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge", {
|
|
244
|
+
owner,
|
|
245
|
+
repo,
|
|
246
|
+
pull_number: number,
|
|
247
|
+
...options?.method !== void 0 ? { merge_method: options.method } : {},
|
|
248
|
+
...options?.commitTitle !== void 0 ? { commit_title: options.commitTitle } : {},
|
|
249
|
+
...options?.commitMessage !== void 0 ? { commit_message: options.commitMessage } : {}
|
|
250
|
+
})).sha;
|
|
251
|
+
}),
|
|
252
|
+
addLabels: Effect.fn("PullRequest.addLabels")(function* (number, labels) {
|
|
253
|
+
const { owner, repo } = yield* Repo;
|
|
254
|
+
yield* Effect.annotateCurrentSpan({
|
|
255
|
+
owner,
|
|
256
|
+
repo,
|
|
257
|
+
number,
|
|
258
|
+
labels: labels.length
|
|
259
|
+
});
|
|
260
|
+
yield* client.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", {
|
|
261
|
+
owner,
|
|
262
|
+
repo,
|
|
263
|
+
issue_number: number,
|
|
264
|
+
labels: [...labels]
|
|
265
|
+
});
|
|
266
|
+
}),
|
|
267
|
+
requestReviewers: Effect.fn("PullRequest.requestReviewers")(function* (number, reviewers) {
|
|
268
|
+
const { owner, repo } = yield* Repo;
|
|
269
|
+
yield* Effect.annotateCurrentSpan({
|
|
270
|
+
owner,
|
|
271
|
+
repo,
|
|
272
|
+
number
|
|
273
|
+
});
|
|
274
|
+
yield* client.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", {
|
|
275
|
+
owner,
|
|
276
|
+
repo,
|
|
277
|
+
pull_number: number,
|
|
278
|
+
...reviewers.users !== void 0 ? { reviewers: [...reviewers.users] } : {},
|
|
279
|
+
...reviewers.teams !== void 0 ? { team_reviewers: [...reviewers.teams] } : {}
|
|
280
|
+
});
|
|
281
|
+
}),
|
|
282
|
+
setAutoMerge: Effect.fn("PullRequest.setAutoMerge")(function* (pullRequest, method) {
|
|
283
|
+
yield* Effect.annotateCurrentSpan({
|
|
284
|
+
number: pullRequest.number,
|
|
285
|
+
method
|
|
286
|
+
});
|
|
287
|
+
if (method === "off") {
|
|
288
|
+
yield* client.graphql(DisableAutoMerge, { pullRequestId: pullRequest.nodeId });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
yield* client.graphql(EnableAutoMerge, {
|
|
292
|
+
pullRequestId: pullRequest.nodeId,
|
|
293
|
+
mergeMethod: GRAPHQL_MERGE_METHOD[method]
|
|
294
|
+
});
|
|
295
|
+
})
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
//#endregion
|
|
300
|
+
export { MergeMethod, PullRequest, PullRequestInfo };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/PullRequestComment.ts
|
|
6
|
+
/**
|
|
7
|
+
* The hidden marker that makes a comment findable again.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* A pure class, not a hardcoded string. The surface this replaces baked
|
|
11
|
+
* `<!-- savvy-web:${key} -->` into the library — one vendor's name, inside a
|
|
12
|
+
* package meant to be general. Here the namespace is the caller's, the marker is
|
|
13
|
+
* testable without a client, and the library has no opinion about whose comments
|
|
14
|
+
* these are.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
var CommentMarker = class extends Schema.Class("CommentMarker")({
|
|
19
|
+
/** Whose comments these are, e.g. your action's name. */
|
|
20
|
+
namespace: Schema.NonEmptyString,
|
|
21
|
+
/** Which comment, within that namespace. */
|
|
22
|
+
key: Schema.NonEmptyString
|
|
23
|
+
}) {
|
|
24
|
+
/** The HTML comment appended to a body so the comment can be found again. */
|
|
25
|
+
get html() {
|
|
26
|
+
return `<!-- ${this.namespace}:${this.key} -->`;
|
|
27
|
+
}
|
|
28
|
+
/** Does this body carry the marker? */
|
|
29
|
+
matches(body) {
|
|
30
|
+
return body.includes(this.html);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A comment this package wrote or found.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
var CommentRecord = class extends Schema.Class("CommentRecord")({
|
|
39
|
+
id: Schema.Int,
|
|
40
|
+
body: Schema.String,
|
|
41
|
+
url: Schema.String
|
|
42
|
+
}) {};
|
|
43
|
+
/**
|
|
44
|
+
* Sticky comments.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
var PullRequestComment = class PullRequestComment extends Context.Service()("@effected/github/PullRequestComment") {
|
|
49
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
50
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
51
|
+
static makeTest = (overrides = {}) => ({
|
|
52
|
+
create: overrides.create ?? (() => unstubbed("create")),
|
|
53
|
+
upsert: overrides.upsert ?? (() => unstubbed("upsert")),
|
|
54
|
+
find: overrides.find ?? (() => unstubbed("find")),
|
|
55
|
+
delete: overrides.delete ?? (() => unstubbed("delete"))
|
|
56
|
+
});
|
|
57
|
+
/** {@link PullRequestComment.makeTest} behind a `Layer`. */
|
|
58
|
+
static layerTest = (overrides = {}) => Layer.succeed(PullRequestComment, PullRequestComment.makeTest(overrides));
|
|
59
|
+
};
|
|
60
|
+
const unstubbed = (member) => {
|
|
61
|
+
throw new Error(`PullRequestComment.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
62
|
+
};
|
|
63
|
+
const recordOf = (raw) => CommentRecord.make({
|
|
64
|
+
id: raw.id,
|
|
65
|
+
body: raw.body ?? "",
|
|
66
|
+
url: raw.html_url
|
|
67
|
+
});
|
|
68
|
+
const make = (client) => {
|
|
69
|
+
const create = Effect.fn("PullRequestComment.create")(function* (issueNumber, body) {
|
|
70
|
+
const { owner, repo } = yield* Repo;
|
|
71
|
+
yield* Effect.annotateCurrentSpan({
|
|
72
|
+
owner,
|
|
73
|
+
repo,
|
|
74
|
+
issueNumber
|
|
75
|
+
});
|
|
76
|
+
const created = yield* client.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
|
|
77
|
+
owner,
|
|
78
|
+
repo,
|
|
79
|
+
issue_number: issueNumber,
|
|
80
|
+
body
|
|
81
|
+
});
|
|
82
|
+
return recordOf(created);
|
|
83
|
+
});
|
|
84
|
+
const find = Effect.fn("PullRequestComment.find")(function* (issueNumber, marker, options) {
|
|
85
|
+
const { owner, repo } = yield* Repo;
|
|
86
|
+
yield* Effect.annotateCurrentSpan({
|
|
87
|
+
owner,
|
|
88
|
+
repo,
|
|
89
|
+
issueNumber,
|
|
90
|
+
marker: marker.key
|
|
91
|
+
});
|
|
92
|
+
const found = (yield* client.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", {
|
|
93
|
+
owner,
|
|
94
|
+
repo,
|
|
95
|
+
issue_number: issueNumber
|
|
96
|
+
}, options?.page)).find((comment) => marker.matches(comment.body ?? ""));
|
|
97
|
+
return found === void 0 ? Option.none() : Option.some(recordOf(found));
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
create,
|
|
101
|
+
find,
|
|
102
|
+
upsert: Effect.fn("PullRequestComment.upsert")(function* (issueNumber, marker, body) {
|
|
103
|
+
const { owner, repo } = yield* Repo;
|
|
104
|
+
const marked = `${body}\n\n${marker.html}`;
|
|
105
|
+
const existing = yield* find(issueNumber, marker);
|
|
106
|
+
if (Option.isNone(existing)) return yield* create(issueNumber, marked);
|
|
107
|
+
const updated = yield* client.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", {
|
|
108
|
+
owner,
|
|
109
|
+
repo,
|
|
110
|
+
comment_id: existing.value.id,
|
|
111
|
+
body: marked
|
|
112
|
+
});
|
|
113
|
+
return recordOf(updated);
|
|
114
|
+
}),
|
|
115
|
+
delete: Effect.fn("PullRequestComment.delete")(function* (commentId) {
|
|
116
|
+
const { owner, repo } = yield* Repo;
|
|
117
|
+
yield* Effect.annotateCurrentSpan({
|
|
118
|
+
owner,
|
|
119
|
+
repo,
|
|
120
|
+
commentId
|
|
121
|
+
});
|
|
122
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}", {
|
|
123
|
+
owner,
|
|
124
|
+
repo,
|
|
125
|
+
comment_id: commentId
|
|
126
|
+
});
|
|
127
|
+
})
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
export { CommentMarker, CommentRecord, PullRequestComment };
|