@blogic-cz/agent-tools 1.16.1 → 1.17.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.
Files changed (57) hide show
  1. package/README.md +25 -0
  2. package/dist/gh-tool/issue/commands.d.ts +5 -0
  3. package/dist/gh-tool/issue/commands.d.ts.map +1 -1
  4. package/dist/gh-tool/issue/core.d.ts +19 -0
  5. package/dist/gh-tool/issue/core.d.ts.map +1 -1
  6. package/dist/gh-tool/issue/index.d.ts +1 -1
  7. package/dist/gh-tool/issue/index.d.ts.map +1 -1
  8. package/dist/gh-tool/issue/triage.d.ts +1 -1
  9. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  10. package/dist/gh-tool/main.d.ts.map +1 -1
  11. package/dist/gh-tool/pr/commands.d.ts +7 -7
  12. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  13. package/dist/gh-tool/pr/core.d.ts +9 -9
  14. package/dist/gh-tool/pr/review.d.ts +3 -3
  15. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  16. package/dist/gh-tool/read/graphql-fallback.d.ts +21 -0
  17. package/dist/gh-tool/read/graphql-fallback.d.ts.map +1 -0
  18. package/dist/gh-tool/read/issues.d.ts +11 -1
  19. package/dist/gh-tool/read/issues.d.ts.map +1 -1
  20. package/dist/gh-tool/read/pulls.d.ts +13 -12
  21. package/dist/gh-tool/read/pulls.d.ts.map +1 -1
  22. package/dist/gh-tool/read/reads.d.ts +22 -5
  23. package/dist/gh-tool/read/reads.d.ts.map +1 -1
  24. package/dist/gh-tool/read/rest-porcelain.d.ts +131 -0
  25. package/dist/gh-tool/read/rest-porcelain.d.ts.map +1 -0
  26. package/dist/gh-tool/read/review-guards.d.ts +1 -1
  27. package/dist/gh-tool/read/review-guards.d.ts.map +1 -1
  28. package/dist/gh-tool/read/review-model.d.ts +1 -1
  29. package/dist/gh-tool/read/review-model.d.ts.map +1 -1
  30. package/dist/gh-tool/read/reviews.d.ts.map +1 -1
  31. package/dist/gh-tool/read/telemetry.d.ts +1 -1
  32. package/dist/gh-tool/read/telemetry.d.ts.map +1 -1
  33. package/dist/gh-tool/read/transport.d.ts.map +1 -1
  34. package/dist/gh-tool/repo.d.ts +3 -0
  35. package/dist/gh-tool/repo.d.ts.map +1 -1
  36. package/dist/gh-tool/types.d.ts +2 -2
  37. package/dist/gh-tool/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/gh-tool/issue/commands.ts +18 -0
  40. package/src/gh-tool/issue/core.ts +45 -1
  41. package/src/gh-tool/issue/index.ts +1 -0
  42. package/src/gh-tool/issue/triage.ts +1 -1
  43. package/src/gh-tool/main.ts +7 -2
  44. package/src/gh-tool/pr/commands.ts +15 -6
  45. package/src/gh-tool/pr/review.ts +27 -7
  46. package/src/gh-tool/read/graphql-fallback.ts +366 -0
  47. package/src/gh-tool/read/issues.ts +23 -1
  48. package/src/gh-tool/read/pulls.ts +60 -10
  49. package/src/gh-tool/read/reads.ts +12 -1
  50. package/src/gh-tool/read/rest-porcelain.ts +571 -0
  51. package/src/gh-tool/read/review-guards.ts +8 -2
  52. package/src/gh-tool/read/review-model.ts +1 -1
  53. package/src/gh-tool/read/reviews.ts +19 -3
  54. package/src/gh-tool/read/telemetry.ts +6 -3
  55. package/src/gh-tool/read/transport.ts +314 -7
  56. package/src/gh-tool/repo.ts +7 -1
  57. package/src/gh-tool/types.ts +2 -2
@@ -0,0 +1,571 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { GitHubCommandError } from "#gh/errors";
3
+ import type { GhError } from "./transport";
4
+
5
+ export type JsonRead = (args: string[]) => Effect.Effect<unknown, GhError>;
6
+ export type ReviewDecisionInput = {
7
+ repo: string;
8
+ number: number;
9
+ baseRefName: string;
10
+ headSha: string;
11
+ };
12
+ export type ReviewDecision = "" | "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "UNKNOWN";
13
+ const User = Schema.Struct({ login: Schema.String, type: Schema.String });
14
+ const Ref = Schema.Struct({
15
+ ref: Schema.String,
16
+ sha: Schema.String,
17
+ label: Schema.optional(Schema.String),
18
+ user: Schema.NullOr(User),
19
+ });
20
+ export const RestPull = Schema.Struct({
21
+ number: Schema.Number,
22
+ html_url: Schema.String,
23
+ title: Schema.String,
24
+ state: Schema.String,
25
+ draft: Schema.Boolean,
26
+ merged: Schema.optional(Schema.Boolean),
27
+ merged_at: Schema.NullOr(Schema.String),
28
+ mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)),
29
+ merge_commit_sha: Schema.optional(Schema.NullOr(Schema.String)),
30
+ body: Schema.NullOr(Schema.String),
31
+ user: User,
32
+ head: Ref,
33
+ base: Ref,
34
+ created_at: Schema.String,
35
+ requested_reviewers: Schema.optional(Schema.Array(User)),
36
+ requested_teams: Schema.optional(
37
+ Schema.Array(Schema.Struct({ name: Schema.String, slug: Schema.String })),
38
+ ),
39
+ });
40
+ export const pullFromRest = (pull: typeof RestPull.Type) => ({
41
+ number: pull.number,
42
+ url: pull.html_url,
43
+ title: pull.title,
44
+ state:
45
+ pull.merged === true || (pull.merged_at !== null && pull.merged_at !== undefined)
46
+ ? "MERGED"
47
+ : pull.state.toUpperCase(),
48
+ isDraft: pull.draft,
49
+ headRefName: pull.head.ref,
50
+ baseRefName: pull.base.ref,
51
+ headRefOid: pull.head.sha,
52
+ headSha: pull.head.sha,
53
+ baseSha: pull.base.sha,
54
+ mergeable:
55
+ pull.mergeable === true ? "MERGEABLE" : pull.mergeable === false ? "CONFLICTING" : "UNKNOWN",
56
+ body: pull.body ?? "",
57
+ author: { login: pull.user.login, is_bot: pull.user.type === "Bot" },
58
+ createdAt: pull.created_at,
59
+ headRepositoryOwner: pull.head.user === null ? null : { login: pull.head.user.login },
60
+ mergeCommit:
61
+ pull.merged === true || (pull.merged_at !== null && pull.merged_at !== undefined)
62
+ ? pull.merge_commit_sha === null || pull.merge_commit_sha === undefined
63
+ ? null
64
+ : { oid: pull.merge_commit_sha }
65
+ : null,
66
+ reviewRequests: [
67
+ ...(pull.requested_reviewers ?? []).map((user) => ({ __typename: "User", login: user.login })),
68
+ ...(pull.requested_teams ?? []).map((team) => ({
69
+ __typename: "Team",
70
+ name: team.name,
71
+ slug: team.slug,
72
+ })),
73
+ ],
74
+ reviewDecision: "UNKNOWN",
75
+ mergeStateStatus: "UNKNOWN",
76
+ });
77
+
78
+ export const parseRest = <A>(schema: Schema.Codec<A>, value: unknown) =>
79
+ Effect.try({
80
+ try: () => Schema.decodeUnknownSync(schema)(value),
81
+ catch: (error) =>
82
+ new GitHubCommandError({
83
+ command: "GitHub REST",
84
+ exitCode: 1,
85
+ message: `Invalid REST response: ${String(error)}`,
86
+ stderr: `Invalid REST response: ${String(error)}`,
87
+ }),
88
+ });
89
+ export const restGet = (read: JsonRead, path: string) => read(["api", "--method", "GET", path]);
90
+ export const restPages = (read: JsonRead, path: string) =>
91
+ read(["api", "--method", "GET", path, "--paginate", "--slurp"]);
92
+ export const restPull = (read: JsonRead, repo: string, number: string) =>
93
+ Effect.gen(function* () {
94
+ return pullFromRest(
95
+ yield* parseRest(RestPull, yield* restGet(read, `repos/${repo}/pulls/${number}`)),
96
+ );
97
+ });
98
+
99
+ type ReviewRule = {
100
+ approvals: number;
101
+ codeOwner: boolean;
102
+ dismissStale: boolean;
103
+ lastPush: boolean;
104
+ };
105
+ const RestReviewRule = Schema.Struct({
106
+ required_approving_review_count: Schema.optional(Schema.Number),
107
+ require_code_owner_review: Schema.optional(Schema.Boolean),
108
+ dismiss_stale_reviews_on_push: Schema.optional(Schema.Boolean),
109
+ require_last_push_approval: Schema.optional(Schema.Boolean),
110
+ });
111
+ const reviewRule = (parameters: unknown) => {
112
+ const decoded = Schema.decodeUnknownSync(RestReviewRule)(parameters);
113
+ return {
114
+ approvals: decoded.required_approving_review_count ?? 0,
115
+ codeOwner: decoded.require_code_owner_review ?? false,
116
+ dismissStale: decoded.dismiss_stale_reviews_on_push ?? false,
117
+ lastPush: decoded.require_last_push_approval ?? false,
118
+ };
119
+ };
120
+ const ClassicReviewRule = Schema.Struct({
121
+ required_approving_review_count: Schema.Number,
122
+ require_code_owner_reviews: Schema.Boolean,
123
+ dismiss_stale_reviews: Schema.Boolean,
124
+ require_last_push_approval: Schema.Boolean,
125
+ });
126
+ const noRulesets = (error: unknown) =>
127
+ error instanceof Error &&
128
+ (/HTTP 404|Not Found/i.test(error.message) ||
129
+ (/HTTP 403/i.test(error.message) &&
130
+ /Upgrade to GitHub Pro|make this repository public/i.test(error.message)));
131
+ const restValue = <A>(read: JsonRead, path: string, schema: Schema.Codec<A>) =>
132
+ restGet(read, path).pipe(
133
+ Effect.flatMap((value) => parseRest(schema, value)),
134
+ Effect.result,
135
+ );
136
+ const Rulesets = Schema.Array(
137
+ Schema.Struct({ type: Schema.String, parameters: Schema.optional(Schema.Unknown) }),
138
+ );
139
+
140
+ export const restReviewDecision = (
141
+ read: JsonRead,
142
+ input: ReviewDecisionInput,
143
+ ): Effect.Effect<ReviewDecision> =>
144
+ Effect.gen(function* () {
145
+ const base = encodeURIComponent(input.baseRefName);
146
+ const rulesetResult = yield* restValue(
147
+ read,
148
+ `repos/${input.repo}/rules/branches/${base}`,
149
+ Rulesets,
150
+ );
151
+ let rulesets: ReviewRule[] = [];
152
+ if (Result.isFailure(rulesetResult)) {
153
+ if (!noRulesets(rulesetResult.failure)) return "UNKNOWN";
154
+ } else {
155
+ try {
156
+ rulesets = rulesetResult.success.flatMap((rule) =>
157
+ rule.type === "pull_request" ? [reviewRule(rule.parameters)] : [],
158
+ );
159
+ } catch {
160
+ return "UNKNOWN";
161
+ }
162
+ }
163
+
164
+ const branchResult = yield* restValue(
165
+ read,
166
+ `repos/${input.repo}/branches/${base}`,
167
+ Schema.Struct({ protected: Schema.Boolean }),
168
+ );
169
+ if (Result.isFailure(branchResult)) return "UNKNOWN";
170
+
171
+ let classic: ReviewRule[] = [];
172
+ if (branchResult.success.protected) {
173
+ const protectionResult = yield* restValue(
174
+ read,
175
+ `repos/${input.repo}/branches/${base}/protection/required_pull_request_reviews`,
176
+ ClassicReviewRule,
177
+ );
178
+ if (Result.isFailure(protectionResult)) {
179
+ if (!/Branch not protected/i.test(protectionResult.failure.message)) return "UNKNOWN";
180
+ } else {
181
+ const rule = protectionResult.success;
182
+ classic = [
183
+ {
184
+ approvals: rule.required_approving_review_count,
185
+ codeOwner: rule.require_code_owner_reviews,
186
+ dismissStale: rule.dismiss_stale_reviews,
187
+ lastPush: rule.require_last_push_approval,
188
+ },
189
+ ];
190
+ }
191
+ }
192
+
193
+ const rules = [...rulesets, ...classic];
194
+ if (rules.length === 0) return "";
195
+ const required: ReviewRule = {
196
+ approvals: Math.max(...rules.map((rule) => rule.approvals)),
197
+ codeOwner: rules.some((rule) => rule.codeOwner),
198
+ dismissStale: rules.some((rule) => rule.dismissStale),
199
+ lastPush: rules.some((rule) => rule.lastPush),
200
+ };
201
+ if (required.codeOwner || required.lastPush) return "UNKNOWN";
202
+ if (required.approvals === 0) return "";
203
+
204
+ const reviewsResult = yield* restPages(
205
+ read,
206
+ `repos/${input.repo}/pulls/${input.number}/reviews`,
207
+ ).pipe(
208
+ Effect.flatMap((value) =>
209
+ parseRest(
210
+ Schema.Array(
211
+ Schema.Array(
212
+ Schema.Struct({
213
+ id: Schema.Number,
214
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
215
+ state: Schema.String,
216
+ submitted_at: Schema.NullOr(Schema.String),
217
+ commit_id: Schema.String,
218
+ }),
219
+ ),
220
+ ),
221
+ value,
222
+ ),
223
+ ),
224
+ Effect.result,
225
+ );
226
+ if (Result.isFailure(reviewsResult)) return "UNKNOWN";
227
+
228
+ const latest = new Map<string, (typeof reviewsResult.success)[number][number]>();
229
+ for (const review of reviewsResult.success.flat()) {
230
+ const state = review.state.toUpperCase();
231
+ if (review.user === null || !["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(state))
232
+ continue;
233
+ const login = review.user.login.toLowerCase();
234
+ const current = latest.get(login);
235
+ if (
236
+ current === undefined ||
237
+ (review.submitted_at ?? "") > (current.submitted_at ?? "") ||
238
+ (review.submitted_at === current.submitted_at && review.id > current.id)
239
+ )
240
+ latest.set(login, review);
241
+ }
242
+ const currentReviews: Array<(typeof reviewsResult.success)[number][number]> = [];
243
+ for (const review of latest.values()) {
244
+ if (review.state.toUpperCase() === "DISMISSED" || review.user === null) continue;
245
+ const permission = yield* restValue(
246
+ read,
247
+ `repos/${input.repo}/collaborators/${encodeURIComponent(review.user.login)}/permission`,
248
+ Schema.Struct({
249
+ permission: Schema.String,
250
+ role_name: Schema.optional(Schema.String),
251
+ }),
252
+ );
253
+ if (Result.isFailure(permission)) return "UNKNOWN";
254
+ const { permission: level, role_name: role } = permission.success;
255
+ if (["admin", "write"].includes(level) || ["admin", "maintain", "write"].includes(role ?? ""))
256
+ currentReviews.push(review);
257
+ }
258
+ if (currentReviews.some((review) => review.state.toUpperCase() === "CHANGES_REQUESTED"))
259
+ return "CHANGES_REQUESTED";
260
+ const approvals = currentReviews.filter(
261
+ (review) =>
262
+ review.state.toUpperCase() === "APPROVED" &&
263
+ (!required.dismissStale || review.commit_id === input.headSha),
264
+ ).length;
265
+ return approvals >= required.approvals ? "APPROVED" : "REVIEW_REQUIRED";
266
+ });
267
+
268
+ const option = (args: readonly string[], flag: string) => {
269
+ const index = args.indexOf(flag);
270
+ return index < 0 ? undefined : args[index + 1];
271
+ };
272
+ const onlyFlags = (
273
+ rest: readonly string[],
274
+ switches: readonly string[],
275
+ valued: readonly string[],
276
+ ): boolean => {
277
+ for (let index = 0; index < rest.length; index++) {
278
+ const flag = rest[index] ?? "";
279
+ if (switches.includes(flag)) continue;
280
+ if (valued.includes(flag) && index + 1 < rest.length) {
281
+ index++;
282
+ continue;
283
+ }
284
+ return false;
285
+ }
286
+ return true;
287
+ };
288
+ export const graphFieldsRequested = (args: readonly string[]) =>
289
+ (option(args, "--json") ?? "")
290
+ .split(",")
291
+ .some((field) => field === "reviewDecision" || field === "mergeStateStatus");
292
+
293
+ export const restPorcelain = (
294
+ args: string[],
295
+ read: JsonRead,
296
+ target: string | undefined,
297
+ resolveDecision: (input: ReviewDecisionInput) => Effect.Effect<ReviewDecision, GhError> = (
298
+ input,
299
+ ) => restReviewDecision(read, input),
300
+ ): Effect.Effect<unknown, GhError> | null => {
301
+ const repo = option(args, "--repo") ?? target ?? "{owner}/{repo}";
302
+ const selector = args[2]?.startsWith("-") === false ? args[2] : undefined;
303
+ if (args[0] === "repo" && args[1] === "view")
304
+ return Effect.gen(function* () {
305
+ const raw = yield* parseRest(
306
+ Schema.Struct({
307
+ owner: Schema.Struct({ login: Schema.String }),
308
+ name: Schema.String,
309
+ default_branch: Schema.String,
310
+ html_url: Schema.String,
311
+ }),
312
+ yield* restGet(read, `repos/${selector ?? repo}`),
313
+ );
314
+ return {
315
+ owner: { login: raw.owner.login },
316
+ name: raw.name,
317
+ defaultBranchRef: { name: raw.default_branch },
318
+ url: raw.html_url,
319
+ };
320
+ });
321
+ if (args[0] !== "pr") return null;
322
+ if (
323
+ args[1] === "merge" &&
324
+ selector !== undefined &&
325
+ /^\d+$/.test(selector) &&
326
+ onlyFlags(args.slice(3), ["--squash", "--rebase", "--merge"], ["--match-head-commit", "--repo"])
327
+ )
328
+ return Effect.gen(function* () {
329
+ const strategy = args.includes("--squash")
330
+ ? "squash"
331
+ : args.includes("--rebase")
332
+ ? "rebase"
333
+ : "merge";
334
+ const sha = option(args, "--match-head-commit");
335
+ const raw = yield* read([
336
+ "api",
337
+ "--method",
338
+ "PUT",
339
+ `repos/${repo}/pulls/${selector}/merge`,
340
+ "-f",
341
+ `merge_method=${strategy}`,
342
+ ...(sha === undefined ? [] : ["-f", `sha=${sha}`]),
343
+ ]);
344
+ const result = yield* parseRest(
345
+ Schema.Struct({ merged: Schema.Boolean, message: Schema.String }),
346
+ raw,
347
+ );
348
+ if (!result.merged)
349
+ return yield* new GitHubCommandError({
350
+ command: "pr merge",
351
+ exitCode: 1,
352
+ message: result.message,
353
+ stderr: result.message,
354
+ });
355
+ return result;
356
+ });
357
+ if (
358
+ args[1] === "close" &&
359
+ selector !== undefined &&
360
+ /^\d+$/.test(selector) &&
361
+ !args.includes("--delete-branch")
362
+ )
363
+ return Effect.gen(function* () {
364
+ const pull = yield* restPull(read, repo, selector);
365
+ if (pull.state !== "OPEN") return pull;
366
+ const comment = option(args, "--comment");
367
+ if (comment !== undefined)
368
+ yield* read([
369
+ "api",
370
+ "--method",
371
+ "POST",
372
+ `repos/${repo}/issues/${selector}/comments`,
373
+ "-f",
374
+ `body=${comment}`,
375
+ ]);
376
+ return yield* read([
377
+ "api",
378
+ "--method",
379
+ "PATCH",
380
+ `repos/${repo}/pulls/${selector}`,
381
+ "-f",
382
+ "state=closed",
383
+ ]);
384
+ });
385
+ if (!args.includes("--json")) return null;
386
+ const requestedFields = new Set((option(args, "--json") ?? "").split(","));
387
+ const fields = new Set([...requestedFields, "reviewDecision"]);
388
+ const projectPull = (pull: ReturnType<typeof pullFromRest>) =>
389
+ Effect.gen(function* () {
390
+ const value = requestedFields.has("reviewDecision")
391
+ ? {
392
+ ...pull,
393
+ reviewDecision: yield* resolveDecision({
394
+ repo,
395
+ number: pull.number,
396
+ baseRefName: pull.baseRefName,
397
+ headSha: pull.headSha,
398
+ }),
399
+ }
400
+ : pull;
401
+ return Object.fromEntries(
402
+ Object.entries(value)
403
+ .filter(([key]) => fields.has(key))
404
+ .toSorted(([a], [b]) => a.localeCompare(b)),
405
+ );
406
+ });
407
+ if (args[1] === "view" && selector !== undefined && /^\d+$/.test(selector))
408
+ return restPull(read, repo, selector).pipe(Effect.flatMap(projectPull));
409
+ if (args[1] === "list" && option(args, "--search") !== undefined)
410
+ return Effect.gen(function* () {
411
+ const state = option(args, "--state") ?? "open";
412
+ const query = [
413
+ option(args, "--search"),
414
+ `repo:${repo}`,
415
+ "is:pr",
416
+ ...(state === "all" ? [] : [`is:${state}`]),
417
+ ...["author", "head", "base"].flatMap((flag) => {
418
+ const value = option(args, `--${flag}`);
419
+ return value === undefined ? [] : [`${flag}:${value}`];
420
+ }),
421
+ ].join(" ");
422
+ const params = new URLSearchParams({
423
+ q: query,
424
+ per_page: "100",
425
+ sort: "created",
426
+ order: "desc",
427
+ });
428
+ const pages = yield* parseRest(
429
+ Schema.Array(
430
+ Schema.Struct({
431
+ incomplete_results: Schema.Boolean,
432
+ items: Schema.Array(Schema.Struct({ number: Schema.Number })),
433
+ }),
434
+ ),
435
+ yield* restPages(read, `search/issues?${params}`),
436
+ );
437
+ if (pages.some((page) => page.incomplete_results))
438
+ return yield* new GitHubCommandError({
439
+ command: "pr list",
440
+ exitCode: 1,
441
+ message: "GitHub REST search returned incomplete results",
442
+ stderr: "GitHub REST search returned incomplete results",
443
+ });
444
+ return yield* Effect.forEach(
445
+ pages.flatMap((page) => page.items).slice(0, Number(option(args, "--limit") ?? 30)),
446
+ (item) => restPull(read, repo, String(item.number)).pipe(Effect.flatMap(projectPull)),
447
+ );
448
+ });
449
+ if (args[1] === "list")
450
+ return Effect.gen(function* () {
451
+ const state = option(args, "--state") ?? "open";
452
+ const params = new URLSearchParams({
453
+ state: state === "merged" ? "closed" : state,
454
+ per_page: "100",
455
+ });
456
+ const head = option(args, "--head");
457
+ if (head?.includes(":")) params.set("head", head);
458
+ const base = option(args, "--base");
459
+ if (base !== undefined) params.set("base", base);
460
+ const pages = yield* parseRest(
461
+ Schema.Array(Schema.Array(RestPull)),
462
+ yield* restPages(read, `repos/${repo}/pulls?${params}`),
463
+ );
464
+ let author = option(args, "--author");
465
+ if (author === "@me")
466
+ author = (yield* parseRest(
467
+ Schema.Struct({ login: Schema.String }),
468
+ yield* restGet(read, "user"),
469
+ )).login;
470
+ const pulls = pages
471
+ .flat()
472
+ .map(pullFromRest)
473
+ .filter((pull) => state !== "merged" || pull.state === "MERGED")
474
+ .filter((pull) => head === undefined || head.includes(":") || pull.headRefName === head)
475
+ .filter(
476
+ (pull) =>
477
+ author === undefined || pull.author.login.toLowerCase() === author.toLowerCase(),
478
+ )
479
+ .slice(0, Number(option(args, "--limit") ?? 30));
480
+ return yield* Effect.forEach(pulls, (pull) =>
481
+ (fields.has("mergeable")
482
+ ? restPull(read, repo, String(pull.number))
483
+ : Effect.succeed(pull)
484
+ ).pipe(Effect.flatMap(projectPull)),
485
+ );
486
+ });
487
+ if (args[1] === "checks" && selector !== undefined && /^\d+$/.test(selector))
488
+ return Effect.gen(function* () {
489
+ const pull = yield* restPull(read, repo, selector);
490
+ const root = `repos/${repo}/commits/${pull.headSha}`;
491
+ const runs = yield* parseRest(
492
+ Schema.Array(
493
+ Schema.Struct({
494
+ check_runs: Schema.Array(
495
+ Schema.Struct({
496
+ name: Schema.String,
497
+ status: Schema.String,
498
+ conclusion: Schema.NullOr(Schema.String),
499
+ html_url: Schema.NullOr(Schema.String),
500
+ }),
501
+ ),
502
+ }),
503
+ ),
504
+ yield* restPages(read, `${root}/check-runs?per_page=100`),
505
+ );
506
+ const statuses = yield* parseRest(
507
+ Schema.Array(
508
+ Schema.Struct({
509
+ statuses: Schema.Array(
510
+ Schema.Struct({
511
+ context: Schema.String,
512
+ state: Schema.String,
513
+ target_url: Schema.NullOr(Schema.String),
514
+ }),
515
+ ),
516
+ }),
517
+ ),
518
+ yield* restPages(read, `${root}/status?per_page=100`),
519
+ );
520
+ const checks = runs.flatMap((page) =>
521
+ page.check_runs.map((run) => ({
522
+ name: run.name,
523
+ state: (run.conclusion ?? run.status).toUpperCase(),
524
+ bucket: bucket(run.conclusion ?? run.status),
525
+ link: run.html_url ?? "",
526
+ })),
527
+ );
528
+ const seen = new Set<string>();
529
+ for (const status of statuses.flatMap((page) => page.statuses)) {
530
+ if (seen.has(status.context)) continue;
531
+ seen.add(status.context);
532
+ checks.push({
533
+ name: status.context,
534
+ state: status.state.toUpperCase(),
535
+ bucket: bucket(status.state),
536
+ link: status.target_url ?? "",
537
+ });
538
+ }
539
+ const checkFields = (option(args, "--json") ?? "").split(",");
540
+ return checks.map((check) =>
541
+ Object.fromEntries(Object.entries(check).filter(([key]) => checkFields.includes(key))),
542
+ );
543
+ });
544
+ return null;
545
+ };
546
+ const bucket = (state: string) => {
547
+ switch (state) {
548
+ case "success":
549
+ return "pass";
550
+ case "neutral":
551
+ case "skipped":
552
+ return "skipping";
553
+ case "cancelled":
554
+ return "cancel";
555
+ case "failure":
556
+ case "error":
557
+ case "timed_out":
558
+ case "action_required":
559
+ case "stale":
560
+ case "startup_failure":
561
+ return "fail";
562
+ case "queued":
563
+ case "in_progress":
564
+ case "pending":
565
+ case "waiting":
566
+ case "requested":
567
+ return "pending";
568
+ default:
569
+ return "unknown";
570
+ }
571
+ };
@@ -53,9 +53,15 @@ export const reviewGuards = {
53
53
  after,
54
54
  });
55
55
  }),
56
- pendingReviewContent: (reviewId: string, after: string | null) =>
56
+ pendingReviewContent: (reviewId: string, after: string | null, repo: RepoRef, pr: number) =>
57
57
  Effect.gen(function* () {
58
58
  const gh = yield* GitHubService;
59
- return yield* gh.runGraphQL(PENDING_REVIEW_CONTENT_QUERY, { reviewId, after });
59
+ return yield* gh.runGraphQL(PENDING_REVIEW_CONTENT_QUERY, {
60
+ reviewId,
61
+ after,
62
+ owner: repo.owner,
63
+ name: repo.name,
64
+ pr,
65
+ });
60
66
  }),
61
67
  };
@@ -138,7 +138,7 @@ export const LAST_HUMAN_REVIEWER_QUERY = `
138
138
 
139
139
  export type ThreadNode<DatabaseId = number> = {
140
140
  id: string;
141
- isResolved: boolean;
141
+ isResolved: boolean | null;
142
142
  comments: {
143
143
  nodes: Array<{
144
144
  id: string;
@@ -6,6 +6,7 @@ import { gatedRead } from "./gated";
6
6
  import { restSnapshot, snapshotDigest } from "./rest";
7
7
  import { OPS } from "./classes";
8
8
  import { repoSlug } from "./keys";
9
+ import { isFallbackResult, markFallbackResult } from "./graphql-fallback";
9
10
  import type { RepoRef } from "./keys";
10
11
  import {
11
12
  REVIEW_THREADS_QUERY,
@@ -24,7 +25,7 @@ import type {
24
25
  const CachedThreads = Schema.Array(
25
26
  Schema.Struct({
26
27
  id: Schema.String,
27
- isResolved: Schema.Boolean,
28
+ isResolved: Schema.NullOr(Schema.Boolean),
28
29
  comments: Schema.Struct({
29
30
  nodes: Schema.Array(
30
31
  Schema.Struct({
@@ -51,6 +52,7 @@ const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
51
52
 
52
53
  const nodes: ThreadNode[] = [];
53
54
  let after: string | null = null;
55
+ let usedFallback = false;
54
56
 
55
57
  while (true) {
56
58
  const response = (yield* service.runGraphQL(REVIEW_THREADS_QUERY, {
@@ -60,6 +62,10 @@ const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
60
62
  after,
61
63
  })) as ThreadsQueryResult;
62
64
 
65
+ if ("fallback" in response) {
66
+ usedFallback = true;
67
+ nodes.length = 0;
68
+ }
63
69
  const page = response.repository.pullRequest.reviewThreads;
64
70
  for (const node of page.nodes) {
65
71
  let commentsAfter = node.comments.pageInfo?.endCursor ?? null;
@@ -68,6 +74,7 @@ const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
68
74
  threadId: node.id,
69
75
  after: commentsAfter,
70
76
  })) as ThreadCommentsQueryResult;
77
+ if ("fallback" in commentsResponse) usedFallback = true;
71
78
  if (commentsResponse.node === null) break;
72
79
  node.comments.nodes.push(...commentsResponse.node.comments.nodes);
73
80
  node.comments.pageInfo = commentsResponse.node.comments.pageInfo;
@@ -97,7 +104,7 @@ const originThreads = Effect.fn("pr.fetchAllThreadNodes")(function* (
97
104
  }
98
105
 
99
106
  if (!page.pageInfo.hasNextPage || page.pageInfo.endCursor === null) {
100
- return nodes;
107
+ return usedFallback ? markFallbackResult(nodes) : nodes;
101
108
  }
102
109
 
103
110
  after = page.pageInfo.endCursor;
@@ -133,9 +140,17 @@ export const reviewThreads = Effect.fn("reads.reviewThreads")(function* (
133
140
  Schema.decodeUnknownSync(CachedThreads)(raw);
134
141
  return raw as ThreadNode[];
135
142
  },
136
- fetch: originThreads(repo, pr),
143
+ fetch: originThreads(repo, pr).pipe(
144
+ Effect.catchTag("GitHubCommandError", (error) =>
145
+ error.stderr === "GitHub GraphQL is blocked; REST cannot perform this operation"
146
+ ? originThreads(repo, pr)
147
+ : Effect.fail(error),
148
+ ),
149
+ ),
137
150
  signal: reviewPages(repo, pr, "prComments").pipe(Effect.map(snapshotDigest)),
138
151
  maxAgeMs: () => OPS.reviewThreads.maxAgeMs,
152
+ cacheableWhen: (nodes) =>
153
+ !isFallbackResult(nodes) && nodes.every((node) => node.isResolved !== null),
139
154
  });
140
155
  });
141
156
  export const lastHumanReviewer = Effect.fn("reads.lastHumanReviewer")(function* (
@@ -147,6 +162,7 @@ export const lastHumanReviewer = Effect.fn("reads.lastHumanReviewer")(function*
147
162
  key: { op: "lastHumanReviewer", repo, pr },
148
163
  policy: OPS.lastHumanReviewer,
149
164
  decode: (raw) => raw as LastHumanReviewerQueryResult,
165
+ cacheableWhen: (value) => !("fallback" in value),
150
166
  fetch: () =>
151
167
  service
152
168
  .runGraphQL(LAST_HUMAN_REVIEWER_QUERY, { owner: repo.owner, name: repo.name, pr })