@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
3
|
+
import { Repo } from "./Repo.js";
|
|
4
|
+
import { encryptSecret } from "./internal/crypto.js";
|
|
5
|
+
import { Context, Effect, Layer, Redacted, Result } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/RepositorySecret.ts
|
|
8
|
+
const ROUTES = {
|
|
9
|
+
actions: {
|
|
10
|
+
publicKey: "GET /repos/{owner}/{repo}/actions/secrets/public-key",
|
|
11
|
+
put: "PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}",
|
|
12
|
+
list: "GET /repos/{owner}/{repo}/actions/secrets",
|
|
13
|
+
remove: "DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}"
|
|
14
|
+
},
|
|
15
|
+
dependabot: {
|
|
16
|
+
publicKey: "GET /repos/{owner}/{repo}/dependabot/secrets/public-key",
|
|
17
|
+
put: "PUT /repos/{owner}/{repo}/dependabot/secrets/{secret_name}",
|
|
18
|
+
list: "GET /repos/{owner}/{repo}/dependabot/secrets",
|
|
19
|
+
remove: "DELETE /repos/{owner}/{repo}/dependabot/secrets/{secret_name}"
|
|
20
|
+
},
|
|
21
|
+
codespaces: {
|
|
22
|
+
publicKey: "GET /repos/{owner}/{repo}/codespaces/secrets/public-key",
|
|
23
|
+
put: "PUT /repos/{owner}/{repo}/codespaces/secrets/{secret_name}",
|
|
24
|
+
list: "GET /repos/{owner}/{repo}/codespaces/secrets",
|
|
25
|
+
remove: "DELETE /repos/{owner}/{repo}/codespaces/secrets/{secret_name}"
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Secrets, encrypted client-side before they leave the process.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Every write is a **two-step**: fetch the store's public key, then `PUT` a
|
|
33
|
+
* libsodium sealed box. The plaintext never crosses the wire, and the key fetch
|
|
34
|
+
* cannot be cached across stores because each has its own key.
|
|
35
|
+
*
|
|
36
|
+
* ## The value is `Redacted`
|
|
37
|
+
*
|
|
38
|
+
* Not decoration. A plaintext secret in a `string` is one interpolation, one
|
|
39
|
+
* `JSON.stringify` of a params object, or one logged error away from a
|
|
40
|
+
* transcript — and the log line that leaks it usually looks like a diagnostic
|
|
41
|
+
* someone added to debug an unrelated failure. `Redacted` closes those paths at
|
|
42
|
+
* the type; this module performs the single `Redacted.value` unwrap, at the
|
|
43
|
+
* moment of encryption, and the sealed box is what continues.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
var RepositorySecret = class RepositorySecret extends Context.Service()("@effected/github/RepositorySecret") {
|
|
48
|
+
/**
|
|
49
|
+
* @remarks
|
|
50
|
+
* `(client) => make(client)` rather than `make`: a static initializer runs
|
|
51
|
+
* while the module body is still evaluating, so naming a `const` declared
|
|
52
|
+
* further down throws at import time with a clean typecheck.
|
|
53
|
+
*/
|
|
54
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
55
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
56
|
+
static makeTest = (overrides = {}) => ({
|
|
57
|
+
set: overrides.set ?? (() => unstubbed("set")),
|
|
58
|
+
list: overrides.list ?? (() => unstubbed("list")),
|
|
59
|
+
delete: overrides.delete ?? (() => unstubbed("delete")),
|
|
60
|
+
setForEnvironment: overrides.setForEnvironment ?? (() => unstubbed("setForEnvironment")),
|
|
61
|
+
listForEnvironment: overrides.listForEnvironment ?? (() => unstubbed("listForEnvironment")),
|
|
62
|
+
deleteForEnvironment: overrides.deleteForEnvironment ?? (() => unstubbed("deleteForEnvironment"))
|
|
63
|
+
});
|
|
64
|
+
/** {@link RepositorySecret.makeTest} behind a `Layer`. */
|
|
65
|
+
static layerTest = (overrides = {}) => Layer.succeed(RepositorySecret, RepositorySecret.makeTest(overrides));
|
|
66
|
+
};
|
|
67
|
+
const unstubbed = (member) => {
|
|
68
|
+
throw new Error(`RepositorySecret.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Seal a value, turning a malformed public key into a typed failure.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* `encryptSecret` returns a `Result` because a base64 decode can fail. A public
|
|
75
|
+
* key GitHub cannot have produced is still *input*, and input failures are
|
|
76
|
+
* typed rather than thrown — so this maps it onto the same `GitHubError` a
|
|
77
|
+
* caller already handles, naming the route it came from.
|
|
78
|
+
*/
|
|
79
|
+
const seal = (route, publicKey, value) => Result.match(encryptSecret(publicKey, Redacted.value(value)), {
|
|
80
|
+
onSuccess: (sealed) => Effect.succeed(sealed),
|
|
81
|
+
onFailure: () => Effect.fail(GitHubError.decode(route, "the secrets public key was not valid base64"))
|
|
82
|
+
});
|
|
83
|
+
const make = (client) => {
|
|
84
|
+
return {
|
|
85
|
+
set: Effect.fn("RepositorySecret.set")(function* (name, value, scope = "actions") {
|
|
86
|
+
const { owner, repo } = yield* Repo;
|
|
87
|
+
yield* Effect.annotateCurrentSpan({
|
|
88
|
+
owner,
|
|
89
|
+
repo,
|
|
90
|
+
scope,
|
|
91
|
+
secret: name
|
|
92
|
+
});
|
|
93
|
+
const routes = ROUTES[scope];
|
|
94
|
+
const publicKey = yield* client.request(routes.publicKey, {
|
|
95
|
+
owner,
|
|
96
|
+
repo
|
|
97
|
+
});
|
|
98
|
+
yield* client.request(routes.put, {
|
|
99
|
+
owner,
|
|
100
|
+
repo,
|
|
101
|
+
secret_name: name,
|
|
102
|
+
encrypted_value: yield* seal(routes.publicKey, publicKey.key, value),
|
|
103
|
+
key_id: publicKey.key_id
|
|
104
|
+
});
|
|
105
|
+
}),
|
|
106
|
+
list: Effect.fn("RepositorySecret.list")(function* (scope = "actions") {
|
|
107
|
+
const { owner, repo } = yield* Repo;
|
|
108
|
+
yield* Effect.annotateCurrentSpan({
|
|
109
|
+
owner,
|
|
110
|
+
repo,
|
|
111
|
+
scope
|
|
112
|
+
});
|
|
113
|
+
return (yield* client.paginate(ROUTES[scope].list, {
|
|
114
|
+
owner,
|
|
115
|
+
repo
|
|
116
|
+
})).map((secret) => ({ name: secret.name }));
|
|
117
|
+
}),
|
|
118
|
+
delete: Effect.fn("RepositorySecret.delete")(function* (name, scope = "actions") {
|
|
119
|
+
const { owner, repo } = yield* Repo;
|
|
120
|
+
yield* Effect.annotateCurrentSpan({
|
|
121
|
+
owner,
|
|
122
|
+
repo,
|
|
123
|
+
scope,
|
|
124
|
+
secret: name
|
|
125
|
+
});
|
|
126
|
+
yield* client.request(ROUTES[scope].remove, {
|
|
127
|
+
owner,
|
|
128
|
+
repo,
|
|
129
|
+
secret_name: name
|
|
130
|
+
});
|
|
131
|
+
}),
|
|
132
|
+
setForEnvironment: Effect.fn("RepositorySecret.setForEnvironment")(function* (environment, name, value) {
|
|
133
|
+
const { owner, repo } = yield* Repo;
|
|
134
|
+
yield* Effect.annotateCurrentSpan({
|
|
135
|
+
owner,
|
|
136
|
+
repo,
|
|
137
|
+
environment,
|
|
138
|
+
secret: name
|
|
139
|
+
});
|
|
140
|
+
const publicKey = yield* client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", {
|
|
141
|
+
owner,
|
|
142
|
+
repo,
|
|
143
|
+
environment_name: environment
|
|
144
|
+
});
|
|
145
|
+
yield* client.request("PUT /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
|
146
|
+
owner,
|
|
147
|
+
repo,
|
|
148
|
+
environment_name: environment,
|
|
149
|
+
secret_name: name,
|
|
150
|
+
encrypted_value: yield* seal("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/public-key", publicKey.key, value),
|
|
151
|
+
key_id: publicKey.key_id
|
|
152
|
+
});
|
|
153
|
+
}),
|
|
154
|
+
listForEnvironment: Effect.fn("RepositorySecret.listForEnvironment")(function* (environment) {
|
|
155
|
+
const { owner, repo } = yield* Repo;
|
|
156
|
+
yield* Effect.annotateCurrentSpan({
|
|
157
|
+
owner,
|
|
158
|
+
repo,
|
|
159
|
+
environment
|
|
160
|
+
});
|
|
161
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", {
|
|
162
|
+
owner,
|
|
163
|
+
repo,
|
|
164
|
+
environment_name: environment
|
|
165
|
+
})).map((secret) => ({ name: secret.name }));
|
|
166
|
+
}),
|
|
167
|
+
deleteForEnvironment: Effect.fn("RepositorySecret.deleteForEnvironment")(function* (environment, name) {
|
|
168
|
+
const { owner, repo } = yield* Repo;
|
|
169
|
+
yield* Effect.annotateCurrentSpan({
|
|
170
|
+
owner,
|
|
171
|
+
repo,
|
|
172
|
+
environment,
|
|
173
|
+
secret: name
|
|
174
|
+
});
|
|
175
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", {
|
|
176
|
+
owner,
|
|
177
|
+
repo,
|
|
178
|
+
environment_name: environment,
|
|
179
|
+
secret_name: name
|
|
180
|
+
});
|
|
181
|
+
})
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
//#endregion
|
|
186
|
+
export { RepositorySecret };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/RepositorySecurity.ts
|
|
6
|
+
/**
|
|
7
|
+
* Repository security features with dedicated endpoints.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* These are **not** `security_and_analysis` fields and cannot ride along on the
|
|
11
|
+
* settings `PATCH`. Each is its own pair of endpoints where **the HTTP verb is
|
|
12
|
+
* the value**, which is why every setter branches on `enabled` rather than
|
|
13
|
+
* sending a body.
|
|
14
|
+
*
|
|
15
|
+
* ## Reading them is inconsistent, and the inconsistency is GitHub's
|
|
16
|
+
*
|
|
17
|
+
* Preserved faithfully rather than smoothed over, because smoothing it would
|
|
18
|
+
* mean inventing a behaviour for one of the three:
|
|
19
|
+
*
|
|
20
|
+
* | Feature | Enabled | Disabled |
|
|
21
|
+
* | :--- | :--- | :--- |
|
|
22
|
+
* | `vulnerability-alerts` | `204` | **`404`** |
|
|
23
|
+
* | `automated-security-fixes` | `200 { enabled: true }` | `200 { enabled: false }` |
|
|
24
|
+
* | `private-vulnerability-reporting` | `200 { enabled: true }` | `200 { enabled: false }` |
|
|
25
|
+
*
|
|
26
|
+
* So `vulnerabilityAlerts` maps `notFound` to `false` — and **only** `notFound`;
|
|
27
|
+
* every other failure still fails. A 404 from the other two is a real failure
|
|
28
|
+
* and stays one, which is why the mapping is not applied uniformly.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
var RepositorySecurity = class RepositorySecurity extends Context.Service()("@effected/github/RepositorySecurity") {
|
|
33
|
+
/**
|
|
34
|
+
* @remarks
|
|
35
|
+
* `(client) => make(client)` rather than `make`: a static initializer runs
|
|
36
|
+
* while the module body is still evaluating, so naming a `const` declared
|
|
37
|
+
* further down throws at import time with a clean typecheck.
|
|
38
|
+
*/
|
|
39
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
40
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
41
|
+
static makeTest = (overrides = {}) => ({
|
|
42
|
+
vulnerabilityAlerts: overrides.vulnerabilityAlerts ?? (() => unstubbed("vulnerabilityAlerts")),
|
|
43
|
+
setVulnerabilityAlerts: overrides.setVulnerabilityAlerts ?? (() => unstubbed("setVulnerabilityAlerts")),
|
|
44
|
+
automatedSecurityFixes: overrides.automatedSecurityFixes ?? (() => unstubbed("automatedSecurityFixes")),
|
|
45
|
+
setAutomatedSecurityFixes: overrides.setAutomatedSecurityFixes ?? (() => unstubbed("setAutomatedSecurityFixes")),
|
|
46
|
+
privateVulnerabilityReporting: overrides.privateVulnerabilityReporting ?? (() => unstubbed("privateVulnerabilityReporting")),
|
|
47
|
+
setPrivateVulnerabilityReporting: overrides.setPrivateVulnerabilityReporting ?? (() => unstubbed("setPrivateVulnerabilityReporting"))
|
|
48
|
+
});
|
|
49
|
+
/** {@link RepositorySecurity.makeTest} behind a `Layer`. */
|
|
50
|
+
static layerTest = (overrides = {}) => Layer.succeed(RepositorySecurity, RepositorySecurity.makeTest(overrides));
|
|
51
|
+
};
|
|
52
|
+
const unstubbed = (member) => {
|
|
53
|
+
throw new Error(`RepositorySecurity.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
54
|
+
};
|
|
55
|
+
const make = (client) => {
|
|
56
|
+
return {
|
|
57
|
+
vulnerabilityAlerts: Effect.fn("RepositorySecurity.vulnerabilityAlerts")(function* () {
|
|
58
|
+
const { owner, repo } = yield* Repo;
|
|
59
|
+
yield* Effect.annotateCurrentSpan({
|
|
60
|
+
owner,
|
|
61
|
+
repo
|
|
62
|
+
});
|
|
63
|
+
return yield* client.request("GET /repos/{owner}/{repo}/vulnerability-alerts", {
|
|
64
|
+
owner,
|
|
65
|
+
repo
|
|
66
|
+
}).pipe(Effect.as(true), Effect.catchIf((error) => error.kind === "notFound", () => Effect.succeed(false)));
|
|
67
|
+
}),
|
|
68
|
+
setVulnerabilityAlerts: Effect.fn("RepositorySecurity.setVulnerabilityAlerts")(function* (enabled) {
|
|
69
|
+
const { owner, repo } = yield* Repo;
|
|
70
|
+
yield* Effect.annotateCurrentSpan({
|
|
71
|
+
owner,
|
|
72
|
+
repo,
|
|
73
|
+
enabled
|
|
74
|
+
});
|
|
75
|
+
yield* enabled ? client.request("PUT /repos/{owner}/{repo}/vulnerability-alerts", {
|
|
76
|
+
owner,
|
|
77
|
+
repo
|
|
78
|
+
}) : client.request("DELETE /repos/{owner}/{repo}/vulnerability-alerts", {
|
|
79
|
+
owner,
|
|
80
|
+
repo
|
|
81
|
+
});
|
|
82
|
+
}),
|
|
83
|
+
automatedSecurityFixes: Effect.fn("RepositorySecurity.automatedSecurityFixes")(function* () {
|
|
84
|
+
const { owner, repo } = yield* Repo;
|
|
85
|
+
yield* Effect.annotateCurrentSpan({
|
|
86
|
+
owner,
|
|
87
|
+
repo
|
|
88
|
+
});
|
|
89
|
+
const data = yield* client.request("GET /repos/{owner}/{repo}/automated-security-fixes", {
|
|
90
|
+
owner,
|
|
91
|
+
repo
|
|
92
|
+
});
|
|
93
|
+
return Boolean(data.enabled);
|
|
94
|
+
}),
|
|
95
|
+
setAutomatedSecurityFixes: Effect.fn("RepositorySecurity.setAutomatedSecurityFixes")(function* (enabled) {
|
|
96
|
+
const { owner, repo } = yield* Repo;
|
|
97
|
+
yield* Effect.annotateCurrentSpan({
|
|
98
|
+
owner,
|
|
99
|
+
repo,
|
|
100
|
+
enabled
|
|
101
|
+
});
|
|
102
|
+
yield* enabled ? client.request("PUT /repos/{owner}/{repo}/automated-security-fixes", {
|
|
103
|
+
owner,
|
|
104
|
+
repo
|
|
105
|
+
}) : client.request("DELETE /repos/{owner}/{repo}/automated-security-fixes", {
|
|
106
|
+
owner,
|
|
107
|
+
repo
|
|
108
|
+
});
|
|
109
|
+
}),
|
|
110
|
+
privateVulnerabilityReporting: Effect.fn("RepositorySecurity.privateVulnerabilityReporting")(function* () {
|
|
111
|
+
const { owner, repo } = yield* Repo;
|
|
112
|
+
yield* Effect.annotateCurrentSpan({
|
|
113
|
+
owner,
|
|
114
|
+
repo
|
|
115
|
+
});
|
|
116
|
+
const data = yield* client.request("GET /repos/{owner}/{repo}/private-vulnerability-reporting", {
|
|
117
|
+
owner,
|
|
118
|
+
repo
|
|
119
|
+
});
|
|
120
|
+
return Boolean(data.enabled);
|
|
121
|
+
}),
|
|
122
|
+
setPrivateVulnerabilityReporting: Effect.fn("RepositorySecurity.setPrivateVulnerabilityReporting")(function* (enabled) {
|
|
123
|
+
const { owner, repo } = yield* Repo;
|
|
124
|
+
yield* Effect.annotateCurrentSpan({
|
|
125
|
+
owner,
|
|
126
|
+
repo,
|
|
127
|
+
enabled
|
|
128
|
+
});
|
|
129
|
+
yield* enabled ? client.request("PUT /repos/{owner}/{repo}/private-vulnerability-reporting", {
|
|
130
|
+
owner,
|
|
131
|
+
repo
|
|
132
|
+
}) : client.request("DELETE /repos/{owner}/{repo}/private-vulnerability-reporting", {
|
|
133
|
+
owner,
|
|
134
|
+
repo
|
|
135
|
+
});
|
|
136
|
+
})
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
export { RepositorySecurity };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
2
|
+
import { Repo } from "./Repo.js";
|
|
3
|
+
import { Context, Effect, Layer } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/RepositoryVariable.ts
|
|
6
|
+
/**
|
|
7
|
+
* Repository and environment variables.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* No encryption and no public key, unlike secrets — but also **no upsert**,
|
|
11
|
+
* which is the asymmetry worth knowing: every write costs a read first, because
|
|
12
|
+
* the create and update routes are different endpoints with different verbs and
|
|
13
|
+
* neither tolerates the other's case.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
var RepositoryVariable = class RepositoryVariable extends Context.Service()("@effected/github/RepositoryVariable") {
|
|
18
|
+
/**
|
|
19
|
+
* @remarks
|
|
20
|
+
* `(client) => make(client)` rather than `make`: a static initializer runs
|
|
21
|
+
* while the module body is still evaluating, so naming a `const` declared
|
|
22
|
+
* further down throws at import time with a clean typecheck.
|
|
23
|
+
*/
|
|
24
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
25
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
26
|
+
static makeTest = (overrides = {}) => ({
|
|
27
|
+
set: overrides.set ?? (() => unstubbed("set")),
|
|
28
|
+
list: overrides.list ?? (() => unstubbed("list")),
|
|
29
|
+
delete: overrides.delete ?? (() => unstubbed("delete")),
|
|
30
|
+
setForEnvironment: overrides.setForEnvironment ?? (() => unstubbed("setForEnvironment")),
|
|
31
|
+
listForEnvironment: overrides.listForEnvironment ?? (() => unstubbed("listForEnvironment")),
|
|
32
|
+
deleteForEnvironment: overrides.deleteForEnvironment ?? (() => unstubbed("deleteForEnvironment"))
|
|
33
|
+
});
|
|
34
|
+
/** {@link RepositoryVariable.makeTest} behind a `Layer`. */
|
|
35
|
+
static layerTest = (overrides = {}) => Layer.succeed(RepositoryVariable, RepositoryVariable.makeTest(overrides));
|
|
36
|
+
};
|
|
37
|
+
const unstubbed = (member) => {
|
|
38
|
+
throw new Error(`RepositoryVariable.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
39
|
+
};
|
|
40
|
+
const make = (client) => {
|
|
41
|
+
/**
|
|
42
|
+
* Does this variable already exist? One by-name read, not a listing.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* GitHub answers 404 for an absent variable, which makes the pre-write check
|
|
46
|
+
* constant cost — paginating the whole collection to answer one yes/no grows
|
|
47
|
+
* with a repository that has nothing to do with the variable being written.
|
|
48
|
+
* Only `notFound` is absorbed: a 403 from a mis-scoped token still fails,
|
|
49
|
+
* which a blanket "treat any error as absent" would destroy, turning a
|
|
50
|
+
* permissions problem into a spurious create.
|
|
51
|
+
*/
|
|
52
|
+
const exists = (route, params) => client.request(route, params).pipe(Effect.as(true), Effect.catchIf((error) => error.kind === "notFound", () => Effect.succeed(false)));
|
|
53
|
+
return {
|
|
54
|
+
set: Effect.fn("RepositoryVariable.set")(function* (name, value) {
|
|
55
|
+
const { owner, repo } = yield* Repo;
|
|
56
|
+
yield* Effect.annotateCurrentSpan({
|
|
57
|
+
owner,
|
|
58
|
+
repo,
|
|
59
|
+
variable: name
|
|
60
|
+
});
|
|
61
|
+
if (yield* exists("GET /repos/{owner}/{repo}/actions/variables/{name}", {
|
|
62
|
+
owner,
|
|
63
|
+
repo,
|
|
64
|
+
name
|
|
65
|
+
})) {
|
|
66
|
+
yield* client.request("PATCH /repos/{owner}/{repo}/actions/variables/{name}", {
|
|
67
|
+
owner,
|
|
68
|
+
repo,
|
|
69
|
+
name,
|
|
70
|
+
value
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
yield* client.request("POST /repos/{owner}/{repo}/actions/variables", {
|
|
75
|
+
owner,
|
|
76
|
+
repo,
|
|
77
|
+
name,
|
|
78
|
+
value
|
|
79
|
+
});
|
|
80
|
+
}),
|
|
81
|
+
list: Effect.fn("RepositoryVariable.list")(function* () {
|
|
82
|
+
const { owner, repo } = yield* Repo;
|
|
83
|
+
yield* Effect.annotateCurrentSpan({
|
|
84
|
+
owner,
|
|
85
|
+
repo
|
|
86
|
+
});
|
|
87
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/actions/variables", {
|
|
88
|
+
owner,
|
|
89
|
+
repo
|
|
90
|
+
})).map((variable) => ({
|
|
91
|
+
name: variable.name,
|
|
92
|
+
value: variable.value
|
|
93
|
+
}));
|
|
94
|
+
}),
|
|
95
|
+
delete: Effect.fn("RepositoryVariable.delete")(function* (name) {
|
|
96
|
+
const { owner, repo } = yield* Repo;
|
|
97
|
+
yield* Effect.annotateCurrentSpan({
|
|
98
|
+
owner,
|
|
99
|
+
repo,
|
|
100
|
+
variable: name
|
|
101
|
+
});
|
|
102
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/actions/variables/{name}", {
|
|
103
|
+
owner,
|
|
104
|
+
repo,
|
|
105
|
+
name
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
setForEnvironment: Effect.fn("RepositoryVariable.setForEnvironment")(function* (environment, name, value) {
|
|
109
|
+
const { owner, repo } = yield* Repo;
|
|
110
|
+
yield* Effect.annotateCurrentSpan({
|
|
111
|
+
owner,
|
|
112
|
+
repo,
|
|
113
|
+
environment,
|
|
114
|
+
variable: name
|
|
115
|
+
});
|
|
116
|
+
if (yield* client.request("GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}", {
|
|
117
|
+
owner,
|
|
118
|
+
repo,
|
|
119
|
+
environment_name: environment,
|
|
120
|
+
name
|
|
121
|
+
}).pipe(Effect.as(true), Effect.catchIf((error) => error.kind === "notFound", () => Effect.succeed(false)))) {
|
|
122
|
+
yield* client.request("PATCH /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}", {
|
|
123
|
+
owner,
|
|
124
|
+
repo,
|
|
125
|
+
environment_name: environment,
|
|
126
|
+
name,
|
|
127
|
+
value
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
yield* client.request("POST /repos/{owner}/{repo}/environments/{environment_name}/variables", {
|
|
132
|
+
owner,
|
|
133
|
+
repo,
|
|
134
|
+
environment_name: environment,
|
|
135
|
+
name,
|
|
136
|
+
value
|
|
137
|
+
});
|
|
138
|
+
}),
|
|
139
|
+
listForEnvironment: Effect.fn("RepositoryVariable.listForEnvironment")(function* (environment) {
|
|
140
|
+
const { owner, repo } = yield* Repo;
|
|
141
|
+
yield* Effect.annotateCurrentSpan({
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
environment
|
|
145
|
+
});
|
|
146
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/environments/{environment_name}/variables", {
|
|
147
|
+
owner,
|
|
148
|
+
repo,
|
|
149
|
+
environment_name: environment
|
|
150
|
+
})).map((variable) => ({
|
|
151
|
+
name: variable.name,
|
|
152
|
+
value: variable.value
|
|
153
|
+
}));
|
|
154
|
+
}),
|
|
155
|
+
deleteForEnvironment: Effect.fn("RepositoryVariable.deleteForEnvironment")(function* (environment, name) {
|
|
156
|
+
const { owner, repo } = yield* Repo;
|
|
157
|
+
yield* Effect.annotateCurrentSpan({
|
|
158
|
+
owner,
|
|
159
|
+
repo,
|
|
160
|
+
environment,
|
|
161
|
+
variable: name
|
|
162
|
+
});
|
|
163
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}", {
|
|
164
|
+
owner,
|
|
165
|
+
repo,
|
|
166
|
+
environment_name: environment,
|
|
167
|
+
name
|
|
168
|
+
});
|
|
169
|
+
})
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
//#endregion
|
|
174
|
+
export { RepositoryVariable };
|
package/Ruleset.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { GitHubError } from "./GitHubError.js";
|
|
2
|
+
import { GitHubClient } from "./GitHubClient.js";
|
|
3
|
+
import { Repo } from "./Repo.js";
|
|
4
|
+
import { Context, Effect, Layer } from "effect";
|
|
5
|
+
|
|
6
|
+
//#region src/Ruleset.ts
|
|
7
|
+
/**
|
|
8
|
+
* Repository rulesets.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* ## An inherited ruleset is never written to
|
|
12
|
+
*
|
|
13
|
+
* `GET /repos/{owner}/{repo}/rulesets` returns rulesets **inherited from the
|
|
14
|
+
* organization** alongside the repository's own. Matching by name alone lets a
|
|
15
|
+
* repository-scoped call issue a `PUT` against the organization's ruleset id —
|
|
16
|
+
* rewriting policy for **every repository the organization owns**, from a caller
|
|
17
|
+
* that never mentioned the organization.
|
|
18
|
+
*
|
|
19
|
+
* {@link RulesetShape.upsert} filters on `source_type` before matching, so an
|
|
20
|
+
* inherited ruleset can never be the target of a write. This arrived as a fix
|
|
21
|
+
* for a live defect in the consumer this module was ported from, where the
|
|
22
|
+
* filter was absent.
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
var Ruleset = class Ruleset extends Context.Service()("@effected/github/Ruleset") {
|
|
27
|
+
/**
|
|
28
|
+
* @remarks
|
|
29
|
+
* `(client) => make(client)` rather than `make`: a static initializer runs
|
|
30
|
+
* while the module body is still evaluating, so naming a `const` declared
|
|
31
|
+
* further down throws at import time with a clean typecheck.
|
|
32
|
+
*/
|
|
33
|
+
static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
|
|
34
|
+
/** An in-memory double; unstubbed members die naming themselves. */
|
|
35
|
+
static makeTest = (overrides = {}) => ({
|
|
36
|
+
upsert: overrides.upsert ?? (() => unstubbed("upsert")),
|
|
37
|
+
list: overrides.list ?? (() => unstubbed("list")),
|
|
38
|
+
delete: overrides.delete ?? (() => unstubbed("delete")),
|
|
39
|
+
teamId: overrides.teamId ?? (() => unstubbed("teamId")),
|
|
40
|
+
roleId: overrides.roleId ?? (() => unstubbed("roleId"))
|
|
41
|
+
});
|
|
42
|
+
/** {@link Ruleset.makeTest} behind a `Layer`. */
|
|
43
|
+
static layerTest = (overrides = {}) => Layer.succeed(Ruleset, Ruleset.makeTest(overrides));
|
|
44
|
+
};
|
|
45
|
+
const unstubbed = (member) => {
|
|
46
|
+
throw new Error(`Ruleset.makeTest: ${member}() was called but not stubbed — pass an override.`);
|
|
47
|
+
};
|
|
48
|
+
/** An inherited ruleset belongs to the organization and is not this repository's to write. */
|
|
49
|
+
const isOwnedByRepository = (ruleset) => ruleset.source_type !== "Organization";
|
|
50
|
+
const make = (client) => {
|
|
51
|
+
return {
|
|
52
|
+
upsert: Effect.fn("Ruleset.upsert")(function* (payload) {
|
|
53
|
+
const { owner, repo } = yield* Repo;
|
|
54
|
+
yield* Effect.annotateCurrentSpan({
|
|
55
|
+
owner,
|
|
56
|
+
repo,
|
|
57
|
+
ruleset: payload.name
|
|
58
|
+
});
|
|
59
|
+
const match = (yield* client.paginate("GET /repos/{owner}/{repo}/rulesets", {
|
|
60
|
+
owner,
|
|
61
|
+
repo
|
|
62
|
+
})).find((ruleset) => ruleset.name === payload.name && isOwnedByRepository(ruleset));
|
|
63
|
+
const body = {
|
|
64
|
+
name: payload.name,
|
|
65
|
+
target: payload.target,
|
|
66
|
+
enforcement: payload.enforcement,
|
|
67
|
+
...payload.conditions !== void 0 ? { conditions: payload.conditions } : {},
|
|
68
|
+
...payload.rules !== void 0 ? { rules: payload.rules } : {},
|
|
69
|
+
...payload.bypass_actors !== void 0 ? { bypass_actors: payload.bypass_actors } : {}
|
|
70
|
+
};
|
|
71
|
+
if (match !== void 0) {
|
|
72
|
+
yield* client.request("PUT /repos/{owner}/{repo}/rulesets/{ruleset_id}", {
|
|
73
|
+
owner,
|
|
74
|
+
repo,
|
|
75
|
+
ruleset_id: match.id,
|
|
76
|
+
...body
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
yield* client.request("POST /repos/{owner}/{repo}/rulesets", {
|
|
81
|
+
owner,
|
|
82
|
+
repo,
|
|
83
|
+
...body
|
|
84
|
+
});
|
|
85
|
+
}),
|
|
86
|
+
list: Effect.fn("Ruleset.list")(function* () {
|
|
87
|
+
const { owner, repo } = yield* Repo;
|
|
88
|
+
yield* Effect.annotateCurrentSpan({
|
|
89
|
+
owner,
|
|
90
|
+
repo
|
|
91
|
+
});
|
|
92
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/rulesets", {
|
|
93
|
+
owner,
|
|
94
|
+
repo
|
|
95
|
+
})).map((ruleset) => ({
|
|
96
|
+
id: ruleset.id,
|
|
97
|
+
name: ruleset.name,
|
|
98
|
+
source_type: ruleset.source_type
|
|
99
|
+
}));
|
|
100
|
+
}),
|
|
101
|
+
delete: Effect.fn("Ruleset.delete")(function* (rulesetId) {
|
|
102
|
+
const { owner, repo } = yield* Repo;
|
|
103
|
+
yield* Effect.annotateCurrentSpan({
|
|
104
|
+
owner,
|
|
105
|
+
repo,
|
|
106
|
+
ruleset_id: rulesetId
|
|
107
|
+
});
|
|
108
|
+
yield* client.request("DELETE /repos/{owner}/{repo}/rulesets/{ruleset_id}", {
|
|
109
|
+
owner,
|
|
110
|
+
repo,
|
|
111
|
+
ruleset_id: rulesetId
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
teamId: Effect.fn("Ruleset.teamId")(function* (slug) {
|
|
115
|
+
const { owner } = yield* Repo;
|
|
116
|
+
yield* Effect.annotateCurrentSpan({
|
|
117
|
+
org: owner,
|
|
118
|
+
team_slug: slug
|
|
119
|
+
});
|
|
120
|
+
return (yield* client.request("GET /orgs/{org}/teams/{team_slug}", {
|
|
121
|
+
org: owner,
|
|
122
|
+
team_slug: slug
|
|
123
|
+
})).id;
|
|
124
|
+
}),
|
|
125
|
+
roleId: Effect.fn("Ruleset.roleId")(function* (name) {
|
|
126
|
+
const { owner } = yield* Repo;
|
|
127
|
+
yield* Effect.annotateCurrentSpan({
|
|
128
|
+
org: owner,
|
|
129
|
+
role: name
|
|
130
|
+
});
|
|
131
|
+
const roles = (yield* client.request("GET /orgs/{org}/organization-roles", { org: owner })).roles ?? [];
|
|
132
|
+
const role = roles.find((candidate) => candidate.name === name);
|
|
133
|
+
if (role === void 0) {
|
|
134
|
+
const available = roles.map((candidate) => candidate.name).join(", ");
|
|
135
|
+
return yield* GitHubError.notFound("Ruleset.roleId", `organization role '${name}' in '${owner}' (available: ${available || "none"})`);
|
|
136
|
+
}
|
|
137
|
+
return role.id;
|
|
138
|
+
})
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
export { Ruleset };
|
package/WorkflowDispatch.js
CHANGED
|
@@ -36,6 +36,7 @@ var WorkflowDispatch = class WorkflowDispatch extends Context.Service()("@effect
|
|
|
36
36
|
static makeTest = (overrides = {}) => ({
|
|
37
37
|
dispatch: overrides.dispatch ?? (() => unstubbed("dispatch")),
|
|
38
38
|
runStatus: overrides.runStatus ?? (() => unstubbed("runStatus")),
|
|
39
|
+
list: overrides.list ?? Effect.sync(() => unstubbed("list")),
|
|
39
40
|
dispatchAndWait: overrides.dispatchAndWait ?? (() => unstubbed("dispatchAndWait"))
|
|
40
41
|
});
|
|
41
42
|
/** {@link WorkflowDispatch.makeTest} behind a `Layer`. */
|
|
@@ -83,6 +84,22 @@ const make = (client) => {
|
|
|
83
84
|
});
|
|
84
85
|
return statusOf(raw);
|
|
85
86
|
}),
|
|
87
|
+
list: Effect.fn("WorkflowDispatch.list")(function* () {
|
|
88
|
+
const { owner, repo } = yield* Repo;
|
|
89
|
+
yield* Effect.annotateCurrentSpan({
|
|
90
|
+
owner,
|
|
91
|
+
repo
|
|
92
|
+
});
|
|
93
|
+
return (yield* client.paginate("GET /repos/{owner}/{repo}/actions/workflows", {
|
|
94
|
+
owner,
|
|
95
|
+
repo
|
|
96
|
+
})).map((workflow) => ({
|
|
97
|
+
id: workflow.id,
|
|
98
|
+
name: workflow.name,
|
|
99
|
+
path: workflow.path,
|
|
100
|
+
state: workflow.state
|
|
101
|
+
}));
|
|
102
|
+
})(),
|
|
86
103
|
dispatchAndWait: Effect.fn("WorkflowDispatch.dispatchAndWait")(function* (workflow, ref, options) {
|
|
87
104
|
const { owner, repo } = yield* Repo;
|
|
88
105
|
const interval = options?.poll?.interval ?? DEFAULT_INTERVAL;
|