@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,366 @@
1
+ import { Console, Context, Effect, Schema } from "effect";
2
+ import type { JsonRead } from "./rest-porcelain";
3
+ import type { GhError } from "./transport";
4
+ import { parseRest, restGet, restPages, restPull, restReviewDecision } from "./rest-porcelain";
5
+ import { GitHubCommandError } from "#gh/errors";
6
+ import { ghSubprocessEvent, recordRequest } from "./telemetry";
7
+
8
+ export type Fallback = { source: "REST"; reason: string; unknown: string[] };
9
+ const fallbackResults = new WeakSet<object>();
10
+ export const markFallbackResult = <A extends object>(value: A): A => {
11
+ fallbackResults.add(value);
12
+ return value;
13
+ };
14
+ export const isFallbackResult = (value: unknown): boolean =>
15
+ typeof value === "object" && value !== null && fallbackResults.has(value);
16
+
17
+ export const FallbackState = Context.Reference<{ notices: Fallback[] } | null>("gh/FallbackState", {
18
+ defaultValue: () => null,
19
+ });
20
+ const fallbackMessage = (unknown: string[]) =>
21
+ unknown.length === 0
22
+ ? "GitHub GraphQL is blocked (rate limit); answered from REST. All fields were provided."
23
+ : `GitHub GraphQL is blocked (rate limit); answered from REST. Not provided: ${unknown.join(", ")}. A null or UNKNOWN value there means "not provided", never false, unresolved or not approved. If your next step depends on it, retry in a few minutes; if it is still not provided, tell the user. Do not guess.`;
24
+ export const reportFallback = (unknown: string[]) =>
25
+ Effect.gen(function* () {
26
+ const notice: Fallback = { source: "REST", reason: "GitHub GraphQL rate limit", unknown };
27
+ const state = yield* FallbackState;
28
+ state?.notices.push(notice);
29
+ if (state === null) yield* Console.warn(fallbackMessage(unknown));
30
+ yield* recordRequest({
31
+ ...ghSubprocessEvent(["api", "graphql"], 0),
32
+ method: "FALLBACK",
33
+ endpoint: "graphql-to-rest",
34
+ outcome: "fallback",
35
+ });
36
+ return notice;
37
+ });
38
+ export const withFallbackOutput = <A, E, R>(program: Effect.Effect<A, E, R>) =>
39
+ Effect.gen(function* () {
40
+ const current = yield* Console.Console;
41
+ const state = { notices: [] as Fallback[] };
42
+ let warned = false;
43
+ const flushWarning = () => {
44
+ if (warned || state.notices.length === 0) return;
45
+ warned = true;
46
+ current.warn(
47
+ fallbackMessage([...new Set(state.notices.flatMap((notice) => notice.unknown))]),
48
+ );
49
+ };
50
+ const console = {
51
+ ...current,
52
+ log: (...args: readonly unknown[]) => {
53
+ flushWarning();
54
+ current.log(
55
+ ...args.map((arg) => {
56
+ if (state.notices.length === 0 || typeof arg !== "string") return arg;
57
+ try {
58
+ const value: unknown = JSON.parse(arg);
59
+ if (value === null || typeof value !== "object") return arg;
60
+ const fallback = {
61
+ source: "REST",
62
+ reason: "GitHub GraphQL rate limit",
63
+ unknown: [...new Set(state.notices.flatMap((notice) => notice.unknown))],
64
+ };
65
+ return JSON.stringify(
66
+ Array.isArray(value) ? { items: value, fallback } : { ...value, fallback },
67
+ null,
68
+ arg.includes("\n") ? 2 : undefined,
69
+ );
70
+ } catch {
71
+ return arg;
72
+ }
73
+ }),
74
+ );
75
+ },
76
+ };
77
+ return yield* program.pipe(
78
+ Effect.provideService(FallbackState, state),
79
+ Effect.provideService(Console.Console, console),
80
+ Effect.ensuring(Effect.sync(flushWarning)),
81
+ );
82
+ });
83
+ const Comment = Schema.Struct({
84
+ id: Schema.Number,
85
+ node_id: Schema.String,
86
+ in_reply_to_id: Schema.optional(Schema.NullOr(Schema.Number)),
87
+ path: Schema.String,
88
+ line: Schema.NullOr(Schema.Number),
89
+ body: Schema.String,
90
+ user: Schema.Struct({ login: Schema.String }),
91
+ commit_id: Schema.String,
92
+ });
93
+ const Reviewer = Schema.Struct({ login: Schema.String, type: Schema.optional(Schema.String) });
94
+ export const restGraphql = (
95
+ query: string,
96
+ variables: Record<string, string | number | null>,
97
+ read: JsonRead,
98
+ ): Effect.Effect<unknown, GhError> | null => {
99
+ if (
100
+ (query.includes("mutation") && !query.includes("submitPullRequestReview")) ||
101
+ typeof variables.owner !== "string" ||
102
+ typeof variables.name !== "string" ||
103
+ typeof variables.pr !== "number"
104
+ )
105
+ return null;
106
+ const repo = `${variables.owner}/${variables.name}`;
107
+ const pr = variables.pr;
108
+ const root = `repos/${repo}/pulls/${pr}`;
109
+ if (
110
+ query.includes("states: [PENDING]") ||
111
+ query.includes("... on PullRequestReview {") ||
112
+ query.includes("submitPullRequestReview")
113
+ )
114
+ return pendingRest(query, variables, read, root, repo, variables.pr);
115
+ if (query.includes("reviewThreads"))
116
+ return Effect.gen(function* () {
117
+ const comments = (yield* parseRest(
118
+ Schema.Array(Schema.Array(Comment)),
119
+ yield* restPages(read, `${root}/comments`),
120
+ )).flat();
121
+ const groups = new Map<number, Array<typeof Comment.Type>>();
122
+ for (const comment of comments) {
123
+ const rootId = comment.in_reply_to_id ?? comment.id;
124
+ const group = groups.get(rootId) ?? [];
125
+ group.push(comment);
126
+ groups.set(rootId, group);
127
+ }
128
+ const nodes = [...groups.entries()].map(([rootId, group]) => ({
129
+ id: `rest-thread:${rootId}`,
130
+ isResolved: null,
131
+ comments: {
132
+ nodes: group
133
+ .toSorted((a, b) => Number(b.id === rootId) - Number(a.id === rootId))
134
+ .map((item) => ({
135
+ id: item.node_id,
136
+ databaseId: item.id,
137
+ path: item.path,
138
+ line: item.line,
139
+ body: item.body,
140
+ author: { login: item.user.login },
141
+ commit: { oid: item.commit_id },
142
+ replyTo:
143
+ item.in_reply_to_id === undefined || item.in_reply_to_id === null
144
+ ? null
145
+ : { id: String(item.in_reply_to_id) },
146
+ })),
147
+ pageInfo: { hasNextPage: false, endCursor: null },
148
+ },
149
+ }));
150
+ const pull = yield* restPull(read, repo, String(pr));
151
+ const reviewDecision = query.includes("reviewDecision")
152
+ ? yield* restReviewDecision(read, {
153
+ repo,
154
+ number: pr,
155
+ baseRefName: pull.baseRefName,
156
+ headSha: pull.headSha,
157
+ })
158
+ : pull.reviewDecision;
159
+ const fallback = yield* reportFallback([
160
+ "review thread resolution",
161
+ ...(query.includes("reviewDecision") && reviewDecision === "UNKNOWN"
162
+ ? ["reviewDecision"]
163
+ : []),
164
+ ]);
165
+ return {
166
+ fallback,
167
+ repository: {
168
+ pullRequest: {
169
+ ...pull,
170
+ reviewDecision,
171
+ reviewRequests: {
172
+ nodes: pull.reviewRequests.map((reviewer) => ({ requestedReviewer: reviewer })),
173
+ },
174
+ reviewThreads: { nodes, pageInfo: { hasNextPage: false, endCursor: null } },
175
+ },
176
+ },
177
+ };
178
+ });
179
+ if (query.includes("reviews(last:") && query.includes("reviewRequests"))
180
+ return Effect.gen(function* () {
181
+ const reviews = (yield* parseRest(
182
+ Schema.Array(
183
+ Schema.Array(
184
+ Schema.Struct({
185
+ user: Schema.NullOr(Reviewer),
186
+ state: Schema.String,
187
+ submitted_at: Schema.NullOr(Schema.String),
188
+ }),
189
+ ),
190
+ ),
191
+ yield* restPages(read, `${root}/reviews`),
192
+ )).flat();
193
+ const requested = yield* parseRest(
194
+ Schema.Struct({
195
+ users: Schema.Array(Reviewer),
196
+ teams: Schema.Array(Schema.Struct({ name: Schema.String })),
197
+ }),
198
+ yield* restGet(read, `${root}/requested_reviewers`),
199
+ );
200
+ const fallback = yield* reportFallback([]);
201
+ return {
202
+ fallback,
203
+ repository: {
204
+ pullRequest: {
205
+ reviewRequests: {
206
+ nodes: [
207
+ ...requested.users.map((user) => ({
208
+ requestedReviewer: {
209
+ __typename: user.type === "Bot" ? "Bot" : "User",
210
+ login: user.login,
211
+ },
212
+ })),
213
+ ...requested.teams.map((team) => ({
214
+ requestedReviewer: { __typename: "Team", name: team.name },
215
+ })),
216
+ ],
217
+ },
218
+ reviews: {
219
+ nodes: reviews.map((review) => ({
220
+ author: review.user === null ? null : { login: review.user.login },
221
+ state: review.state,
222
+ submittedAt: review.submitted_at,
223
+ })),
224
+ },
225
+ },
226
+ },
227
+ };
228
+ });
229
+ return null;
230
+ };
231
+ export const isGraphqlLimit = (error: unknown) =>
232
+ error instanceof GitHubCommandError &&
233
+ /rate.?limit|already exceeded|secondary limit|abuse detection|HTTP 429/i.test(error.stderr);
234
+ export const rateLimitResponse = (stdout: string): string | null => {
235
+ try {
236
+ const value = Schema.decodeUnknownSync(
237
+ Schema.Struct({
238
+ errors: Schema.optional(Schema.Array(Schema.Unknown)),
239
+ type: Schema.optional(Schema.String),
240
+ code: Schema.optional(Schema.String),
241
+ message: Schema.optional(Schema.String),
242
+ }),
243
+ )(JSON.parse(stdout));
244
+ const detail = JSON.stringify(value.errors ?? value);
245
+ return /RATE_LIMIT|graphql_rate_limit|rate limit|secondary limit/i.test(detail) ? detail : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ };
250
+ export const graphqlLimitError = () =>
251
+ new GitHubCommandError({
252
+ command: "gh api graphql",
253
+ exitCode: 1,
254
+ message: "GraphQL rate limit already exceeded",
255
+ stderr: "GraphQL rate limit already exceeded",
256
+ });
257
+
258
+ const PendingReview = Schema.Struct({
259
+ id: Schema.Number,
260
+ node_id: Schema.String,
261
+ state: Schema.String,
262
+ body: Schema.NullOr(Schema.String),
263
+ user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
264
+ pull_request_url: Schema.String,
265
+ });
266
+ const pendingRest = (
267
+ query: string,
268
+ variables: Record<string, string | number | null>,
269
+ read: JsonRead,
270
+ root: string,
271
+ repo: string,
272
+ pr: number,
273
+ ): Effect.Effect<unknown, GhError> =>
274
+ Effect.gen(function* () {
275
+ const reviews = (yield* parseRest(
276
+ Schema.Array(Schema.Array(PendingReview)),
277
+ yield* restPages(read, `${root}/reviews`),
278
+ )).flat();
279
+ const viewer = yield* parseRest(
280
+ Schema.Struct({ login: Schema.String }),
281
+ yield* restGet(read, "user"),
282
+ );
283
+ const pageInfo = { hasNextPage: false, endCursor: null };
284
+ if (query.includes("states: [PENDING]")) {
285
+ const fallback = yield* reportFallback([]);
286
+ return {
287
+ fallback,
288
+ viewer,
289
+ repository: {
290
+ pullRequest: {
291
+ reviews: {
292
+ nodes: reviews
293
+ .filter((review) => review.state === "PENDING")
294
+ .map((review) => ({
295
+ id: review.node_id,
296
+ state: review.state,
297
+ author: review.user,
298
+ })),
299
+ pageInfo,
300
+ },
301
+ },
302
+ },
303
+ };
304
+ }
305
+ const review = reviews.find((item) => item.node_id === variables.reviewId);
306
+ if (
307
+ review === undefined ||
308
+ review.state !== "PENDING" ||
309
+ review.user?.login !== viewer.login ||
310
+ review.pull_request_url !== `https://api.github.com/${root}`
311
+ )
312
+ return yield* new GitHubCommandError({
313
+ command: "gh-tool pr submit-review",
314
+ exitCode: 1,
315
+ message: "REST could not verify an owned pending review on this PR",
316
+ stderr: "REST could not verify an owned pending review on this PR",
317
+ });
318
+ if (query.includes("submitPullRequestReview")) {
319
+ if (
320
+ typeof variables.event !== "string" ||
321
+ !["COMMENT", "APPROVE", "REQUEST_CHANGES"].includes(variables.event) ||
322
+ typeof variables.body !== "string"
323
+ )
324
+ return yield* new GitHubCommandError({
325
+ command: "gh-tool pr submit-review",
326
+ exitCode: 1,
327
+ message: "Invalid review event or body",
328
+ stderr: "Invalid review event or body",
329
+ });
330
+ const result = yield* parseRest(
331
+ Schema.Struct({ node_id: Schema.String, state: Schema.String }),
332
+ yield* read([
333
+ "api",
334
+ "--method",
335
+ "POST",
336
+ `${root}/reviews/${review.id}/events`,
337
+ "-f",
338
+ `event=${variables.event}`,
339
+ "-f",
340
+ `body=${variables.body}`,
341
+ ]),
342
+ );
343
+ const fallback = yield* reportFallback([]);
344
+ return {
345
+ fallback,
346
+ submitPullRequestReview: { pullRequestReview: { id: result.node_id, state: result.state } },
347
+ };
348
+ }
349
+ const comments = (yield* parseRest(
350
+ Schema.Array(Schema.Array(Schema.Struct({ body: Schema.String }))),
351
+ yield* restPages(read, `${root}/reviews/${review.id}/comments`),
352
+ )).flat();
353
+ const fallback = yield* reportFallback([]);
354
+ return {
355
+ fallback,
356
+ viewer,
357
+ node: {
358
+ id: review.node_id,
359
+ state: review.state,
360
+ body: review.body,
361
+ author: review.user,
362
+ pullRequest: { number: pr, repository: { nameWithOwner: repo } },
363
+ comments: { nodes: comments, pageInfo },
364
+ },
365
+ };
366
+ });
@@ -17,6 +17,7 @@ const IssueListItem = Schema.Struct({
17
17
  url: Schema.String,
18
18
  labels: Schema.mutable(Schema.Array(Label)),
19
19
  assignees: Schema.mutable(Schema.Array(Login)),
20
+ author: Schema.NullOr(Login),
20
21
  createdAt: Schema.String,
21
22
  });
22
23
  const IssueView = Schema.Struct({
@@ -25,6 +26,18 @@ const IssueView = Schema.Struct({
25
26
  body: Schema.String,
26
27
  closedAt: Schema.NullOr(Schema.String),
27
28
  });
29
+ const IssueNodeLookup = Schema.Struct({
30
+ repository: Schema.NullOr(
31
+ Schema.Struct({
32
+ issue: Schema.NullOr(
33
+ Schema.Struct({
34
+ id: Schema.String,
35
+ url: Schema.optional(Schema.String),
36
+ }),
37
+ ),
38
+ }),
39
+ ),
40
+ });
28
41
  const IssueComment = Schema.Struct({
29
42
  id: Schema.Number,
30
43
  user: Login,
@@ -39,7 +52,7 @@ export type IssueListFilter = {
39
52
  limit: number;
40
53
  includeBody?: boolean;
41
54
  };
42
- export const ISSUE_LIST_FIELDS = "number,title,state,url,labels,assignees,createdAt";
55
+ export const ISSUE_LIST_FIELDS = "number,title,state,url,labels,assignees,author,createdAt";
43
56
  export const ISSUE_VIEW_FIELDS =
44
57
  "number,title,state,url,labels,assignees,author,body,createdAt,closedAt";
45
58
 
@@ -69,6 +82,15 @@ export const issueView = Effect.fn("reads.issueView")(function* (
69
82
  });
70
83
  });
71
84
 
85
+ export const issueNode = Effect.fn("reads.issueNode")(function* (repo: RepoRef, issue: number) {
86
+ const gh = yield* GitHubService;
87
+ const response = yield* gh.runGraphQL(
88
+ "query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { issue(number: $number) { id url } } }",
89
+ { owner: repo.owner, name: repo.name, number: issue },
90
+ );
91
+ return parsePreserving(IssueNodeLookup)(response).repository?.issue ?? null;
92
+ });
93
+
72
94
  export const issueList = Effect.fn("reads.issueList")(function* (
73
95
  repo: RepoRef,
74
96
  filter: IssueListFilter,
@@ -8,13 +8,14 @@ import { gatedRead } from "./gated";
8
8
  import { restSnapshot, snapshotDigest } from "./rest";
9
9
  import { OPS } from "./classes";
10
10
  import { repoSlug } from "./keys";
11
+ import { isFallbackResult } from "./graphql-fallback";
11
12
  import type { RepoRef } from "./keys";
12
13
  import { parsePreserving } from "./porcelain";
13
14
 
14
15
  export const PR_VIEW_FIELDS =
15
- "number,url,title,headRefName,baseRefName,headRefOid,state,isDraft,mergeable,body,author,reviewDecision,reviewRequests,createdAt,headRepositoryOwner,mergeCommit";
16
+ "number,url,title,headRefName,baseRefName,headRefOid,state,isDraft,mergeable,body,author,reviewRequests,createdAt,headRepositoryOwner,mergeCommit,reviewDecision";
16
17
  export const PR_LIST_FIELDS =
17
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable,author,createdAt,reviewDecision,headRepositoryOwner";
18
+ "number,url,title,headRefName,baseRefName,state,isDraft,mergeable,author,createdAt,headRepositoryOwner,reviewDecision";
18
19
  export type PrListFilter = {
19
20
  state: string;
20
21
  limit: number;
@@ -38,7 +39,7 @@ const Pull = Schema.Struct({
38
39
  ),
39
40
  createdAt: Schema.optional(Schema.String),
40
41
  reviewDecision: Schema.optional(Schema.String),
41
- headRepositoryOwner: Schema.optional(Schema.Struct({ login: Schema.String })),
42
+ headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
42
43
  });
43
44
 
44
45
  const PullView = Schema.Struct({
@@ -48,7 +49,13 @@ const PullView = Schema.Struct({
48
49
  baseSha: Schema.optional(Schema.NullOr(Schema.String)),
49
50
  body: Schema.String,
50
51
  author: Schema.Struct({ login: Schema.String, is_bot: Schema.Boolean }),
51
- reviewDecision: Schema.Literals(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", ""]),
52
+ reviewDecision: Schema.Literals([
53
+ "APPROVED",
54
+ "CHANGES_REQUESTED",
55
+ "REVIEW_REQUIRED",
56
+ "UNKNOWN",
57
+ "",
58
+ ]),
52
59
  reviewRequests: Schema.mutable(
53
60
  Schema.Array(
54
61
  Schema.Union([
@@ -159,7 +166,7 @@ export const prView = Effect.fn("reads.prView")(function* (
159
166
  if (selector.by === "currentBranch" || repo === null) {
160
167
  const fence = yield* readFence;
161
168
  const data = (yield* fetch()).value;
162
- if (repo !== null && consistency !== "origin" && fence !== null) {
169
+ if (repo !== null && consistency !== "origin" && fence !== null && !("fallback" in data)) {
163
170
  const head = yield* prHead(repo, data.number).pipe(Effect.orElseSucceed(() => null));
164
171
  if (head !== null && head.headSha === data.headRefOid)
165
172
  yield* seedRead(
@@ -177,6 +184,7 @@ export const prView = Effect.fn("reads.prView")(function* (
177
184
  fetch: Effect.suspend(() => gh.runGhJson<PrView>(args)),
178
185
  signal: prHead(repo, selector.number).pipe(Effect.map((head) => head.signature)),
179
186
  maxAgeMs: () => OPS.prView.maxAgeMs,
187
+ cacheableWhen: (value) => !("fallback" in value),
180
188
  });
181
189
  });
182
190
 
@@ -214,6 +222,8 @@ export const prList = Effect.fn("reads.prList")(function* (
214
222
  gh
215
223
  .runGhJson<PrListItem[]>(args)
216
224
  .pipe(Effect.map((value) => ({ kind: "fresh" as const, value }))),
225
+ cacheableWhen: (value) =>
226
+ !isFallbackResult(value) && value.every((item) => !("fallback" in item)),
217
227
  });
218
228
  });
219
229
 
@@ -353,7 +363,7 @@ type PrReviewTriageSummaryPage = {
353
363
  };
354
364
  readonly reviewThreads: {
355
365
  readonly nodes: ReadonlyArray<{
356
- readonly isResolved: boolean;
366
+ readonly isResolved: boolean | null;
357
367
  readonly comments: {
358
368
  readonly nodes: ReadonlyArray<{ readonly replyTo: { readonly id: string } | null }>;
359
369
  };
@@ -376,7 +386,7 @@ export type PrReviewTriageSummary = {
376
386
  readonly reviewRequests: ReadonlyArray<{ readonly login: string }>;
377
387
  };
378
388
  readonly summary: {
379
- readonly unresolvedReviewThreadsCount: number;
389
+ readonly unresolvedReviewThreadsCount: number | null;
380
390
  readonly unrepliedReviewThreadsCount: number;
381
391
  };
382
392
  readonly checks: ReadonlyArray<{ readonly name: string; readonly bucket: string }>;
@@ -392,14 +402,16 @@ export const prReviewTriageSummary = Effect.fn("reads.prReviewTriageSummary")(fu
392
402
  PR_REVIEW_TRIAGE_SUMMARY_QUERY,
393
403
  variables,
394
404
  )) as PrReviewTriageSummaryPage;
395
- const firstPage = first.repository.pullRequest;
405
+ let firstPage = first.repository.pullRequest;
396
406
  let unresolvedReviewThreadsCount = 0;
407
+ let resolutionUnknown = false;
397
408
  let unrepliedReviewThreadsCount = 0;
398
409
  const countThreads = (
399
410
  threads: PrReviewTriageSummaryPage["repository"]["pullRequest"]["reviewThreads"]["nodes"],
400
411
  ) => {
401
412
  for (const thread of threads) {
402
- if (!thread.isResolved) unresolvedReviewThreadsCount++;
413
+ if (thread.isResolved === null) resolutionUnknown = true;
414
+ if (thread.isResolved === false) unresolvedReviewThreadsCount++;
403
415
  if (!thread.comments.nodes.some((comment) => comment.replyTo !== null))
404
416
  unrepliedReviewThreadsCount++;
405
417
  }
@@ -413,6 +425,12 @@ export const prReviewTriageSummary = Effect.fn("reads.prReviewTriageSummary")(fu
413
425
  after: pageInfo.endCursor,
414
426
  })) as PrReviewTriageSummaryPage;
415
427
  const pullRequest = response.repository.pullRequest;
428
+ if ("fallback" in response) {
429
+ unresolvedReviewThreadsCount = 0;
430
+ unrepliedReviewThreadsCount = 0;
431
+ resolutionUnknown = false;
432
+ firstPage = pullRequest;
433
+ }
416
434
  countThreads(pullRequest.reviewThreads.nodes);
417
435
  pageInfo = pullRequest.reviewThreads.pageInfo;
418
436
  }
@@ -451,7 +469,10 @@ export const prReviewTriageSummary = Effect.fn("reads.prReviewTriageSummary")(fu
451
469
  mergeable: firstPage.mergeable,
452
470
  reviewRequests,
453
471
  },
454
- summary: { unresolvedReviewThreadsCount, unrepliedReviewThreadsCount },
472
+ summary: {
473
+ unresolvedReviewThreadsCount: resolutionUnknown ? null : unresolvedReviewThreadsCount,
474
+ unrepliedReviewThreadsCount,
475
+ },
455
476
  checks,
456
477
  };
457
478
  });
@@ -464,3 +485,32 @@ const movedHead = (pr: number) =>
464
485
  stderr: "PR head changed while collecting checks",
465
486
  retryable: true,
466
487
  });
488
+
489
+ export const prReviewDecision = Effect.fn("reads.prReviewDecision")(function* (
490
+ repo: RepoRef,
491
+ pr: number,
492
+ ) {
493
+ const gh = yield* GitHubService;
494
+ const result = yield* gh.runGhJson<unknown>([
495
+ "pr",
496
+ "view",
497
+ String(pr),
498
+ "--repo",
499
+ repoSlug(repo),
500
+ "--json",
501
+ "reviewDecision",
502
+ ]);
503
+ return yield* Effect.try({
504
+ try: () =>
505
+ Schema.decodeUnknownSync(Schema.Struct({ reviewDecision: PullView.fields.reviewDecision }))(
506
+ result,
507
+ ).reviewDecision,
508
+ catch: (cause) =>
509
+ new GitHubCommandError({
510
+ command: "pr view",
511
+ exitCode: 1,
512
+ message: "Invalid review decision",
513
+ stderr: String(cause),
514
+ }),
515
+ });
516
+ });
@@ -5,7 +5,7 @@ import type { CheckRunAnnotation } from "#gh/types";
5
5
  import { cachedRead, ReadCache } from "./cache";
6
6
  import { OPS } from "./classes";
7
7
  import { repoSlug } from "./keys";
8
- import { issueView, issueList, issueComments } from "./issues";
8
+ import { issueView, issueList, issueComments, issueNode } from "./issues";
9
9
  import {
10
10
  prView,
11
11
  prList,
@@ -14,6 +14,7 @@ import {
14
14
  prBody,
15
15
  checkSignal,
16
16
  prReviewTriageSummary,
17
+ prReviewDecision,
17
18
  } from "./pulls";
18
19
  import { prComments, prReviews, reviewThreads, lastHumanReviewer } from "./reviews";
19
20
  import { reviewGuards } from "./review-guards";
@@ -40,6 +41,7 @@ const RateLimitResponse = Schema.Struct({
40
41
  }),
41
42
  ),
42
43
  });
44
+ const ViewerLogin = Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) });
43
45
 
44
46
  export type RateLimitResource = {
45
47
  readonly limit: number;
@@ -110,6 +112,12 @@ export const getPRStatusRateLimit = Effect.fn("reads.getPRStatusRateLimit")(func
110
112
  return { core, graphql } satisfies PRStatusRateLimit;
111
113
  });
112
114
 
115
+ export const viewerLogin = Effect.fn("reads.viewerLogin")(function* () {
116
+ const gh = yield* GitHubService;
117
+ const response = yield* gh.runGraphQL("query { viewer { login } }", {});
118
+ return parsePreserving(ViewerLogin)(response).viewer;
119
+ });
120
+
113
121
  const completedAttemptJobs = Effect.fn("reads.completedAttemptJobs")(function* (
114
122
  repo: RepoRef,
115
123
  runId: number,
@@ -384,6 +392,7 @@ export const reads = {
384
392
  prList,
385
393
  prChecks,
386
394
  prReviewTriageSummary,
395
+ prReviewDecision,
387
396
  prHead,
388
397
  prBody,
389
398
  checkSignal,
@@ -396,4 +405,6 @@ export const reads = {
396
405
  issueView,
397
406
  issueList,
398
407
  issueComments,
408
+ issueNode,
409
+ viewerLogin,
399
410
  };