@blogic-cz/agent-tools 1.5.7 → 1.5.9

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 (62) hide show
  1. package/README.md +21 -8
  2. package/dist/az-tool/service.d.ts.map +1 -1
  3. package/dist/azdo-tool/service.d.ts.map +1 -1
  4. package/dist/credential-guard/index.d.ts +2 -0
  5. package/dist/credential-guard/index.d.ts.map +1 -1
  6. package/dist/db-tool/service.d.ts.map +1 -1
  7. package/dist/gh-tool/gist.d.ts +13 -0
  8. package/dist/gh-tool/gist.d.ts.map +1 -1
  9. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  11. package/dist/gh-tool/release.d.ts +46 -1
  12. package/dist/gh-tool/release.d.ts.map +1 -1
  13. package/dist/gh-tool/text-input.d.ts +5 -3
  14. package/dist/gh-tool/text-input.d.ts.map +1 -1
  15. package/dist/k8s-tool/service.d.ts.map +1 -1
  16. package/dist/logs-tool/main.d.ts +1 -2
  17. package/dist/logs-tool/main.d.ts.map +1 -1
  18. package/dist/observability-tool/shared.d.ts.map +1 -1
  19. package/dist/session-tool/main.d.ts +1 -2
  20. package/dist/session-tool/main.d.ts.map +1 -1
  21. package/dist/shared/audit.d.ts.map +1 -1
  22. package/dist/shared/content-security.d.ts +6 -0
  23. package/dist/shared/content-security.d.ts.map +1 -0
  24. package/dist/shared/credential-patterns.d.ts +11 -0
  25. package/dist/shared/credential-patterns.d.ts.map +1 -0
  26. package/dist/shared/effect-version.d.ts.map +1 -1
  27. package/dist/shared/error-renderer.d.ts.map +1 -1
  28. package/dist/shared/format.d.ts.map +1 -1
  29. package/dist/shared/index.d.ts +1 -0
  30. package/dist/shared/index.d.ts.map +1 -1
  31. package/dist/shared/output-boundary.d.ts +3 -0
  32. package/dist/shared/output-boundary.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/audit-tool/main.ts +3 -1
  35. package/src/az-tool/main.ts +3 -1
  36. package/src/az-tool/service.ts +9 -0
  37. package/src/azdo-tool/main.ts +3 -1
  38. package/src/azdo-tool/service.ts +17 -0
  39. package/src/credential-guard/claude-hook.ts +2 -1
  40. package/src/credential-guard/index.ts +936 -87
  41. package/src/db-tool/main.ts +3 -1
  42. package/src/db-tool/service.ts +25 -2
  43. package/src/gh-tool/gist.ts +63 -64
  44. package/src/gh-tool/main.ts +3 -1
  45. package/src/gh-tool/pr/core.ts +10 -3
  46. package/src/gh-tool/pr/review.ts +199 -12
  47. package/src/gh-tool/release.ts +104 -29
  48. package/src/gh-tool/text-input.ts +19 -38
  49. package/src/k8s-tool/main.ts +3 -1
  50. package/src/k8s-tool/service.ts +25 -0
  51. package/src/logs-tool/main.ts +7 -2
  52. package/src/observability-tool/main.ts +5 -7
  53. package/src/observability-tool/shared.ts +41 -1
  54. package/src/session-tool/main.ts +18 -3
  55. package/src/shared/audit.ts +11 -10
  56. package/src/shared/content-security.ts +91 -0
  57. package/src/shared/credential-patterns.ts +62 -0
  58. package/src/shared/effect-version.ts +2 -1
  59. package/src/shared/error-renderer.ts +15 -2
  60. package/src/shared/format.ts +33 -6
  61. package/src/shared/index.ts +5 -0
  62. package/src/shared/output-boundary.ts +29 -0
@@ -14,6 +14,7 @@ import {
14
14
  VERSION,
15
15
  } from "#shared";
16
16
  import { AuditServiceLayer, withAudit } from "#shared/audit";
17
+ import { withRedactedOutput } from "#shared/output-boundary";
17
18
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
18
19
  import { DbConfigService, makeDbConfigLayer } from "./config-service";
19
20
  import { DbConnectionError } from "./errors";
@@ -238,6 +239,7 @@ const mainCommand = Command.make("db-tool", {}).pipe(
238
239
 
239
240
  const cli = Command.run(mainCommand, {
240
241
  version: VERSION,
242
+ renderErrors: false,
241
243
  });
242
244
 
243
245
  const dbConfigLayer = makeDbConfigLayer(profileArg);
@@ -252,7 +254,7 @@ const MainLayer = DbService.layer.pipe(
252
254
  Layer.provideMerge(AuditServiceLayer),
253
255
  );
254
256
 
255
- const program = withAudit("db", cli).pipe(
257
+ const program = withRedactedOutput(withAudit("db", cli)).pipe(
256
258
  Effect.provide(MainLayer),
257
259
  Effect.tapCause(renderCauseToStderr),
258
260
  );
@@ -6,6 +6,7 @@ import type { DbConfig, DbMutationOperation, QueryResult, SchemaMode } from "./t
6
6
  import { ConfigService } from "#config";
7
7
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
8
8
  import { resolveEnvTemplate } from "#shared/env-template";
9
+ import { unsafeOutboundTextReason } from "#shared/content-security";
9
10
  import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
10
11
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
11
12
  import { buildApiProbeArgs } from "#shared/k8s-probe";
@@ -501,8 +502,18 @@ export class DbService extends Context.Service<
501
502
  readOnly: isFullyReadOnly(config),
502
503
  });
503
504
 
504
- const runSql = (config: DbConfig, password: string, sql: string) =>
505
- sqlClient.run(toConnection(config, password), sql);
505
+ const unsafeSqlError = (reason: string) =>
506
+ new DbQueryError({
507
+ message: `Refusing to send SQL containing ${reason}.`,
508
+ sql: "[redacted]",
509
+ });
510
+
511
+ const runSql = (config: DbConfig, password: string, sql: string) => {
512
+ const unsafeReason = unsafeOutboundTextReason(sql);
513
+ return unsafeReason === null
514
+ ? sqlClient.run(toConnection(config, password), sql)
515
+ : Effect.fail(unsafeSqlError(unsafeReason));
516
+ };
506
517
 
507
518
  const fetchSingleColumn = (config: DbConfig, password: string, sql: string) =>
508
519
  runSql(config, password, sql).pipe(
@@ -772,6 +783,11 @@ export class DbService extends Context.Service<
772
783
  sql: string,
773
784
  limit?: number,
774
785
  ) {
786
+ const unsafeReason = unsafeOutboundTextReason(sql);
787
+ if (unsafeReason !== null) {
788
+ return yield* unsafeSqlError(unsafeReason);
789
+ }
790
+
775
791
  const config = yield* getConfigForEnv(env);
776
792
  const startTimeMs = yield* Clock.currentTimeMillis;
777
793
  const resolvedConfig = yield* resolveDbConfig(config, env);
@@ -827,6 +843,13 @@ export class DbService extends Context.Service<
827
843
  mode: SchemaMode,
828
844
  table?: string,
829
845
  ) {
846
+ if (table !== undefined) {
847
+ const unsafeReason = unsafeOutboundTextReason(table);
848
+ if (unsafeReason !== null) {
849
+ return yield* unsafeSqlError(unsafeReason);
850
+ }
851
+ }
852
+
830
853
  const config = yield* getConfigForEnv(env);
831
854
  const startTimeMs = yield* Clock.currentTimeMillis;
832
855
  const resolvedConfig = yield* resolveDbConfig(config, env);
@@ -1,9 +1,15 @@
1
1
  import { Command, Flag } from "effect/unstable/cli";
2
2
  import { Effect, Option } from "effect";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
4
6
 
5
7
  import { formatOption, logFormatted } from "#shared";
6
- import { isSensitivePath, resolveOptionalTextInput, validateOutboundFile } from "#gh/text-input";
8
+ import {
9
+ isSensitivePath,
10
+ readValidatedOutboundFile,
11
+ resolveOptionalTextInput,
12
+ } from "#gh/text-input";
7
13
  import { GitHubCommandError } from "./errors";
8
14
  import { GitHubService } from "./service";
9
15
 
@@ -118,29 +124,13 @@ const validateFilePath = (path: string, command: string) =>
118
124
  // `gh gist create/edit` read content from files only; inline --body is staged in a temp file
119
125
  // whose basename becomes the gist filename.
120
126
  const stageBody = Effect.fn("gist.stageBody")(function* (opts: {
121
- body: string;
127
+ body: string | Uint8Array;
122
128
  filename: string;
123
129
  command: string;
124
130
  }) {
125
131
  const directory = yield* Effect.acquireRelease(
126
132
  Effect.tryPromise({
127
- try: async () => {
128
- const proc = Bun.spawn(
129
- ["mktemp", "-d", join(process.env.TMPDIR ?? "/tmp", "gh-tool-gist-XXXXXX")],
130
- { stdout: "pipe", stderr: "pipe" },
131
- );
132
- const [exitCode, stdout, stderr] = await Promise.all([
133
- proc.exited,
134
- new Response(proc.stdout).text(),
135
- new Response(proc.stderr).text(),
136
- ]);
137
-
138
- if (exitCode !== 0) {
139
- throw new Error(stderr.trim() || `mktemp exited with code ${exitCode}`);
140
- }
141
-
142
- return stdout.trim();
143
- },
133
+ try: () => mkdtemp(join(process.env.TMPDIR ?? tmpdir(), "gh-tool-gist-")),
144
134
  catch: (error) =>
145
135
  inputError(
146
136
  `Failed to stage gist content: ${error instanceof Error ? error.message : String(error)}`,
@@ -149,20 +139,14 @@ const stageBody = Effect.fn("gist.stageBody")(function* (opts: {
149
139
  }),
150
140
  (tempDirectory) =>
151
141
  Effect.tryPromise({
152
- try: async () => {
153
- const proc = Bun.spawn(["rm", "-rf", tempDirectory], {
154
- stdout: "ignore",
155
- stderr: "ignore",
156
- });
157
- await proc.exited;
158
- },
142
+ try: () => rm(tempDirectory, { recursive: true, force: true }),
159
143
  catch: () => undefined,
160
144
  }).pipe(Effect.ignore),
161
145
  );
162
146
  const path = join(directory, opts.filename);
163
147
 
164
148
  yield* Effect.tryPromise({
165
- try: () => Bun.write(path, opts.body),
149
+ try: () => writeFile(path, opts.body),
166
150
  catch: (error) =>
167
151
  inputError(
168
152
  `Failed to stage gist content: ${error instanceof Error ? error.message : String(error)}`,
@@ -293,45 +277,55 @@ export const createGist = Effect.fn("gist.createGist")(function* (opts: {
293
277
  public: boolean;
294
278
  }) {
295
279
  const gh = yield* GitHubService;
280
+ return yield* Effect.scoped(
281
+ Effect.gen(function* () {
282
+ const snapshots: string[] = [];
283
+ for (const path of opts.paths) {
284
+ const validated = yield* readValidatedOutboundFile(path, "gh-tool gist create");
285
+ const filename = path.split(/[\\/]/).at(-1) ?? path;
286
+ snapshots.push(
287
+ yield* stageBody({ body: validated.bytes, filename, command: "gh-tool gist create" }),
288
+ );
289
+ }
296
290
 
297
- for (const path of opts.paths) yield* validateOutboundFile(path, "gh-tool gist create");
298
-
299
- const args = ["gist", "create", ...opts.paths];
291
+ const args = ["gist", "create", ...snapshots];
300
292
 
301
- if (opts.description !== null) {
302
- args.push("--desc", opts.description);
303
- }
293
+ if (opts.description !== null) {
294
+ args.push("--desc", opts.description);
295
+ }
304
296
 
305
- if (opts.public) {
306
- args.push("--public");
307
- }
297
+ if (opts.public) {
298
+ args.push("--public");
299
+ }
308
300
 
309
- const result = yield* gh.runGh(args);
310
- const url =
311
- result.stdout
312
- .trim()
313
- .split("\n")
314
- .map((line) => line.trim())
315
- .findLast((line) => line.startsWith("https://")) ?? "";
316
-
317
- if (url === "") {
318
- return yield* Effect.fail(
319
- inputError("gh gist create did not return a gist URL", "gh-tool gist create"),
320
- );
321
- }
301
+ const result = yield* gh.runGh(args);
302
+ const url =
303
+ result.stdout
304
+ .trim()
305
+ .split("\n")
306
+ .map((line) => line.trim())
307
+ .findLast((line) => line.startsWith("https://")) ?? "";
308
+
309
+ if (url === "") {
310
+ return yield* Effect.fail(
311
+ inputError("gh gist create did not return a gist URL", "gh-tool gist create"),
312
+ );
313
+ }
322
314
 
323
- const created: GistCreateResult = {
324
- created: true,
325
- id: url.split("/").at(-1) ?? "",
326
- url,
327
- public: opts.public,
328
- files: opts.paths.map((path) => path.split("/").at(-1) ?? path),
329
- };
315
+ const created: GistCreateResult = {
316
+ created: true,
317
+ id: url.split("/").at(-1) ?? "",
318
+ url,
319
+ public: opts.public,
320
+ files: opts.paths.map((path) => path.split(/[\\/]/).at(-1) ?? path),
321
+ };
330
322
 
331
- return created;
323
+ return created;
324
+ }),
325
+ );
332
326
  });
333
327
 
334
- const editGist = Effect.fn("gist.editGist")(function* (opts: {
328
+ export const editGist = Effect.fn("gist.editGist")(function* (opts: {
335
329
  id: string;
336
330
  description: string | null;
337
331
  add: string | null;
@@ -341,15 +335,13 @@ const editGist = Effect.fn("gist.editGist")(function* (opts: {
341
335
  }) {
342
336
  const gh = yield* GitHubService;
343
337
 
344
- for (const path of [opts.sourcePath, opts.add]) {
345
- if (path !== null) yield* validateOutboundFile(path, "gh-tool gist edit");
346
- }
347
-
348
338
  const args = ["gist", "edit", opts.id];
349
339
  const changes: string[] = [];
350
340
 
351
341
  if (opts.sourcePath !== null) {
352
- args.push(opts.sourcePath);
342
+ const validated = yield* readValidatedOutboundFile(opts.sourcePath, "gh-tool gist edit");
343
+ const filename = opts.filename ?? opts.sourcePath.split(/[\\/]/).at(-1) ?? "";
344
+ args.push(yield* stageBody({ body: validated.bytes, filename, command: "gh-tool gist edit" }));
353
345
  changes.push(`content:${opts.filename ?? opts.sourcePath.split("/").at(-1)}`);
354
346
  }
355
347
 
@@ -359,7 +351,14 @@ const editGist = Effect.fn("gist.editGist")(function* (opts: {
359
351
  }
360
352
 
361
353
  if (opts.add !== null) {
362
- args.push("--add", opts.add);
354
+ const validated = yield* readValidatedOutboundFile(opts.add, "gh-tool gist edit");
355
+ const filename = opts.add.split(/[\\/]/).at(-1) ?? opts.add;
356
+ const snapshot = yield* stageBody({
357
+ body: validated.bytes,
358
+ filename,
359
+ command: "gh-tool gist edit",
360
+ });
361
+ args.push("--add", snapshot);
363
362
  changes.push(`add:${opts.add.split("/").at(-1)}`);
364
363
  }
365
364
 
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect";
5
5
 
6
6
  import { makeSchemaCommand, renderCauseToStderr, VERSION } from "#shared";
7
7
  import { AuditServiceLayer, withAudit } from "#shared/audit";
8
+ import { withRedactedOutput } from "#shared/output-boundary";
8
9
  import { ConfigServiceLayer } from "#config";
9
10
  import {
10
11
  issueCloseCommand,
@@ -240,6 +241,7 @@ WORKFLOW FOR AI AGENTS:
240
241
 
241
242
  const cli = Command.run(mainCommand, {
242
243
  version: VERSION,
244
+ renderErrors: false,
243
245
  });
244
246
 
245
247
  const MainLayer = GitHubService.layer.pipe(
@@ -248,7 +250,7 @@ const MainLayer = GitHubService.layer.pipe(
248
250
  Layer.provideMerge(ConfigServiceLayer),
249
251
  );
250
252
 
251
- const program = withAudit("gh", cli).pipe(
253
+ const program = withRedactedOutput(withAudit("gh", cli)).pipe(
252
254
  Effect.provide(MainLayer),
253
255
  Effect.tapCause(renderCauseToStderr),
254
256
  );
@@ -21,6 +21,7 @@ import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
21
21
  import { GitHubService } from "#gh/service";
22
22
  import type { GhResult } from "#gh/service";
23
23
  import { logText } from "#shared";
24
+ import { redactSensitiveText } from "#shared/content-security";
24
25
 
25
26
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
26
27
  import { pollUntilResolved } from "#shared/poll-until-resolved";
@@ -114,14 +115,18 @@ const resolveMergeSha = Effect.fn("pr.resolveMergeSha")(function* (
114
115
  Effect.map((sha) => ({ sha, failed: false as const })),
115
116
  Effect.catch((error) =>
116
117
  Console.error(
117
- `Warning: PR #${pr} was merged, but its merge commit SHA could not be read: ${error.message}`,
118
+ redactSensitiveText(
119
+ `Warning: PR #${pr} was merged, but its merge commit SHA could not be read: ${error.message}`,
120
+ ),
118
121
  ).pipe(Effect.as({ sha: null, failed: true as const })),
119
122
  ),
120
123
  );
121
124
 
122
125
  if (result.sha === null && !result.failed) {
123
126
  yield* Console.error(
124
- `Warning: PR #${pr} was merged, but its merge commit SHA is still unknown after ${MERGE_SHA_WAIT_SECONDS}s.`,
127
+ redactSensitiveText(
128
+ `Warning: PR #${pr} was merged, but its merge commit SHA is still unknown after ${MERGE_SHA_WAIT_SECONDS}s.`,
129
+ ),
125
130
  );
126
131
  }
127
132
 
@@ -1088,7 +1093,9 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1088
1093
  if (error._tag !== "GitHubCommandError") {
1089
1094
  return rollbackNote === ""
1090
1095
  ? Effect.fail(error)
1091
- : Console.error(rollbackNote.trim()).pipe(Effect.andThen(Effect.fail(error)));
1096
+ : Console.error(redactSensitiveText(rollbackNote.trim())).pipe(
1097
+ Effect.andThen(Effect.fail(error)),
1098
+ );
1092
1099
  }
1093
1100
 
1094
1101
  const stderr = error.stderr.toLowerCase();
@@ -15,6 +15,7 @@ import type { REVIEW_EVENTS } from "#gh/config";
15
15
 
16
16
  import { GitHubCommandError } from "#gh/errors";
17
17
  import { GitHubService } from "#gh/service";
18
+ import { validateOutboundText } from "#gh/text-input";
18
19
 
19
20
  import { viewPR } from "./core";
20
21
 
@@ -83,16 +84,39 @@ const RESOLVE_THREAD_MUTATION = `
83
84
  `;
84
85
 
85
86
  const PENDING_REVIEWS_QUERY = `
86
- query($owner: String!, $name: String!, $pr: Int!) {
87
+ query($owner: String!, $name: String!, $pr: Int!, $after: String) {
87
88
  viewer { login }
88
89
  repository(owner: $owner, name: $name) {
89
90
  pullRequest(number: $pr) {
90
- reviews(last: 100, states: [PENDING]) {
91
+ reviews(first: 100, after: $after, states: [PENDING]) {
91
92
  nodes {
92
93
  id
93
94
  state
94
95
  author { login }
95
96
  }
97
+ pageInfo { hasNextPage endCursor }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ `;
103
+
104
+ const PENDING_REVIEW_CONTENT_QUERY = `
105
+ query($reviewId: ID!, $after: String) {
106
+ viewer { login }
107
+ node(id: $reviewId) {
108
+ ... on PullRequestReview {
109
+ id
110
+ state
111
+ body
112
+ author { login }
113
+ pullRequest {
114
+ number
115
+ repository { nameWithOwner }
116
+ }
117
+ comments(first: 100, after: $after) {
118
+ nodes { body }
119
+ pageInfo { hasNextPage endCursor }
96
120
  }
97
121
  }
98
122
  }
@@ -188,11 +212,27 @@ type PendingReviewsQueryResult = {
188
212
  state: string;
189
213
  author: { login: string };
190
214
  }>;
215
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
191
216
  };
192
217
  };
193
218
  };
194
219
  };
195
220
 
221
+ type PendingReviewContentQueryResult = {
222
+ viewer: { login: string };
223
+ node: {
224
+ id: string;
225
+ state: string;
226
+ body: string | null;
227
+ author: { login: string };
228
+ pullRequest: { number: number; repository: { nameWithOwner: string } };
229
+ comments: {
230
+ nodes: Array<{ body: string }>;
231
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
232
+ };
233
+ } | null;
234
+ };
235
+
196
236
  type SubmitReviewResult = {
197
237
  submitPullRequestReview: {
198
238
  pullRequestReview: { id: string; state: string };
@@ -1080,18 +1120,76 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
1080
1120
  let targetReviewId = reviewId;
1081
1121
 
1082
1122
  if (targetReviewId === null) {
1083
- const pending = (yield* service.runGraphQL(PENDING_REVIEWS_QUERY, {
1084
- owner: repoInfo.owner,
1085
- name: repoInfo.name,
1086
- pr: resolvedPr,
1087
- })) as PendingReviewsQueryResult;
1123
+ let after: string | null = null;
1124
+ let hasNextPage = true;
1125
+ let viewerLogin: string | null = null;
1126
+ let ownPendingReviewId: string | null = null;
1127
+ const seenPendingCursors = new Set<string>();
1128
+
1129
+ while (hasNextPage) {
1130
+ const pending = (yield* service.runGraphQL(PENDING_REVIEWS_QUERY, {
1131
+ owner: repoInfo.owner,
1132
+ name: repoInfo.name,
1133
+ pr: resolvedPr,
1134
+ after,
1135
+ })) as PendingReviewsQueryResult;
1136
+
1137
+ if (
1138
+ typeof pending.viewer?.login !== "string" ||
1139
+ !Array.isArray(pending.repository?.pullRequest?.reviews?.nodes) ||
1140
+ typeof pending.repository.pullRequest.reviews?.pageInfo?.hasNextPage !== "boolean" ||
1141
+ (pending.repository.pullRequest.reviews.pageInfo.endCursor !== null &&
1142
+ typeof pending.repository.pullRequest.reviews.pageInfo.endCursor !== "string") ||
1143
+ pending.repository.pullRequest.reviews.nodes.some(
1144
+ (review) =>
1145
+ typeof review?.id !== "string" ||
1146
+ typeof review.state !== "string" ||
1147
+ typeof review.author?.login !== "string",
1148
+ )
1149
+ ) {
1150
+ return yield* Effect.fail(
1151
+ new GitHubCommandError({
1152
+ command: "gh-tool pr submit-review",
1153
+ exitCode: 1,
1154
+ message: "Could not verify pending reviews; refusing to submit",
1155
+ stderr: "Pending review lookup returned incomplete data",
1156
+ }),
1157
+ );
1158
+ }
1088
1159
 
1089
- const viewerLogin = pending.viewer.login;
1090
- const pendingReviews = pending.repository.pullRequest.reviews.nodes;
1160
+ viewerLogin ??= pending.viewer.login;
1161
+ const own = pending.repository.pullRequest.reviews.nodes.find(
1162
+ (review) => review.author?.login === viewerLogin && review.state === "PENDING",
1163
+ );
1164
+ ownPendingReviewId ??= own?.id ?? null;
1091
1165
 
1092
- const ownPendingReview = pendingReviews.find((review) => review.author.login === viewerLogin);
1166
+ const pageInfo = pending.repository.pullRequest.reviews.pageInfo;
1167
+ hasNextPage = pageInfo.hasNextPage;
1168
+ after = pageInfo.endCursor;
1169
+ if (hasNextPage && (after === null || after.length === 0)) {
1170
+ return yield* Effect.fail(
1171
+ new GitHubCommandError({
1172
+ command: "gh-tool pr submit-review",
1173
+ exitCode: 1,
1174
+ message: "Could not verify all pending reviews; refusing to submit",
1175
+ stderr: "Pending review lookup returned an incomplete page cursor",
1176
+ }),
1177
+ );
1178
+ }
1179
+ if (hasNextPage && after !== null && seenPendingCursors.has(after)) {
1180
+ return yield* Effect.fail(
1181
+ new GitHubCommandError({
1182
+ command: "gh-tool pr submit-review",
1183
+ exitCode: 1,
1184
+ message: "Could not verify all pending reviews; refusing to submit",
1185
+ stderr: "Pending review lookup repeated a page cursor",
1186
+ }),
1187
+ );
1188
+ }
1189
+ if (hasNextPage && after !== null) seenPendingCursors.add(after);
1190
+ }
1093
1191
 
1094
- if (!ownPendingReview) {
1192
+ if (ownPendingReviewId === null) {
1095
1193
  return yield* Effect.fail(
1096
1194
  new GitHubCommandError({
1097
1195
  command: "gh-tool pr submit-review",
@@ -1102,7 +1200,96 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
1102
1200
  );
1103
1201
  }
1104
1202
 
1105
- targetReviewId = ownPendingReview.id;
1203
+ targetReviewId = ownPendingReviewId;
1204
+ }
1205
+
1206
+ if (targetReviewId === null) {
1207
+ return yield* Effect.fail(
1208
+ new GitHubCommandError({
1209
+ command: "gh-tool pr submit-review",
1210
+ exitCode: 1,
1211
+ message: "Could not identify pending review; refusing to submit",
1212
+ stderr: "Pending review lookup returned no review ID",
1213
+ }),
1214
+ );
1215
+ }
1216
+
1217
+ let afterComment: string | null = null;
1218
+ let commentsHaveNextPage = true;
1219
+ const seenCommentCursors = new Set<string>();
1220
+ while (commentsHaveNextPage) {
1221
+ const pendingReview = (yield* service.runGraphQL(PENDING_REVIEW_CONTENT_QUERY, {
1222
+ reviewId: targetReviewId,
1223
+ after: afterComment,
1224
+ })) as PendingReviewContentQueryResult;
1225
+
1226
+ const review = pendingReview?.node;
1227
+ if (
1228
+ typeof pendingReview?.viewer?.login !== "string" ||
1229
+ review === null ||
1230
+ review === undefined ||
1231
+ review.id !== targetReviewId ||
1232
+ review.state !== "PENDING" ||
1233
+ review.author?.login !== pendingReview.viewer.login ||
1234
+ review.pullRequest?.number !== resolvedPr ||
1235
+ typeof review.pullRequest.repository?.nameWithOwner !== "string" ||
1236
+ review.pullRequest.repository.nameWithOwner.toLowerCase() !==
1237
+ `${repoInfo.owner}/${repoInfo.name}`.toLowerCase() ||
1238
+ typeof review.body !== "string" ||
1239
+ !Array.isArray(review.comments?.nodes) ||
1240
+ typeof review.comments.pageInfo?.hasNextPage !== "boolean" ||
1241
+ (review.comments.pageInfo.endCursor !== null &&
1242
+ typeof review.comments.pageInfo.endCursor !== "string")
1243
+ ) {
1244
+ return yield* Effect.fail(
1245
+ new GitHubCommandError({
1246
+ command: "gh-tool pr submit-review",
1247
+ exitCode: 1,
1248
+ message: "Could not verify pending review contents; refusing to submit",
1249
+ stderr: "Pending review lookup returned incomplete or non-owned review data",
1250
+ }),
1251
+ );
1252
+ }
1253
+
1254
+ yield* validateOutboundText(review.body, "gh-tool pr submit-review");
1255
+ for (const comment of review.comments.nodes) {
1256
+ if (typeof comment?.body !== "string") {
1257
+ return yield* Effect.fail(
1258
+ new GitHubCommandError({
1259
+ command: "gh-tool pr submit-review",
1260
+ exitCode: 1,
1261
+ message: "Could not verify pending review comments; refusing to submit",
1262
+ stderr: "Pending review lookup returned a comment without a body",
1263
+ }),
1264
+ );
1265
+ }
1266
+ yield* validateOutboundText(comment.body, "gh-tool pr submit-review");
1267
+ }
1268
+
1269
+ const pageInfo = review.comments.pageInfo;
1270
+ commentsHaveNextPage = pageInfo.hasNextPage;
1271
+ afterComment = pageInfo.endCursor;
1272
+ if (commentsHaveNextPage && (afterComment === null || afterComment.length === 0)) {
1273
+ return yield* Effect.fail(
1274
+ new GitHubCommandError({
1275
+ command: "gh-tool pr submit-review",
1276
+ exitCode: 1,
1277
+ message: "Could not verify all pending review comments; refusing to submit",
1278
+ stderr: "Pending review lookup returned an incomplete comment cursor",
1279
+ }),
1280
+ );
1281
+ }
1282
+ if (commentsHaveNextPage && afterComment !== null && seenCommentCursors.has(afterComment)) {
1283
+ return yield* Effect.fail(
1284
+ new GitHubCommandError({
1285
+ command: "gh-tool pr submit-review",
1286
+ exitCode: 1,
1287
+ message: "Could not verify all pending review comments; refusing to submit",
1288
+ stderr: "Pending review lookup repeated a comment cursor",
1289
+ }),
1290
+ );
1291
+ }
1292
+ if (commentsHaveNextPage && afterComment !== null) seenCommentCursors.add(afterComment);
1106
1293
  }
1107
1294
 
1108
1295
  const result = (yield* service.runGraphQL(SUBMIT_REVIEW_MUTATION, {