@effected/github 0.3.0 → 0.4.1
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/CodeScanning.js +80 -0
- package/DeploymentEnvironment.js +75 -0
- package/GitHubClient.js +40 -7
- package/GitHubRepository.js +174 -5
- package/README.md +76 -2
- package/RepositorySecret.js +186 -0
- package/RepositorySecurity.js +141 -0
- package/RepositoryVariable.js +174 -0
- package/Ruleset.js +143 -0
- package/WorkflowDispatch.js +17 -0
- package/index.d.ts +693 -11
- package/index.js +8 -2
- package/internal/crypto.js +63 -0
- package/package.json +3 -1
package/CodeScanning.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/CodeScanning.ts
|
|
6
|
+
/**
|
|
7
|
+
* CodeQL default setup.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var CodeScanning = class CodeScanning extends Context.Service()("@effected/github/CodeScanning") {
|
|
12
|
+
/**
|
|
13
|
+
* @remarks
|
|
14
|
+
* The callback is written `(client) => make(client)` rather than passed as
|
|
15
|
+
* `make` directly, and that is load-bearing: a static initializer runs while
|
|
16
|
+
* the module body is still evaluating, so naming a `const` declared further
|
|
17
|
+
* down throws `Cannot access 'make' before initialization` **at import time**,
|
|
18
|
+
* with a clean typecheck.
|
|
19
|
+
*/
|
|
20
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
21
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
22
|
+
static makeTest = (overrides = {}) => ({
|
|
23
|
+
configure: overrides.configure ?? (() => unstubbed("configure")),
|
|
24
|
+
languages: overrides.languages ?? (() => unstubbed("languages"))
|
|
25
|
+
});
|
|
26
|
+
/** {@link CodeScanning.makeTest} behind a `Layer`. */
|
|
27
|
+
static layerTest = (overrides = {}) => Layer.succeed(CodeScanning, CodeScanning.makeTest(overrides));
|
|
28
|
+
};
|
|
29
|
+
const unstubbed = (member) => {
|
|
30
|
+
throw new Error(`CodeScanning.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
31
|
+
};
|
|
32
|
+
const SETUP_KEYS = [
|
|
33
|
+
"state",
|
|
34
|
+
"languages",
|
|
35
|
+
"query_suite",
|
|
36
|
+
"threat_model",
|
|
37
|
+
"runner_type",
|
|
38
|
+
"runner_label"
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Every method resolves {@link Repo} per call rather than once at layer
|
|
42
|
+
* construction, for the reason `GitBranch` states: capturing the coordinate
|
|
43
|
+
* would make a scoped `Repo.provide` silently do nothing.
|
|
44
|
+
*/
|
|
45
|
+
const make = (client) => {
|
|
46
|
+
return {
|
|
47
|
+
configure: Effect.fn("CodeScanning.configure")(function* (setup) {
|
|
48
|
+
const { owner, repo } = yield* Repo;
|
|
49
|
+
yield* Effect.annotateCurrentSpan({
|
|
50
|
+
owner,
|
|
51
|
+
repo
|
|
52
|
+
});
|
|
53
|
+
const body = {};
|
|
54
|
+
for (const key of SETUP_KEYS) {
|
|
55
|
+
const value = setup[key];
|
|
56
|
+
if (value !== void 0) body[key] = key === "languages" ? [...value] : value;
|
|
57
|
+
}
|
|
58
|
+
yield* client.request("PATCH /repos/{owner}/{repo}/code-scanning/default-setup", {
|
|
59
|
+
owner,
|
|
60
|
+
repo,
|
|
61
|
+
...body
|
|
62
|
+
});
|
|
63
|
+
}),
|
|
64
|
+
languages: Effect.fn("CodeScanning.languages")(function* () {
|
|
65
|
+
const { owner, repo } = yield* Repo;
|
|
66
|
+
yield* Effect.annotateCurrentSpan({
|
|
67
|
+
owner,
|
|
68
|
+
repo
|
|
69
|
+
});
|
|
70
|
+
const detected = yield* client.request("GET /repos/{owner}/{repo}/languages", {
|
|
71
|
+
owner,
|
|
72
|
+
repo
|
|
73
|
+
});
|
|
74
|
+
return Object.keys(detected);
|
|
75
|
+
})
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
export { CodeScanning };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/DeploymentEnvironment.ts
|
|
6
|
+
/**
|
|
7
|
+
* Deployment environments.
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
var DeploymentEnvironment = class DeploymentEnvironment extends Context.Service()("@effected/github/DeploymentEnvironment") {
|
|
12
|
+
/**
|
|
13
|
+
* @remarks
|
|
14
|
+
* `(client) => make(client)` rather than `make`: a static initializer runs
|
|
15
|
+
* while the module body is still evaluating, so naming a `const` declared
|
|
16
|
+
* further down throws at import time with a clean typecheck.
|
|
17
|
+
*/
|
|
18
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
19
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
20
|
+
static makeTest = (overrides = {}) => ({
|
|
21
|
+
upsert: overrides.upsert ?? (() => unstubbed("upsert")),
|
|
22
|
+
list: overrides.list ?? (() => unstubbed("list")),
|
|
23
|
+
delete: overrides.delete ?? (() => unstubbed("delete"))
|
|
24
|
+
});
|
|
25
|
+
/** {@link DeploymentEnvironment.makeTest} behind a `Layer`. */
|
|
26
|
+
static layerTest = (overrides = {}) => Layer.succeed(DeploymentEnvironment, DeploymentEnvironment.makeTest(overrides));
|
|
27
|
+
};
|
|
28
|
+
const unstubbed = (member) => {
|
|
29
|
+
throw new Error(`DeploymentEnvironment.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
30
|
+
};
|
|
31
|
+
const make = (client) => {
|
|
32
|
+
return {
|
|
33
|
+
upsert: Effect.fn("DeploymentEnvironment.upsert")(function* (name, config = {}) {
|
|
34
|
+
const { owner, repo } = yield* Repo;
|
|
35
|
+
yield* Effect.annotateCurrentSpan({
|
|
36
|
+
owner,
|
|
37
|
+
repo,
|
|
38
|
+
environment: name
|
|
39
|
+
});
|
|
40
|
+
yield* client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}", {
|
|
41
|
+
...config,
|
|
42
|
+
owner,
|
|
43
|
+
repo,
|
|
44
|
+
environment_name: name
|
|
45
|
+
});
|
|
46
|
+
}),
|
|
47
|
+
list: Effect.fn("DeploymentEnvironment.list")(function* () {
|
|
48
|
+
const { owner, repo } = yield* Repo;
|
|
49
|
+
yield* Effect.annotateCurrentSpan({
|
|
50
|
+
owner,
|
|
51
|
+
repo
|
|
52
|
+
});
|
|
53
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/environments", {
|
|
54
|
+
owner,
|
|
55
|
+
repo
|
|
56
|
+
})).map((environment) => ({ name: environment.name }));
|
|
57
|
+
}),
|
|
58
|
+
delete: Effect.fn("DeploymentEnvironment.delete")(function* (name) {
|
|
59
|
+
const { owner, repo } = yield* Repo;
|
|
60
|
+
yield* Effect.annotateCurrentSpan({
|
|
61
|
+
owner,
|
|
62
|
+
repo,
|
|
63
|
+
environment: name
|
|
64
|
+
});
|
|
65
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}", {
|
|
66
|
+
owner,
|
|
67
|
+
repo,
|
|
68
|
+
environment_name: name
|
|
69
|
+
});
|
|
70
|
+
})
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { DeploymentEnvironment };
|
package/GitHubClient.js
CHANGED
|
@@ -160,27 +160,60 @@ const makeClientShape = (options) => Effect.gen(function* () {
|
|
|
160
160
|
const makeFixture = (fixtures) => {
|
|
161
161
|
const requested = fixtures.requested;
|
|
162
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
163
|
const perPage = perPageOf(options);
|
|
166
164
|
requested?.push({
|
|
165
|
+
kind: "paginate",
|
|
167
166
|
route,
|
|
167
|
+
params: _params,
|
|
168
168
|
perPage
|
|
169
169
|
});
|
|
170
|
+
const recorded = fixtures.paginate?.[route];
|
|
171
|
+
if (recorded instanceof GitHubError) return Stream.fail(recorded);
|
|
172
|
+
const items = recorded;
|
|
173
|
+
if (items === void 0) switch (fixtures.unstubbed ?? "die") {
|
|
174
|
+
case "fail": return Stream.fail(GitHubError.notFound("GitHubClient.paginate", `fixture for ${route}`));
|
|
175
|
+
case "empty": return Stream.empty;
|
|
176
|
+
default: return Stream.die(/* @__PURE__ */ new Error(`GitHubClient.paginate: no fixture for ${route}`));
|
|
177
|
+
}
|
|
170
178
|
return paginate(() => fromArray(items, perPage), options?.maxPages);
|
|
171
179
|
};
|
|
180
|
+
const missing = (method, route) => {
|
|
181
|
+
switch (fixtures.unstubbed ?? "die") {
|
|
182
|
+
case "fail": return Effect.fail(GitHubError.notFound(method, `fixture for ${route}`));
|
|
183
|
+
case "empty": return Effect.succeed({});
|
|
184
|
+
default: return Effect.die(/* @__PURE__ */ new Error(`${method}: no fixture for ${route}`));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
172
187
|
return {
|
|
173
|
-
request: (route,
|
|
188
|
+
request: (route, params) => {
|
|
189
|
+
requested?.push({
|
|
190
|
+
kind: "request",
|
|
191
|
+
route,
|
|
192
|
+
params
|
|
193
|
+
});
|
|
174
194
|
const data = fixtures.request?.[route];
|
|
175
|
-
|
|
195
|
+
if (data === void 0) return missing("GitHubClient.request", route);
|
|
196
|
+
return data instanceof GitHubError ? Effect.fail(data) : Effect.succeed(data);
|
|
176
197
|
},
|
|
177
|
-
requestDecoded: (route,
|
|
198
|
+
requestDecoded: (route, params, schema) => {
|
|
199
|
+
requested?.push({
|
|
200
|
+
kind: "requestDecoded",
|
|
201
|
+
route,
|
|
202
|
+
params
|
|
203
|
+
});
|
|
178
204
|
const data = fixtures.request?.[route];
|
|
179
|
-
|
|
205
|
+
if (data === void 0) return missing("GitHubClient.requestDecoded", route);
|
|
206
|
+
if (data instanceof GitHubError) return Effect.fail(data);
|
|
207
|
+
return Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "fixture did not match its schema", error))));
|
|
180
208
|
},
|
|
181
209
|
paginate: (route, params, options) => Stream.runCollect(paginateStream(route, params, options)),
|
|
182
210
|
paginateStream,
|
|
183
|
-
graphql: (document,
|
|
211
|
+
graphql: (document, variables) => {
|
|
212
|
+
requested?.push({
|
|
213
|
+
kind: "graphql",
|
|
214
|
+
route: document.name,
|
|
215
|
+
params: variables
|
|
216
|
+
});
|
|
184
217
|
const raw = fixtures.graphql?.[document.name];
|
|
185
218
|
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
219
|
},
|
package/GitHubRepository.js
CHANGED
|
@@ -1,9 +1,130 @@
|
|
|
1
|
+
import { GraphQLDocument } from "./GraphQL.js";
|
|
1
2
|
import { GitHubClient } from "./GitHubClient.js";
|
|
2
3
|
import { Repo } from "./Repo.js";
|
|
3
|
-
import { Context, Effect, Layer } from "effect";
|
|
4
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
4
5
|
|
|
5
6
|
//#region src/GitHubRepository.ts
|
|
6
7
|
/**
|
|
8
|
+
* Fields in the user-facing `security_and_analysis` block that GitHub accepts
|
|
9
|
+
* as `{ status: "enabled" | "disabled" }`.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* A caller supplies the bare string; it is wrapped before sending.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const SECURITY_ANALYSIS_STATUS_FIELDS = /* @__PURE__ */ new Set([
|
|
17
|
+
"advanced_security",
|
|
18
|
+
"code_security",
|
|
19
|
+
"secret_scanning",
|
|
20
|
+
"secret_scanning_push_protection",
|
|
21
|
+
"secret_scanning_ai_detection",
|
|
22
|
+
"secret_scanning_non_provider_patterns",
|
|
23
|
+
"secret_scanning_delegated_alert_dismissal",
|
|
24
|
+
"secret_scanning_delegated_bypass",
|
|
25
|
+
"dependabot_security_updates"
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Settings reachable **only** through the GraphQL `updateRepository` mutation,
|
|
29
|
+
* mapped from snake_case keys to camelCase GraphQL input fields.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* GitHub never exposed these two on the REST repository endpoint. Setting
|
|
33
|
+
* either forces a second round trip to learn the repository's node id.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
const GRAPHQL_ONLY_SETTINGS = {
|
|
38
|
+
has_sponsorships: "hasSponsorshipsEnabled",
|
|
39
|
+
has_pull_requests: "hasPullRequestsEnabled"
|
|
40
|
+
};
|
|
41
|
+
/** `{ status: "enabled" | "disabled" }` — the form GitHub accepts and the type declares. */
|
|
42
|
+
const isStatusObject = (raw) => {
|
|
43
|
+
if (raw === null || typeof raw !== "object") return false;
|
|
44
|
+
const status = raw.status;
|
|
45
|
+
return status === "enabled" || status === "disabled";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Translate a user-facing `security_and_analysis` block into the shape
|
|
49
|
+
* `PATCH /repos/{owner}/{repo}` expects.
|
|
50
|
+
*
|
|
51
|
+
* @remarks
|
|
52
|
+
* **Both shapes are accepted.** A bare `"enabled"` / `"disabled"` is wrapped;
|
|
53
|
+
* an already-wrapped `{ status }` — which is what `RepositoryPatch` actually
|
|
54
|
+
* types, since it is GitHub's own parameter type — passes through untouched.
|
|
55
|
+
* Accepting only the bare string would silently drop the block for a caller
|
|
56
|
+
* following the types.
|
|
57
|
+
*
|
|
58
|
+
* Reviewer entries must already carry a numeric `reviewer_id` and
|
|
59
|
+
* `reviewer_type`; resolving those from team slugs is the caller's job — see
|
|
60
|
+
* `Ruleset.teamId`.
|
|
61
|
+
*
|
|
62
|
+
* An **empty** `delegated_bypass_reviewers` array is treated as "no change"
|
|
63
|
+
* rather than "no reviewers". GitHub rejects `{ reviewers: [] }` outright when
|
|
64
|
+
* delegated bypass is enabled, so forwarding it would turn an omission into a
|
|
65
|
+
* failure.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
const transformSecurityAndAnalysis = (value) => {
|
|
70
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
71
|
+
const input = value;
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, raw] of Object.entries(input)) {
|
|
74
|
+
if (raw === void 0) continue;
|
|
75
|
+
if (SECURITY_ANALYSIS_STATUS_FIELDS.has(key) && (raw === "enabled" || raw === "disabled")) out[key] = { status: raw };
|
|
76
|
+
else if (SECURITY_ANALYSIS_STATUS_FIELDS.has(key) && isStatusObject(raw)) out[key] = raw;
|
|
77
|
+
else if (key === "delegated_bypass_reviewers" && Array.isArray(raw) && raw.length > 0) out.secret_scanning_delegated_bypass_options = { reviewers: raw };
|
|
78
|
+
}
|
|
79
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
80
|
+
};
|
|
81
|
+
/** The mutation's answer. Only its shape matters — the id is never read. */
|
|
82
|
+
const UpdateRepositoryResponse = Schema.Struct({ updateRepository: Schema.Struct({ repository: Schema.Struct({ id: Schema.String }) }) });
|
|
83
|
+
/**
|
|
84
|
+
* The `updateRepository` mutation, as an owned document.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Named `UpdateRepository`; {@link GitHubClient.layerFixture} keys its GraphQL
|
|
88
|
+
* fixtures by that name.
|
|
89
|
+
*/
|
|
90
|
+
const UpdateRepository = GraphQLDocument.make({
|
|
91
|
+
name: "UpdateRepository",
|
|
92
|
+
document: `mutation UpdateRepository($input: UpdateRepositoryInput!) {
|
|
93
|
+
updateRepository(input: $input) {
|
|
94
|
+
repository { id }
|
|
95
|
+
}
|
|
96
|
+
}`,
|
|
97
|
+
response: UpdateRepositoryResponse
|
|
98
|
+
})();
|
|
99
|
+
/**
|
|
100
|
+
* Keys GitHub rejects when the strategy that owns them is being turned off.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* Sending `merge_commit_title` in the same request that sets
|
|
104
|
+
* `allow_merge_commit: false` is a 422, so the dependent keys go out with the
|
|
105
|
+
* strategy that owns them rather than alone.
|
|
106
|
+
*/
|
|
107
|
+
const DEPENDENT_MERGE_KEYS = {
|
|
108
|
+
allow_merge_commit: ["merge_commit_title", "merge_commit_message"],
|
|
109
|
+
allow_squash_merge: ["squash_merge_commit_title", "squash_merge_commit_message"]
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Everything both write paths owe the API before a patch is sent.
|
|
113
|
+
*
|
|
114
|
+
* @remarks
|
|
115
|
+
* Shared by `updateSettings` and `applySettings` deliberately: a caller should
|
|
116
|
+
* not get a different `security_and_analysis` shape depending on which one they
|
|
117
|
+
* reached for.
|
|
118
|
+
*/
|
|
119
|
+
const preparePatch = (patch) => {
|
|
120
|
+
const out = { ...patch };
|
|
121
|
+
const securityAndAnalysis = transformSecurityAndAnalysis(out.security_and_analysis);
|
|
122
|
+
if (securityAndAnalysis === void 0) delete out.security_and_analysis;
|
|
123
|
+
else out.security_and_analysis = securityAndAnalysis;
|
|
124
|
+
for (const [strategy, dependents] of Object.entries(DEPENDENT_MERGE_KEYS)) if (out[strategy] === false) for (const dependent of dependents) delete out[dependent];
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
7
128
|
* Repository settings and coordinates.
|
|
8
129
|
*
|
|
9
130
|
* @public
|
|
@@ -15,7 +136,9 @@ var GitHubRepository = class GitHubRepository extends Context.Service()("@effect
|
|
|
15
136
|
settings: overrides.settings ?? Effect.sync(() => unstubbed("settings")),
|
|
16
137
|
updateSettings: overrides.updateSettings ?? (() => unstubbed("updateSettings")),
|
|
17
138
|
defaultBranch: overrides.defaultBranch ?? Effect.sync(() => unstubbed("defaultBranch")),
|
|
18
|
-
nodeId: overrides.nodeId ?? Effect.sync(() => unstubbed("nodeId"))
|
|
139
|
+
nodeId: overrides.nodeId ?? Effect.sync(() => unstubbed("nodeId")),
|
|
140
|
+
ownerType: overrides.ownerType ?? Effect.sync(() => unstubbed("ownerType")),
|
|
141
|
+
applySettings: overrides.applySettings ?? (() => unstubbed("applySettings"))
|
|
19
142
|
});
|
|
20
143
|
/** {@link GitHubRepository.makeTest} behind a `Layer`. */
|
|
21
144
|
static layerTest = (overrides = {}) => Layer.succeed(GitHubRepository, GitHubRepository.makeTest(overrides));
|
|
@@ -35,6 +158,11 @@ const make = (client) => {
|
|
|
35
158
|
repo
|
|
36
159
|
});
|
|
37
160
|
})();
|
|
161
|
+
const ownerType = Effect.fn("GitHubRepository.ownerType")(function* () {
|
|
162
|
+
const { owner } = yield* Repo;
|
|
163
|
+
yield* Effect.annotateCurrentSpan({ owner });
|
|
164
|
+
return (yield* client.request("GET /users/{username}", { username: owner })).type === "Organization" ? "Organization" : "User";
|
|
165
|
+
})();
|
|
38
166
|
return {
|
|
39
167
|
settings,
|
|
40
168
|
updateSettings: Effect.fn("GitHubRepository.updateSettings")(function* (patch) {
|
|
@@ -45,15 +173,56 @@ const make = (client) => {
|
|
|
45
173
|
fields: Object.keys(patch).length
|
|
46
174
|
});
|
|
47
175
|
return yield* client.request("PATCH /repos/{owner}/{repo}", {
|
|
48
|
-
...patch,
|
|
176
|
+
...preparePatch(patch),
|
|
49
177
|
owner,
|
|
50
178
|
repo
|
|
51
179
|
});
|
|
52
180
|
}),
|
|
53
181
|
defaultBranch: Effect.map(settings, (repository) => repository.default_branch),
|
|
54
|
-
nodeId: Effect.map(settings, (repository) => repository.node_id)
|
|
182
|
+
nodeId: Effect.map(settings, (repository) => repository.node_id),
|
|
183
|
+
ownerType,
|
|
184
|
+
applySettings: Effect.fn("GitHubRepository.applySettings")(function* (input) {
|
|
185
|
+
const { owner, repo } = yield* Repo;
|
|
186
|
+
yield* Effect.annotateCurrentSpan({
|
|
187
|
+
owner,
|
|
188
|
+
repo
|
|
189
|
+
});
|
|
190
|
+
const rest = {};
|
|
191
|
+
const graphql = {};
|
|
192
|
+
const graphqlKeys = [];
|
|
193
|
+
for (const [key, value] of Object.entries(input)) {
|
|
194
|
+
const graphqlField = Object.hasOwn(GRAPHQL_ONLY_SETTINGS, key) ? GRAPHQL_ONLY_SETTINGS[key] : void 0;
|
|
195
|
+
if (graphqlField !== void 0) {
|
|
196
|
+
graphql[graphqlField] = value;
|
|
197
|
+
graphqlKeys.push(key);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
rest[key] = value;
|
|
201
|
+
}
|
|
202
|
+
const prepared = preparePatch(rest);
|
|
203
|
+
const restKeys = Object.keys(prepared);
|
|
204
|
+
if (restKeys.length > 0) yield* client.request("PATCH /repos/{owner}/{repo}", {
|
|
205
|
+
...prepared,
|
|
206
|
+
owner,
|
|
207
|
+
repo
|
|
208
|
+
});
|
|
209
|
+
if (Object.keys(graphql).length > 0) {
|
|
210
|
+
const repository = yield* client.request("GET /repos/{owner}/{repo}", {
|
|
211
|
+
owner,
|
|
212
|
+
repo
|
|
213
|
+
});
|
|
214
|
+
yield* client.graphql(UpdateRepository, { input: {
|
|
215
|
+
repositoryId: repository.node_id,
|
|
216
|
+
...graphql
|
|
217
|
+
} });
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
rest: restKeys,
|
|
221
|
+
graphql: graphqlKeys
|
|
222
|
+
};
|
|
223
|
+
})
|
|
55
224
|
};
|
|
56
225
|
};
|
|
57
226
|
|
|
58
227
|
//#endregion
|
|
59
|
-
export { GitHubRepository };
|
|
228
|
+
export { GRAPHQL_ONLY_SETTINGS, GitHubRepository, SECURITY_ANALYSIS_STATUS_FIELDS, transformSecurityAndAnalysis };
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
Typed GitHub REST and GraphQL for [Effect](https://effect.website) v4. `client.request("GET /repos/{owner}/{repo}", { owner, repo })` types both the parameters and the returned `data` from the route literal alone — no `operation: string`, no callback, no cast. One `GitHubError` covers every REST failure with a `kind` you branch on instead of grepping a message, one pagination engine backs every paginating route and `client.request`'s `Stream` form, and a set of resource services (`GitBranch`, `GitTag`, `CheckRun`, `PullRequest`, `PullRequestComment`, `GitHubRelease`, `Attestation`) turn multi-call dances — "does this branch already exist?", "conclude this check run no matter how the program exits" — into one call. `GitHubApp` mints and revokes installation tokens for App auth.
|
|
8
|
+
Typed GitHub REST and GraphQL for [Effect](https://effect.website) v4. `client.request("GET /repos/{owner}/{repo}", { owner, repo })` types both the parameters and the returned `data` from the route literal alone — no `operation: string`, no callback, no cast. One `GitHubError` covers every REST failure with a `kind` you branch on instead of grepping a message, one pagination engine backs every paginating route and `client.request`'s `Stream` form, and a set of resource services (`GitBranch`, `GitTag`, `CheckRun`, `PullRequest`, `PullRequestComment`, `GitHubRelease`, `Attestation`) turn multi-call dances — "does this branch already exist?", "conclude this check run no matter how the program exits" — into one call. A second tier writes the configuration half: secrets, variables, rulesets, deployment environments, the security toggles and CodeQL default setup. `GitHubApp` mints and revokes installation tokens for App auth.
|
|
9
9
|
|
|
10
10
|
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
11
|
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
@@ -101,6 +101,55 @@ const program = Effect.gen(function* () {
|
|
|
101
101
|
|
|
102
102
|
`GitTag.latestSemver` walks tags and picks the newest version-shaped one in a single pass, over `@effected/semver`'s synchronous comparator — no round trip per candidate. `PullRequest.upsert` and `PullRequestComment.upsert` (a marker-tagged sticky comment) follow the same one-call-one-intent shape.
|
|
103
103
|
|
|
104
|
+
## Repository configuration
|
|
105
|
+
|
|
106
|
+
Six services cover the half of a repository that is policy rather than content: `RepositorySecret`, `RepositoryVariable`, `Ruleset`, `DeploymentEnvironment`, `RepositorySecurity` and `CodeScanning`. They are shaped for a program applying the same configuration across a fleet, so every list read paginates to the end and a truncated page never becomes a wrong decision.
|
|
107
|
+
|
|
108
|
+
Secrets carry the libsodium sealed box GitHub's API requires, which is why the value is a `Redacted<string>` rather than a plain one:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { RepositorySecret } from "@effected/github";
|
|
112
|
+
import { Effect, Redacted } from "effect";
|
|
113
|
+
|
|
114
|
+
declare const token: string;
|
|
115
|
+
|
|
116
|
+
const program = Effect.gen(function* () {
|
|
117
|
+
const secrets = yield* RepositorySecret;
|
|
118
|
+
yield* secrets.set("NPM_TOKEN", Redacted.make(token)); // the "actions" store by default
|
|
119
|
+
yield* secrets.setForEnvironment("production", "DEPLOY_KEY", Redacted.make(token));
|
|
120
|
+
return yield* secrets.list("dependabot");
|
|
121
|
+
});
|
|
122
|
+
// ReadonlyArray<SecretInfo> — names only. GitHub returns a secret's value from no endpoint,
|
|
123
|
+
// so a diff against desired state detects a deleted secret and never an edited one.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`actions`, `dependabot` and `codespaces` are three stores on the same repository, each with its own public key, and the `scope` argument picks between them. The encryption lives in one module that only `RepositorySecret` imports, so a consumer that never writes a secret never links the crypto pair. `RepositoryVariable` mirrors the same six methods for the values that are not secret, where `set` lists first because GitHub splits create and update across two routes.
|
|
127
|
+
|
|
128
|
+
`Ruleset.upsert` matches an existing ruleset by name **and** `source_type`. That second field is why the projection carries it: `GET /repos/{owner}/{repo}/rulesets` returns the rulesets a repository inherits from its organization alongside its own, and matching on name alone would let a repository-scoped write `PUT` the organization's ruleset id — rewriting policy for every repository that organization owns. `Ruleset.teamId` and `Ruleset.roleId` resolve the numeric ids a bypass actor needs, scoped to the organization in `Repo`.
|
|
129
|
+
|
|
130
|
+
The rest follow their endpoints' own grain. `DeploymentEnvironment.upsert` is a plain `PUT` because GitHub's route is idempotent, and its `delete` takes the environment's secrets and variables with it, which fixes the order of any cleanup pass. `RepositorySecurity` reads and writes the three toggles GitHub keeps off the repository endpoint: vulnerability alerts, automated security fixes and private vulnerability reporting. `CodeScanning.configure` applies a CodeQL default setup and returns as soon as GitHub accepts it — the endpoint answers `202` and configures asynchronously, and nothing here polls — while `CodeScanning.languages` reports what GitHub detects in the repository, which is what you filter a configured language list against before calling `configure`.
|
|
131
|
+
|
|
132
|
+
`GitHubRepository` owns the repository's own settings. `updateSettings` is the faithfully typed `PATCH`; `applySettings` is the applicator above it, taking an open map, routing each key to whichever API can actually set it, and reporting what went out:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { GitHubRepository } from "@effected/github";
|
|
136
|
+
import { Effect } from "effect";
|
|
137
|
+
|
|
138
|
+
const program = Effect.gen(function* () {
|
|
139
|
+
const repository = yield* GitHubRepository;
|
|
140
|
+
return yield* repository.applySettings({
|
|
141
|
+
has_issues: true,
|
|
142
|
+
has_sponsorships: false, // GraphQL-only: costs one extra read for the node id
|
|
143
|
+
security_and_analysis: { secret_scanning: "enabled" },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// AppliedSettings — { rest: [...], graphql: [...] }, in the caller's own key names.
|
|
147
|
+
// The two lists agree with the input right up until preparation drops a field
|
|
148
|
+
// GitHub would reject, which is what a person reading a dry run is checking for.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`security_and_analysis` accepts both the bare `"enabled"` a human writes in a config file and the `{ status: "enabled" }` GitHub's own parameter type declares. A map touching neither GraphQL-only setting never reads the node id, so the common case stays one request. `ownerType` answers `"User"` or `"Organization"` for the repository in `Repo`, which is how a shared settings template drops the fields GitHub accepts only on an organization-owned repository before applying itself to a personal one.
|
|
152
|
+
|
|
104
153
|
## GitHub App authentication
|
|
105
154
|
|
|
106
155
|
`GitHubApp.clientLayer` builds a `GitHubClient` authenticated as an app installation. The token is minted on build, re-minted a minute before it expires, and revoked on release — best effort — so a workflow does not leave live credentials behind:
|
|
@@ -150,12 +199,34 @@ const TestBranches = GitBranch.layerTest({
|
|
|
150
199
|
});
|
|
151
200
|
```
|
|
152
201
|
|
|
153
|
-
`GitHubClient.layerFixture(fixtures)` is the one recorded-response double that pages for real: it builds a `PageSource` over the recorded array and hands it to the same pagination engine the live client uses, so a truncation path behaves identically under test and in production.
|
|
202
|
+
`GitHubClient.layerFixture(fixtures)` is the one recorded-response double that pages for real: it builds a `PageSource` over the recorded array and hands it to the same pagination engine the live client uses, so a truncation path behaves identically under test and in production. Three things about its contract are worth knowing before you write against it:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { GitHubClient, GitHubError } from "@effected/github";
|
|
206
|
+
|
|
207
|
+
const fixtures = {
|
|
208
|
+
request: {
|
|
209
|
+
"GET /repos/{owner}/{repo}": { default_branch: "main" },
|
|
210
|
+
// A recorded GitHubError *is* the response: this route fails with it.
|
|
211
|
+
"PATCH /repos/{owner}/{repo}": GitHubError.notFound("updateSettings", "repo"),
|
|
212
|
+
},
|
|
213
|
+
paginate: { "GET /repos/{owner}/{repo}/rulesets": [{ id: 1, name: "main", source_type: "Repository" }] },
|
|
214
|
+
requested: [], // filled in as the test runs
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const TestClient = GitHubClient.layerFixture(fixtures);
|
|
218
|
+
// A route with no entry above DIES naming itself, rather than failing typed.
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- **An unstubbed route dies by default.** A missing fixture is test wiring rather than a domain outcome, and a typed failure is only loud in code that does not catch — a program handling `GitHubError` per resource turns a missing stub into a different execution path, and the assertions then fail for reasons that name no fixture. `unstubbed: "fail"` restores the old typed not-found, and `"empty"` serves an empty value for a suite whose subject is decisions rather than endpoints.
|
|
222
|
+
- **A recorded `GitHubError` value is the response.** That is how a suite stubs a 404, a 422 or a rate limit deliberately. Leaning on a route's absence says only "unwired"; a recorded error says which route fails and why.
|
|
223
|
+
- **`fixtures.requested` records every call.** Each `RecordedCall` carries the `kind` of surface used, the `route` (the document name for `graphql`), the `params` the call was made with, and `perPage` for a paginated read. Params are what let a test assert what a method *sent*, which is the question any normalising write turns on.
|
|
154
224
|
|
|
155
225
|
## Features
|
|
156
226
|
|
|
157
227
|
- `GitHubClient` — the typed transport: `request`, `requestDecoded` (a mandatory-schema escape hatch for routes outside the generated map), `paginate` / `paginateStream`, `graphql`, and `rateLimit` (observation only — nothing here throttles on your behalf).
|
|
158
228
|
- `Repo` / `RepoRef` — the `{ owner, repo }` coordinate, resolved per call through `R`, with `Repo.provide` for multi-repository programs.
|
|
229
|
+
- `GitHubRepository` — the repository's settings as GitHub's own generated type, plus `defaultBranch`, `nodeId`, `ownerType` for gating organization-only fields, and `applySettings` reporting the keys it actually sent.
|
|
159
230
|
- `GitHubError` / `GitHubGraphQLError` — one error per transport, `kind`-routed with `hasKind` for `Effect.catchIf`.
|
|
160
231
|
- `RetryPolicy` — the client's one retry policy: full-jitter backoff, server-advised delays honored up to a ceiling.
|
|
161
232
|
- `GitHubApp` — App JWT signing, installation token minting/revocation, app and installation identity, and `clientLayer` for an App-authenticated `GitHubClient`.
|
|
@@ -163,6 +234,9 @@ const TestBranches = GitBranch.layerTest({
|
|
|
163
234
|
- `CheckRun` — `withCheckRun` concludes on every exit path; `CheckRunOutput.truncated()` cuts rendered output to GitHub's byte limits.
|
|
164
235
|
- `PullRequest` / `PullRequestComment` — upserts for both, `listFiles` answering with the same full `CommitFile` records a commit read returns, `headSha`/`baseSha` on `PullRequestInfo`, plus `CommentMarker` for finding a sticky comment again.
|
|
165
236
|
- `GitHubRelease` — releases and asset uploads, including the one route (`uploadAsset`, with the endpoint's optional display label) outside GitHub's generated endpoint map.
|
|
237
|
+
- `RepositorySecret` / `RepositoryVariable` — repository and environment secrets and variables, with the sealed-box encryption GitHub's secrets API demands kept in one module nothing else imports.
|
|
238
|
+
- `Ruleset` / `DeploymentEnvironment` / `RepositorySecurity` / `CodeScanning` — the rest of the configuration tier: rulesets matched by name and scope, idempotent environment writes, the three security toggles GitHub keeps off the repository endpoint, and CodeQL default setup with the language detection that gates it.
|
|
239
|
+
- `WorkflowDispatch` — fire a `workflow_dispatch` event, `list` the repository's workflows with the state string GitHub reports, or dispatch and wait for the run it created.
|
|
166
240
|
- `BotIdentity` — the author and committer a bot commits as, with `signoff` rendering the DCO trailer that a commit made through the Git Data API never gets from `git commit -s`.
|
|
167
241
|
- `Attestation` — upload and list attestations against a subject digest; building and signing the bundle is `@effected/sbom`'s job.
|
|
168
242
|
- `TokenPermissions` — a pure comparator between granted and required permissions, reaching nothing but `effect`.
|