@blogic-cz/agent-tools 1.10.0 → 1.11.1

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 (52) hide show
  1. package/README.md +13 -5
  2. package/dist/db-tool/service.d.ts +2 -1
  3. package/dist/db-tool/service.d.ts.map +1 -1
  4. package/dist/db-tool/sql-client.d.ts +1 -0
  5. package/dist/db-tool/sql-client.d.ts.map +1 -1
  6. package/dist/gh-tool/issue/triage.d.ts +3 -3
  7. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  8. package/dist/gh-tool/pr/commands.d.ts +7 -5
  9. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  10. package/dist/gh-tool/pr/core.d.ts +18 -0
  11. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  13. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  14. package/dist/gh-tool/read/api.d.ts.map +1 -1
  15. package/dist/gh-tool/read/pulls.d.ts.map +1 -1
  16. package/dist/gh-tool/read/review-model.d.ts +11 -8
  17. package/dist/gh-tool/read/review-model.d.ts.map +1 -1
  18. package/dist/gh-tool/read/reviews.d.ts.map +1 -1
  19. package/dist/gh-tool/read/waits.d.ts +1 -1
  20. package/dist/gh-tool/read/waits.d.ts.map +1 -1
  21. package/dist/gh-tool/types.d.ts +9 -3
  22. package/dist/gh-tool/types.d.ts.map +1 -1
  23. package/dist/observability-tool/logs.d.ts +1 -1
  24. package/dist/observability-tool/logs.d.ts.map +1 -1
  25. package/dist/observability-tool/metrics.d.ts +1 -1
  26. package/dist/observability-tool/metrics.d.ts.map +1 -1
  27. package/dist/observability-tool/shared.d.ts +2 -1
  28. package/dist/observability-tool/shared.d.ts.map +1 -1
  29. package/dist/observability-tool/trace.d.ts +1 -1
  30. package/dist/observability-tool/trace.d.ts.map +1 -1
  31. package/dist/shared/content-security.d.ts +3 -1
  32. package/dist/shared/content-security.d.ts.map +1 -1
  33. package/dist/shared/secret-env.d.ts +22 -0
  34. package/dist/shared/secret-env.d.ts.map +1 -0
  35. package/package.json +1 -1
  36. package/src/db-tool/main.ts +2 -0
  37. package/src/db-tool/service.ts +122 -140
  38. package/src/db-tool/sql-client.ts +7 -4
  39. package/src/gh-tool/pr/commands.ts +12 -7
  40. package/src/gh-tool/pr/core.ts +41 -4
  41. package/src/gh-tool/pr/review.ts +18 -1
  42. package/src/gh-tool/pr/stack.ts +12 -2
  43. package/src/gh-tool/read/api.ts +6 -0
  44. package/src/gh-tool/read/pulls.ts +4 -1
  45. package/src/gh-tool/read/review-model.ts +11 -8
  46. package/src/gh-tool/read/reviews.ts +48 -2
  47. package/src/gh-tool/read/waits.ts +12 -2
  48. package/src/gh-tool/types.ts +15 -3
  49. package/src/observability-tool/main.ts +7 -1
  50. package/src/observability-tool/shared.ts +20 -14
  51. package/src/shared/content-security.ts +7 -2
  52. package/src/shared/secret-env.ts +230 -0
@@ -0,0 +1,22 @@
1
+ import { Context, Layer } from "effect";
2
+ export type LookupResult = {
3
+ readonly _tag: "found";
4
+ readonly value: string;
5
+ } | {
6
+ readonly _tag: "absent";
7
+ } | {
8
+ readonly _tag: "failed";
9
+ readonly reason: string;
10
+ };
11
+ export type ZshRunner = (name: string, timeoutMs: number) => Promise<LookupResult>;
12
+ declare const SecretEnvResolver_base: Context.ServiceClass<SecretEnvResolver, "@agent-tools/SecretEnvResolver", {
13
+ readonly resolveSecretEnv: (name: string) => Promise<string>;
14
+ readonly lookupConfigEnv: (name: string) => Promise<LookupResult>;
15
+ }>;
16
+ export declare class SecretEnvResolver extends SecretEnvResolver_base {
17
+ static get liveLayer(): Layer.Layer<SecretEnvResolver, never, never>;
18
+ static readonly testLayer: (runner?: ZshRunner) => Layer.Layer<SecretEnvResolver, never, never>;
19
+ }
20
+ export declare function makeSecretEnvResolverLayer(runner?: ZshRunner, timeoutMs?: number): Layer.Layer<SecretEnvResolver, never, never>;
21
+ export {};
22
+ //# sourceMappingURL=secret-env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-env.d.ts","sourceRoot":"","sources":["../../src/shared/secret-env.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAqBxC,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzD,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;;+BAmJpD,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC;8BAClC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC;;AAJrE,qBAAa,iBAAkB,SAAQ,sBAMF;IACnC,MAAM,KAAK,SAAS,iDAEnB;IACD,MAAM,CAAC,QAAQ,CAAC,SAAS,YAAY,SAAS,kDACT;CACtC;AAED,wBAAgB,0BAA0B,CACxC,MAAM,GAAE,SAA6B,EACrC,SAAS,SAAqB,gDAwC/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure platform, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -14,6 +14,7 @@ import {
14
14
  VERSION,
15
15
  } from "#shared";
16
16
  import { AuditServiceLayer, withAudit } from "#shared/audit";
17
+ import { SecretEnvResolver } from "#shared/secret-env";
17
18
  import { withRedactedOutput } from "#shared/output-boundary";
18
19
  import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
19
20
  import { DbConfigService, makeDbConfigLayer } from "./config-service";
@@ -248,6 +249,7 @@ const MainLayer = DbService.layer.pipe(
248
249
  // provideMerge (not provide) so DbConfigService stays in the program context for the `envs` command,
249
250
  // which reads it directly rather than going through DbService.
250
251
  Layer.provideMerge(DbSqlClient.layer),
252
+ Layer.provideMerge(SecretEnvResolver.liveLayer),
251
253
  Layer.provideMerge(dbConfigLayer),
252
254
  Layer.provideMerge(ConfigServiceLayer),
253
255
  Layer.provideMerge(BunServices.layer),
@@ -1,5 +1,5 @@
1
1
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
2
- import { Clock, Context, Duration, Effect, Layer, Ref, Result, Stream } from "effect";
2
+ import { Clock, Context, Duration, Effect, Layer, Result, Stream } from "effect";
3
3
 
4
4
  import type { DbConfig, DbMutationOperation, QueryResult, SchemaMode } from "./types";
5
5
 
@@ -7,6 +7,7 @@ import { ConfigService } from "#config";
7
7
  import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
8
8
  import { resolveEnvTemplate } from "#shared/env-template";
9
9
  import { registerResolvedSecret, unsafeOutboundTextReason } from "#shared/content-security";
10
+ import { SecretEnvResolver } from "#shared/secret-env";
10
11
  import { resolveEnvironmentScopedPrerequisites } from "#shared/prerequisites/config";
11
12
  import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
12
13
  import { buildApiProbeArgs } from "#shared/k8s-probe";
@@ -43,12 +44,6 @@ const MIN_TCP_PORT = 1;
43
44
  const MAX_TCP_PORT = 65535;
44
45
  const INTEGER_ONLY = /^\d+$/;
45
46
 
46
- // Bun.env is the primary source: it carries the values Bun loads from a .env file, which is where
47
- // a per-worktree host/port lives. It aliases process.env, which is also the only one that exists
48
- // when this module runs outside Bun (the vitest runner).
49
- const readProcessEnv = (envVar: string): string | undefined =>
50
- (typeof Bun === "undefined" ? process.env : Bun.env)[envVar];
51
-
52
47
  export type ResolvedPortOverride =
53
48
  | { readonly ok: true; readonly port: number }
54
49
  | { readonly ok: false; readonly message: string };
@@ -159,6 +154,7 @@ export class DbService extends Context.Service<
159
154
  Effect.gen(function* () {
160
155
  const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
161
156
  const sqlClient = yield* DbSqlClient;
157
+ const secretEnvResolver = yield* SecretEnvResolver;
162
158
  const agentToolsConfig = yield* ConfigService;
163
159
  const dbConfig = yield* DbConfigService;
164
160
 
@@ -185,152 +181,138 @@ export class DbService extends Context.Service<
185
181
  const apiProbeTimeoutMs = dbConfig.apiProbeTimeoutMs ?? 2000;
186
182
  const remotePort = dbConfig.remotePort ?? 5432;
187
183
 
188
- const zshrcEnvCache = yield* Ref.make<Record<string, string> | null>(null);
189
184
  const envTemplateRegex = /^\$\{([A-Za-z0-9_]+)\}$/;
185
+ const lookupConfigEnv = (name: string) =>
186
+ Effect.tryPromise({
187
+ try: () => secretEnvResolver.lookupConfigEnv(name),
188
+ catch: () => new Error(`${name} lookup failed`),
189
+ });
190
190
 
191
- const loadEnvFromZshrc = Effect.fn("DbService.loadEnvFromZshrc")(function* () {
192
- const cached = yield* Ref.get(zshrcEnvCache);
193
- if (cached !== null) {
194
- return cached;
195
- }
196
-
197
- const home = process.env.HOME;
198
- if (!home || home.trim() === "") {
199
- yield* Ref.set(zshrcEnvCache, {});
200
- return {};
201
- }
202
-
203
- const zshrcPath = `${home}/.zshrc`;
204
- const content = yield* Effect.tryPromise(async () => {
205
- const file = Bun.file(zshrcPath);
206
- if (!(await file.exists())) {
207
- return "";
191
+ const resolvePassword = (config: DbConfig, env: string) =>
192
+ Effect.gen(function* () {
193
+ if (config.password) {
194
+ registerResolvedSecret(config.password);
195
+ return config.password;
208
196
  }
209
- return await file.text();
210
- }).pipe(Effect.orElseSucceed(() => ""));
211
197
 
212
- const envVars: Record<string, string> = {};
213
- const regex = /^export\s+([A-Z_][A-Z0-9_]*)=["']?([^"'\n]+)["']?/gm;
214
- let match = regex.exec(content);
215
-
216
- while (match !== null) {
217
- envVars[match[1]] = match[2];
218
- match = regex.exec(content);
219
- }
220
-
221
- yield* Ref.set(zshrcEnvCache, envVars);
222
- return envVars;
223
- });
198
+ const passwordEnvVar = config.passwordEnvVar;
199
+ if (passwordEnvVar) {
200
+ return yield* Effect.tryPromise({
201
+ try: () => secretEnvResolver.resolveSecretEnv(passwordEnvVar),
202
+ catch: () =>
203
+ new Error(
204
+ `${passwordEnvVar} is not in the environment or in the interactive zsh`,
205
+ ),
206
+ }).pipe(
207
+ Effect.mapError(
208
+ () =>
209
+ new DbConnectionError({
210
+ message: `${passwordEnvVar} is not in the environment or in the interactive zsh`,
211
+ environment: env,
212
+ }),
213
+ ),
214
+ );
215
+ }
224
216
 
225
- const resolvePassword = Effect.fn("DbService.resolvePassword")(function* (
226
- config: DbConfig,
227
- env: string,
228
- ) {
229
- if (config.password) {
230
- registerResolvedSecret(config.password);
231
- return config.password;
232
- }
217
+ // Local databases typically don't need a password
218
+ return "";
219
+ });
233
220
 
234
- if (config.passwordEnvVar) {
235
- const fromEnv = process.env[config.passwordEnvVar];
236
- if (fromEnv) {
237
- registerResolvedSecret(fromEnv);
238
- return fromEnv;
221
+ const resolveConfigString = (value: string, env: string, label: string) =>
222
+ Effect.gen(function* () {
223
+ const match = value.match(envTemplateRegex);
224
+ if (!match) return value;
225
+
226
+ const envVar = match[1];
227
+ const result = yield* lookupConfigEnv(envVar).pipe(
228
+ Effect.mapError(
229
+ () =>
230
+ new DbConnectionError({
231
+ message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
232
+ environment: env,
233
+ }),
234
+ ),
235
+ );
236
+ if (result._tag === "failed") {
237
+ return yield* new DbConnectionError({
238
+ message: `${envVar} lookup failed (required for '${label}' config field) in environment ${env}.`,
239
+ environment: env,
240
+ });
239
241
  }
240
-
241
- const zshrcEnv = yield* loadEnvFromZshrc();
242
- const fromZsh = zshrcEnv[config.passwordEnvVar];
243
- if (fromZsh) {
244
- registerResolvedSecret(fromZsh);
245
- return fromZsh;
242
+ if (result._tag === "absent") {
243
+ return yield* new DbConnectionError({
244
+ message: `${envVar} is not in the environment or in the interactive zsh (required for '${label}' config field) in environment ${env}.`,
245
+ environment: env,
246
+ });
246
247
  }
247
-
248
- return yield* new DbConnectionError({
249
- message: `Environment variable ${config.passwordEnvVar} is not set.`,
250
- environment: env,
251
- });
252
- }
253
-
254
- // Local databases typically don't need a password
255
- return "";
256
- });
257
-
258
- const resolveConfigString = Effect.fn("DbService.resolveConfigString")(function* (
259
- value: string,
260
- env: string,
261
- label: string,
262
- zshrcEnv: Record<string, string>,
263
- ) {
264
- const match = value.match(envTemplateRegex);
265
- if (!match) return value;
266
-
267
- const envVar = match[1];
268
- const fromEnv = Bun.env[envVar];
269
- if (fromEnv !== undefined) return fromEnv;
270
-
271
- const fromZsh = zshrcEnv[envVar];
272
- if (fromZsh !== undefined) return fromZsh;
273
-
274
- return yield* new DbConnectionError({
275
- message: `Environment variable ${envVar} (required for '${label}' config field) is not set in environment ${env}.`,
276
- environment: env,
248
+ return result.value;
277
249
  });
278
- });
279
250
 
280
- const lookupEnvOverride = (envVar: string, zshrcEnv: Record<string, string>) =>
281
- readProcessEnv(envVar) ?? zshrcEnv[envVar];
282
-
283
- const resolveDbConfig = Effect.fn("DbService.resolveDbConfig")(function* (
284
- config: DbConfig,
285
- env: string,
286
- ) {
287
- const needsEnvResolution =
288
- envTemplateRegex.test(config.user) ||
289
- envTemplateRegex.test(config.database) ||
290
- config.hostEnvVar !== undefined ||
291
- config.portEnvVar !== undefined;
292
- const zshrcEnv = needsEnvResolution ? yield* loadEnvFromZshrc() : {};
293
-
294
- const host = resolveHostOverride(
295
- config.host,
296
- config.hostEnvVar,
297
- config.hostEnvVar ? lookupEnvOverride(config.hostEnvVar, zshrcEnv) : undefined,
298
- );
299
- const portOverride = resolvePortOverride(
300
- env,
301
- config.port,
302
- config.portEnvVar,
303
- config.portEnvVar ? lookupEnvOverride(config.portEnvVar, zshrcEnv) : undefined,
304
- );
251
+ const resolveDbConfig = (config: DbConfig, env: string) =>
252
+ Effect.gen(function* () {
253
+ const resolveOverride = (name: string | undefined) => {
254
+ if (!name) return Effect.succeed(undefined);
255
+ return lookupConfigEnv(name).pipe(
256
+ Effect.flatMap((result) => {
257
+ if (result._tag === "absent") return Effect.succeed(undefined);
258
+ if (result._tag === "failed") {
259
+ return Effect.fail(
260
+ new DbConnectionError({
261
+ message: `${name} lookup failed in environment ${env}.`,
262
+ environment: env,
263
+ }),
264
+ );
265
+ }
266
+ return Effect.succeed(result.value);
267
+ }),
268
+ Effect.mapError(
269
+ () =>
270
+ new DbConnectionError({
271
+ message: `${name} lookup failed in environment ${env}.`,
272
+ environment: env,
273
+ }),
274
+ ),
275
+ );
276
+ };
277
+ const hostValue = yield* resolveOverride(config.hostEnvVar);
278
+ const portValue = yield* resolveOverride(config.portEnvVar);
279
+
280
+ const host = resolveHostOverride(config.host, config.hostEnvVar, hostValue);
281
+ const portOverride = resolvePortOverride(
282
+ env,
283
+ config.port,
284
+ config.portEnvVar,
285
+ portValue,
286
+ );
305
287
 
306
- if (!portOverride.ok) {
307
- return yield* new DbConnectionError({
308
- message: portOverride.message,
309
- environment: env,
310
- });
311
- }
288
+ if (!portOverride.ok) {
289
+ return yield* new DbConnectionError({
290
+ message: portOverride.message,
291
+ environment: env,
292
+ });
293
+ }
312
294
 
313
- // A host override can flip the tunnel decision (a forwarded localhost port vs a directly
314
- // reachable host), so re-derive the access mode whenever the override changed the host.
315
- const needsTunnel =
316
- host === config.host
317
- ? config.needsTunnel
318
- : resolveDbAccessMode(
319
- env,
320
- host,
321
- dbConfig.kubectl !== undefined,
322
- config.allowedMutations,
323
- ).needsTunnel;
295
+ // A host override can flip the tunnel decision (a forwarded localhost port vs a directly
296
+ // reachable host), so re-derive the access mode whenever the override changed the host.
297
+ const needsTunnel =
298
+ host === config.host
299
+ ? config.needsTunnel
300
+ : resolveDbAccessMode(
301
+ env,
302
+ host,
303
+ dbConfig.kubectl !== undefined,
304
+ config.allowedMutations,
305
+ ).needsTunnel;
324
306
 
325
- return {
326
- ...config,
327
- host,
328
- port: portOverride.port,
329
- needsTunnel,
330
- user: yield* resolveConfigString(config.user, env, "user", zshrcEnv),
331
- database: yield* resolveConfigString(config.database, env, "database", zshrcEnv),
332
- };
333
- });
307
+ return {
308
+ ...config,
309
+ host,
310
+ port: portOverride.port,
311
+ needsTunnel,
312
+ user: yield* resolveConfigString(config.user, env, "user"),
313
+ database: yield* resolveConfigString(config.database, env, "database"),
314
+ };
315
+ });
334
316
 
335
317
  const executeShellCommand = (command: ChildProcess.Command) =>
336
318
  Effect.scoped(
@@ -1,6 +1,7 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
 
3
3
  import { DbQueryError } from "./errors";
4
+ import { redactSensitiveText } from "#shared/content-security";
4
5
 
5
6
  export type DbConnection = {
6
7
  readonly host: string;
@@ -22,6 +23,11 @@ type BunQueryResult = unknown[] & { count?: number; command?: string };
22
23
  export const toErrorMessage = (cause: unknown): string =>
23
24
  cause instanceof Error ? cause.message : String(cause);
24
25
 
26
+ export const toDbQueryError = (cause: unknown, sql: string): DbQueryError => {
27
+ const message = redactSensitiveText(toErrorMessage(cause));
28
+ return new DbQueryError({ message, sql, stderr: message });
29
+ };
30
+
25
31
  const runWithBunSql = (connection: DbConnection, sql: string) =>
26
32
  Effect.tryPromise({
27
33
  try: async (): Promise<DbQueryOutcome> => {
@@ -61,10 +67,7 @@ const runWithBunSql = (connection: DbConnection, sql: string) =>
61
67
  await client.close().catch(() => undefined);
62
68
  }
63
69
  },
64
- catch: (cause) => {
65
- const message = toErrorMessage(cause);
66
- return new DbQueryError({ message, sql, stderr: message });
67
- },
70
+ catch: (cause) => toDbQueryError(cause, sql),
68
71
  });
69
72
 
70
73
  export class DbSqlClient extends Context.Service<
@@ -41,7 +41,7 @@ import {
41
41
  detectPRStatus,
42
42
  editPR,
43
43
  fetchPRView,
44
- fetchChecks,
44
+ fetchCheckReport,
45
45
  fetchChecksBatch,
46
46
  fetchChecksForCommand,
47
47
  fetchFailedChecks,
@@ -191,8 +191,10 @@ type ReviewTriageClassification = {
191
191
  export const classifyReviewTriage = (
192
192
  summary: ReviewTriageSummary,
193
193
  checks: readonly CheckResult[],
194
+ checksStatus: "reported" | "none_reported" = "reported",
194
195
  ): ReviewTriageClassification => {
195
196
  const reasons = [
197
+ ...(checksStatus === "none_reported" ? ["checks_not_reported"] : []),
196
198
  ...(checks.some((check) => check.bucket === "fail") ? ["failed_checks"] : []),
197
199
  ...(checks.some((check) => check.bucket === "cancel") ? ["cancelled_checks"] : []),
198
200
  ...(checks.some(
@@ -397,7 +399,6 @@ const countFeedbackOrigins = (items: ReadonlyArray<{ feedbackOrigin: string }>)
397
399
 
398
400
  export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
399
401
  prNumber: number | null,
400
- format: "toon" | "json" = "toon",
401
402
  ) {
402
403
  const { info, value } = yield* withStableHead(
403
404
  prNumber,
@@ -405,16 +406,17 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
405
406
  Effect.all([
406
407
  fetchThreads(number, false, false, headSha),
407
408
  fetchDiscussionSummary(number),
408
- fetchChecks(number, false, false, 0, format === "json"),
409
+ fetchCheckReport(number),
409
410
  fetchReviews(number, null, null, null, headSha),
410
411
  fetchComments(number, null, headSha),
411
412
  ]),
412
413
  { operation: "review triage", command: "gh-tool pr review-triage" },
413
414
  );
414
- const [allThreads, summary, checks, reviews, inlineComments] = value;
415
+ const [allThreads, summary, checkReport, reviews, inlineComments] = value;
416
+ const checks = checkReport.checks ?? [];
415
417
  const unresolvedThreads = allThreads.filter((thread) => !thread.isResolved);
416
418
  const visibleOpenThreads = allThreads.filter((thread) => thread.isVisibleOpen);
417
- const classification = classifyReviewTriage(summary, checks);
419
+ const classification = classifyReviewTriage(summary, checks, checkReport.checksStatus);
418
420
 
419
421
  // Single merge-readiness verdict so agents stop re-stitching mergeable + checks + threads +
420
422
  // review state across separate calls (F2). `blocking` names exactly what's left to do.
@@ -423,6 +425,7 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
423
425
  if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
424
426
  if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
425
427
  if (checks.some((check) => check.bucket === "cancel")) blocking.push("cancelled_checks");
428
+ if (checkReport.checksStatus === "none_reported") blocking.push("checks_not_reported");
426
429
  if (
427
430
  checks.some(
428
431
  (check) => !["pass", "fail", "pending", "skipping", "cancel"].includes(check.bucket),
@@ -466,6 +469,8 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
466
469
  visibleOpenThreads,
467
470
  summary,
468
471
  checks,
472
+ checksStatus: checkReport.checksStatus,
473
+ ...(checkReport.checksStatus === "none_reported" ? { checksReason: checkReport.reason } : {}),
469
474
  reviews,
470
475
  inlineComments,
471
476
  feedbackOriginCounts: {
@@ -1609,7 +1614,7 @@ export const prReviewTriageCommand = Command.make(
1609
1614
  repo,
1610
1615
  Effect.gen(function* () {
1611
1616
  const prNumber = Option.getOrNull(pr);
1612
- const result = yield* fetchReviewTriage(prNumber, format);
1617
+ const result = yield* fetchReviewTriage(prNumber);
1613
1618
  yield* logFormatted(
1614
1619
  omitTriageSections(result, Option.getOrNull(omit)?.split(",") ?? []),
1615
1620
  format,
@@ -1654,7 +1659,7 @@ export const prReviewTriageBatchCommand = Command.make(
1654
1659
  Effect.gen(function* () {
1655
1660
  const numbers = yield* parsePrNumbers(prs);
1656
1661
  const results = yield* Effect.all(
1657
- numbers.map((prNumber) => fetchReviewTriage(prNumber, format)),
1662
+ numbers.map((prNumber) => fetchReviewTriage(prNumber)),
1658
1663
  { concurrency: "unbounded" },
1659
1664
  );
1660
1665
  yield* logFormatted(results, format);
@@ -257,6 +257,8 @@ export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
257
257
  // for the caller's whole --timeout.
258
258
  const CHECK_REGISTRATION_POLL_SECONDS = 5;
259
259
  const CHECK_REGISTRATION_GRACE_SECONDS = 60;
260
+ const CHECK_POLL_INTERVAL_MS = 15_000;
261
+ const CHECK_POLL_MAX_INTERVAL_MS = 60_000;
260
262
  const NO_CHECKS_REPORTED_REASON =
261
263
  "gh reported no checks for this head. Checks may not be registered yet, the PR may have no CI, or GitHub returned an empty rollup. This is not a CI verdict.";
262
264
 
@@ -1469,9 +1471,21 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1469
1471
  const started = Number(yield* Clock.currentTimeMillis);
1470
1472
  const graceDeadlineMs =
1471
1473
  started + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
1474
+ let previousChecks = "";
1475
+ let intervalMs = CHECK_POLL_INTERVAL_MS;
1472
1476
  const poll = Effect.gen(function* () {
1473
1477
  const checks = yield* fetchCheckResults(pr);
1474
- return { checks, at: Number(yield* Clock.currentTimeMillis) };
1478
+ const signature = JSON.stringify(checks);
1479
+ intervalMs =
1480
+ signature === previousChecks
1481
+ ? Math.min(CHECK_POLL_MAX_INTERVAL_MS, intervalMs * 2)
1482
+ : CHECK_POLL_INTERVAL_MS;
1483
+ previousChecks = signature;
1484
+ return {
1485
+ checks,
1486
+ at: Number(yield* Clock.currentTimeMillis),
1487
+ intervalMs: checks.length === 0 ? CHECK_REGISTRATION_POLL_SECONDS * 1000 : intervalMs,
1488
+ };
1475
1489
  });
1476
1490
  const watchOutcome = yield* waits
1477
1491
  .until(
@@ -1481,7 +1495,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1481
1495
  ? at >= graceDeadlineMs
1482
1496
  : (failFast && checks.some((check) => check.bucket === "fail")) ||
1483
1497
  checks.every((check) => check.bucket !== "pending"),
1484
- { intervalMs: CHECK_REGISTRATION_POLL_SECONDS * 1000, timeoutMs: timeoutSeconds * 1000 },
1498
+ { intervalMs: (value) => value.intervalMs, timeoutMs: timeoutSeconds * 1000 },
1485
1499
  )
1486
1500
  .pipe(
1487
1501
  Effect.timeoutOrElse({
@@ -1504,6 +1518,16 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1504
1518
  stderr: "",
1505
1519
  });
1506
1520
  const results = yield* fetchCheckResults(pr);
1521
+ if (
1522
+ results.some((check) => check.bucket === "fail") &&
1523
+ (failFast || !results.some((check) => check.bucket === "pending"))
1524
+ )
1525
+ return yield* new GitHubCommandError({
1526
+ command: buildChecksCommand(pr, true),
1527
+ exitCode: 1,
1528
+ message: "",
1529
+ stderr: "",
1530
+ });
1507
1531
  if (!quiet && graceExpired) {
1508
1532
  const prRef = pr === null ? "<number>" : String(pr);
1509
1533
  yield* Console.warn(
@@ -1608,7 +1632,7 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1608
1632
  const initial = yield* viewPR(pr);
1609
1633
  const snapshot = yield* collectWithStableState(
1610
1634
  initial,
1611
- (info) => fetchCheckResults(info.number),
1635
+ (info) => fetchCheckReport(info.number),
1612
1636
  (info) =>
1613
1637
  fetchPRView(info.number, "origin").pipe(
1614
1638
  Effect.map((refreshed) => ({ ...refreshed, baseSha: initial.baseSha })),
@@ -1616,7 +1640,20 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1616
1640
  (before, after) => after.headSha === before.headSha,
1617
1641
  );
1618
1642
  if (snapshot !== null) {
1619
- return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value, {
1643
+ if (snapshot.value.checksStatus === "none_reported") {
1644
+ return {
1645
+ evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
1646
+ status: "none_reported",
1647
+ reason: snapshot.value.reason,
1648
+ message: "No checks were reported for this PR head.",
1649
+ summary: { total: 0, failed: 0, pending: 0, passed: 0 },
1650
+ failedChecks: [],
1651
+ pendingChecks: [],
1652
+ hint: "This is not a CI verdict. Check whether CI exists or has registered for this head.",
1653
+ nextCommands: [`bun agent-tools-gh pr checks --pr ${snapshot.state.number}`],
1654
+ };
1655
+ }
1656
+ return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value.checks, {
1620
1657
  withLogs: withLogs || fullLogs,
1621
1658
  fullLogs,
1622
1659
  evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
@@ -113,6 +113,9 @@ const enrichThreads = (
113
113
  feedbackOrigin: feedbackOrigin(comment.commit?.oid ?? null, currentHeadSha),
114
114
  path: comment.path,
115
115
  line: comment.line,
116
+ originalLine: root?.originalLine ?? null,
117
+ originalCommitSha: root?.originalCommitSha ?? null,
118
+ diffHunk: root?.diffHunk ?? null,
116
119
  body: comment.body,
117
120
  isResolved: node.isResolved,
118
121
  hasReply,
@@ -131,7 +134,18 @@ const enrichThreads = (
131
134
  const dedupedByKey = new Map<string, number>();
132
135
  const deduped: ReviewThread[] = [];
133
136
  for (const thread of mapped) {
134
- const key = `${thread.path}\u0000${thread.line}\u0000${thread.body.trim()}`;
137
+ const originalLocation =
138
+ thread.line === null
139
+ ? JSON.stringify([
140
+ thread.originalLine,
141
+ thread.originalCommitSha,
142
+ thread.diffHunk,
143
+ thread.originalLine === null || thread.originalCommitSha === null
144
+ ? thread.commentId
145
+ : null,
146
+ ])
147
+ : "";
148
+ const key = `${thread.path}\u0000${thread.line}\u0000${thread.body.trim()}\u0000${originalLocation}`;
135
149
  const existingIndex = dedupedByKey.get(key);
136
150
  if (existingIndex === undefined) {
137
151
  dedupedByKey.set(key, deduped.length);
@@ -224,6 +238,9 @@ export const fetchComments = Effect.fn("pr.fetchComments")(function* (
224
238
  body: c.body,
225
239
  path: c.path,
226
240
  line: c.line,
241
+ originalLine: c.original_line ?? null,
242
+ originalCommitSha: c.original_commit_id ?? null,
243
+ diffHunk: c.diff_hunk ?? null,
227
244
  createdAt: c.created_at,
228
245
  reviewId: c.pull_request_review_id ?? null,
229
246
  updatedAt: c.updated_at,
@@ -10,7 +10,7 @@ import { GitHubMergeError } from "#gh/errors";
10
10
 
11
11
  import type { MergeStrategy, StackMember, StackMergeBlocker, StackMergeResult } from "#gh/types";
12
12
 
13
- import { fetchCheckResults, fetchPRView } from "./core";
13
+ import { fetchCheckReport, fetchPRView } from "./core";
14
14
  import { readStack } from "./stack-read";
15
15
 
16
16
  export { readStack };
@@ -62,7 +62,17 @@ const collectBlockers = Effect.fn("pr.collectStackBlockers")(function* (members:
62
62
  continue;
63
63
  }
64
64
 
65
- const checks = yield* fetchCheckResults(member.number);
65
+ const report = yield* fetchCheckReport(member.number);
66
+ if (report.checksStatus === "none_reported") {
67
+ blockers.push({
68
+ number: member.number,
69
+ reason: "checks_none_reported",
70
+ detail: `checks: none_reported. ${report.reason}`,
71
+ });
72
+ continue;
73
+ }
74
+
75
+ const checks = report.checks;
66
76
  const failing = checks.filter((check) => check.bucket === "fail");
67
77
  if (failing.length > 0) {
68
78
  blockers.push({
@@ -208,6 +208,12 @@ const githubApiAttempt = Effect.fn("gh.githubApiAttempt")(function* <T>(
208
208
  command: `${method} ${opts.path}`,
209
209
  exitCode: response.status,
210
210
  stderr: text,
211
+ ...(rateLimit
212
+ ? {
213
+ hint: "Wait for the reset before retrying. Check the remaining quota once with the shown command; it does not consume the quota.",
214
+ nextCommand: "gh api rate_limit",
215
+ }
216
+ : {}),
211
217
  ...(response.status >= 500 || (rateLimit && canWait) ? { retryable: true } : {}),
212
218
  });
213
219
  if (rateLimit && canWait && waitMs !== undefined) rateLimitRetryDelays.set(error, waitMs);
@@ -227,7 +227,10 @@ export const prChecks = Effect.fn("reads.prChecks")(function* (
227
227
  const sha = head.headSha;
228
228
  let verified = true;
229
229
  const stableFetch = Effect.gen(function* () {
230
- const before = yield* prHead(repo, number, "origin").pipe(Effect.orElseSucceed(() => null));
230
+ const before =
231
+ headSha === null
232
+ ? head
233
+ : yield* prHead(repo, number, "origin").pipe(Effect.orElseSucceed(() => null));
231
234
  if (before !== null && before.headSha !== sha) return yield* movedHead(number);
232
235
  const value = yield* gh.runGhJson<CheckResult[]>([
233
236
  "pr",