@effected/github 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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/GitHubApp.js CHANGED
@@ -16,7 +16,7 @@ import githubAppJwt from "universal-github-app-jwt";
16
16
  *
17
17
  * @public
18
18
  */
19
- var GitHubAppError = class GitHubAppError extends Schema.TaggedErrorClass()("GitHubAppError", {
19
+ var GitHubAppError = class GitHubAppError extends Schema.TaggedError()("GitHubAppError", {
20
20
  /** Which step failed. */
21
21
  kind: Schema.Literals([
22
22
  "jwt",
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, _params) => {
188
+ request: (route, params) => {
189
+ requested?.push({
190
+ kind: "request",
191
+ route,
192
+ params
193
+ });
174
194
  const data = fixtures.request?.[route];
175
- return data === void 0 ? Effect.fail(GitHubError.notFound("GitHubClient.request", `fixture for ${route}`)) : Effect.succeed(data);
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, _params, schema) => {
198
+ requestDecoded: (route, params, schema) => {
199
+ requested?.push({
200
+ kind: "requestDecoded",
201
+ route,
202
+ params
203
+ });
178
204
  const data = fixtures.request?.[route];
179
- return data === void 0 ? Effect.fail(GitHubError.notFound("GitHubClient.requestDecoded", `fixture for ${route}`)) : Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubError.decode(route, "fixture did not match its schema", error))));
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, _variables) => {
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/GitHubError.js CHANGED
@@ -41,7 +41,7 @@ const GitHubErrorKind = Schema.Literals([
41
41
  *
42
42
  * @public
43
43
  */
44
- var GitHubError = class GitHubError extends Schema.TaggedErrorClass()("GitHubError", {
44
+ var GitHubError = class GitHubError extends Schema.TaggedError()("GitHubError", {
45
45
  /** Structural routing. Branch on this, never on the rendered message. */
46
46
  kind: GitHubErrorKind,
47
47
  /** What was attempted: a resource method (`"GitBranch.upsert"`) or a raw route. */
@@ -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/GraphQL.js CHANGED
@@ -23,7 +23,7 @@ var GraphQLErrorEntry = class extends Schema.Class("GraphQLErrorEntry")({
23
23
  *
24
24
  * @public
25
25
  */
26
- var GitHubGraphQLError = class GitHubGraphQLError extends Schema.TaggedErrorClass()("GitHubGraphQLError", {
26
+ var GitHubGraphQLError = class GitHubGraphQLError extends Schema.TaggedError()("GitHubGraphQLError", {
27
27
  /**
28
28
  * Structural routing, mirroring `GitHubError`'s.
29
29
  *
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](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`.
package/Repo.js CHANGED
@@ -6,7 +6,7 @@ import { Config, Context, Effect, Layer, Result, Schema } from "effect";
6
6
  *
7
7
  * @public
8
8
  */
9
- var InvalidRepoRefError = class extends Schema.TaggedErrorClass()("InvalidRepoRefError", {
9
+ var InvalidRepoRefError = class extends Schema.TaggedError()("InvalidRepoRefError", {
10
10
  /** What was handed in. */
11
11
  input: Schema.String }) {
12
12
  get message() {