@trim21/personal-pi-extensions 0.0.128 → 0.0.131

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/gh-readonly.ts +413 -336
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.128",
3
+ "version": "0.0.131",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -66,5 +66,5 @@
66
66
  "prettier --write"
67
67
  ]
68
68
  },
69
- "packageManager": "pnpm@11.18.0"
69
+ "packageManager": "pnpm@11.20.0"
70
70
  }
@@ -429,15 +429,22 @@ export function statusIcon(conclusion: string | null): string {
429
429
  /**
430
430
  * Extract step content from raw job log by matching step names to "Run " groups.
431
431
  *
432
- * User-defined steps (actions, shell commands) each emit a `##[group]Run <name>`
433
- * at depth 1. We match API step names against these group names by stripping the
434
- * "Run " / "Post Run " prefix and comparing the action name.
432
+ * Each top-level step emits a `##[group]Run <name>` / `##[group]Post Run <name>`
433
+ * marker at depth 1. Composite actions emit their internal steps as *additional*
434
+ * depth-1 groups *after* the composite's own `##[endgroup]` (e.g. the internal
435
+ * `Run actions/setup-python@…` groups inside `Run pypa/cibuildwheel@…`), so the
436
+ * log's "Run " groups are NOT one-per-step.
435
437
  *
436
- * This handles composite actions correctly: their internal actions produce extra
437
- * "Run " groups that don't match any API step name, so they are naturally skipped.
438
+ * To handle that we treat a group as an *anchor* only when its action name
439
+ * (after stripping the "Run "/"Post Run " prefix) matches a top-level API step
440
+ * name. Composite-action internals match no API step and are absorbed into the
441
+ * span of the enclosing step instead of truncating it.
438
442
  *
439
- * Step 1 ("Set up job") maps to everything before the first matched "Run "/"Post Run " group.
440
- * Steps 2+ map to the "Run "/"Post Run " group whose action name matches the step name.
443
+ * Step 1 ("Set up job") maps to everything before the first anchor group.
444
+ * Steps with an anchor map to the span from their anchor to the next anchor.
445
+ * Explicitly named steps that lack a "Run " prefix (e.g. a step named
446
+ * "Setup node" running actions/setup-node) are located between the previous
447
+ * and next anchor's groups.
441
448
  * Steps that were skipped and never executed return null.
442
449
  *
443
450
  * Returns null if no matching group is found.
@@ -452,34 +459,7 @@ export function extractStepFromLog(
452
459
 
453
460
  const lines = log.split("\n");
454
461
 
455
- // Step 1 ("Set up job"): everything before the first "Run " or "Post Run " group at depth 1
456
- if (stepNumber === 1) {
457
- let depth = 0;
458
- for (let i = 0; i < lines.length; i++) {
459
- const line = lines[i];
460
- if (line.includes("##[endgroup]")) {
461
- if (depth > 0) depth--;
462
- continue;
463
- }
464
- if (line.includes("##[group]")) {
465
- depth++;
466
- if (depth === 1) {
467
- const m = line.match(/##\[group\](.*)/);
468
- const name = m ? m[1].trim() : "";
469
- if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
470
- return lines.slice(0, i).join("\n").trimEnd();
471
- }
472
- }
473
- }
474
- }
475
- return lines.join("\n").trimEnd();
476
- }
477
-
478
- // Steps 2+: match "Run "/"Post Run " group by comparing the action name
479
- // (the part after "Run " or "Post Run " prefix)
480
- const stepAction = targetStep.name.replace(/^(Run |Post Run )/, "").trim();
481
-
482
- // Collect all "Run "/"Post Run " groups at depth 1
462
+ // Collect depth-1 "Run "/"Post Run " groups in log order.
483
463
  const groups: Array<{ line: number; action: string }> = [];
484
464
  let depth = 0;
485
465
  for (let i = 0; i < lines.length; i++) {
@@ -494,20 +474,386 @@ export function extractStepFromLog(
494
474
  const m = line.match(/##\[group\](.*)/);
495
475
  const name = m ? m[1].trim() : "";
496
476
  if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
497
- const action = name.replace(/^(Run |Post Run )/, "").trim();
498
- groups.push({ line: i, action });
477
+ groups.push({ line: i, action: name.replace(/^(Run |Post Run )/, "").trim() });
499
478
  }
500
479
  }
501
480
  }
502
481
  }
503
482
 
504
- // Find the matching group by action name
505
- const matchedIdx = groups.findIndex((g) => g.action === stepAction);
506
- if (matchedIdx === -1) return null;
483
+ // Step 1 ("Set up job"): everything before the first "Run "/"Post Run " group.
484
+ if (stepNumber === 1) {
485
+ return lines
486
+ .slice(0, groups[0]?.line ?? lines.length)
487
+ .join("\n")
488
+ .trimEnd();
489
+ }
490
+
491
+ // API steps that produce a "Run "/"Post Run " log group, in step order.
492
+ const runSteps = apiSteps
493
+ .filter((s) => /^(Run |Post Run )/.test(s.name))
494
+ .map((s) => ({ number: s.number, action: s.name.replace(/^(Run |Post Run )/, "").trim() }))
495
+ .sort((a, b) => a.number - b.number);
496
+
497
+ // Greedily assign each run step the first unclaimed group whose action name
498
+ // matches (log order). Leftover groups are composite-action internals.
499
+ const used = new Set<number>();
500
+ const stepToGroup = new Map<number, number>(); // api step number -> group index
501
+ for (const rs of runSteps) {
502
+ const gi = groups.findIndex((g, idx) => !used.has(idx) && g.action === rs.action);
503
+ if (gi !== -1) {
504
+ used.add(gi);
505
+ stepToGroup.set(rs.number, gi);
506
+ }
507
+ }
508
+
509
+ // Anchor sequence in log order.
510
+ const anchors = [...stepToGroup.entries()]
511
+ .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
512
+ .sort((a, b) => a.line - b.line);
513
+
514
+ // Direct anchor hit: span from this anchor to the next one.
515
+ const anchorIdx = anchors.findIndex((a) => a.stepNum === stepNumber);
516
+ if (anchorIdx !== -1) {
517
+ const start = anchors[anchorIdx].line;
518
+ const end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
519
+ return lines.slice(start, end).join("\n").trimEnd();
520
+ }
521
+
522
+ // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits in
523
+ // the gap between the previous and next anchors' groups. Take the first
524
+ // unclaimed group in that span.
525
+ const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
526
+ (acc, a) => (a.stepNum < stepNumber ? a : acc),
527
+ undefined,
528
+ );
529
+ const nextAnchor = anchors.find((a) => a.stepNum > stepNumber);
530
+
531
+ const spanStart = prevAnchor ? prevAnchor.line + 1 : 0;
532
+ const spanEnd = nextAnchor ? nextAnchor.line : lines.length;
533
+
534
+ for (let gi = 0; gi < groups.length; gi++) {
535
+ if (used.has(gi)) continue;
536
+ const g = groups[gi];
537
+ if (g.line >= spanStart && g.line < spanEnd) {
538
+ return lines.slice(g.line, spanEnd).join("\n").trimEnd();
539
+ }
540
+ }
541
+
542
+ return null;
543
+ }
544
+
545
+ // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
546
+
547
+ export interface CiLogsJob {
548
+ id: number;
549
+ name: string;
550
+ status: string;
551
+ conclusion: string | null;
552
+ steps: StepInfo[];
553
+ }
554
+
555
+ export type CiLogsResult = {
556
+ content: Array<{ type: "text"; text: string }>;
557
+ details: Record<string, unknown>;
558
+ };
559
+
560
+ /** GitHub Actions runner line prefix: `2026-08-05T16:35:50.8358826Z `. */
561
+ const RUNNER_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z /;
562
+ /** ANSI color escape sequences. */
563
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
564
+
565
+ /**
566
+ * Strip the runner framing from a step's raw log, leaving the command's own
567
+ * output as plain text: removes the per-line timestamp prefix, ANSI color
568
+ * escapes and `##[group]` / `##[endgroup]` marker lines. `##[error]` /
569
+ * `##[warning]` lines are kept — their message is part of the output.
570
+ */
571
+ export function cleanStepOutput(stepLog: string): string {
572
+ return stepLog
573
+ .split("\n")
574
+ .map((line) =>
575
+ line
576
+ .replace(/^\uFEFF/, "") // UTF-8 BOM on the first line
577
+ .replace(RUNNER_TIMESTAMP_RE, "")
578
+ .replace(ANSI_RE, "")
579
+ .replace(/\r$/, "")
580
+ .trimEnd(),
581
+ )
582
+ .filter((line) => !line.startsWith("##[group]") && !line.startsWith("##[endgroup]"))
583
+ .join("\n")
584
+ .trim();
585
+ }
586
+
587
+ export interface StepLogParams {
588
+ runId: string;
589
+ job?: string;
590
+ step: string;
591
+ offset?: number;
592
+ limit?: number;
593
+ }
594
+
595
+ /**
596
+ * Render the result of `read-github-ci-logs` for a single step: the step's
597
+ * complete log as plain text (no runner framing). `job` is required — a step
598
+ * only exists inside a specific job. `offset`/`limit` control the returned
599
+ * text. Pure — no network, no `gh`.
600
+ */
601
+ export async function renderStepLog(
602
+ params: StepLogParams,
603
+ jobs: CiLogsJob[],
604
+ fetchJobLog: (jobId: number) => Promise<string>,
605
+ onUpdate?: (msg: CiLogsResult) => void,
606
+ ): Promise<CiLogsResult> {
607
+ const { job, step, offset, limit } = params;
608
+
609
+ if (!job) {
610
+ return {
611
+ content: [{ type: "text", text: "`job` is required when fetching a step's logs." }],
612
+ details: {},
613
+ };
614
+ }
615
+
616
+ const isNumeric = /^\d+$/.test(job);
617
+ const targetJob = jobs.find((j) => (isNumeric ? String(j.id) === job : j.name === job));
618
+ if (!targetJob) {
619
+ return {
620
+ content: [
621
+ {
622
+ type: "text",
623
+ text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
624
+ },
625
+ ],
626
+ details: {},
627
+ };
628
+ }
629
+
630
+ if (targetJob.status === "queued") {
631
+ return {
632
+ content: [
633
+ {
634
+ type: "text",
635
+ text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
636
+ },
637
+ ],
638
+ details: {},
639
+ };
640
+ }
641
+
642
+ // Resolve step name → number
643
+ const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
644
+ if (!found) {
645
+ return {
646
+ content: [
647
+ {
648
+ type: "text",
649
+ text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
650
+ },
651
+ ],
652
+ details: {},
653
+ };
654
+ }
655
+ const stepNum = found.number;
656
+
657
+ if (stepNum < 1 || stepNum > targetJob.steps.length) {
658
+ return {
659
+ content: [
660
+ {
661
+ type: "text",
662
+ text: `Step ${stepNum} out of range. Job "${targetJob.name}" has ${targetJob.steps.length} steps (1-${targetJob.steps.length}).`,
663
+ },
664
+ ],
665
+ details: {},
666
+ };
667
+ }
668
+
669
+ onUpdate?.({
670
+ content: [{ type: "text", text: `Fetching logs for step ${stepNum}...` }],
671
+ details: {},
672
+ });
673
+
674
+ const rawLog = await fetchJobLog(targetJob.id);
675
+
676
+ const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
677
+ if (stepLog === null) {
678
+ return {
679
+ content: [
680
+ {
681
+ type: "text",
682
+ text: `Could not extract step ${stepNum} from job "${targetJob.name}" logs. The log may be malformed or empty. Try fetching without \`step\` to see the full job log.`,
683
+ },
684
+ ],
685
+ details: {},
686
+ };
687
+ }
688
+
689
+ const clean = cleanStepOutput(stepLog);
690
+
691
+ // Apply offset on the cleaned text, then truncate.
692
+ const totalLines = clean.split("\n").length;
693
+ let logToShow = clean;
694
+ let appliedOffset = false;
695
+ if (offset !== undefined && offset !== null && offset > 1) {
696
+ if (offset > totalLines) {
697
+ return {
698
+ content: [
699
+ {
700
+ type: "text",
701
+ text: `Offset ${offset} exceeds step log length (${totalLines} lines).`,
702
+ },
703
+ ],
704
+ details: {},
705
+ };
706
+ }
707
+ logToShow = clean
708
+ .split("\n")
709
+ .slice(offset - 1)
710
+ .join("\n");
711
+ appliedOffset = true;
712
+ }
713
+
714
+ const maxLines = limit ?? 500;
715
+ const maxBytes = 60 * 1024;
716
+ const { text, truncated: tr } = truncate(logToShow, maxLines, maxBytes);
717
+ const shownLines = text.split("\n").length;
718
+
719
+ return {
720
+ content: [{ type: "text", text }],
721
+ details: {
722
+ summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
723
+ truncated: tr,
724
+ job: {
725
+ name: targetJob.name,
726
+ conclusion: targetJob.conclusion,
727
+ steps: stepsDetail(targetJob, new Set([stepNum])),
728
+ },
729
+ totalLines,
730
+ shownLines,
731
+ offset: appliedOffset ? offset : undefined,
732
+ },
733
+ };
734
+ }
735
+
736
+ export interface JobLogsParams {
737
+ runId: string;
738
+ job?: string;
739
+ offset?: number;
740
+ limit?: number;
741
+ }
742
+
743
+ export interface JobLogsStep {
744
+ name: string;
745
+ output?: string;
746
+ }
747
+
748
+ export interface JobLogsOutput {
749
+ name: string;
750
+ steps: JobLogsStep[];
751
+ }
752
+
753
+ /**
754
+ * Render the result of `read-github-ci-logs` without a `step`: a JSON array of
755
+ * jobs `[{ name, steps: [{ name, output? }] }]`. Every step is listed by name;
756
+ * only failed steps carry an `output` (their log as plain text). `job` is an
757
+ * optional filter; `offset`/`limit` control the size of each `output` text.
758
+ * Pure — no network, no `gh`.
759
+ */
760
+ export async function renderJobLogs(
761
+ params: JobLogsParams,
762
+ jobs: CiLogsJob[],
763
+ fetchJobLog: (jobId: number) => Promise<string>,
764
+ ): Promise<CiLogsResult> {
765
+ const { job, offset, limit } = params;
766
+
767
+ if (!jobs || jobs.length === 0) {
768
+ return {
769
+ content: [{ type: "text", text: `No jobs found for run ${params.runId}` }],
770
+ details: {},
771
+ };
772
+ }
773
+
774
+ let targetJobs = jobs;
775
+ if (job) {
776
+ const isNumeric = /^\d+$/.test(job);
777
+ targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) === job : j.name === job));
778
+ if (targetJobs.length === 0) {
779
+ return {
780
+ content: [
781
+ {
782
+ type: "text",
783
+ text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
784
+ },
785
+ ],
786
+ details: {},
787
+ };
788
+ }
789
+ }
790
+
791
+ const output: JobLogsOutput[] = [];
792
+
793
+ for (const j of targetJobs) {
794
+ const steps: JobLogsStep[] = [];
795
+ let rawLog: string | null = null;
796
+
797
+ for (const s of j.steps) {
798
+ if (s.conclusion !== "failure") {
799
+ steps.push({ name: s.name });
800
+ continue;
801
+ }
802
+
803
+ try {
804
+ rawLog ??= await fetchJobLog(j.id);
805
+ const stepLog = extractStepFromLog(rawLog, s.number, j.steps);
806
+ if (!stepLog) {
807
+ steps.push({ name: s.name });
808
+ continue;
809
+ }
810
+
811
+ const clean = cleanStepOutput(stepLog);
812
+ const totalLines = clean.split("\n").length;
507
813
 
508
- const start = groups[matchedIdx].line;
509
- const end = matchedIdx + 1 < groups.length ? groups[matchedIdx + 1].line : lines.length;
510
- return lines.slice(start, end).join("\n").trimEnd();
814
+ // Apply offset on the cleaned text, then truncate.
815
+ let logToShow = clean;
816
+ if (offset !== undefined && offset !== null && offset > 1) {
817
+ if (offset > totalLines) {
818
+ steps.push({ name: s.name });
819
+ continue;
820
+ }
821
+ logToShow = clean
822
+ .split("\n")
823
+ .slice(offset - 1)
824
+ .join("\n");
825
+ }
826
+
827
+ const { text } = truncate(logToShow, limit ?? 500, 60 * 1024);
828
+ steps.push({ name: s.name, ...(text ? { output: text } : {}) });
829
+ } catch {
830
+ // Log fetch failed — list the step without an output.
831
+ steps.push({ name: s.name });
832
+ }
833
+ }
834
+
835
+ output.push({ name: j.name, steps });
836
+ }
837
+
838
+ const totalJobs = output.length;
839
+ const failedJobs = output.filter((j) => j.steps.some((s) => s.output !== undefined)).length;
840
+ const totalFailedSteps = output.reduce(
841
+ (acc, j) => acc + j.steps.filter((s) => s.output !== undefined).length,
842
+ 0,
843
+ );
844
+
845
+ return {
846
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
847
+ details: {
848
+ summary: `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${totalFailedSteps} failed step${totalFailedSteps > 1 ? "s" : ""}`,
849
+ truncated: undefined,
850
+ jobs: targetJobs.map((j) => ({
851
+ name: j.name,
852
+ conclusion: j.conclusion,
853
+ steps: stepsDetail(j, undefined),
854
+ })),
855
+ },
856
+ };
511
857
  }
512
858
 
513
859
  // ── tools ────────────────────────────────────────────────────────────────────
@@ -777,7 +1123,7 @@ export default function (pi: ExtensionAPI) {
777
1123
  name: "read-github-ci-logs",
778
1124
  label: "GitHub CI Logs",
779
1125
  description:
780
- "Get CI logs from a GitHub Actions workflow run. Without step: shows a summary of jobs and steps with their statuses. With step: returns logs for that specific step only, supports offset/limit for long steps. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1126
+ "Get CI logs from a GitHub Actions workflow run. Without step: returns a JSON array of jobs [{name, steps:[{name, output?}]}] where every step is listed by name and failed steps carry their log as plain text in `output`. With step (requires job): returns that step's complete log as plain text. offset/limit control the size of every expanded output. Use run_id from list-github-workflow-runs. Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
781
1127
  promptSnippet: "Read GitHub CI logs",
782
1128
  parameters: Type.Object({
783
1129
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
@@ -785,330 +1131,61 @@ export default function (pi: ExtensionAPI) {
785
1131
  job: Type.Optional(
786
1132
  Type.String({
787
1133
  description:
788
- "Job name or ID. Required when multiple jobs exist and fetching step logs. Optional when showing summary (filters to that job).",
1134
+ "Job name or ID. Optional filter when listing jobs; required when fetching a specific step's logs.",
789
1135
  }),
790
1136
  ),
791
1137
  step: Type.Optional(
792
1138
  Type.String({
793
1139
  description:
794
- "Step name to fetch logs for (from summary table). Omit to show job/step summary instead of raw logs.",
1140
+ "Step name to fetch the complete log for. Requires `job`. Omit to list jobs/steps with failed step logs expanded.",
795
1141
  }),
796
1142
  ),
797
1143
  offset: Type.Optional(
798
1144
  Type.Number({
799
1145
  description:
800
- "Line number to start reading from within the step's log (1-indexed). Useful for long steps where the error is at the end. Only meaningful with step.",
1146
+ "Line number to start each output text from (1-indexed). Useful for long outputs where the error is at the end.",
801
1147
  }),
802
1148
  ),
803
1149
  limit: Type.Optional(
804
1150
  Type.Number({
805
- description:
806
- "Maximum number of lines to return from the step's log. Only meaningful with step.",
1151
+ description: "Maximum number of lines per output text (default 500).",
807
1152
  }),
808
1153
  ),
809
1154
  }),
810
1155
  async execute(_id, params, signal, onUpdate, ctx) {
811
1156
  const { run_id, repo, job, step, offset, limit } = params;
812
1157
 
813
- // ── Fetch specific step logs ───────────────────────────────────────
814
- if (step !== undefined && step !== null) {
815
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
816
-
817
- const jobsOut = await ghExec(
818
- ["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`],
819
- { cwd: ctx.cwd, signal, input: params },
820
- );
821
- const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
822
-
823
- if (!jobs || jobs.length === 0) {
824
- return {
825
- content: [{ type: "text", text: `No jobs found for run ${run_id}` }],
826
- details: {},
827
- };
828
- }
829
-
830
- let targetJob: (typeof jobs)[0] | undefined;
831
- if (job) {
832
- const isNumeric = /^\d+$/.test(job);
833
- targetJob = jobs.find((j) => (isNumeric ? String(j.id) === job : j.name === job));
834
- if (!targetJob) {
835
- return {
836
- content: [
837
- {
838
- type: "text",
839
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
840
- },
841
- ],
842
- details: {},
843
- };
844
- }
845
- } else if (jobs.length === 1) {
846
- targetJob = jobs[0];
847
- } else {
848
- return {
849
- content: [
850
- {
851
- type: "text",
852
- text: `Multiple jobs found. Specify \`job\`: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
853
- },
854
- ],
855
- details: {},
856
- };
857
- }
858
-
859
- if (targetJob.status === "queued") {
860
- return {
861
- content: [
862
- {
863
- type: "text",
864
- text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
865
- },
866
- ],
867
- details: {},
868
- };
869
- }
1158
+ const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
1159
+ const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
1160
+ cwd: ctx.cwd,
1161
+ signal,
1162
+ input: params,
1163
+ });
1164
+ const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
870
1165
 
871
- // Resolve step name number
872
- const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
873
- if (!found) {
874
- return {
875
- content: [
876
- {
877
- type: "text",
878
- text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
879
- },
880
- ],
881
- details: {},
882
- };
883
- }
884
- const stepNum = found.number;
885
-
886
- if (stepNum < 1 || stepNum > targetJob.steps.length) {
887
- return {
888
- content: [
889
- {
890
- type: "text",
891
- text: `Step ${stepNum} out of range. Job "${targetJob.name}" has ${targetJob.steps.length} steps (1-${targetJob.steps.length}).`,
892
- },
893
- ],
894
- details: {},
895
- };
896
- }
1166
+ const fetchJobLog = (jobId: number): Promise<string> =>
1167
+ getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
897
1168
 
1169
+ // ── Fetch a specific step's logs (requires `job`) ─────────────────
1170
+ if (step !== undefined && step !== null) {
898
1171
  onUpdate?.({
899
- content: [{ type: "text", text: `Fetching logs for step ${stepNum}...` }],
1172
+ content: [{ type: "text", text: `Fetching job list...` }],
900
1173
  details: {},
901
1174
  });
902
-
903
- const rawLog = await getJobLog(
904
- String(run_id),
905
- targetJob.id,
906
- effectiveRepo,
907
- signal,
908
- ctx.cwd,
909
- params,
1175
+ return renderStepLog(
1176
+ { runId: String(run_id), job, step, offset, limit },
1177
+ jobs,
1178
+ fetchJobLog,
1179
+ onUpdate,
910
1180
  );
911
-
912
- const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
913
- if (stepLog === null) {
914
- return {
915
- content: [
916
- {
917
- type: "text",
918
- text: `Could not extract step ${stepNum} from job "${targetJob.name}" logs. The log may be malformed or empty. Try fetching without \`step\` to see the full job log.`,
919
- },
920
- ],
921
- details: {},
922
- };
923
- }
924
-
925
- // Calculate full step stats
926
- const totalLines = stepLog.split("\n").length;
927
-
928
- // Apply offset — slice lines before truncation
929
- let logToShow = stepLog;
930
- let appliedOffset = false;
931
- if (offset !== undefined && offset !== null && offset > 1) {
932
- if (offset > totalLines) {
933
- return {
934
- content: [
935
- {
936
- type: "text",
937
- text: `Offset ${offset} exceeds step log length (${totalLines} lines).`,
938
- },
939
- ],
940
- details: {},
941
- };
942
- }
943
- logToShow = stepLog
944
- .split("\n")
945
- .slice(offset - 1)
946
- .join("\n");
947
- appliedOffset = true;
948
- }
949
-
950
- const maxLines = limit ?? 3000;
951
- const maxBytes = 80 * 1024;
952
- const { text, truncated: tr } = truncate(logToShow, maxLines, maxBytes);
953
-
954
- const shownLines = text.split("\n").length;
955
- const stepName = targetJob.steps[stepNum - 1]?.name ?? `Step ${stepNum}`;
956
- const offsetNote = appliedOffset ? ` (lines ${offset!}-${offset! + shownLines - 1})` : "";
957
- const meta = [
958
- `## ${targetJob.name} / ${stepName} (step ${stepNum}${offsetNote})`,
959
- `Total: ${totalLines} lines | Shown: ${shownLines} lines${tr ? " (truncated)" : ""}`,
960
- ].join("\n");
961
-
962
- return {
963
- content: [
964
- { type: "text", text: meta },
965
- { type: "text", text },
966
- ],
967
- details: {
968
- summary: `Step ${stepNum} — ${targetJob.name} / ${stepName}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
969
- truncated: tr,
970
- job: {
971
- name: targetJob.name,
972
- conclusion: targetJob.conclusion,
973
- steps: stepsDetail(targetJob, new Set([stepNum])),
974
- },
975
- totalLines,
976
- shownLines,
977
- offset: appliedOffset ? offset : undefined,
978
- },
979
- };
980
1181
  }
981
1182
 
982
- // ── Show step summary ──────────────────────────────────────────────
1183
+ // ── List jobs/steps, with failed step logs expanded ────────────────
983
1184
  onUpdate?.({
984
1185
  content: [{ type: "text", text: `Fetching job list...` }],
985
1186
  details: {},
986
1187
  });
987
-
988
- const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
989
-
990
- const jobsOut = await ghExec(["api", `/repos/${effectiveRepo}/actions/runs/${run_id}/jobs`], {
991
- cwd: ctx.cwd,
992
- signal,
993
- input: params,
994
- });
995
- const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
996
-
997
- if (!jobs || jobs.length === 0) {
998
- return {
999
- content: [{ type: "text", text: `No jobs found for run ${run_id}` }],
1000
- details: {},
1001
- };
1002
- }
1003
-
1004
- let targetJobs = jobs;
1005
- if (job) {
1006
- const isNumeric = /^\d+$/.test(job);
1007
- targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) === job : j.name === job));
1008
- if (targetJobs.length === 0) {
1009
- return {
1010
- content: [
1011
- {
1012
- type: "text",
1013
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1014
- },
1015
- ],
1016
- details: {},
1017
- };
1018
- }
1019
- }
1020
-
1021
- let output = `## CI Summary for Run ${run_id}\n\n`;
1022
-
1023
- for (const j of targetJobs) {
1024
- const jIcon = statusIcon(j.conclusion);
1025
- output += `### ${jIcon} Job: \`${j.name}\` (id: ${j.id}) — ${j.conclusion ?? j.status}\n\n`;
1026
- output += `| Step# | Name | Status |\n|-------|------|--------|\n`;
1027
- for (const s of j.steps) {
1028
- const sIcon = statusIcon(s.conclusion);
1029
- output += `| ${s.number} | ${s.name} | ${sIcon} ${s.conclusion ?? s.status} |\n`;
1030
- }
1031
- output += `\n`;
1032
- }
1033
-
1034
- output += `---\n`;
1035
- output += `To view a specific step's logs, call again with \`step=<number>\` (and \`job="<name>"\` if multiple jobs).\n`;
1036
-
1037
- // ── Auto-include failed step logs ──────────────────────────────────
1038
- const contents: Array<{ type: "text"; text: string }> = [{ type: "text", text: output }];
1039
- let fetchedCount = 0;
1040
- const maxFailed = 5;
1041
- const expandedSteps = new Map<number, Set<number>>(); // jobId → step numbers
1042
-
1043
- for (const j of targetJobs) {
1044
- if (fetchedCount >= maxFailed) {
1045
- contents.push({
1046
- type: "text",
1047
- text: `(... ${maxFailed} failed step logs shown; use \`step\` to fetch more)`,
1048
- });
1049
- break;
1050
- }
1051
-
1052
- const failedSteps = j.steps.filter((s) => s.conclusion === "failure");
1053
- if (failedSteps.length === 0) continue;
1054
-
1055
- try {
1056
- const rawLog = await getJobLog(
1057
- String(run_id),
1058
- j.id,
1059
- effectiveRepo,
1060
- signal,
1061
- ctx.cwd,
1062
- params,
1063
- );
1064
-
1065
- for (const fs of failedSteps) {
1066
- if (fetchedCount >= maxFailed) break;
1067
- fetchedCount++;
1068
-
1069
- const stepLog = extractStepFromLog(rawLog, fs.number, j.steps);
1070
- if (!stepLog) continue;
1071
-
1072
- const totalLines = stepLog.split("\n").length;
1073
- const maxLines = 500; // tighter limit for auto-included logs
1074
- const { text: logText, truncated: logTr } = truncate(stepLog, maxLines, 60 * 1024);
1075
- const shownLines = logText.split("\n").length;
1076
- const trNote = logTr ? " (truncated)" : "";
1077
-
1078
- contents.push({
1079
- type: "text",
1080
- text: `\n### ❌ ${j.name} / ${fs.name} (step ${fs.number})\nTotal: ${totalLines} lines | Shown: ${shownLines} lines${trNote}\n`,
1081
- });
1082
- contents.push({ type: "text", text: logText });
1083
- if (!expandedSteps.has(j.id)) expandedSteps.set(j.id, new Set());
1084
- expandedSteps.get(j.id)!.add(fs.number);
1085
- }
1086
- } catch (err: unknown) {
1087
- const msg = err instanceof Error ? err.message : String(err);
1088
- contents.push({
1089
- type: "text",
1090
- text: `\n⚠️ Could not auto-fetch logs for ${j.name}: ${msg}`,
1091
- });
1092
- }
1093
- }
1094
-
1095
- const totalJobs = targetJobs.length;
1096
- const failedJobs = targetJobs.filter((j) => j.conclusion === "failure").length;
1097
- const totalFailedSteps = targetJobs.reduce(
1098
- (acc, j) => acc + j.steps.filter((s) => s.conclusion === "failure").length,
1099
- 0,
1100
- );
1101
- return {
1102
- content: contents,
1103
- details: {
1104
- summary: `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${totalFailedSteps} failed step${totalFailedSteps > 1 ? "s" : ""}`,
1105
- jobs: targetJobs.map((j) => ({
1106
- name: j.name,
1107
- conclusion: j.conclusion,
1108
- steps: stepsDetail(j, expandedSteps.get(j.id)),
1109
- })),
1110
- },
1111
- };
1188
+ return renderJobLogs({ runId: String(run_id), job, offset, limit }, jobs, fetchJobLog);
1112
1189
  },
1113
1190
  });
1114
1191