@effected/github 0.25.1 → 0.27.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.
@@ -1,4 +1,5 @@
1
1
  import { GraphQLDocument } from "./GraphQL.js";
2
+ import { reservedSettingKey } from "./internal/requestOptions.js";
2
3
  import { unstubbed, unstubbedRead } from "./internal/unstubbed.js";
3
4
  import { GitHubClient } from "./GitHubClient.js";
4
5
  import { Repo } from "./Repo.js";
@@ -8,7 +9,7 @@ import { Context, Effect, Layer, Schema } from "effect";
8
9
  // src/GitHubRepository.ts
9
10
  /**
10
11
  * A {@link RepositoryPatch} from a draft, dropping every explicitly-`undefined`
11
- * field.
12
+ * field, plus any settings octokit's types do not know yet.
12
13
  *
13
14
  * @remarks
14
15
  * The supported spelling for "apply only what was configured" — the natural
@@ -18,6 +19,13 @@ import { Context, Effect, Layer, Schema } from "effect";
18
19
  * `PATCH` treats an absent field as "leave it alone", while an explicit `null`
19
20
  * or `undefined` is a value.
20
21
  *
22
+ * `options.extra` is the typed escape hatch for a setting GitHub accepts
23
+ * before octokit's generated types declare it: the draft stays checked, and
24
+ * only the extras ride unchecked. The result is still typed `RepositoryPatch`,
25
+ * and `GitHubRepository.updateSettings` sends every key it carries. A known key
26
+ * passed through `extra` throws a `RangeError` rather than skipping the draft's
27
+ * type check (see {@link RepositoryPatchOptions.extra}).
28
+ *
21
29
  * A key-by-key loop still defeats TypeScript's correlation between two indexed
22
30
  * accesses (`draft[key] = source[key]` over a union `key`), which no helper can
23
31
  * fix — build the draft as a literal where you can.
@@ -27,23 +35,60 @@ import { Context, Effect, Layer, Schema } from "effect";
27
35
  * import { repositoryPatch } from "@effected/github";
28
36
  *
29
37
  * // `config.has_issues` is `boolean | undefined`; absent fields drop out.
30
- * const patch = repositoryPatch({
31
- * has_issues: config.has_issues,
32
- * has_wiki: config.has_wiki,
33
- * description: config.description,
34
- * });
38
+ * const patch = repositoryPatch(
39
+ * {
40
+ * has_issues: config.has_issues,
41
+ * has_wiki: config.has_wiki,
42
+ * description: config.description,
43
+ * },
44
+ * // A setting GitHub accepts before octokit's types declare it.
45
+ * { extra: { has_new_feature: true } },
46
+ * );
35
47
  * ```
36
48
  *
37
49
  * @param draft - The fields to apply, any of which may be `undefined`.
50
+ * @param options - `extra`: settings the generated type does not declare.
38
51
  * @returns A patch carrying only the fields that were actually set.
39
52
  *
40
53
  * @public
41
54
  */
42
- const repositoryPatch = (draft) => {
55
+ const repositoryPatch = (draft, options) => {
43
56
  const out = {};
44
- for (const [key, value] of Object.entries(draft)) if (value !== void 0) out[key] = value;
57
+ for (const [key, value] of Object.entries(draft)) {
58
+ refuseReserved(key);
59
+ if (value !== void 0) out[key] = value;
60
+ }
61
+ for (const [key, value] of Object.entries(options?.extra ?? {})) {
62
+ refuseReserved(key);
63
+ if (Object.hasOwn(GRAPHQL_ONLY_SETTINGS, key)) throw new RangeError(`repositoryPatch: "${key}" can only be set through GraphQL; use GitHubRepository.applySettings, not a REST patch`);
64
+ if (REPOSITORY_REST_SETTING_KEYS.has(key)) throw new RangeError(`repositoryPatch: "${key}" is a declared repository setting; pass it in the draft, where its type is checked, not in extra`);
65
+ if (value !== void 0) out[key] = value;
66
+ }
45
67
  return out;
46
68
  };
69
+ /** A `RangeError` for a key octokit would read as something other than a setting. */
70
+ const refuseReserved = (key) => {
71
+ const reason = reservedSettingKey(key);
72
+ if (reason !== void 0) throw new RangeError(`repositoryPatch: "${key}" ${reason}; it is never sent`);
73
+ };
74
+ /**
75
+ * The parameters of a repository PATCH, with the settings as octokit's `data`.
76
+ *
77
+ * @remarks
78
+ * `data` is octokit's explicit body: `@octokit/endpoint` sends it verbatim and
79
+ * reads no key inside it as a request option or a path parameter. Spreading
80
+ * the settings beside `owner` and `repo` instead lets an open map choose the
81
+ * request's host, headers, fetch implementation or path, which is how an
82
+ * authenticated PATCH reaches another host with the token attached. So the
83
+ * confinement holds by construction; the reserved-name refusals are defence in
84
+ * depth. The generated parameter type has no `data`, so the cast is here, on
85
+ * the one shape both write paths send.
86
+ */
87
+ const patchParams = (owner, repo, body) => ({
88
+ owner,
89
+ repo,
90
+ data: body
91
+ });
47
92
  /**
48
93
  * Fields in the user-facing `security_and_analysis` block that GitHub accepts
49
94
  * as `{ status: "enabled" | "disabled" }`.
@@ -268,26 +313,29 @@ const make = (client) => {
268
313
  repo,
269
314
  fields: Object.keys(patch).length
270
315
  });
271
- return yield* client.request("PATCH /repos/{owner}/{repo}", {
272
- ...preparePatch(patch),
273
- owner,
274
- repo
275
- });
316
+ return yield* client.request("PATCH /repos/{owner}/{repo}", patchParams(owner, repo, preparePatch(patch)));
276
317
  }),
277
318
  defaultBranch: Effect.map(settings, (repository) => repository.default_branch),
278
319
  nodeId: Effect.map(settings, (repository) => repository.node_id),
279
320
  ownerType,
280
- applySettings: Effect.fn("GitHubRepository.applySettings")(function* (input) {
321
+ applySettings: Effect.fn("GitHubRepository.applySettings")(function* (input, options) {
281
322
  const { owner, repo } = yield* Repo;
323
+ const sendUnknown = options?.unknownKeys === "send";
282
324
  yield* Effect.annotateCurrentSpan({
283
325
  owner,
284
- repo
326
+ repo,
327
+ unknownKeys: sendUnknown ? "send" : "ignore"
285
328
  });
286
329
  const rest = {};
287
330
  const graphql = {};
288
331
  const graphqlKeys = [];
289
332
  const ignored = [];
333
+ const unverified = [];
290
334
  for (const [key, value] of Object.entries(input)) {
335
+ if (reservedSettingKey(key) !== void 0) {
336
+ ignored.push(key);
337
+ continue;
338
+ }
291
339
  const graphqlField = Object.hasOwn(GRAPHQL_ONLY_SETTINGS, key) ? GRAPHQL_ONLY_SETTINGS[key] : void 0;
292
340
  if (graphqlField !== void 0) {
293
341
  graphql[graphqlField] = value;
@@ -295,19 +343,22 @@ const make = (client) => {
295
343
  continue;
296
344
  }
297
345
  const removedIn = Object.hasOwn(REMOVED_BY_PINNED_VERSION, key) ? REMOVED_BY_PINNED_VERSION[key] : void 0;
298
- if (!REPOSITORY_REST_SETTING_KEYS.has(key) || removedIn !== void 0 && client.apiVersion >= removedIn) {
346
+ if (removedIn !== void 0 && client.apiVersion >= removedIn) {
299
347
  ignored.push(key);
300
348
  continue;
301
349
  }
350
+ if (!REPOSITORY_REST_SETTING_KEYS.has(key)) {
351
+ if (!sendUnknown) {
352
+ ignored.push(key);
353
+ continue;
354
+ }
355
+ unverified.push(key);
356
+ }
302
357
  rest[key] = value;
303
358
  }
304
359
  const prepared = preparePatch(rest);
305
360
  const restKeys = Object.keys(prepared);
306
- if (restKeys.length > 0) yield* client.request("PATCH /repos/{owner}/{repo}", {
307
- ...prepared,
308
- owner,
309
- repo
310
- });
361
+ if (restKeys.length > 0) yield* client.request("PATCH /repos/{owner}/{repo}", patchParams(owner, repo, prepared));
311
362
  if (Object.keys(graphql).length > 0) {
312
363
  const repository = yield* client.request("GET /repos/{owner}/{repo}", {
313
364
  owner,
@@ -321,7 +372,8 @@ const make = (client) => {
321
372
  return {
322
373
  rest: restKeys,
323
374
  graphql: graphqlKeys,
324
- ignored
375
+ ignored,
376
+ unverified: unverified.filter((key) => Object.hasOwn(prepared, key))
325
377
  };
326
378
  })
327
379
  };
package/GraphQL.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { retryAfterMillisFrom } from "./internal/headers.js";
2
+ import { scrubThrowable } from "./internal/scrub.js";
2
3
  import { Schema } from "effect";
3
4
 
4
5
  // src/GraphQL.ts
@@ -78,6 +79,11 @@ var GitHubGraphQLError = class GitHubGraphQLError extends Schema.TaggedError()("
78
79
  * failure with a `status`. Both arrive as throwables and both are read
79
80
  * structurally, for the same reason the REST classifier does it — the error
80
81
  * classes live in packages this one does not declare.
82
+ *
83
+ * The kept `cause` is the same credential-free copy
84
+ * {@link GitHubError.fromOctokit} keeps: the GraphQL document, its variables
85
+ * and any partial `data` are dropped with the rest of the request and response
86
+ * bodies.
81
87
  */
82
88
  static fromThrowable(operation, error, nowMillis) {
83
89
  const record = typeof error === "object" && error !== null ? error : void 0;
@@ -92,7 +98,7 @@ var GitHubGraphQLError = class GitHubGraphQLError extends Schema.TaggedError()("
92
98
  reason,
93
99
  errors: entries,
94
100
  ...retryAfterMillis !== void 0 ? { retryAfterMillis } : {},
95
- cause: error
101
+ cause: scrubThrowable(error)
96
102
  });
97
103
  }
98
104
  };
package/README.md CHANGED
@@ -8,10 +8,10 @@
8
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-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
- > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
11
+ > Effect v4 (`effect` `~4.1.0`) and still in `0.x` development. Stable Effect
12
12
  > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
13
  > `@effect/*` versions on the line the kit is built and tested against, install
14
- > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
14
+ > [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
17
17
  > complete and may change across `0.x` releases. Pin an exact version — even a
@@ -25,7 +25,7 @@ Hand-rolled octokit wrappers tend to converge on the same shape: a `rest<T>(oper
25
25
 
26
26
  The error model gets the same treatment. `GitHubError.kind` is a literal union (`notFound`, `alreadyExists`, `rejected`, `unauthorized`, `rateLimited`, `transport`, `decode`) produced by one classification step, `GitHubError.fromOctokit`, instead of one hand-written mapper per resource. `kind: "alreadyExists"` is what makes `GitBranch.upsert` and `GitTag.upsert` a single round trip in the common case, rather than a create-then-catch-then-check-then-reset dance repeated at every call site that needs it.
27
27
 
28
- This package also owns the octokit runtime so nothing downstream has to. `@octokit/rest` and `@octokit/auth-app` are deliberately absent — the rest wrapper is a request-logging plugin plus 1.4 MB of duplicate endpoint types, and `auth-app` drags in `createOAuthUserAuth`, roughly 492 KB of OAuth machinery this package never calls. GitHub App JWTs are signed by the kit's own `@effected/jwt` instead: WebCrypto RS256 with no runtime dependencies, so App auth runs on Cloudflare Workers as well as Node. A consumer that only authenticates with a token it already holds never links the JWT signer at all: the App-authenticated layer lives in its own module (`GitHubApp`), and nothing here is gathered into a namespace object that would defeat that split.
28
+ This package also owns the octokit runtime so nothing downstream has to. `@octokit/rest` and `@octokit/auth-app` are deliberately absent — the rest wrapper is a request-logging plugin plus 1.4 MB of duplicate endpoint types, and `auth-app` drags in `createOAuthUserAuth`, roughly 492 KB of OAuth machinery this package never calls. GitHub App JWTs are signed by the kit's own `@effected/jwt` instead, over core `effect/jose`: RS256 with no extra runtime dependencies, so App auth runs on Cloudflare Workers as well as Node. The App layers (`GitHubApp.layer`, `layerWith`, `clientLayer`, `appClientLayer`) require core's `Signature` service; provide `Signature.layerWebCrypto.pipe(Layer.provide(SecureRandom.layerWebCrypto))` from `effect/crypto` once at the edge. A consumer that only authenticates with a token it already holds never links the JWT signer at all: the App-authenticated layer lives in its own module (`GitHubApp`), and nothing here is gathered into a namespace object that would defeat that split.
29
29
 
30
30
  ## Install
31
31