@blogic-cz/agent-tools 1.7.1 → 1.7.3

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.
@@ -121,6 +121,7 @@ export type FailedCheckJob = {
121
121
  export type FailedCheckRunContext = {
122
122
  runId: number;
123
123
  attempt: number | null;
124
+ headSha: string | null;
124
125
  url: string | null;
125
126
  workflowName: string | null;
126
127
  status: string;
@@ -226,6 +227,7 @@ export type FailedChecksReport = {
226
227
  export type WorkflowRunDetail = {
227
228
  databaseId: number;
228
229
  attempt: number | null;
230
+ headSha: string | null;
229
231
  url: string;
230
232
  workflowName: string | null;
231
233
  status: string;
@@ -240,6 +242,8 @@ export type WorkflowRunDetail = {
240
242
  name: string;
241
243
  status: string;
242
244
  conclusion: string | null;
245
+ startedAt: string | null;
246
+ completedAt: string | null;
243
247
  }>;
244
248
  }>;
245
249
  };
@@ -454,18 +454,72 @@ export function cleanLogLine(line: string): string {
454
454
  .trim();
455
455
  }
456
456
 
457
- export function parseRawJobLogs(raw: string): LogEntry[] {
458
- const entries: LogEntry[] = [];
457
+ type StepTiming = {
458
+ name: string;
459
+ conclusion: string | null;
460
+ startedAt: string | null;
461
+ completedAt: string | null;
462
+ };
463
+
464
+ type StepWindow = { name: string; failed: boolean; start: number; end: number };
465
+
466
+ const LINE_SECOND_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/;
467
+
468
+ // Older gh versions reject --allow-escape-sequences; they print escapes without it.
469
+ export const readJobLogApi = Effect.fn("workflow.readJobLogApi")(function* (path: string) {
470
+ const gh = yield* GitHubService;
471
+ return yield* gh
472
+ .runGh(["api", path, "--allow-escape-sequences"])
473
+ .pipe(
474
+ Effect.catchTag("GitHubCommandError", (error) =>
475
+ `${error.stderr}${error.message}`.includes("unknown flag: --allow-escape-sequences")
476
+ ? gh.runGh(["api", path])
477
+ : Effect.fail(error),
478
+ ),
479
+ );
480
+ });
481
+
482
+ // Step times have one-second precision and several steps can share a second, so a
483
+ // boundary line goes to the failed step: a few extra lines beat a lost final error.
484
+ // Returns undefined for a line without a timestamp and null for a time outside every step.
485
+ function stepAtTime(line: string, windows: ReadonlyArray<StepWindow>) {
486
+ const second = LINE_SECOND_RE.exec(line)?.[1];
487
+ if (second === undefined) return undefined;
488
+ const time = Date.parse(`${second}Z`);
489
+ const inside = windows.filter((w) => w.start <= time && time <= w.end);
490
+ return (inside.find((w) => w.failed) ?? inside.at(-1))?.name ?? null;
491
+ }
492
+
493
+ export function parseRawJobLogs(raw: string, steps: ReadonlyArray<StepTiming> = []): LogEntry[] {
494
+ return parseAttributedJobLogs(raw, steps).map(({ step, message }) => ({ step, message }));
495
+ }
496
+
497
+ // `established` is true only when gh labelled the line or its own time falls inside a step window.
498
+ function parseAttributedJobLogs(raw: string, steps: ReadonlyArray<StepTiming>) {
499
+ const entries: Array<LogEntry & { established: boolean }> = [];
459
500
  let currentStep = "(unknown)";
501
+ let timedStep: string | undefined;
502
+ const timed = steps
503
+ .flatMap((step) => (step.startedAt ? [{ ...step, start: Date.parse(step.startedAt) }] : []))
504
+ .toSorted((a, b) => a.start - b.start);
505
+ const windows = timed.map((step, index) => ({
506
+ name: step.name,
507
+ failed: step.conclusion === "failure",
508
+ start: step.start,
509
+ end: step.completedAt ? Date.parse(step.completedAt) : (timed[index + 1]?.start ?? step.start),
510
+ }));
460
511
 
461
512
  for (const rawLine of raw.split("\n")) {
462
513
  const cliLine = rawLine.match(/^[^\t]+\t([^\t]+)\t(\uFEFF?\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?.*)$/);
463
514
  const line = (cliLine?.[2] ?? rawLine).replace(/^\uFEFF/, "").replace(/\r$/, "");
515
+ const byTime = stepAtTime(line, windows);
516
+ if (byTime !== undefined) timedStep = byTime ?? undefined;
464
517
 
465
518
  // Step group markers
466
519
  const groupMatch = line.match(/##\[group\](.+)/);
467
520
  if (groupMatch) {
468
521
  if (!cliLine) currentStep = groupMatch[1].trim();
522
+ if (byTime === undefined) timedStep = undefined;
469
523
  continue;
470
524
  }
471
525
  if (line.includes("##[endgroup]")) continue;
@@ -473,7 +527,11 @@ export function parseRawJobLogs(raw: string): LogEntry[] {
473
527
  const cleaned = cleanLogLine(line);
474
528
  if (cleaned.length === 0) continue;
475
529
 
476
- entries.push({ step: cliLine?.[1] ?? currentStep, message: cleaned });
530
+ entries.push({
531
+ step: cliLine?.[1] ?? timedStep ?? currentStep,
532
+ message: cleaned,
533
+ established: cliLine !== null || typeof byTime === "string",
534
+ });
477
535
  }
478
536
 
479
537
  return entries;
@@ -509,7 +567,15 @@ const normalizeFingerprint = (message: string) =>
509
567
  .slice(0, 240);
510
568
 
511
569
  export const diagnoseLogEntries = (entries: readonly LogEntry[]): LogDiagnosis => {
512
- const lines = entries.map((entry) => entry.message);
570
+ const fetchProgress =
571
+ /^(?:\s*(?:\*\s+\[new branch\]|\[new branch\]|From\s+\S+|\*\s+\[new tag\]|\s*->\s+origin\/)|\s*\+?git fetch\b|.*refs\/(?:heads|remotes)\/)/i;
572
+ const lines = entries
573
+ .filter(
574
+ ({ message }) =>
575
+ /fatal:|error:|failed|exception|could not resolve|connection|timed out/i.test(message) ||
576
+ !fetchProgress.test(message),
577
+ )
578
+ .map((entry) => entry.message);
513
579
  const text = lines.join("\n");
514
580
  const find = (pattern: RegExp) => lines.find((line) => pattern.test(line)) ?? null;
515
581
  const testsStarted =
@@ -592,31 +658,12 @@ const resolveJobId = Effect.fn("workflow.resolveJobId")(function* (
592
658
  });
593
659
  });
594
660
 
595
- const filterFailedStepEntries = Effect.fn("workflow.filterFailedStepEntries")(function* (
596
- runId: number,
597
- jobId: number,
598
- entries: LogEntry[],
599
- repo: string | null,
600
- ) {
601
- const jobs = yield* listJobs(runId, repo);
602
- const job = jobs.find((j) => j.databaseId === jobId);
603
- if (!job) return entries;
604
-
605
- const failedStepNames = new Set(
606
- job.steps.filter((s) => s.conclusion === "failure").map((s) => s.name),
607
- );
608
-
609
- if (failedStepNames.size === 0) return entries;
610
-
611
- const filtered = entries.filter((e) => failedStepNames.has(e.step));
612
- return filtered.length > 0 ? filtered : entries;
613
- });
614
-
615
661
  export const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
616
662
  runId: number;
617
663
  job: string;
618
664
  jobId?: number | null;
619
665
  failedStepNames?: readonly string[] | null;
666
+ steps?: ReadonlyArray<StepTiming>;
620
667
  failedStepsOnly: boolean;
621
668
  diagnose?: boolean;
622
669
  format: string;
@@ -646,30 +693,33 @@ export const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts:
646
693
 
647
694
  const jobId = opts.jobId ?? (yield* resolveJobId(opts.runId, opts.job, opts.repo));
648
695
 
649
- const apiLogs = Effect.suspend(() =>
650
- gh
651
- .runGh(["api", `repos/${owner}/${repoName}/actions/jobs/${jobId}/logs`])
652
- .pipe(Effect.map((r) => r.stdout)),
696
+ // `gh run view --log` can omit lines, so the job log endpoint is the primary source.
697
+ const apiLogs = readJobLogApi(`repos/${owner}/${repoName}/actions/jobs/${jobId}/logs`).pipe(
698
+ Effect.map((r) => r.stdout),
653
699
  );
654
700
  const cliLogs = fetchLogs(opts.runId, opts.failedStepsOnly, jobId, opts.repo).pipe(
655
701
  Effect.map((r) => r.log),
656
702
  );
657
- const raw = opts.failedStepsOnly
658
- ? yield* cliLogs.pipe(
659
- Effect.flatMap((log) => (log.trim() ? Effect.succeed(log) : apiLogs)),
660
- Effect.catchTag("GitHubCommandError", () => apiLogs),
661
- )
662
- : yield* apiLogs.pipe(Effect.catchTag("GitHubCommandError", () => cliLogs));
703
+ const raw = yield* apiLogs.pipe(Effect.catchTag("GitHubCommandError", () => cliLogs));
663
704
 
664
- let entries = parseRawJobLogs(raw);
705
+ const steps = !opts.failedStepsOnly
706
+ ? []
707
+ : (opts.steps ??
708
+ (yield* listJobs(opts.runId, opts.repo)).find((j) => j.databaseId === jobId)?.steps ??
709
+ []);
710
+ const attributed = parseAttributedJobLogs(raw, steps);
711
+ let entries: LogEntry[] = attributed.map(({ step, message }) => ({ step, message }));
665
712
 
666
713
  if (opts.failedStepsOnly) {
667
- if (Array.isArray(opts.failedStepNames) && opts.failedStepNames.length > 0) {
668
- const wanted = new Set(opts.failedStepNames);
669
- const filtered = entries.filter((e) => wanted.has(e.step));
670
- if (filtered.length > 0) entries = filtered;
671
- } else {
672
- entries = yield* filterFailedStepEntries(opts.runId, jobId, entries, opts.repo);
714
+ const wanted = new Set(
715
+ opts.failedStepNames?.length
716
+ ? opts.failedStepNames
717
+ : steps.filter((s) => s.conclusion === "failure").map((s) => s.name),
718
+ );
719
+ const untimed = steps.some((s) => wanted.has(s.name) && !s.startedAt);
720
+ const kept = attributed.filter((e) => wanted.has(e.step) || !e.established);
721
+ if (!untimed && kept.some((e) => wanted.has(e.step))) {
722
+ entries = kept.map(({ step, message }) => ({ step, message }));
673
723
  }
674
724
  }
675
725
 
@@ -6,6 +6,7 @@ export const retryTransient = <A, E, R>(opts: {
6
6
  attempt: () => Effect.Effect<A, E, R>;
7
7
  isTransient: (error: E) => boolean;
8
8
  maxRetries: number;
9
+ retryDelayMs?: (error: E, attempt: number, fallbackMs: number) => number;
9
10
  }): Effect.Effect<A, E, R> => {
10
11
  const loop = (attempt: number): Effect.Effect<A, E, R> =>
11
12
  opts
@@ -13,9 +14,12 @@ export const retryTransient = <A, E, R>(opts: {
13
14
  .pipe(
14
15
  Effect.catch((error: E) =>
15
16
  opts.isTransient(error) && attempt < opts.maxRetries
16
- ? Effect.sleep(Duration.millis(BASE_DELAY_MS * 2 ** attempt)).pipe(
17
- Effect.flatMap(() => loop(attempt + 1)),
18
- )
17
+ ? Effect.sleep(
18
+ Duration.millis(
19
+ opts.retryDelayMs?.(error, attempt, BASE_DELAY_MS * 2 ** attempt) ??
20
+ BASE_DELAY_MS * 2 ** attempt,
21
+ ),
22
+ ).pipe(Effect.flatMap(() => loop(attempt + 1)))
19
23
  : Effect.fail(error),
20
24
  ),
21
25
  );