@effected/github 0.23.1 → 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/GitHubIssue.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { GraphQLDocument } from "./GraphQL.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
2
3
  import { GitHubClient } from "./GitHubClient.js";
3
4
  import { Repo } from "./Repo.js";
4
5
  import { numericId } from "./internal/ids.js";
@@ -127,45 +128,20 @@ const CrossReferencedDocument = GraphQLDocument.make({
127
128
  var GitHubIssue = class GitHubIssue extends Context.Service()("@effected/github/GitHubIssue") {
128
129
  /** The live service, built over a `GitHubClient`. */
129
130
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
130
- /** An in-memory double; unstubbed members die naming themselves. */
131
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
131
132
  static makeTest = (overrides = {}) => ({
132
- get: overrides.get ?? (() => unstubbed("get")),
133
- list: overrides.list ?? (() => unstubbed("list")),
134
- close: overrides.close ?? (() => unstubbed("close")),
135
- comment: overrides.comment ?? (() => unstubbed("comment")),
136
- commentOnce: overrides.commentOnce ?? (() => unstubbed("commentOnce")),
137
- linkedIssues: overrides.linkedIssues ?? (() => unstubbed("linkedIssues")),
138
- isCrossReferencedBy: overrides.isCrossReferencedBy ?? (() => unstubbed("isCrossReferencedBy"))
133
+ get: overrides.get ?? (() => dies("get")),
134
+ list: overrides.list ?? (() => dies("list")),
135
+ close: overrides.close ?? (() => dies("close")),
136
+ comment: overrides.comment ?? (() => dies("comment")),
137
+ commentOnce: overrides.commentOnce ?? (() => dies("commentOnce")),
138
+ linkedIssues: overrides.linkedIssues ?? (() => dies("linkedIssues")),
139
+ isCrossReferencedBy: overrides.isCrossReferencedBy ?? (() => dies("isCrossReferencedBy"))
139
140
  });
140
141
  /** {@link GitHubIssue.makeTest} behind a `Layer`. */
141
142
  static layerTest = (overrides = {}) => Layer.succeed(GitHubIssue, GitHubIssue.makeTest(overrides));
142
143
  };
143
- const unstubbed = (member) => {
144
- throw new Error(`GitHubIssue.makeTest: ${member}() was called but not stubbed — pass an override.`);
145
- };
146
- /**
147
- * The REST calendar version this module's requests pin.
148
- *
149
- * @remarks
150
- * The routes are not deprecated — the **default api-version** is. When no
151
- * `x-github-api-version` header is sent (octokit sends none), GitHub serves
152
- * calendar version 2022-11-28, which it deprecated when 2026-03-10 shipped and
153
- * sunsets on 2028-03-10. Every response from an endpoint the new version
154
- * changed then carries a `Deprecation` header, which `@octokit/request` prints
155
- * straight to the consumer's console — the constructor's `log` option cannot
156
- * silence it, because the fetch wrapper resolves `request.log ?? console`.
157
- * `PATCH /repos/{owner}/{repo}/issues/{issue_number}` is on that list (the
158
- * singular `assignee` parameter was removed), so every `close` warned once per
159
- * issue in consumer workflow logs.
160
- *
161
- * Nothing this module sends or reads changed in 2026-03-10: `state` and
162
- * `state_reason` are untouched, and the projections read only fields both
163
- * versions carry. Pinning the current version is therefore the whole fix, and
164
- * it deliberately does **not** hide future deprecations — when GitHub
165
- * deprecates 2026-03-10 in turn, the header comes back and octokit warns
166
- * again.
167
- */
168
- const API_VERSION_HEADERS = { "x-github-api-version": "2026-03-10" };
144
+ const dies = unstubbed("GitHubIssue.makeTest");
169
145
  /** GitHub sends a label as either a string or an object; callers want the name. */
170
146
  const labelNames = (labels) => labels.flatMap((label) => typeof label === "string" ? [label] : label.name !== void 0 ? [label.name] : []);
171
147
  const project = (raw) => IssueInfo.make({
@@ -187,8 +163,7 @@ const make = (client) => ({
187
163
  const raw = yield* client.request("GET /repos/{owner}/{repo}/issues/{issue_number}", {
188
164
  owner,
189
165
  repo,
190
- issue_number: number,
191
- headers: API_VERSION_HEADERS
166
+ issue_number: number
192
167
  });
193
168
  return project(raw);
194
169
  }),
@@ -202,8 +177,7 @@ const make = (client) => ({
202
177
  owner,
203
178
  repo,
204
179
  ...options?.state !== void 0 ? { state: options.state } : {},
205
- ...options?.labels !== void 0 ? { labels: options.labels.join(",") } : {},
206
- headers: API_VERSION_HEADERS
180
+ ...options?.labels !== void 0 ? { labels: options.labels.join(",") } : {}
207
181
  }, options?.page)).map(project);
208
182
  }),
209
183
  close: Effect.fn("GitHubIssue.close")(function* (number, reason) {
@@ -218,8 +192,7 @@ const make = (client) => ({
218
192
  repo,
219
193
  issue_number: number,
220
194
  state: "closed",
221
- ...reason !== void 0 ? { state_reason: reason } : {},
222
- headers: API_VERSION_HEADERS
195
+ ...reason !== void 0 ? { state_reason: reason } : {}
223
196
  });
224
197
  }),
225
198
  comment: Effect.fn("GitHubIssue.comment")(function* (number, body) {
@@ -233,8 +206,7 @@ const make = (client) => ({
233
206
  owner,
234
207
  repo,
235
208
  issue_number: number,
236
- body,
237
- headers: API_VERSION_HEADERS
209
+ body
238
210
  });
239
211
  return numericId(created.id);
240
212
  }),
@@ -249,8 +221,7 @@ const make = (client) => ({
249
221
  const existing = (yield* client.paginate("GET /repos/{owner}/{repo}/issues/{issue_number}/comments", {
250
222
  owner,
251
223
  repo,
252
- issue_number: issueNumber,
253
- headers: API_VERSION_HEADERS
224
+ issue_number: issueNumber
254
225
  })).find((comment) => marker.matches(comment.body ?? ""));
255
226
  if (existing !== void 0) return CommentOnceResult.make({
256
227
  wrote: false,
@@ -264,8 +235,7 @@ const make = (client) => ({
264
235
  owner,
265
236
  repo,
266
237
  issue_number: issueNumber,
267
- body: `${body}\n\n${marker.html}`,
268
- headers: API_VERSION_HEADERS
238
+ body: `${body}\n\n${marker.html}`
269
239
  });
270
240
  return CommentOnceResult.make({
271
241
  wrote: true,
package/GitHubRelease.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
2
3
  import { GitHubClient } from "./GitHubClient.js";
3
4
  import { Repo } from "./Repo.js";
4
5
  import { Context, Effect, Layer, Option, Schema } from "effect";
@@ -59,22 +60,20 @@ var ReleaseAsset = class extends Schema.Class("ReleaseAsset")({
59
60
  var GitHubRelease = class GitHubRelease extends Context.Service()("@effected/github/GitHubRelease") {
60
61
  /** The live service, built over a `GitHubClient`. */
61
62
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
62
- /** An in-memory double; unstubbed members die naming themselves. */
63
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
63
64
  static makeTest = (overrides = {}) => ({
64
- create: overrides.create ?? (() => unstubbed("create")),
65
- getByTag: overrides.getByTag ?? (() => unstubbed("getByTag")),
66
- getByTagOption: overrides.getByTagOption ?? (() => unstubbed("getByTagOption")),
67
- list: overrides.list ?? (() => unstubbed("list")),
68
- update: overrides.update ?? (() => unstubbed("update")),
69
- uploadAsset: overrides.uploadAsset ?? (() => unstubbed("uploadAsset")),
70
- listAssets: overrides.listAssets ?? (() => unstubbed("listAssets"))
65
+ create: overrides.create ?? (() => dies("create")),
66
+ getByTag: overrides.getByTag ?? (() => dies("getByTag")),
67
+ getByTagOption: overrides.getByTagOption ?? (() => dies("getByTagOption")),
68
+ list: overrides.list ?? (() => dies("list")),
69
+ update: overrides.update ?? (() => dies("update")),
70
+ uploadAsset: overrides.uploadAsset ?? (() => dies("uploadAsset")),
71
+ listAssets: overrides.listAssets ?? (() => dies("listAssets"))
71
72
  });
72
73
  /** {@link GitHubRelease.makeTest} behind a `Layer`. */
73
74
  static layerTest = (overrides = {}) => Layer.succeed(GitHubRelease, GitHubRelease.makeTest(overrides));
74
75
  };
75
- const unstubbed = (member) => {
76
- throw new Error(`GitHubRelease.makeTest: ${member}() was called but not stubbed — pass an override.`);
77
- };
76
+ const dies = unstubbed("GitHubRelease.makeTest");
78
77
  const project = (raw) => ReleaseInfo.make({
79
78
  id: raw.id,
80
79
  tag: raw.tag_name,
@@ -1,6 +1,8 @@
1
1
  import { GraphQLDocument } from "./GraphQL.js";
2
+ import { unstubbed, unstubbedRead } from "./internal/unstubbed.js";
2
3
  import { GitHubClient } from "./GitHubClient.js";
3
4
  import { Repo } from "./Repo.js";
5
+ import { REMOVED_BY_PINNED_VERSION, REPOSITORY_REST_SETTING_KEYS } from "./internal/repositoryRestSettingKeys.js";
4
6
  import { Context, Effect, Layer, Schema } from "effect";
5
7
 
6
8
  // src/GitHubRepository.ts
@@ -63,17 +65,21 @@ const SECURITY_ANALYSIS_STATUS_FIELDS = /* @__PURE__ */ new Set([
63
65
  "dependabot_security_updates"
64
66
  ]);
65
67
  /**
66
- * Settings reachable **only** through the GraphQL `updateRepository` mutation,
67
- * 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.
68
70
  *
69
71
  * @remarks
70
- * GitHub never exposed these on the REST repository endpoint — the
71
- * `PATCH /repos/{owner}/{repo}` route accepts none of them, and
72
- * `has_discussions` is the treacherous one: the REST **read** returns it, so
73
- * routing its write to the PATCH looks symmetric, but the PATCH silently
74
- * ignores unknown body fields and answers 200, so the write would look applied
75
- * while the repository never changed. Setting any of these forces a second
76
- * 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.
77
83
  *
78
84
  * @public
79
85
  */
@@ -170,12 +176,13 @@ const preparePatch = (patch) => {
170
176
  };
171
177
  /**
172
178
  * Read and update a repository's settings, including settings only GraphQL can
173
- * 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.
174
181
  *
175
182
  * @remarks
176
183
  * Provide it with {@link GitHubRepository.layer}, which needs a `GitHubClient`;
177
- * every member also needs a `Repo` in `R`. `settings`, `defaultBranch`, `nodeId`
178
- * 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.
179
186
  *
180
187
  * @example
181
188
  * ```ts
@@ -186,7 +193,7 @@ const preparePatch = (patch) => {
186
193
  * const repository = yield* GitHubRepository;
187
194
  * const branch = yield* repository.defaultBranch;
188
195
  * const applied = yield* repository.applySettings({ has_wiki: false, has_discussions: true });
189
- * return { branch, applied }; // applied.rest, applied.graphql
196
+ * return { branch, applied }; // applied.rest, applied.graphql, applied.ignored
190
197
  * });
191
198
  * ```
192
199
  *
@@ -195,21 +202,22 @@ const preparePatch = (patch) => {
195
202
  var GitHubRepository = class GitHubRepository extends Context.Service()("@effected/github/GitHubRepository") {
196
203
  /** The live service, built over a `GitHubClient`. */
197
204
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
198
- /** An in-memory double; unstubbed members die naming themselves. */
205
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
199
206
  static makeTest = (overrides = {}) => ({
200
- settings: overrides.settings ?? Effect.sync(() => unstubbed("settings")),
201
- updateSettings: overrides.updateSettings ?? (() => unstubbed("updateSettings")),
202
- defaultBranch: overrides.defaultBranch ?? Effect.sync(() => unstubbed("defaultBranch")),
203
- nodeId: overrides.nodeId ?? Effect.sync(() => unstubbed("nodeId")),
204
- ownerType: overrides.ownerType ?? Effect.sync(() => unstubbed("ownerType")),
205
- applySettings: overrides.applySettings ?? (() => unstubbed("applySettings"))
207
+ settings: overrides.settings ?? unread("settings"),
208
+ updateSettings: overrides.updateSettings ?? (() => dies("updateSettings")),
209
+ defaultBranch: overrides.defaultBranch ?? unread("defaultBranch"),
210
+ nodeId: overrides.nodeId ?? unread("nodeId"),
211
+ ownerType: overrides.ownerType ?? unread("ownerType"),
212
+ applySettings: overrides.applySettings ?? (() => dies("applySettings")),
213
+ topics: overrides.topics ?? unread("topics"),
214
+ setTopics: overrides.setTopics ?? (() => dies("setTopics"))
206
215
  });
207
216
  /** {@link GitHubRepository.makeTest} behind a `Layer`. */
208
217
  static layerTest = (overrides = {}) => Layer.succeed(GitHubRepository, GitHubRepository.makeTest(overrides));
209
218
  };
210
- const unstubbed = (member) => {
211
- throw new Error(`GitHubRepository.makeTest: ${member} was read but not stubbed — pass an override.`);
212
- };
219
+ const dies = unstubbed("GitHubRepository.makeTest");
220
+ const unread = unstubbedRead("GitHubRepository.makeTest");
213
221
  const make = (client) => {
214
222
  const settings = Effect.fn("GitHubRepository.settings")(function* () {
215
223
  const { owner, repo } = yield* Repo;
@@ -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/GitTag.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
+ import { unstubbed } from "./internal/unstubbed.js";
2
3
  import { GitHubClient } from "./GitHubClient.js";
3
4
  import { Repo } from "./Repo.js";
4
5
  import { Context, Effect, Layer, Option, Result, Schema, Stream } from "effect";
@@ -63,21 +64,19 @@ const versionFromTag = (tag) => {
63
64
  var GitTag = class GitTag extends Context.Service()("@effected/github/GitTag") {
64
65
  /** The live service, built over a `GitHubClient`. */
65
66
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
66
- /** An in-memory double; unstubbed members die naming themselves. */
67
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
67
68
  static makeTest = (overrides = {}) => ({
68
- create: overrides.create ?? (() => unstubbed("create")),
69
- upsert: overrides.upsert ?? (() => unstubbed("upsert")),
70
- delete: overrides.delete ?? (() => unstubbed("delete")),
71
- list: overrides.list ?? (() => unstubbed("list")),
72
- resolve: overrides.resolve ?? (() => unstubbed("resolve")),
73
- latestSemver: overrides.latestSemver ?? (() => unstubbed("latestSemver"))
69
+ create: overrides.create ?? (() => dies("create")),
70
+ upsert: overrides.upsert ?? (() => dies("upsert")),
71
+ delete: overrides.delete ?? (() => dies("delete")),
72
+ list: overrides.list ?? (() => dies("list")),
73
+ resolve: overrides.resolve ?? (() => dies("resolve")),
74
+ latestSemver: overrides.latestSemver ?? (() => dies("latestSemver"))
74
75
  });
75
76
  /** {@link GitTag.makeTest} behind a `Layer`. */
76
77
  static layerTest = (overrides = {}) => Layer.succeed(GitTag, GitTag.makeTest(overrides));
77
78
  };
78
- const unstubbed = (member) => {
79
- throw new Error(`GitTag.makeTest: ${member}() was called but not stubbed — pass an override.`);
80
- };
79
+ const dies = unstubbed("GitTag.makeTest");
81
80
  const shortTag = (tag) => tag.replace(/^refs\/tags\//, "").replace(/^tags\//, "");
82
81
  const rejectEmpty = (operation, tag) => {
83
82
  const short = shortTag(tag).trim();
@@ -1,3 +1,4 @@
1
+ import { unstubbed } from "./internal/unstubbed.js";
1
2
  import { Clock, Context, Duration, Effect, Layer, Option, Ref } from "effect";
2
3
 
3
4
  // src/InstallationTokenStore.ts
@@ -38,13 +39,14 @@ var InstallationTokenStore = class InstallationTokenStore extends Context.Servic
38
39
  }));
39
40
  })
40
41
  })));
41
- /** An in-memory double; unstubbed members die naming themselves. */
42
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
42
43
  static makeTest = (overrides = {}) => ({
43
- get: overrides.get ?? (() => Effect.die(/* @__PURE__ */ new Error("InstallationTokenStore.makeTest: get() was called but not stubbed"))),
44
- set: overrides.set ?? (() => Effect.die(/* @__PURE__ */ new Error("InstallationTokenStore.makeTest: set() was called but not stubbed")))
44
+ get: overrides.get ?? (() => dies("get")),
45
+ set: overrides.set ?? (() => dies("set"))
45
46
  });
46
47
  /** {@link InstallationTokenStore.makeTest} behind a `Layer`. */
47
48
  static layerTest = (overrides = {}) => Layer.succeed(InstallationTokenStore, InstallationTokenStore.makeTest(overrides));
48
49
  };
50
+ const dies = unstubbed("InstallationTokenStore.makeTest");
49
51
 
50
52
  export { InstallationTokenStore };
package/PullRequest.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { GitHubError } from "./GitHubError.js";
2
- import { GraphQLDocument } from "./GraphQL.js";
2
+ import { GitHubGraphQLError, GraphQLDocument } from "./GraphQL.js";
3
+ import { unstubbed } from "./internal/unstubbed.js";
3
4
  import { GitHubClient } from "./GitHubClient.js";
4
5
  import { Repo } from "./Repo.js";
5
6
  import { fileOf } from "./GitHubCommit.js";
@@ -54,7 +55,27 @@ var PullRequestInfo = class extends Schema.Class("PullRequestInfo")({
54
55
  mergedAt: Schema.Option(Schema.DateTimeUtcFromString),
55
56
  /** The description, when GitHub sent one. */
56
57
  body: Schema.optionalKey(Schema.String),
57
- /** 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
+ */
58
79
  mergeCommitSha: Schema.optionalKey(Schema.String)
59
80
  }) {};
60
81
  const AutoMergeResponse = Schema.Struct({});
@@ -74,6 +95,24 @@ const DisableAutoMerge = GraphQLDocument.make({
74
95
  }`,
75
96
  response: AutoMergeResponse
76
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;
77
116
  /** GraphQL spells the merge methods in capitals. */
78
117
  const GRAPHQL_MERGE_METHOD = {
79
118
  merge: "MERGE",
@@ -110,48 +149,82 @@ const GRAPHQL_MERGE_METHOD = {
110
149
  var PullRequest = class PullRequest extends Context.Service()("@effected/github/PullRequest") {
111
150
  /** The live service, built over a `GitHubClient`. */
112
151
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
113
- /** An in-memory double; unstubbed members die naming themselves. */
152
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
114
153
  static makeTest = (overrides = {}) => ({
115
- get: overrides.get ?? (() => unstubbed("get")),
116
- list: overrides.list ?? (() => unstubbed("list")),
117
- listFiles: overrides.listFiles ?? (() => unstubbed("listFiles")),
118
- listAssociatedWithCommit: overrides.listAssociatedWithCommit ?? (() => unstubbed("listAssociatedWithCommit")),
119
- create: overrides.create ?? (() => unstubbed("create")),
120
- update: overrides.update ?? (() => unstubbed("update")),
121
- upsert: overrides.upsert ?? (() => unstubbed("upsert")),
122
- merge: overrides.merge ?? (() => unstubbed("merge")),
123
- addLabels: overrides.addLabels ?? (() => unstubbed("addLabels")),
124
- requestReviewers: overrides.requestReviewers ?? (() => unstubbed("requestReviewers")),
125
- setAutoMerge: overrides.setAutoMerge ?? (() => unstubbed("setAutoMerge"))
154
+ get: overrides.get ?? (() => dies("get")),
155
+ list: overrides.list ?? (() => dies("list")),
156
+ listFiles: overrides.listFiles ?? (() => dies("listFiles")),
157
+ listAssociatedWithCommit: overrides.listAssociatedWithCommit ?? (() => dies("listAssociatedWithCommit")),
158
+ create: overrides.create ?? (() => dies("create")),
159
+ update: overrides.update ?? (() => dies("update")),
160
+ upsert: overrides.upsert ?? (() => dies("upsert")),
161
+ merge: overrides.merge ?? (() => dies("merge")),
162
+ addLabels: overrides.addLabels ?? (() => dies("addLabels")),
163
+ requestReviewers: overrides.requestReviewers ?? (() => dies("requestReviewers")),
164
+ setAutoMerge: overrides.setAutoMerge ?? (() => dies("setAutoMerge"))
126
165
  });
127
166
  /** {@link PullRequest.makeTest} behind a `Layer`. */
128
167
  static layerTest = (overrides = {}) => Layer.succeed(PullRequest, PullRequest.makeTest(overrides));
129
168
  };
130
- const unstubbed = (member) => {
131
- throw new Error(`PullRequest.makeTest: ${member}() was called but not stubbed — pass an override.`);
132
- };
169
+ const dies = unstubbed("PullRequest.makeTest");
133
170
  const project = (raw) => Effect.try({
134
- try: () => PullRequestInfo.make({
135
- number: raw.number,
136
- nodeId: raw.node_id,
137
- url: raw.html_url,
138
- title: raw.title,
139
- state: raw.state === "closed" ? "closed" : "open",
140
- head: raw.head.ref,
141
- headSha: raw.head.sha,
142
- base: raw.base.ref,
143
- baseSha: raw.base.sha,
144
- draft: raw.draft ?? false,
145
- merged: raw.merged ?? raw.merged_at != null,
146
- mergedAt: raw.merged_at == null ? Option.none() : Option.some(DateTime.makeUnsafe(raw.merged_at)),
147
- ...raw.body != null ? { body: raw.body } : {},
148
- ...raw.merge_commit_sha != null ? { mergeCommitSha: raw.merge_commit_sha } : {}
149
- }),
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
+ },
150
190
  catch: (error) => GitHubError.decode("PullRequest", "GitHub returned an unexpected pull request", error)
151
191
  });
152
192
  /** `owner:branch` is what GitHub's `head` filter wants for a cross-fork search. */
153
193
  const qualifyHead = (owner, head) => head.includes(":") ? head : `${owner}:${head}`;
154
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);
155
228
  const list = Effect.fn("PullRequest.list")(function* (options) {
156
229
  const { owner, repo } = yield* Repo;
157
230
  const raw = yield* client.paginate("GET /repos/{owner}/{repo}/pulls", {
@@ -161,7 +234,8 @@ const make = (client) => {
161
234
  ...options?.head !== void 0 ? { head: qualifyHead(owner, options.head) } : {},
162
235
  ...options?.base !== void 0 ? { base: options.base } : {}
163
236
  }, options?.page);
164
- return yield* Effect.forEach(raw, project);
237
+ const pulls = yield* Effect.forEach(raw, project);
238
+ return options?.mergeCommits === true ? yield* withMergeCommits(pulls) : pulls;
165
239
  });
166
240
  const create = Effect.fn("PullRequest.create")(function* (input) {
167
241
  const { owner, repo } = yield* Repo;
@@ -198,7 +272,7 @@ const make = (client) => {
198
272
  ...patch.state !== void 0 ? { state: patch.state } : {},
199
273
  ...patch.base !== void 0 ? { base: patch.base } : {}
200
274
  });
201
- return yield* project(updated);
275
+ return yield* withMergeCommit(yield* project(updated));
202
276
  });
203
277
  return {
204
278
  list,
@@ -216,7 +290,7 @@ const make = (client) => {
216
290
  repo,
217
291
  pull_number: number
218
292
  });
219
- return yield* project(raw);
293
+ return yield* withMergeCommit(yield* project(raw));
220
294
  }),
221
295
  listFiles: Effect.fn("PullRequest.listFiles")(function* (number, options) {
222
296
  const { owner, repo } = yield* Repo;
@@ -243,7 +317,8 @@ const make = (client) => {
243
317
  repo,
244
318
  commit_sha: sha
245
319
  }, options?.page);
246
- return yield* Effect.forEach(raw, project);
320
+ const pulls = yield* Effect.forEach(raw, project);
321
+ return options?.mergeCommits === true ? yield* withMergeCommits(pulls) : pulls;
247
322
  }),
248
323
  upsert: Effect.fn("PullRequest.upsert")(function* (input) {
249
324
  const found = (yield* list({
@@ -1,3 +1,4 @@
1
+ import { unstubbed } from "./internal/unstubbed.js";
1
2
  import { GitHubClient } from "./GitHubClient.js";
2
3
  import { Repo } from "./Repo.js";
3
4
  import { numericId } from "./internal/ids.js";
@@ -66,19 +67,17 @@ var CommentRecord = class extends Schema.Class("CommentRecord")({
66
67
  var PullRequestComment = class PullRequestComment extends Context.Service()("@effected/github/PullRequestComment") {
67
68
  /** The live service, built over a `GitHubClient`. */
68
69
  static layer = Layer.effect(this, Effect.map(GitHubClient, (client) => make(client)));
69
- /** An in-memory double; unstubbed members die naming themselves. */
70
+ /** An in-memory double; an unstubbed member dies when run, naming itself. */
70
71
  static makeTest = (overrides = {}) => ({
71
- create: overrides.create ?? (() => unstubbed("create")),
72
- upsert: overrides.upsert ?? (() => unstubbed("upsert")),
73
- find: overrides.find ?? (() => unstubbed("find")),
74
- delete: overrides.delete ?? (() => unstubbed("delete"))
72
+ create: overrides.create ?? (() => dies("create")),
73
+ upsert: overrides.upsert ?? (() => dies("upsert")),
74
+ find: overrides.find ?? (() => dies("find")),
75
+ delete: overrides.delete ?? (() => dies("delete"))
75
76
  });
76
77
  /** {@link PullRequestComment.makeTest} behind a `Layer`. */
77
78
  static layerTest = (overrides = {}) => Layer.succeed(PullRequestComment, PullRequestComment.makeTest(overrides));
78
79
  };
79
- const unstubbed = (member) => {
80
- throw new Error(`PullRequestComment.makeTest: ${member}() was called but not stubbed — pass an override.`);
81
- };
80
+ const dies = unstubbed("PullRequestComment.makeTest");
82
81
  const recordOf = (raw) => CommentRecord.make({
83
82
  id: numericId(raw.id),
84
83
  body: raw.body ?? "",