@blogic-cz/agent-tools 1.3.0 → 1.4.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 (42) hide show
  1. package/README.md +6 -0
  2. package/dist/gh-tool/branch.d.ts +2 -2
  3. package/dist/gh-tool/branch.d.ts.map +1 -1
  4. package/dist/gh-tool/gist.d.ts +6 -6
  5. package/dist/gh-tool/gist.d.ts.map +1 -1
  6. package/dist/gh-tool/issue/commands.d.ts +8 -8
  7. package/dist/gh-tool/issue/commands.d.ts.map +1 -1
  8. package/dist/gh-tool/issue/core.d.ts +8 -9
  9. package/dist/gh-tool/issue/core.d.ts.map +1 -1
  10. package/dist/gh-tool/issue/triage.d.ts +5 -5
  11. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  12. package/dist/gh-tool/pr/commands.d.ts +36 -36
  13. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  14. package/dist/gh-tool/pr/core.d.ts +13 -13
  15. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  16. package/dist/gh-tool/pr/review.d.ts +14 -15
  17. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  18. package/dist/gh-tool/pr/stack-read.d.ts +1 -1
  19. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
  20. package/dist/gh-tool/pr/stack.d.ts +12 -1
  21. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  22. package/dist/gh-tool/release.d.ts +6 -6
  23. package/dist/gh-tool/release.d.ts.map +1 -1
  24. package/dist/gh-tool/repo.d.ts +3 -4
  25. package/dist/gh-tool/repo.d.ts.map +1 -1
  26. package/dist/gh-tool/service.d.ts +2 -2
  27. package/dist/gh-tool/service.d.ts.map +1 -1
  28. package/dist/gh-tool/workflow.d.ts +13 -13
  29. package/dist/gh-tool/workflow.d.ts.map +1 -1
  30. package/dist/observability-tool/shared.d.ts.map +1 -1
  31. package/dist/observability-tool/trace.d.ts +5 -0
  32. package/dist/observability-tool/trace.d.ts.map +1 -1
  33. package/dist/observability-tool/types.d.ts +8 -0
  34. package/dist/observability-tool/types.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/gh-tool/pr/commands.ts +46 -2
  37. package/src/gh-tool/pr/core.ts +74 -2
  38. package/src/gh-tool/pr/stack.ts +45 -0
  39. package/src/gh-tool/service.ts +2 -2
  40. package/src/observability-tool/shared.ts +5 -0
  41. package/src/observability-tool/trace.ts +163 -2
  42. package/src/observability-tool/types.ts +9 -0
@@ -29,7 +29,7 @@ import {
29
29
  REVIEW_EVENTS,
30
30
  } from "#gh/config";
31
31
 
32
- import { mergeStack, readStack } from "./stack";
32
+ import { mergeStack, readStack, unstackStack } from "./stack";
33
33
  import {
34
34
  closePR,
35
35
  collectWithStableState,
@@ -414,6 +414,23 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
414
414
  if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
415
415
  blocking.push(`review=${info.reviewDecision}`);
416
416
  }
417
+
418
+ // A member of a GitHub stack cannot merge on its own while open members sit below it, so a
419
+ // verdict that reads only this PR would report ready for a PR nothing can land.
420
+ const stackView = yield* readStack({ pr: info.number }).pipe(
421
+ Effect.catch(() => Effect.succeed(null)),
422
+ );
423
+ const ownPosition = stackView?.members.find((member) => member.number === info.number)?.position;
424
+ const openBelow =
425
+ stackView?.isStacked === true && ownPosition !== undefined
426
+ ? stackView.members.filter(
427
+ (member) => member.state === "open" && member.position < ownPosition,
428
+ )
429
+ : [];
430
+ if (openBelow.length > 0) {
431
+ blocking.push(`stack_members_below=${openBelow.map((member) => member.number).join(",")}`);
432
+ }
433
+
417
434
  const ready = {
418
435
  ready: blocking.length === 0,
419
436
  mergeable: info.mergeable,
@@ -1657,7 +1674,34 @@ const prStackMergeCommand = Command.make(
1657
1674
  ),
1658
1675
  );
1659
1676
 
1677
+ const prStackUnstackCommand = Command.make(
1678
+ "unstack",
1679
+ {
1680
+ confirm: Flag.boolean("confirm").pipe(
1681
+ Flag.withDescription(
1682
+ "Actually unstack (without this flag, only shows what would be removed)",
1683
+ ),
1684
+ Flag.withDefault(false),
1685
+ ),
1686
+ format: formatOption,
1687
+ pr: Flag.integer("pr").pipe(Flag.withDescription("Any PR in the stack to dissolve")),
1688
+ repo: repoOption,
1689
+ },
1690
+ ({ confirm, format, pr, repo }) =>
1691
+ withRepo(
1692
+ repo,
1693
+ Effect.gen(function* () {
1694
+ const result = yield* unstackStack({ confirm, pr });
1695
+ yield* logFormatted(result, format);
1696
+ }),
1697
+ ),
1698
+ ).pipe(
1699
+ Command.withDescription(
1700
+ "Remove every unmerged PR from the stack, dissolving it (dry-run by default)",
1701
+ ),
1702
+ );
1703
+
1660
1704
  export const prStackCommand = Command.make("stack", {}).pipe(
1661
- Command.withSubcommands([prStackViewCommand, prStackMergeCommand]),
1705
+ Command.withSubcommands([prStackViewCommand, prStackMergeCommand, prStackUnstackCommand]),
1662
1706
  Command.withDescription("Stacked pull request operations"),
1663
1707
  );
@@ -19,6 +19,7 @@ import type {
19
19
  import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
20
20
  import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
21
21
  import { GitHubService } from "#gh/service";
22
+ import type { GhResult } from "#gh/service";
22
23
  import { logText } from "#shared";
23
24
 
24
25
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
@@ -201,6 +202,13 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
201
202
  // `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
202
203
  // is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
203
204
  export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
205
+ // `gh` reports "no checks reported" both for the seconds after a push, before its checks
206
+ // register, and forever for a PR that has none — the message cannot tell them apart. So the
207
+ // wait for registration gets its own short budget: long enough to cover the race (measured
208
+ // at roughly 40s on blogic-cz/agent-tools#134), short enough that a check-less PR is not held
209
+ // for the caller's whole --timeout.
210
+ const CHECK_REGISTRATION_POLL_SECONDS = 5;
211
+ const CHECK_REGISTRATION_GRACE_SECONDS = 60;
204
212
 
205
213
  export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
206
214
  const gh = yield* GitHubService;
@@ -1181,6 +1189,24 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
1181
1189
 
1182
1190
  const repo = yield* gh.getRepoInfo();
1183
1191
 
1192
+ // GitHub rejects a base change on a stacked PR with a bare 422. Say what the state is and
1193
+ // which operation changes it, rather than letting the validation error through.
1194
+ if (opts.base !== null) {
1195
+ const stackView = yield* readStack({ pr: opts.pr }).pipe(
1196
+ Effect.catch(() => Effect.succeed(null)),
1197
+ );
1198
+ if (stackView?.isStacked === true) {
1199
+ return yield* new GitHubCommandError({
1200
+ command: "pr edit --base",
1201
+ exitCode: 1,
1202
+ stderr: `PR #${opts.pr} belongs to GitHub stack #${stackView.stackNumber}`,
1203
+ message: `Cannot retarget PR #${opts.pr}: it belongs to GitHub stack #${stackView.stackNumber}, whose members' bases GitHub owns`,
1204
+ hint: "Dissolve the stack first with 'pr stack unstack', which removes every unmerged member, then retarget. Merging the stack instead needs no retarget at all.",
1205
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
1206
+ });
1207
+ }
1208
+ }
1209
+
1184
1210
  const editArgs = [
1185
1211
  "api",
1186
1212
  "--method",
@@ -1226,7 +1252,45 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1226
1252
  // Block for the caller's requested --timeout (no artificial cap — blocking isn't the problem;
1227
1253
  // --timeout is validated >= 1s at the CLI boundary). On timeout return a snapshot, never
1228
1254
  // nothing — that was the actual token-wasting bug.
1229
- const watchOutcome = yield* gh.runGh(watchArgs).pipe(
1255
+ // A push registers its checks a moment after the ref lands, and `gh pr checks --watch`
1256
+ // exits non-zero in that window. A watch was asked to wait, so keep waiting: the outer
1257
+ // timeout still bounds it, and a PR that genuinely has no checks returns an empty
1258
+ // snapshot at that deadline rather than an error.
1259
+ let watchResult: GhResult | null = null;
1260
+ let registered = false;
1261
+ let graceExpired = false;
1262
+ const watchThroughRegistration = Effect.gen(function* () {
1263
+ const graceStart = yield* Clock.currentTimeMillis;
1264
+ const graceDeadlineMs =
1265
+ Number(graceStart) + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
1266
+ yield* Effect.whileLoop({
1267
+ while: () => !registered && !graceExpired,
1268
+ body: () =>
1269
+ gh.runGh(watchArgs).pipe(
1270
+ Effect.flatMap((result) => {
1271
+ watchResult = result;
1272
+ registered = true;
1273
+ return Effect.void;
1274
+ }),
1275
+ Effect.catchTag("GitHubCommandError", (error) =>
1276
+ NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
1277
+ ? Effect.gen(function* () {
1278
+ const now = yield* Clock.currentTimeMillis;
1279
+ if (Number(now) >= graceDeadlineMs) {
1280
+ graceExpired = true;
1281
+ return;
1282
+ }
1283
+ yield* Effect.sleep(Duration.seconds(CHECK_REGISTRATION_POLL_SECONDS));
1284
+ })
1285
+ : Effect.fail(error),
1286
+ ),
1287
+ ),
1288
+ step: () => undefined,
1289
+ });
1290
+ return watchResult;
1291
+ });
1292
+
1293
+ const watchOutcome = yield* watchThroughRegistration.pipe(
1230
1294
  Effect.timeoutOrElse({
1231
1295
  duration: timeoutSeconds * 1000,
1232
1296
  orElse: () => Effect.succeed(null),
@@ -1234,7 +1298,15 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1234
1298
  );
1235
1299
 
1236
1300
  const results = yield* fetchCheckResults(pr);
1237
- if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
1301
+ if (!quiet && graceExpired) {
1302
+ const prRef = pr === null ? "<number>" : String(pr);
1303
+ yield* Console.warn(
1304
+ `ℹ️ No checks registered within ${CHECK_REGISTRATION_GRACE_SECONDS}s of watching; ` +
1305
+ `returning the current snapshot. If this PR should have checks, the push event was ` +
1306
+ `likely dropped, and re-watching will find nothing again — dispatch them on the PR ` +
1307
+ `head instead:\n agent-tools-gh pr trigger-checks --pr ${prRef} --workflow <file.yml>`,
1308
+ );
1309
+ } else if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
1238
1310
  const pending = results.filter((c) => c.bucket === "pending").length;
1239
1311
  yield* Console.warn(
1240
1312
  `ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
@@ -237,3 +237,48 @@ export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
237
237
  hint: "Inspect the stack state and branch protections, then retry.",
238
238
  });
239
239
  });
240
+
241
+ export const unstackStack = Effect.fn("pr.unstackStack")(function* (opts: {
242
+ pr: number;
243
+ confirm: boolean;
244
+ }) {
245
+ const gh = yield* GitHubService;
246
+ const repo = yield* gh.getRepoInfo();
247
+ const view = yield* readStack({ pr: opts.pr });
248
+
249
+ if (!view.isStacked || view.stackNumber === null) {
250
+ return yield* new GitHubMergeError({
251
+ message: `PR #${opts.pr} is not part of a GitHub stack`,
252
+ reason: "unknown",
253
+ hint: "There is no stack to dissolve.",
254
+ nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
255
+ });
256
+ }
257
+
258
+ const unmerged = view.members.filter((member) => member.state === "open");
259
+
260
+ const plan = {
261
+ stackNumber: view.stackNumber,
262
+ pr: opts.pr,
263
+ removes: unmerged.map((member) => member.number),
264
+ };
265
+
266
+ if (!opts.confirm) {
267
+ return { ...plan, dissolved: false, unstacked: false, dryRun: true };
268
+ }
269
+
270
+ // The endpoint removes every unmerged member at once; it has no per-PR form. A 204 means
271
+ // nothing was left and the stack is gone, a 200 means some members could not be removed.
272
+ const result = yield* gh.apiRequest<unknown>({
273
+ path: `repos/${repo.owner}/${repo.name}/stacks/${view.stackNumber}/unstack`,
274
+ method: "POST",
275
+ alsoAcceptStatus: [204],
276
+ });
277
+
278
+ return {
279
+ ...plan,
280
+ dissolved: result.status === 204,
281
+ unstacked: true,
282
+ dryRun: false,
283
+ };
284
+ });
@@ -68,13 +68,13 @@ const isSafeRetryRead = (args: readonly string[]): boolean => {
68
68
  return args.some((a) => READ_VERBS.has(a));
69
69
  };
70
70
 
71
- type GhResult = {
71
+ export type GhResult = {
72
72
  stdout: string;
73
73
  stderr: string;
74
74
  exitCode: number;
75
75
  };
76
76
 
77
- type GhError = GitHubCommandError | GitHubAuthError | GitHubNotFoundError;
77
+ export type GhError = GitHubCommandError | GitHubAuthError | GitHubNotFoundError;
78
78
 
79
79
  export class GitHubService extends Context.Service<
80
80
  GitHubService,
@@ -26,6 +26,11 @@ export function formatObservabilityError(error: unknown): string {
26
26
  return error.message;
27
27
  }
28
28
 
29
+ // Structured causes carry their text in `message`; String() would render "[object Object]".
30
+ if (typeof error === "object" && error !== null && "message" in error) {
31
+ return String((error as { message: unknown }).message);
32
+ }
33
+
29
34
  return String(error);
30
35
  }
31
36
 
@@ -25,6 +25,7 @@ import type {
25
25
  SpanResolution,
26
26
  TempoSearchResponse,
27
27
  TempoTraceResponse,
28
+ TraceSearchHit,
28
29
  TraceSummary,
29
30
  } from "./types";
30
31
 
@@ -355,6 +356,95 @@ function resolveTraceFromId(
355
356
  });
356
357
  }
357
358
 
359
+ /**
360
+ * Strict counterpart of relativeToEpoch for user-supplied windows: an unparseable bound is
361
+ * refused rather than silently collapsed to "now", which would report a zero-width window as
362
+ * "no traces found".
363
+ */
364
+ function strictRelativeToEpoch(value: string, nowEpoch: number): number | undefined {
365
+ const trimmed = value.trim();
366
+ if (trimmed === "now") return nowEpoch;
367
+ return /^now-\d+[smhd]$/.test(trimmed) ? relativeToEpoch(trimmed, nowEpoch) : undefined;
368
+ }
369
+
370
+ /** Tempo rejects a search window wider than this, so the CLI says so before the API does. */
371
+ const MAX_SEARCH_RANGE_SECONDS = 168 * 3600;
372
+
373
+ export function summarizeSearchHits(response: TempoSearchResponse): TraceSearchHit[] {
374
+ return (response.traces ?? [])
375
+ .filter((trace) => trace.traceID !== undefined)
376
+ .map((trace) => {
377
+ const startedNano = nanoToBigInt(trace.startTimeUnixNano);
378
+
379
+ return {
380
+ traceId: trace.traceID as string,
381
+ startedAt:
382
+ startedNano === undefined
383
+ ? undefined
384
+ : new Date(Number(startedNano / 1_000_000n)).toISOString(),
385
+ rootServiceName: trace.rootServiceName,
386
+ rootTraceName: trace.rootTraceName,
387
+ durationMs: trace.durationMs,
388
+ matchedSpans: trace.spanSets?.reduce(
389
+ (total, spanSet) => total + (spanSet.matched ?? spanSet.spans?.length ?? 0),
390
+ 0,
391
+ ),
392
+ };
393
+ })
394
+ .toSorted((left, right) => (right.startedAt ?? "").localeCompare(left.startedAt ?? ""));
395
+ }
396
+
397
+ export function searchTempoByQuery(
398
+ config: ObservabilityEnvConfig,
399
+ query: string,
400
+ window: SearchWindow,
401
+ limit: number,
402
+ ): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
403
+ return Effect.gen(function* () {
404
+ const tempoUid = yield* requireTempoUid(config);
405
+ const now = Math.floor(Date.now() / 1000);
406
+ const startEpoch = strictRelativeToEpoch(window.start, now);
407
+ const endEpoch = strictRelativeToEpoch(window.end, now);
408
+
409
+ if (startEpoch === undefined || endEpoch === undefined) {
410
+ const rejected = startEpoch === undefined ? window.start : window.end;
411
+ return yield* new ObservabilityToolError({
412
+ cause: {
413
+ message: `Unparseable time "${rejected}" — use "now" or "now-<number><s|m|h|d>", e.g. now-6h`,
414
+ code: "INVALID_TIME_RANGE",
415
+ retryable: false,
416
+ },
417
+ });
418
+ }
419
+
420
+ if (startEpoch >= endEpoch) {
421
+ return yield* new ObservabilityToolError({
422
+ cause: {
423
+ message: `Search window ${window.start} → ${window.end} ends at or before it starts`,
424
+ code: "INVALID_TIME_RANGE",
425
+ retryable: false,
426
+ },
427
+ });
428
+ }
429
+
430
+ if (endEpoch - startEpoch > MAX_SEARCH_RANGE_SECONDS) {
431
+ return yield* new ObservabilityToolError({
432
+ cause: {
433
+ message: `Search window ${window.start} → ${window.end} exceeds the 168h Tempo limit — query a narrower range`,
434
+ code: "SEARCH_RANGE_TOO_WIDE",
435
+ retryable: false,
436
+ },
437
+ });
438
+ }
439
+
440
+ const searchUrl =
441
+ `/api/datasources/proxy/uid/${tempoUid}/api/search` +
442
+ `?q=${encodeURIComponent(query)}&start=${startEpoch}&end=${endEpoch}&limit=${limit}`;
443
+
444
+ return yield* observabilityFetch<TempoSearchResponse>(config, searchUrl);
445
+ });
446
+ }
447
+
358
448
  function handleTraceGet(
359
449
  id: string,
360
450
  format: OutputFormat,
@@ -524,9 +614,80 @@ const findCommand = Command.make(
524
614
  profile: profileOption,
525
615
  },
526
616
  ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
527
- ).pipe(Command.withDescription("Alias for 'trace get' — resolve a trace by trace ID or span ID"));
617
+ ).pipe(
618
+ Command.withDescription(
619
+ "Alias for 'trace get' — resolves an id, and is not the TraceQL search; that is 'trace search'",
620
+ ),
621
+ );
622
+
623
+ const searchCommand = Command.make(
624
+ "search",
625
+ {
626
+ query: Argument.string("query"),
627
+ format: formatOption,
628
+ env: envOption,
629
+ profile: profileOption,
630
+ limit: Flag.integer("limit").pipe(
631
+ Flag.withDescription("Max traces to return (default: 20)"),
632
+ Flag.withDefault(20),
633
+ ),
634
+ start: Flag.string("start").pipe(
635
+ Flag.withDescription("Start time (default: now-1h, max span now-168h)"),
636
+ Flag.withDefault("now-1h"),
637
+ ),
638
+ end: Flag.string("end").pipe(
639
+ Flag.withDescription("End time (default: now)"),
640
+ Flag.withDefault("now"),
641
+ ),
642
+ },
643
+ ({ query, format, env, profile, limit, start, end }) => {
644
+ const startedAt = Date.now();
645
+
646
+ return Effect.gen(function* () {
647
+ const config = yield* resolveConfig(env, profile);
648
+ const response = yield* searchTempoByQuery(config, query, { start, end }, limit);
649
+ const traces = summarizeSearchHits(response);
650
+
651
+ const result = {
652
+ success: true,
653
+ message: `Found ${traces.length} trace(s) matching the TraceQL query`,
654
+ data: {
655
+ environment: env,
656
+ grafanaUrl: config.url,
657
+ tempoDatasourceUid: config.tempoUid ?? null,
658
+ query,
659
+ start,
660
+ end,
661
+ limit,
662
+ traceCount: traces.length,
663
+ traces,
664
+ },
665
+ executionTimeMs: Date.now() - startedAt,
666
+ };
667
+
668
+ yield* logText(formatOutput(result, format));
669
+ }).pipe(
670
+ Effect.catch((error) =>
671
+ Effect.gen(function* () {
672
+ const result = {
673
+ success: false,
674
+ message: "Failed to search Tempo",
675
+ error: formatObservabilityError(error),
676
+ hint: 'Query is TraceQL, e.g. { name = "GraphQL Document Validation" && status = error }. The window may not exceed 168h',
677
+ executionTimeMs: Date.now() - startedAt,
678
+ };
679
+ yield* logText(formatOutput(result, format));
680
+ }),
681
+ ),
682
+ );
683
+ },
684
+ ).pipe(
685
+ Command.withDescription(
686
+ "Search Tempo with a TraceQL query — find traces when no trace or span ID is known yet",
687
+ ),
688
+ );
528
689
 
529
690
  export const traceCommand = Command.make("trace", {}).pipe(
530
691
  Command.withDescription("Tempo trace operations"),
531
- Command.withSubcommands([getCommand, logsCommand, findCommand]),
692
+ Command.withSubcommands([getCommand, searchCommand, logsCommand, findCommand]),
532
693
  );
@@ -127,6 +127,15 @@ export type TempoSearchResponse = {
127
127
  readonly metrics?: Record<string, unknown>;
128
128
  };
129
129
 
130
+ export type TraceSearchHit = {
131
+ readonly traceId: string;
132
+ readonly startedAt?: string;
133
+ readonly rootServiceName?: string;
134
+ readonly rootTraceName?: string;
135
+ readonly durationMs?: number;
136
+ readonly matchedSpans?: number;
137
+ };
138
+
130
139
  export const IdKind = Schema.Literals(["trace_id", "span_id"]);
131
140
  export type IdKind = typeof IdKind.Type;
132
141