@effected/github 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Attestation.js CHANGED
@@ -12,6 +12,10 @@ import { Context, Effect, Layer, Schema } from "effect";
12
12
  * with a stated sunset. Pinning the version means the response is on a contract
13
13
  * the generated types do not describe — which is why this module is the one that
14
14
  * uses `requestDecoded` with owned schemas rather than the route table.
15
+ *
16
+ * Sent as a per-call header, which wins over the client's own
17
+ * `apiVersion`, so these reads stay on this contract even when a consumer
18
+ * pins the client to another version.
15
19
  */
16
20
  const API_VERSION = "2026-03-10";
17
21
  /**
package/GitHubApp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
2
  import { GitHubGraphQLError } from "./GraphQL.js";
3
3
  import { unstubbed } from "./internal/unstubbed.js";
4
- import { GitHubClient, makeClientShape } from "./GitHubClient.js";
4
+ import { GitHubClient, makeClientShape, resolveApiVersion } from "./GitHubClient.js";
5
5
  import { numericId } from "./internal/ids.js";
6
6
  import { InstallationTokenStore } from "./InstallationTokenStore.js";
7
7
  import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore, Stream } from "effect";
@@ -544,7 +544,7 @@ const asBearer = (token, options) => makeClientShape({
544
544
  });
545
545
  const appFailure = (kind) => (error) => Effect.fail(GitHubAppError.of(kind, error.reason, error));
546
546
  function makeApp(options) {
547
- return Effect.sync(() => {
547
+ return Effect.map(resolveApiVersion(options.apiVersion), () => {
548
548
  const installations = Effect.fn("GitHubApp.installations")(function* (credentials) {
549
549
  return (yield* (yield* asApp(credentials, options)).paginate("GET /app/installations", {}).pipe(Effect.catch(appFailure("installation")))).map((entry) => {
550
550
  const account = entry.account ?? void 0;
@@ -634,6 +634,7 @@ const normalizePermissions = (raw) => {
634
634
  * release.
635
635
  */
636
636
  const makeRotatingClient = (mint, release, options, operation) => Effect.gen(function* () {
637
+ const apiVersion = yield* resolveApiVersion(options.apiVersion);
637
638
  const held = yield* Ref.make(Option.none());
638
639
  const revokeHeld = Effect.flatMap(Ref.get(held), (current) => Option.isSome(current) ? Effect.ignore(release(current.value.credential.token)) : Effect.void);
639
640
  const rotate = Effect.gen(function* () {
@@ -678,7 +679,8 @@ const makeRotatingClient = (mint, release, options, operation) => Effect.gen(fun
678
679
  paginate: (route, params, pageOptions) => Effect.flatMap(current, (client) => client.paginate(route, params, pageOptions)),
679
680
  paginateStream: (route, params, pageOptions) => Stream.unwrap(Effect.map(current, (client) => client.paginateStream(route, params, pageOptions))),
680
681
  graphql: (document, variables) => Effect.flatMap(currentForGraphQL, (client) => client.graphql(document, variables)),
681
- rateLimit: Effect.flatMap(Ref.get(held), (state) => Option.isSome(state) ? state.value.client.rateLimit : Effect.succeed(Option.none()))
682
+ rateLimit: Effect.flatMap(Ref.get(held), (state) => Option.isSome(state) ? state.value.client.rateLimit : Effect.succeed(Option.none())),
683
+ apiVersion
682
684
  };
683
685
  });
684
686
 
package/GitHubClient.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
2
  import { GitHubGraphQLError } from "./GraphQL.js";
3
3
  import { RetryPolicy } from "./Resilience.js";
4
- import { makeTransport } from "./internal/octokit.js";
4
+ import { DEFAULT_API_VERSION, makeTransport } from "./internal/octokit.js";
5
5
  import { fromArray, paginate } from "./internal/paginate.js";
6
6
  import { unstubbed } from "./internal/unstubbed.js";
7
7
  import { Clock, Config, Context, Effect, Layer, Option, Schema, Stream } from "effect";
@@ -149,11 +149,14 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
149
149
  * member **dies** naming itself.
150
150
  *
151
151
  * @remarks
152
- * No member has an honest default. A fabricated response — an empty list, a
152
+ * No request member has an honest default. A fabricated response — an empty list, a
153
153
  * made-up sha — would leak into the code under test as fact, so the double
154
154
  * fails loudly instead, which also makes it proof that a test touches nothing
155
155
  * but what it stubbed.
156
156
  *
157
+ * `rateLimit` answers `Option.none()` and `apiVersion` the live default,
158
+ * because a real client legitimately answers both without a request.
159
+ *
157
160
  * For recorded responses that page for real, use
158
161
  * {@link GitHubClient.layerFixture}.
159
162
  */
@@ -163,7 +166,8 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
163
166
  paginate: overrides.paginate ?? (() => dies("paginate")),
164
167
  paginateStream: overrides.paginateStream ?? (() => Stream.fromEffect(dies("paginateStream"))),
165
168
  graphql: overrides.graphql ?? (() => dies("graphql")),
166
- rateLimit: overrides.rateLimit ?? Effect.succeed(Option.none())
169
+ rateLimit: overrides.rateLimit ?? Effect.succeed(Option.none()),
170
+ apiVersion: overrides.apiVersion ?? "2026-03-10"
167
171
  });
168
172
  /** {@link GitHubClient.makeTest} behind a `Layer`. */
169
173
  static layerTest = (overrides = {}) => Layer.succeed(GitHubClient, GitHubClient.makeTest(overrides));
@@ -188,6 +192,21 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
188
192
  */
189
193
  static layerFixture = (fixtures) => Layer.succeed(GitHubClient, makeFixture(fixtures));
190
194
  };
195
+ /** A calendar api-version: `YYYY-MM-DD`. */
196
+ const API_VERSION_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
197
+ /**
198
+ * The api-version a client pins: the option when it is a calendar version,
199
+ * the default when it is absent, and a defect naming the option otherwise.
200
+ *
201
+ * @remarks
202
+ * A defect, not a typed failure: a malformed version is a wiring mistake no
203
+ * caller recovers from, and every layer that builds a client would otherwise
204
+ * widen its error channel for it. Exported for `GitHubApp`, which validates
205
+ * eagerly at its own layer construction. Not part of the public surface.
206
+ *
207
+ * @internal
208
+ */
209
+ const resolveApiVersion = (option) => option === void 0 ? Effect.succeed(DEFAULT_API_VERSION) : API_VERSION_PATTERN.test(option) ? Effect.succeed(option) : Effect.die(/* @__PURE__ */ new Error(`GitHubClient: the apiVersion option must be a calendar version like "${DEFAULT_API_VERSION}" (YYYY-MM-DD), got ${JSON.stringify(option)}`));
191
210
  /**
192
211
  * Builds the live shape over a constructed transport.
193
212
  *
@@ -199,12 +218,14 @@ var GitHubClient = class GitHubClient extends Context.Service()("@effected/githu
199
218
  * @internal
200
219
  */
201
220
  const makeClientShape = (options) => Effect.gen(function* () {
221
+ const apiVersion = yield* resolveApiVersion(options.apiVersion);
202
222
  const transport = yield* makeTransport({
203
223
  token: options.token,
204
224
  retry: resolvePolicy(options.retry),
205
225
  baseUrl: options.baseUrl,
206
226
  userAgent: options.userAgent,
207
- fetch: options.fetch
227
+ fetch: options.fetch,
228
+ apiVersion
208
229
  });
209
230
  const request = Effect.fn("GitHubClient.request")(function* (route, params) {
210
231
  yield* Effect.annotateCurrentSpan({ route });
@@ -236,7 +257,8 @@ const makeClientShape = (options) => Effect.gen(function* () {
236
257
  const raw = yield* transport.graphql(document.name, document.document, document.encodeVariables(variables));
237
258
  return yield* document.decode(raw).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(GitHubGraphQLError.decode(document.name, "response did not match its schema", error))));
238
259
  }),
239
- rateLimit: transport.rateLimit
260
+ rateLimit: transport.rateLimit,
261
+ apiVersion
240
262
  };
241
263
  });
242
264
  const makeFixture = (fixtures) => {
@@ -303,8 +325,9 @@ const makeFixture = (fixtures) => {
303
325
  const raw = fixtures.graphql?.[document.name];
304
326
  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))));
305
327
  },
306
- rateLimit: Effect.succeed(Option.fromUndefinedOr(fixtures.rateLimit))
328
+ rateLimit: Effect.succeed(Option.fromUndefinedOr(fixtures.rateLimit)),
329
+ apiVersion: fixtures.apiVersion ?? "2026-03-10"
307
330
  };
308
331
  };
309
332
 
310
- export { GitHubClient, GitHubFixtures, RawFailure, makeClientShape };
333
+ export { GitHubClient, GitHubFixtures, RawFailure, makeClientShape, resolveApiVersion };
package/GitHubIssue.js CHANGED
@@ -142,29 +142,6 @@ var GitHubIssue = class GitHubIssue extends Context.Service()("@effected/github/
142
142
  static layerTest = (overrides = {}) => Layer.succeed(GitHubIssue, GitHubIssue.makeTest(overrides));
143
143
  };
144
144
  const dies = unstubbed("GitHubIssue.makeTest");
145
- /**
146
- * The REST calendar version this module's requests pin.
147
- *
148
- * @remarks
149
- * The routes are not deprecated — the **default api-version** is. When no
150
- * `x-github-api-version` header is sent (octokit sends none), GitHub serves
151
- * calendar version 2022-11-28, which it deprecated when 2026-03-10 shipped and
152
- * sunsets on 2028-03-10. Every response from an endpoint the new version
153
- * changed then carries a `Deprecation` header, which `@octokit/request` prints
154
- * straight to the consumer's console — the constructor's `log` option cannot
155
- * silence it, because the fetch wrapper resolves `request.log ?? console`.
156
- * `PATCH /repos/{owner}/{repo}/issues/{issue_number}` is on that list (the
157
- * singular `assignee` parameter was removed), so every `close` warned once per
158
- * issue in consumer workflow logs.
159
- *
160
- * Nothing this module sends or reads changed in 2026-03-10: `state` and
161
- * `state_reason` are untouched, and the projections read only fields both
162
- * versions carry. Pinning the current version is therefore the whole fix, and
163
- * it deliberately does **not** hide future deprecations — when GitHub
164
- * deprecates 2026-03-10 in turn, the header comes back and octokit warns
165
- * again.
166
- */
167
- const API_VERSION_HEADERS = { "x-github-api-version": "2026-03-10" };
168
145
  /** GitHub sends a label as either a string or an object; callers want the name. */
169
146
  const labelNames = (labels) => labels.flatMap((label) => typeof label === "string" ? [label] : label.name !== void 0 ? [label.name] : []);
170
147
  const project = (raw) => IssueInfo.make({
@@ -186,8 +163,7 @@ const make = (client) => ({
186
163
  const raw = yield* client.request("GET /repos/{owner}/{repo}/issues/{issue_number}", {
187
164
  owner,
188
165
  repo,
189
- issue_number: number,
190
- headers: API_VERSION_HEADERS
166
+ issue_number: number
191
167
  });
192
168
  return project(raw);
193
169
  }),
@@ -201,8 +177,7 @@ const make = (client) => ({
201
177
  owner,
202
178
  repo,
203
179
  ...options?.state !== void 0 ? { state: options.state } : {},
204
- ...options?.labels !== void 0 ? { labels: options.labels.join(",") } : {},
205
- headers: API_VERSION_HEADERS
180
+ ...options?.labels !== void 0 ? { labels: options.labels.join(",") } : {}
206
181
  }, options?.page)).map(project);
207
182
  }),
208
183
  close: Effect.fn("GitHubIssue.close")(function* (number, reason) {
@@ -217,8 +192,7 @@ const make = (client) => ({
217
192
  repo,
218
193
  issue_number: number,
219
194
  state: "closed",
220
- ...reason !== void 0 ? { state_reason: reason } : {},
221
- headers: API_VERSION_HEADERS
195
+ ...reason !== void 0 ? { state_reason: reason } : {}
222
196
  });
223
197
  }),
224
198
  comment: Effect.fn("GitHubIssue.comment")(function* (number, body) {
@@ -232,8 +206,7 @@ const make = (client) => ({
232
206
  owner,
233
207
  repo,
234
208
  issue_number: number,
235
- body,
236
- headers: API_VERSION_HEADERS
209
+ body
237
210
  });
238
211
  return numericId(created.id);
239
212
  }),
@@ -248,8 +221,7 @@ const make = (client) => ({
248
221
  const existing = (yield* client.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", {
249
222
  owner,
250
223
  repo,
251
- issue_number: issueNumber,
252
- headers: API_VERSION_HEADERS
224
+ issue_number: issueNumber
253
225
  })).find((comment) => marker.matches(comment.body ?? ""));
254
226
  if (existing !== void 0) return CommentOnceResult.make({
255
227
  wrote: false,
@@ -263,8 +235,7 @@ const make = (client) => ({
263
235
  owner,
264
236
  repo,
265
237
  issue_number: issueNumber,
266
- body: `${body}\n\n${marker.html}`,
267
- headers: API_VERSION_HEADERS
238
+ body: `${body}\n\n${marker.html}`
268
239
  });
269
240
  return CommentOnceResult.make({
270
241
  wrote: true,
@@ -2,6 +2,7 @@ import { GraphQLDocument } from "./GraphQL.js";
2
2
  import { unstubbed, unstubbedRead } from "./internal/unstubbed.js";
3
3
  import { GitHubClient } from "./GitHubClient.js";
4
4
  import { Repo } from "./Repo.js";
5
+ import { REMOVED_BY_PINNED_VERSION, REPOSITORY_REST_SETTING_KEYS } from "./internal/repositoryRestSettingKeys.js";
5
6
  import { Context, Effect, Layer, Schema } from "effect";
6
7
 
7
8
  // src/GitHubRepository.ts
@@ -64,17 +65,21 @@ const SECURITY_ANALYSIS_STATUS_FIELDS = /* @__PURE__ */ new Set([
64
65
  "dependabot_security_updates"
65
66
  ]);
66
67
  /**
67
- * Settings reachable **only** through the GraphQL `updateRepository` mutation,
68
- * mapped from snake_case keys to camelCase GraphQL input fields.
68
+ * Settings written through the GraphQL `updateRepository` mutation, mapped
69
+ * from snake_case keys to camelCase GraphQL input fields.
69
70
  *
70
71
  * @remarks
71
- * GitHub never exposed these on the REST repository endpoint — the
72
- * `PATCH /repos/{owner}/{repo}` route accepts none of them, and
73
- * `has_discussions` is the treacherous one: the REST **read** returns it, so
74
- * routing its write to the PATCH looks symmetric, but the PATCH silently
75
- * ignores unknown body fields and answers 200, so the write would look applied
76
- * while the repository never changed. Setting any of these forces a second
77
- * round trip to learn the repository's node id.
72
+ * `applySettings` checks this map **first**, so a key here always goes
73
+ * through GraphQL, even when the REST patch also declares it.
74
+ * `has_sponsorships` and `has_discussions` are GraphQL-only: the
75
+ * `PATCH /repos/{owner}/{repo}` route accepts neither, and `has_discussions`
76
+ * is the treacherous one — the REST **read** returns it, so routing its write
77
+ * to the PATCH looks symmetric, but the PATCH silently ignores unknown body
78
+ * fields and answers 200, so the write would look applied while the
79
+ * repository never changed. `has_pull_requests` is in the REST patch's body
80
+ * too; GraphQL is preferred for it so the three toggles travel together.
81
+ * Setting any of these forces a second round trip to learn the repository's
82
+ * node id.
78
83
  *
79
84
  * @public
80
85
  */
@@ -171,12 +176,13 @@ const preparePatch = (patch) => {
171
176
  };
172
177
  /**
173
178
  * Read and update a repository's settings, including settings only GraphQL can
174
- * write, and look up its default branch, node id and owner type.
179
+ * write, read and replace its topics, and look up its default branch, node id
180
+ * and owner type.
175
181
  *
176
182
  * @remarks
177
183
  * Provide it with {@link GitHubRepository.layer}, which needs a `GitHubClient`;
178
- * every member also needs a `Repo` in `R`. `settings`, `defaultBranch`, `nodeId`
179
- * and `ownerType` are `Effect` values, not functions.
184
+ * every member also needs a `Repo` in `R`. `settings`, `defaultBranch`, `nodeId`,
185
+ * `ownerType` and `topics` are `Effect` values, not functions.
180
186
  *
181
187
  * @example
182
188
  * ```ts
@@ -187,7 +193,7 @@ const preparePatch = (patch) => {
187
193
  * const repository = yield* GitHubRepository;
188
194
  * const branch = yield* repository.defaultBranch;
189
195
  * const applied = yield* repository.applySettings({ has_wiki: false, has_discussions: true });
190
- * return { branch, applied }; // applied.rest, applied.graphql
196
+ * return { branch, applied }; // applied.rest, applied.graphql, applied.ignored
191
197
  * });
192
198
  * ```
193
199
  *
@@ -203,7 +209,9 @@ var GitHubRepository = class GitHubRepository extends Context.Service()("@effect
203
209
  defaultBranch: overrides.defaultBranch ?? unread("defaultBranch"),
204
210
  nodeId: overrides.nodeId ?? unread("nodeId"),
205
211
  ownerType: overrides.ownerType ?? unread("ownerType"),
206
- applySettings: overrides.applySettings ?? (() => dies("applySettings"))
212
+ applySettings: overrides.applySettings ?? (() => dies("applySettings")),
213
+ topics: overrides.topics ?? unread("topics"),
214
+ setTopics: overrides.setTopics ?? (() => dies("setTopics"))
207
215
  });
208
216
  /** {@link GitHubRepository.makeTest} behind a `Layer`. */
209
217
  static layerTest = (overrides = {}) => Layer.succeed(GitHubRepository, GitHubRepository.makeTest(overrides));
@@ -229,6 +237,30 @@ const make = (client) => {
229
237
  })();
230
238
  return {
231
239
  settings,
240
+ topics: Effect.fn("GitHubRepository.topics")(function* () {
241
+ const { owner, repo } = yield* Repo;
242
+ yield* Effect.annotateCurrentSpan({
243
+ owner,
244
+ repo
245
+ });
246
+ return (yield* client.request("GET /repos/{owner}/{repo}/topics", {
247
+ owner,
248
+ repo
249
+ })).names;
250
+ })(),
251
+ setTopics: Effect.fn("GitHubRepository.setTopics")(function* (names) {
252
+ const { owner, repo } = yield* Repo;
253
+ yield* Effect.annotateCurrentSpan({
254
+ owner,
255
+ repo,
256
+ topics: names.length
257
+ });
258
+ return (yield* client.request("PUT /repos/{owner}/{repo}/topics", {
259
+ owner,
260
+ repo,
261
+ names: [...names]
262
+ })).names;
263
+ }),
232
264
  updateSettings: Effect.fn("GitHubRepository.updateSettings")(function* (patch) {
233
265
  const { owner, repo } = yield* Repo;
234
266
  yield* Effect.annotateCurrentSpan({
@@ -254,6 +286,7 @@ const make = (client) => {
254
286
  const rest = {};
255
287
  const graphql = {};
256
288
  const graphqlKeys = [];
289
+ const ignored = [];
257
290
  for (const [key, value] of Object.entries(input)) {
258
291
  const graphqlField = Object.hasOwn(GRAPHQL_ONLY_SETTINGS, key) ? GRAPHQL_ONLY_SETTINGS[key] : void 0;
259
292
  if (graphqlField !== void 0) {
@@ -261,6 +294,11 @@ const make = (client) => {
261
294
  graphqlKeys.push(key);
262
295
  continue;
263
296
  }
297
+ 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) {
299
+ ignored.push(key);
300
+ continue;
301
+ }
264
302
  rest[key] = value;
265
303
  }
266
304
  const prepared = preparePatch(rest);
@@ -282,7 +320,8 @@ const make = (client) => {
282
320
  }
283
321
  return {
284
322
  rest: restKeys,
285
- graphql: graphqlKeys
323
+ graphql: graphqlKeys,
324
+ ignored
286
325
  };
287
326
  })
288
327
  };
package/PullRequest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
- import { GraphQLDocument } from "./GraphQL.js";
2
+ import { GitHubGraphQLError, GraphQLDocument } from "./GraphQL.js";
3
3
  import { unstubbed } from "./internal/unstubbed.js";
4
4
  import { GitHubClient } from "./GitHubClient.js";
5
5
  import { Repo } from "./Repo.js";
@@ -55,7 +55,27 @@ var PullRequestInfo = class extends Schema.Class("PullRequestInfo")({
55
55
  mergedAt: Schema.Option(Schema.DateTimeUtcFromString),
56
56
  /** The description, when GitHub sent one. */
57
57
  body: Schema.optionalKey(Schema.String),
58
- /** The merge commit, once there is one. */
58
+ /**
59
+ * The sha of the commit the merge produced; absent until the pull request
60
+ * has merged.
61
+ *
62
+ * @remarks
63
+ * Read from GraphQL's `PullRequest.mergeCommit.oid`. The REST pull request
64
+ * object no longer carries `merge_commit_sha` under the api-version this
65
+ * package pins, so a merged pull request costs one extra GraphQL round trip
66
+ * per single read (`get`, `update`, `upsert`), and an open or
67
+ * closed-unmerged one costs nothing. When the REST response does carry the
68
+ * field (a client pinned to an older api-version), it is used for a merged
69
+ * pull request and the GraphQL read is skipped.
70
+ *
71
+ * **A list read leaves it absent unless asked.** `list` and
72
+ * `listAssociatedWithCommit` fill it only when called with
73
+ * `mergeCommits: true`, at one GraphQL call per 100 merged pull requests.
74
+ *
75
+ * It is never the speculative test-merge commit GitHub computes for an open
76
+ * pull request, so `mergeCommitSha === sha` means "this commit is what the
77
+ * pull request merged as".
78
+ */
59
79
  mergeCommitSha: Schema.optionalKey(Schema.String)
60
80
  }) {};
61
81
  const AutoMergeResponse = Schema.Struct({});
@@ -75,6 +95,24 @@ const DisableAutoMerge = GraphQLDocument.make({
75
95
  }`,
76
96
  response: AutoMergeResponse
77
97
  })();
98
+ /**
99
+ * One answer per requested id, **in request order**: a merged pull request
100
+ * carries its merge commit, and a node of another type answers `{}`.
101
+ */
102
+ const MergeCommitsResponse = Schema.Struct({ nodes: Schema.Array(Schema.NullOr(Schema.Struct({ mergeCommit: Schema.optionalKey(Schema.NullOr(Schema.Struct({ oid: Schema.String }))) }))) });
103
+ const MergeCommits = GraphQLDocument.make({
104
+ name: "PullRequestMergeCommits",
105
+ document: `query PullRequestMergeCommits($ids: [ID!]!) {
106
+ nodes(ids: $ids) {
107
+ ... on PullRequest { mergeCommit { oid } }
108
+ }
109
+ }`,
110
+ response: MergeCommitsResponse
111
+ })();
112
+ /** GraphQL's `nodes(ids:)` accepts at most this many ids per call. */
113
+ const NODES_PER_QUERY = 100;
114
+ /** How many `nodes(ids:)` calls a list's merge-commit read keeps in flight. */
115
+ const MERGE_COMMIT_CONCURRENCY = 4;
78
116
  /** GraphQL spells the merge methods in capitals. */
79
117
  const GRAPHQL_MERGE_METHOD = {
80
118
  merge: "MERGE",
@@ -130,27 +168,63 @@ var PullRequest = class PullRequest extends Context.Service()("@effected/github/
130
168
  };
131
169
  const dies = unstubbed("PullRequest.makeTest");
132
170
  const project = (raw) => Effect.try({
133
- try: () => PullRequestInfo.make({
134
- number: raw.number,
135
- nodeId: raw.node_id,
136
- url: raw.html_url,
137
- title: raw.title,
138
- state: raw.state === "closed" ? "closed" : "open",
139
- head: raw.head.ref,
140
- headSha: raw.head.sha,
141
- base: raw.base.ref,
142
- baseSha: raw.base.sha,
143
- draft: raw.draft ?? false,
144
- merged: raw.merged ?? raw.merged_at != null,
145
- mergedAt: raw.merged_at == null ? Option.none() : Option.some(DateTime.makeUnsafe(raw.merged_at)),
146
- ...raw.body != null ? { body: raw.body } : {},
147
- ...raw.merge_commit_sha != null ? { mergeCommitSha: raw.merge_commit_sha } : {}
148
- }),
171
+ try: () => {
172
+ const merged = raw.merged ?? raw.merged_at != null;
173
+ return PullRequestInfo.make({
174
+ number: raw.number,
175
+ nodeId: raw.node_id,
176
+ url: raw.html_url,
177
+ title: raw.title,
178
+ state: raw.state === "closed" ? "closed" : "open",
179
+ head: raw.head.ref,
180
+ headSha: raw.head.sha,
181
+ base: raw.base.ref,
182
+ baseSha: raw.base.sha,
183
+ draft: raw.draft ?? false,
184
+ merged,
185
+ mergedAt: raw.merged_at == null ? Option.none() : Option.some(DateTime.makeUnsafe(raw.merged_at)),
186
+ ...raw.body != null ? { body: raw.body } : {},
187
+ ...raw.merge_commit_sha != null && merged ? { mergeCommitSha: raw.merge_commit_sha } : {}
188
+ });
189
+ },
149
190
  catch: (error) => GitHubError.decode("PullRequest", "GitHub returned an unexpected pull request", error)
150
191
  });
151
192
  /** `owner:branch` is what GitHub's `head` filter wants for a cross-fork search. */
152
193
  const qualifyHead = (owner, head) => head.includes(":") ? head : `${owner}:${head}`;
153
194
  const make = (client) => {
195
+ /**
196
+ * Fill `mergeCommitSha` for every merged pull request that lacks it, from
197
+ * GraphQL, in as few `nodes(ids:)` calls as the batch limit allows.
198
+ *
199
+ * @remarks
200
+ * The REST object stopped carrying `merge_commit_sha`, and the field is an
201
+ * `optionalKey`, so without this it would decode clean as always-absent —
202
+ * a silent break for anything matching a commit against it. Nothing is
203
+ * requested when no pull request in the batch needs it.
204
+ *
205
+ * `nodes(ids:)` answers positionally — one entry per id, in order, `null`
206
+ * for an id that resolves to nothing — so each answer is joined to the id
207
+ * at its index. An answer of the wrong length cannot be joined that way and
208
+ * fails as a decode error rather than pairing shas with the wrong pulls.
209
+ */
210
+ const withMergeCommits = (pulls) => {
211
+ const ids = pulls.filter((pull) => pull.merged && pull.mergeCommitSha === void 0).map((pull) => pull.nodeId);
212
+ if (ids.length === 0) return Effect.succeed(pulls);
213
+ const batches = [];
214
+ for (let i = 0; i < ids.length; i += NODES_PER_QUERY) batches.push(ids.slice(i, i + NODES_PER_QUERY));
215
+ return Effect.forEach(batches, (batch) => Effect.flatMap(client.graphql(MergeCommits, { ids: batch }), (answer) => answer.nodes.length === batch.length ? Effect.succeed(batch.map((id, index) => [id, answer.nodes[index]?.mergeCommit?.oid])) : Effect.fail(GitHubGraphQLError.decode(MergeCommits.name, `nodes(ids:) answered ${answer.nodes.length} entries for ${batch.length} ids`))), { concurrency: MERGE_COMMIT_CONCURRENCY }).pipe(Effect.map((answers) => {
216
+ const oids = /* @__PURE__ */ new Map();
217
+ for (const [id, oid] of answers.flat()) if (oid !== void 0) oids.set(id, oid);
218
+ return pulls.map((pull) => {
219
+ const oid = pull.mergeCommitSha === void 0 ? oids.get(pull.nodeId) : void 0;
220
+ return oid === void 0 ? pull : PullRequestInfo.make({
221
+ ...pull,
222
+ mergeCommitSha: oid
223
+ });
224
+ });
225
+ }));
226
+ };
227
+ const withMergeCommit = (pull) => Effect.map(withMergeCommits([pull]), (pulls) => pulls[0] ?? pull);
154
228
  const list = Effect.fn("PullRequest.list")(function* (options) {
155
229
  const { owner, repo } = yield* Repo;
156
230
  const raw = yield* client.paginate("GET /repos/{owner}/{repo}/pulls", {
@@ -160,7 +234,8 @@ const make = (client) => {
160
234
  ...options?.head !== void 0 ? { head: qualifyHead(owner, options.head) } : {},
161
235
  ...options?.base !== void 0 ? { base: options.base } : {}
162
236
  }, options?.page);
163
- return yield* Effect.forEach(raw, project);
237
+ const pulls = yield* Effect.forEach(raw, project);
238
+ return options?.mergeCommits === true ? yield* withMergeCommits(pulls) : pulls;
164
239
  });
165
240
  const create = Effect.fn("PullRequest.create")(function* (input) {
166
241
  const { owner, repo } = yield* Repo;
@@ -197,7 +272,7 @@ const make = (client) => {
197
272
  ...patch.state !== void 0 ? { state: patch.state } : {},
198
273
  ...patch.base !== void 0 ? { base: patch.base } : {}
199
274
  });
200
- return yield* project(updated);
275
+ return yield* withMergeCommit(yield* project(updated));
201
276
  });
202
277
  return {
203
278
  list,
@@ -215,7 +290,7 @@ const make = (client) => {
215
290
  repo,
216
291
  pull_number: number
217
292
  });
218
- return yield* project(raw);
293
+ return yield* withMergeCommit(yield* project(raw));
219
294
  }),
220
295
  listFiles: Effect.fn("PullRequest.listFiles")(function* (number, options) {
221
296
  const { owner, repo } = yield* Repo;
@@ -242,7 +317,8 @@ const make = (client) => {
242
317
  repo,
243
318
  commit_sha: sha
244
319
  }, options?.page);
245
- return yield* Effect.forEach(raw, project);
320
+ const pulls = yield* Effect.forEach(raw, project);
321
+ return options?.mergeCommits === true ? yield* withMergeCommits(pulls) : pulls;
246
322
  }),
247
323
  upsert: Effect.fn("PullRequest.upsert")(function* (input) {
248
324
  const found = (yield* list({
package/README.md CHANGED
@@ -184,9 +184,13 @@ const program = Effect.gen(function* () {
184
184
  security_and_analysis: { secret_scanning: "enabled" },
185
185
  });
186
186
  });
187
- // AppliedSettings — { rest: [...], graphql: [...] }, in the caller's own key names.
188
- // The two lists agree with the input right up until preparation drops a field
187
+ // AppliedSettings — { rest: [...], graphql: [...], ignored: [...] }, in the caller's own key names.
188
+ // The lists agree with the input right up until preparation drops a field
189
189
  // GitHub would reject, which is what a person reading a dry run is checking for.
190
+ // `ignored` names keys neither API accepts (a typo, say): they are never sent,
191
+ // because the REST PATCH would ignore them and still answer 200. The REST key
192
+ // list is generated from GitHub's OpenAPI description; a key a later
193
+ // api-version removed is ignored only for a client pinned at or past it.
190
194
  ```
191
195
 
192
196
  `updateSettings` takes octokit's own generated params, and those spell an optional field as `has_issues?: boolean` rather than `has_issues?: boolean | undefined` — so under `exactOptionalPropertyTypes` a `Partial<T>` built from your own settings schema does not assign to it at all. `repositoryPatch` is the supported way out, and it exists so the answer is never a cast:
@@ -295,14 +299,14 @@ const TestClient = GitHubClient.layerFixture(fixtures);
295
299
 
296
300
  - `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).
297
301
  - `Repo` / `RepoRef` — the `{ owner, repo }` coordinate, resolved per call through `R`, with `Repo.provide` for multi-repository programs.
298
- - `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.
302
+ - `GitHubRepository` — the repository's settings as GitHub's own generated type, plus `defaultBranch`, `nodeId`, `ownerType` for gating organization-only fields, `applySettings` reporting the keys it actually sent, and `topics` / `setTopics(names)`, where `setTopics` replaces the whole set and returns what GitHub stored (lowercased).
299
303
  - `repositoryPatch` / `RepositoryPatchDraft` — build an `updateSettings` patch from fields that may be `undefined`, under `exactOptionalPropertyTypes`, without a cast.
300
304
  - `GitHubError` / `GitHubGraphQLError` — one error per transport, `kind`-routed with `hasKind` for `Effect.catchIf`; a REST 422's validation entries ride on `validation`, matched with `hasValidationCode`.
301
305
  - `RetryPolicy` — the client's one retry policy: full-jitter backoff, server-advised delays honored up to a ceiling.
302
306
  - `GitHubApp` — App JWT signing, installation token minting/revocation, app and installation identity, and `clientLayer` for an App-authenticated `GitHubClient`.
303
307
  - `GitBranch` / `GitTag` — Git Database API refs, with `upsert` collapsing the create-or-reset dance to one call and `GitTag.latestSemver` picking the newest version-shaped tag in one pass.
304
308
  - `CheckRun` — `withCheckRun` concludes on every exit path; `CheckRunOutput.truncated()` cuts rendered output to GitHub's byte limits.
305
- - `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.
309
+ - `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. `PullRequestInfo.mergeCommitSha` is read from GraphQL `mergeCommit.oid` for merged pull requests only, because the REST object no longer carries it: `get`, `update` and `upsert` always fill it, while `list` and `listAssociatedWithCommit` fill it only with `mergeCommits: true` (one GraphQL call per 100 merged pull requests).
306
310
  - `GitHubIssue` — issues and their comments, including `commentOnce` for a marked comment that is posted exactly once and never edited, and `linkedIssues` for what a pull request closes.
307
311
  - `harvestIssueReferences` / `parseBareLineReference` — a compatibility re-export of the two closing-reference dialects that moved to `@effected/github-references`, with `CLOSING_KEYWORDS` and their result types.
308
312
  - `GitHubRelease` — releases and asset uploads, including the one route (`uploadAsset`, with the endpoint's optional display label) outside GitHub's generated endpoint map.
@@ -104,6 +104,8 @@ const DispatchResponse = Schema.Struct({
104
104
  });
105
105
  const decodeDispatchResponse = Schema.decodeUnknownEffect(DispatchResponse);
106
106
  const decodeDispatchedRun = Schema.decodeUnknownEffect(DispatchedRun);
107
+ /** The api-version that reports the run unasked and removed `return_run_details`. */
108
+ const RUN_DETAILS_BY_DEFAULT = "2026-03-10";
107
109
  const make = (client) => {
108
110
  const dispatch = Effect.fn("WorkflowDispatch.dispatch")(function* (workflow, ref, inputs) {
109
111
  const { owner, repo } = yield* Repo;
@@ -135,7 +137,7 @@ const make = (client) => {
135
137
  workflow_id: workflow,
136
138
  ref,
137
139
  ...inputs !== void 0 ? { inputs } : {},
138
- return_run_details: true
140
+ ...client.apiVersion < RUN_DETAILS_BY_DEFAULT ? { return_run_details: true } : {}
139
141
  });
140
142
  if (data === "" || data === null) return Option.none();
141
143
  const run = yield* decodeDispatchResponse(data).pipe(Effect.flatMap((raw) => decodeDispatchedRun({
package/index.d.ts CHANGED
@@ -761,7 +761,8 @@ export declare class PageOptions extends PageOptions_base {
761
761
  * Every member is an `Effect`, a `Stream`, or a function returning one — the
762
762
  * rule that keeps `Layer.mock` and `layerTest(Partial<Shape>)` useful. That
763
763
  * includes `rateLimit`, which is an `Effect`-valued property rather than a
764
- * getter for exactly this reason.
764
+ * getter for exactly this reason. The one plain value is `apiVersion`, a
765
+ * string fixed at construction that no double needs to stub.
765
766
  *
766
767
  * @public
767
768
  */
@@ -821,6 +822,20 @@ interface GitHubClientShape {
821
822
  * a caller that wants to pace itself proactively reads this.
822
823
  */
823
824
  readonly rateLimit: Effect.Effect<Option.Option<RateLimitSnapshot>>;
825
+ /**
826
+ * The REST api-version this client pins, as sent in `x-github-api-version`.
827
+ *
828
+ * @remarks
829
+ * A plain value, not an `Effect`: it is fixed when the client is built and
830
+ * reading it does no work. Resource services read it to shape a request for
831
+ * the version it goes out under — a body field one version requires and a
832
+ * later one removed, say. Calendar versions are `YYYY-MM-DD`, so two of them
833
+ * compare correctly as strings: `client.apiVersion < "2026-03-10"`.
834
+ *
835
+ * A call that sets its own `x-github-api-version` header goes out under that
836
+ * version instead; this is the version of every other call.
837
+ */
838
+ readonly apiVersion: string;
824
839
  }
825
840
  /**
826
841
  * How a client layer is built.
@@ -846,6 +861,25 @@ interface GitHubClientOptions {
846
861
  * a hand-written double of this service.
847
862
  */
848
863
  readonly fetch?: typeof globalThis.fetch | undefined;
864
+ /**
865
+ * The REST api-version every request pins, sent as `x-github-api-version`.
866
+ * Defaults to `"2026-03-10"`.
867
+ *
868
+ * @remarks
869
+ * Without the header GitHub serves its oldest supported version, which it
870
+ * deprecates and then sunsets, so the client always sends one. A call that
871
+ * sets its own `x-github-api-version` header keeps it: the per-call header
872
+ * wins over this option.
873
+ *
874
+ * A GitHub Enterprise Server release that predates the default version
875
+ * rejects the header, so a client pointed at one through `baseUrl` sets the
876
+ * newest version that server supports here.
877
+ *
878
+ * Must be a calendar version, `YYYY-MM-DD`. Anything else is a wiring
879
+ * mistake and kills the fiber building the client, with a defect naming
880
+ * this option, rather than reaching GitHub as a header it rejects.
881
+ */
882
+ readonly apiVersion?: string | undefined;
849
883
  }
850
884
  /**
851
885
  * One call served by {@link GitHubClient.layerFixture}, as recorded in
@@ -989,6 +1023,15 @@ export interface GitHubFixtures {
989
1023
  readonly unstubbed?: "die" | "fail" | "empty" | undefined;
990
1024
  /** What `rateLimit` answers. */
991
1025
  readonly rateLimit?: RateLimitSnapshot | undefined;
1026
+ /**
1027
+ * What `apiVersion` answers; defaults to the live client's default,
1028
+ * `"2026-03-10"`.
1029
+ *
1030
+ * @remarks
1031
+ * Set it to drive a resource's version-dependent branch — a request body
1032
+ * an older api-version needs — without a live client.
1033
+ */
1034
+ readonly apiVersion?: string | undefined;
992
1035
  /**
993
1036
  * Every call the fixture served, in order. Populated as the test runs.
994
1037
  *
@@ -1065,11 +1108,14 @@ export declare class GitHubClient extends GitHubClient_base {
1065
1108
  * member **dies** naming itself.
1066
1109
  *
1067
1110
  * @remarks
1068
- * No member has an honest default. A fabricated response — an empty list, a
1111
+ * No request member has an honest default. A fabricated response — an empty list, a
1069
1112
  * made-up sha — would leak into the code under test as fact, so the double
1070
1113
  * fails loudly instead, which also makes it proof that a test touches nothing
1071
1114
  * but what it stubbed.
1072
1115
  *
1116
+ * `rateLimit` answers `Option.none()` and `apiVersion` the live default,
1117
+ * because a real client legitimately answers both without a request.
1118
+ *
1073
1119
  * For recorded responses that page for real, use
1074
1120
  * {@link GitHubClient.layerFixture}.
1075
1121
  */
@@ -1465,7 +1511,8 @@ interface AuthenticatedAppShape {
1465
1511
  *
1466
1512
  * @remarks
1467
1513
  * Irreversible from the app's side: only the account can install it again.
1468
- * An installation that does not exist (or is already gone) fails with
1514
+ * GitHub answers 202 and removes the installation in the background, so it
1515
+ * may still be listed briefly after this returns. An installation that does not exist (or is already gone) fails with
1469
1516
  * `GitHubError { kind: "notFound" }`.
1470
1517
  */
1471
1518
  readonly uninstall: (installationId: number) => Effect.Effect<void, GitHubError>;
@@ -1936,7 +1983,15 @@ export declare class CheckRun extends CheckRun_base {
1936
1983
  interface CodeScanningSetup {
1937
1984
  /** Whether default setup is `configured` or `not-configured`. */
1938
1985
  readonly state?: "configured" | "not-configured" | undefined;
1939
- /** The CodeQL languages to analyse. */
1986
+ /**
1987
+ * The CodeQL languages to analyse.
1988
+ *
1989
+ * @remarks
1990
+ * CodeQL's names, not linguist's: JavaScript and TypeScript are the one
1991
+ * combined `javascript-typescript`, and the separate `javascript` and
1992
+ * `typescript` values are no longer accepted under the api-version the
1993
+ * client pins.
1994
+ */
1940
1995
  readonly languages?: ReadonlyArray<string> | undefined;
1941
1996
  /** `default` or `extended`. */
1942
1997
  readonly query_suite?: string | undefined;
@@ -2671,6 +2726,15 @@ interface GitHubAppOptions {
2671
2726
  readonly retry?: RetryPolicy | "off" | undefined;
2672
2727
  /** A replacement `fetch`, for tests and proxies. */
2673
2728
  readonly fetch?: typeof globalThis.fetch | undefined;
2729
+ /**
2730
+ * The REST api-version the app's clients pin; defaults to `"2026-03-10"`.
2731
+ *
2732
+ * @remarks
2733
+ * The same option as `GitHubClientOptions.apiVersion`, applied to every
2734
+ * client this module builds: as the app, as an installation, and
2735
+ * unauthenticated.
2736
+ */
2737
+ readonly apiVersion?: string | undefined;
2674
2738
  }
2675
2739
  declare const GitHubApp_base: Context.ServiceClass<GitHubApp, "@effected/github/GitHubApp", GitHubAppShape>;
2676
2740
  /**
@@ -3600,17 +3664,21 @@ type OwnerType = "User" | "Organization";
3600
3664
  */
3601
3665
  export declare const SECURITY_ANALYSIS_STATUS_FIELDS: ReadonlySet<string>;
3602
3666
  /**
3603
- * Settings reachable **only** through the GraphQL `updateRepository` mutation,
3604
- * mapped from snake_case keys to camelCase GraphQL input fields.
3667
+ * Settings written through the GraphQL `updateRepository` mutation, mapped
3668
+ * from snake_case keys to camelCase GraphQL input fields.
3605
3669
  *
3606
3670
  * @remarks
3607
- * GitHub never exposed these on the REST repository endpoint — the
3608
- * `PATCH /repos/{owner}/{repo}` route accepts none of them, and
3609
- * `has_discussions` is the treacherous one: the REST **read** returns it, so
3610
- * routing its write to the PATCH looks symmetric, but the PATCH silently
3611
- * ignores unknown body fields and answers 200, so the write would look applied
3612
- * while the repository never changed. Setting any of these forces a second
3613
- * round trip to learn the repository's node id.
3671
+ * `applySettings` checks this map **first**, so a key here always goes
3672
+ * through GraphQL, even when the REST patch also declares it.
3673
+ * `has_sponsorships` and `has_discussions` are GraphQL-only: the
3674
+ * `PATCH /repos/{owner}/{repo}` route accepts neither, and `has_discussions`
3675
+ * is the treacherous one — the REST **read** returns it, so routing its write
3676
+ * to the PATCH looks symmetric, but the PATCH silently ignores unknown body
3677
+ * fields and answers 200, so the write would look applied while the
3678
+ * repository never changed. `has_pull_requests` is in the REST patch's body
3679
+ * too; GraphQL is preferred for it so the three toggles travel together.
3680
+ * Setting any of these forces a second round trip to learn the repository's
3681
+ * node id.
3614
3682
  *
3615
3683
  * @public
3616
3684
  */
@@ -3650,7 +3718,7 @@ export declare const transformSecurityAndAnalysis: (value: unknown) => Record<st
3650
3718
  * agree right up until a field is dropped, which is exactly the case anyone
3651
3719
  * reading a dry run is trying to check.
3652
3720
  *
3653
- * Both lists use the **caller's** key names, not the wire names, because the
3721
+ * Every list uses the **caller's** key names, not the wire names, because the
3654
3722
  * audience for them is a person reading a plan against the config they wrote.
3655
3723
  *
3656
3724
  * @public
@@ -3660,10 +3728,25 @@ interface AppliedSettings {
3660
3728
  readonly rest: ReadonlyArray<string>;
3661
3729
  /** Keys sent through the GraphQL mutation, named as the caller supplied them. */
3662
3730
  readonly graphql: ReadonlyArray<string>;
3731
+ /**
3732
+ * Keys neither API accepts, which were **not sent**.
3733
+ *
3734
+ * @remarks
3735
+ * A key that is not a GraphQL-only setting and not in the REST patch's
3736
+ * declared body — a typo such as `has_discussion`, or a setting GitHub has
3737
+ * not described — lands here, as does a key the client's api-version
3738
+ * removed from the patch (`use_squash_pr_title_as_default` under
3739
+ * `2026-03-10` and later; a client pinned to an older version still sends
3740
+ * it). Sending it would be worse than useless: the
3741
+ * REST `PATCH` ignores unknown fields and answers 200, so the key would be
3742
+ * reported applied while the repository never changed. A caller that wants
3743
+ * an unknown key to fail the run checks this list.
3744
+ */
3745
+ readonly ignored: ReadonlyArray<string>;
3663
3746
  }
3664
3747
  /**
3665
- * Read and update a repository's settings, and look up its default branch, node
3666
- * id and owner type.
3748
+ * Read and update a repository's settings and topics, and look up its default
3749
+ * branch, node id and owner type.
3667
3750
  *
3668
3751
  * @public
3669
3752
  */
@@ -3709,30 +3792,58 @@ interface GitHubRepositoryShape {
3709
3792
  * `updateSettings` is the thin, faithfully-typed PATCH and returns what
3710
3793
  * GitHub then reports. This is the **applicator**: it takes an open map,
3711
3794
  * routes each key to whichever API can actually set it, and returns an
3712
- * {@link AppliedSettings} naming the keys that were sent through each.
3795
+ * {@link AppliedSettings} naming the keys that were sent through each and
3796
+ * the keys neither accepts.
3713
3797
  *
3714
3798
  * Three settings — `has_sponsorships`, `has_pull_requests` and
3715
- * `has_discussions` ({@link GRAPHQL_ONLY_SETTINGS}) — are only reachable
3716
- * through GraphQL's `updateRepository`, which addresses a repository by **node
3717
- * id**. So a map touching any of them costs an extra read; a map touching none
3799
+ * `has_discussions` ({@link GRAPHQL_ONLY_SETTINGS}) — are written through
3800
+ * GraphQL's `updateRepository`, which addresses a repository by **node id**;
3801
+ * the first and last are reachable no other way, and GraphQL is preferred
3802
+ * for `has_pull_requests`. So a map touching any of them costs an extra read; a map touching none
3718
3803
  * does not, which is the common case.
3719
3804
  *
3720
3805
  * The map is open by design. GitHub's settings surface is large and moving,
3721
- * and a closed type here would date the package — but it also means a typo is
3722
- * forwarded rather than rejected, so a caller that owns a schema should
3723
- * validate before calling.
3806
+ * and a closed type here would date the package. A key that is neither
3807
+ * GraphQL-only nor in the REST patch's declared body — the key list is
3808
+ * generated from GitHub's OpenAPI description — is not sent and is reported
3809
+ * in `ignored`, rather than forwarded to a `PATCH` that would ignore it and
3810
+ * answer 200. It does not fail the call, so additive GitHub API growth never
3811
+ * breaks a run; a caller that owns a schema should still validate first.
3724
3812
  */
3725
3813
  readonly applySettings: (settings: Record<string, unknown>) => Effect.Effect<AppliedSettings, GitHubError | GitHubGraphQLError, Repo>;
3814
+ /**
3815
+ * The repository's topics, as GitHub stores them.
3816
+ *
3817
+ * @remarks
3818
+ * Reads `GET /repos/{owner}/{repo}/topics`, which answers the whole set in
3819
+ * one object, so there is nothing to paginate. GitHub stores every topic
3820
+ * lowercased, so this is the value to compare a desired set against.
3821
+ */
3822
+ readonly topics: Effect.Effect<ReadonlyArray<string>, GitHubError, Repo>;
3823
+ /**
3824
+ * Replace the repository's whole topic set, and return the set GitHub stored.
3825
+ *
3826
+ * @remarks
3827
+ * `PUT /repos/{owner}/{repo}/topics` replaces rather than merges, so `names`
3828
+ * is the complete desired set and an empty array clears every topic.
3829
+ *
3830
+ * **The return value is not `names`.** GitHub lowercases every topic it
3831
+ * stores, so a caller that records what it sent — `Effect`, say — sees drift
3832
+ * on the next read, which answers `effect`. Record the returned array, which
3833
+ * is GitHub's own answer to the write, as the applied state.
3834
+ */
3835
+ readonly setTopics: (names: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string>, GitHubError, Repo>;
3726
3836
  }
3727
3837
  declare const GitHubRepository_base: Context.ServiceClass<GitHubRepository, "@effected/github/GitHubRepository", GitHubRepositoryShape>;
3728
3838
  /**
3729
3839
  * Read and update a repository's settings, including settings only GraphQL can
3730
- * write, and look up its default branch, node id and owner type.
3840
+ * write, read and replace its topics, and look up its default branch, node id
3841
+ * and owner type.
3731
3842
  *
3732
3843
  * @remarks
3733
3844
  * Provide it with {@link GitHubRepository.layer}, which needs a `GitHubClient`;
3734
- * every member also needs a `Repo` in `R`. `settings`, `defaultBranch`, `nodeId`
3735
- * and `ownerType` are `Effect` values, not functions.
3845
+ * every member also needs a `Repo` in `R`. `settings`, `defaultBranch`, `nodeId`,
3846
+ * `ownerType` and `topics` are `Effect` values, not functions.
3736
3847
  *
3737
3848
  * @example
3738
3849
  * ```ts
@@ -3743,7 +3854,7 @@ declare const GitHubRepository_base: Context.ServiceClass<GitHubRepository, "@ef
3743
3854
  * const repository = yield* GitHubRepository;
3744
3855
  * const branch = yield* repository.defaultBranch;
3745
3856
  * const applied = yield* repository.applySettings({ has_wiki: false, has_discussions: true });
3746
- * return { branch, applied }; // applied.rest, applied.graphql
3857
+ * return { branch, applied }; // applied.rest, applied.graphql, applied.ignored
3747
3858
  * });
3748
3859
  * ```
3749
3860
  *
@@ -3919,7 +4030,27 @@ declare const PullRequestInfo_base: Schema.Class<PullRequestInfo, Schema.Struct<
3919
4030
  readonly mergedAt: Schema.Option<Schema.DateTimeUtcFromString>;
3920
4031
  /** The description, when GitHub sent one. */
3921
4032
  readonly body: Schema.optionalKey<Schema.String>;
3922
- /** The merge commit, once there is one. */
4033
+ /**
4034
+ * The sha of the commit the merge produced; absent until the pull request
4035
+ * has merged.
4036
+ *
4037
+ * @remarks
4038
+ * Read from GraphQL's `PullRequest.mergeCommit.oid`. The REST pull request
4039
+ * object no longer carries `merge_commit_sha` under the api-version this
4040
+ * package pins, so a merged pull request costs one extra GraphQL round trip
4041
+ * per single read (`get`, `update`, `upsert`), and an open or
4042
+ * closed-unmerged one costs nothing. When the REST response does carry the
4043
+ * field (a client pinned to an older api-version), it is used for a merged
4044
+ * pull request and the GraphQL read is skipped.
4045
+ *
4046
+ * **A list read leaves it absent unless asked.** `list` and
4047
+ * `listAssociatedWithCommit` fill it only when called with
4048
+ * `mergeCommits: true`, at one GraphQL call per 100 merged pull requests.
4049
+ *
4050
+ * It is never the speculative test-merge commit GitHub computes for an open
4051
+ * pull request, so `mergeCommitSha === sha` means "this commit is what the
4052
+ * pull request merged as".
4053
+ */
3923
4054
  readonly mergeCommitSha: Schema.optionalKey<Schema.String>;
3924
4055
  }>, {}>;
3925
4056
  /**
@@ -3953,7 +4084,7 @@ interface UpsertedPullRequest {
3953
4084
  */
3954
4085
  interface PullRequestShape {
3955
4086
  /** Read one pull request. Fails `notFound` when it does not exist. */
3956
- readonly get: (number: number) => Effect.Effect<PullRequestInfo, GitHubError, Repo>;
4087
+ readonly get: (number: number) => Effect.Effect<PullRequestInfo, GitHubError | GitHubGraphQLError, Repo>;
3957
4088
  /**
3958
4089
  * Open, closed or all pull requests, optionally filtered.
3959
4090
  *
@@ -3970,13 +4101,20 @@ interface PullRequestShape {
3970
4101
  * in the wrong account — the filter then matches nothing, silently. When the
3971
4102
  * head may live in a fork, pass a qualified `owner:ref` built from the source
3972
4103
  * owner rather than from anything this projection carries.
4104
+ *
4105
+ * **`mergeCommitSha` is absent on every result unless `mergeCommits` is
4106
+ * `true`.** The REST list no longer carries it, and filling it costs one
4107
+ * GraphQL call per 100 merged pull requests in the result, so it is opt-in.
4108
+ * Only with the option can this fail with `GitHubGraphQLError`.
3973
4109
  */
3974
4110
  readonly list: (options?: {
3975
4111
  readonly head?: string | undefined;
3976
4112
  readonly base?: string | undefined;
3977
4113
  readonly state?: "open" | "closed" | "all" | undefined;
3978
4114
  readonly page?: PageOptions | undefined;
3979
- }) => Effect.Effect<ReadonlyArray<PullRequestInfo>, GitHubError, Repo>;
4115
+ /** Fill `mergeCommitSha` on merged results from GraphQL. Defaults to `false`. */
4116
+ readonly mergeCommits?: boolean | undefined;
4117
+ }) => Effect.Effect<ReadonlyArray<PullRequestInfo>, GitHubError | GitHubGraphQLError, Repo>;
3980
4118
  /**
3981
4119
  * The files a pull request changes.
3982
4120
  *
@@ -3994,10 +4132,17 @@ interface PullRequestShape {
3994
4132
  *
3995
4133
  * @remarks
3996
4134
  * Paginated: pass `page` to bound the walk.
4135
+ *
4136
+ * **`mergeCommitSha` is absent on every result unless `mergeCommits` is
4137
+ * `true`**, at one GraphQL call per 100 merged pull requests — so a caller
4138
+ * matching `sha` against the merge commit passes it. Only with the option
4139
+ * can this fail with `GitHubGraphQLError`.
3997
4140
  */
3998
4141
  readonly listAssociatedWithCommit: (sha: string, options?: {
3999
4142
  readonly page?: PageOptions | undefined;
4000
- }) => Effect.Effect<ReadonlyArray<PullRequestInfo>, GitHubError, Repo>;
4143
+ /** Fill `mergeCommitSha` on merged results from GraphQL. Defaults to `false`. */
4144
+ readonly mergeCommits?: boolean | undefined;
4145
+ }) => Effect.Effect<ReadonlyArray<PullRequestInfo>, GitHubError | GitHubGraphQLError, Repo>;
4001
4146
  /** Open a pull request from `head` into `base`. */
4002
4147
  readonly create: (input: {
4003
4148
  readonly title: string;
@@ -4012,7 +4157,7 @@ interface PullRequestShape {
4012
4157
  readonly body?: string | undefined;
4013
4158
  readonly state?: "open" | "closed" | undefined;
4014
4159
  readonly base?: string | undefined;
4015
- }) => Effect.Effect<PullRequestInfo, GitHubError, Repo>;
4160
+ }) => Effect.Effect<PullRequestInfo, GitHubError | GitHubGraphQLError, Repo>;
4016
4161
  /**
4017
4162
  * Update the open pull request for `head`→`base`, or open one.
4018
4163
  *
@@ -4026,7 +4171,7 @@ interface PullRequestShape {
4026
4171
  readonly base: string;
4027
4172
  readonly body?: string | undefined;
4028
4173
  readonly draft?: boolean | undefined;
4029
- }) => Effect.Effect<UpsertedPullRequest, GitHubError, Repo>;
4174
+ }) => Effect.Effect<UpsertedPullRequest, GitHubError | GitHubGraphQLError, Repo>;
4030
4175
  /** Merge a pull request and return the merge commit sha. `method` defaults to GitHub's choice. */
4031
4176
  readonly merge: (number: number, options?: {
4032
4177
  readonly method?: "merge" | "squash" | "rebase" | undefined;
@@ -4704,15 +4849,21 @@ interface WorkflowDispatchShape {
4704
4849
  * Fire a `workflow_dispatch` event and report the run it created.
4705
4850
  *
4706
4851
  * @remarks
4707
- * Sends the same request as {@link WorkflowDispatchShape.dispatch} with
4708
- * `return_run_details: true`. GitHub then answers 200 with the run's id and
4709
- * URLs, which arrive as `Option.some` of a {@link DispatchedRun}; a 200 body
4710
- * that does not have that shape fails with a `decode` `GitHubError`.
4711
- *
4712
- * A 204 with no body is `Option.none()`. That is what a GitHub Enterprise
4713
- * Server predating the field answers — it ignores `return_run_details` and
4714
- * still dispatches — so it is **not a failure**: the workflow was dispatched,
4715
- * the server just did not say which run it created.
4852
+ * Sends the same request as {@link WorkflowDispatchShape.dispatch}. Under
4853
+ * the api-version the client pins by default, GitHub answers every dispatch
4854
+ * with 200 and the run's id and URLs, which arrive as `Option.some` of a
4855
+ * {@link DispatchedRun}; a 200 body that does not have that shape fails with
4856
+ * a `decode` `GitHubError`.
4857
+ *
4858
+ * A 204 with no body is `Option.none()`. That is what an older api-version
4859
+ * answers when it does not report the run — a GitHub Enterprise Server that
4860
+ * predates the field, say — and it is **not a failure**: the workflow was
4861
+ * dispatched, the server just did not say which run it created.
4862
+ *
4863
+ * Older api-versions report the run only when asked, so a client pinned
4864
+ * before `2026-03-10` (`GitHubClientShape.apiVersion`) sends
4865
+ * `return_run_details: true`; from `2026-03-10` on the flag is removed from
4866
+ * the request and is not sent.
4716
4867
  *
4717
4868
  * Under `GitHubClient.layerFixture`, stub the 204 the way every other
4718
4869
  * no-body route is stubbed: answer the dispatch route with `null`. `""`
@@ -4762,8 +4913,8 @@ interface WorkflowDispatchShape {
4762
4913
  *
4763
4914
  * The run is identified by {@link WorkflowDispatchShape.dispatchWithRun}:
4764
4915
  * when GitHub reports the run it created, exactly that run is polled, so
4765
- * concurrent dispatches cannot be confused. When it does not (a 204 from a
4766
- * GitHub Enterprise Server predating `return_run_details`), the run is found
4916
+ * concurrent dispatches cannot be confused. When it does not (a 204 from an
4917
+ * older api-version), the run is found
4767
4918
  * instead by branch, creation time and workflow path — and on that fallback
4768
4919
  * alone, concurrent dispatches of the same workflow on the same ref can be
4769
4920
  * confused.
@@ -6,6 +6,8 @@ import { Octokit } from "@octokit/core";
6
6
  import { composePaginateRest } from "@octokit/plugin-paginate-rest";
7
7
 
8
8
  // src/internal/octokit.ts
9
+ /** The REST api-version a client pins when its options name none. */
10
+ const DEFAULT_API_VERSION = "2026-03-10";
9
11
  const SILENT_LOG = {
10
12
  debug: () => {},
11
13
  info: () => {},
@@ -25,13 +27,19 @@ const SILENT_LOG = {
25
27
  * maps Effect's logs to whatever its runtime wants, so no GitHub Actions
26
28
  * knowledge lives in this client.
27
29
  */
28
- const makeOctokit = (options) => new Octokit({
29
- ...options.token !== void 0 ? { auth: Redacted.value(options.token) } : {},
30
- ...options.baseUrl !== void 0 ? { baseUrl: options.baseUrl } : {},
31
- ...options.userAgent !== void 0 ? { userAgent: options.userAgent } : {},
32
- ...options.fetch !== void 0 ? { request: { fetch: options.fetch } } : {},
33
- log: SILENT_LOG
34
- });
30
+ const makeOctokit = (options) => {
31
+ const octokit = new Octokit({
32
+ ...options.token !== void 0 ? { auth: Redacted.value(options.token) } : {},
33
+ ...options.baseUrl !== void 0 ? { baseUrl: options.baseUrl } : {},
34
+ ...options.userAgent !== void 0 ? { userAgent: options.userAgent } : {},
35
+ ...options.fetch !== void 0 ? { request: { fetch: options.fetch } } : {},
36
+ log: SILENT_LOG
37
+ });
38
+ octokit.hook.before("request", (request) => {
39
+ request.headers["x-github-api-version"] ??= options.apiVersion;
40
+ });
41
+ return octokit;
42
+ };
35
43
  /**
36
44
  * Build a transport over a freshly constructed octokit instance.
37
45
  *
@@ -105,4 +113,4 @@ const readThrownHeaders = (error) => {
105
113
  return typeof headers === "object" && headers !== null ? headers : void 0;
106
114
  };
107
115
 
108
- export { makeTransport };
116
+ export { DEFAULT_API_VERSION, makeTransport };
@@ -0,0 +1,44 @@
1
+ // src/internal/repositoryRestSettingKeys.ts
2
+ /**
3
+ * Every key the REST repository PATCH accepts, as GitHub's OpenAPI description declares it.
4
+ *
5
+ * @internal
6
+ */
7
+ const REPOSITORY_REST_SETTING_KEYS = /* @__PURE__ */ new Set([
8
+ "allow_auto_merge",
9
+ "allow_forking",
10
+ "allow_merge_commit",
11
+ "allow_rebase_merge",
12
+ "allow_squash_merge",
13
+ "allow_update_branch",
14
+ "archived",
15
+ "default_branch",
16
+ "delete_branch_on_merge",
17
+ "description",
18
+ "has_issues",
19
+ "has_projects",
20
+ "has_pull_requests",
21
+ "has_wiki",
22
+ "homepage",
23
+ "is_template",
24
+ "merge_commit_message",
25
+ "merge_commit_title",
26
+ "name",
27
+ "private",
28
+ "pull_request_creation_policy",
29
+ "security_and_analysis",
30
+ "squash_merge_commit_message",
31
+ "squash_merge_commit_title",
32
+ "use_squash_pr_title_as_default",
33
+ "visibility",
34
+ "web_commit_signoff_required"
35
+ ]);
36
+ /**
37
+ * Keys in {@link REPOSITORY_REST_SETTING_KEYS} that an api-version removed,
38
+ * mapped to that version: a client at or past it does not send the key.
39
+ *
40
+ * @internal
41
+ */
42
+ const REMOVED_BY_PINNED_VERSION = { use_squash_pr_title_as_default: "2026-03-10" };
43
+
44
+ export { REMOVED_BY_PINNED_VERSION, REPOSITORY_REST_SETTING_KEYS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/github",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "private": false,
5
5
  "description": "Typed GitHub REST and GraphQL services over the octokit core request surface, with app auth and resource helpers.",
6
6
  "keywords": [
@@ -41,7 +41,7 @@
41
41
  "@effected/github-references": "^0.11.0",
42
42
  "@effected/jwt": "^0.6.0",
43
43
  "@effected/oidc": "^0.1.2",
44
- "@effected/semver": "^0.17.0",
44
+ "@effected/semver": "^0.18.0",
45
45
  "@octokit/core": "^7.0.6",
46
46
  "@octokit/plugin-paginate-rest": "^16.0.0",
47
47
  "@octokit/types": "^18.0.0",