@trim21/personal-pi-extensions 0.1.542 → 0.1.546

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 +1 -1
  2. package/src/gh-readonly.ts +159 -611
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.542",
3
+ "version": "0.1.546",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -33,7 +33,7 @@ import { spawn } from "node:child_process";
33
33
  import { existsSync } from "node:fs";
34
34
  import { mkdir, readFile, writeFile } from "node:fs/promises";
35
35
  import { homedir } from "node:os";
36
- import { delimiter, dirname, join, resolve } from "node:path";
36
+ import { delimiter, dirname, join } from "node:path";
37
37
 
38
38
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
39
39
  import { Type } from "typebox";
@@ -266,6 +266,8 @@ const stepSchema = Type.Object({
266
266
 
267
267
  const jobRunSchema = Type.Object({
268
268
  id: Type.Number(),
269
+ run_id: Type.Number(),
270
+ run_url: Type.String(),
269
271
  name: Type.String(),
270
272
  status: Type.String(),
271
273
  conclusion: Type.Union([Type.String(), Type.Null()]),
@@ -401,44 +403,48 @@ export interface StepInfo {
401
403
  conclusion: string | null;
402
404
  }
403
405
 
404
- export interface JobInfo {
405
- id: number;
406
- name: string;
407
- conclusion: string | null;
408
- steps: StepInfo[];
409
- }
410
-
411
- /** Build GitHub-UI-style step list for details, marking expanded steps. */
412
- export function stepsDetail(
413
- job: JobInfo,
414
- expandedSteps?: Set<number>,
415
- ): { number: number; name: string; conclusion: string | null; expanded?: boolean }[] {
416
- return job.steps.map((s) => ({
417
- number: s.number,
418
- name: s.name,
419
- conclusion: s.conclusion,
420
- ...(expandedSteps?.has(s.number) && { expanded: true }),
421
- }));
422
- }
423
-
424
406
  // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
425
407
  // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
426
408
  const seq = createSeqState();
427
409
 
410
+ /**
411
+ * `OWNER/REPO` from a job's `run_url`
412
+ * (`https://api.github.com/repos/OWNER/REPO/actions/runs/123`). The path is
413
+ * parsed as a URL rather than pattern-matched, and GitHub canonicalizes the
414
+ * owner/repo casing in these fields — so this is the spelling to key the log
415
+ * cache on, independent of whatever `repo` the caller passed.
416
+ */
417
+ export function repoFromRunUrl(runUrl: string): string {
418
+ const segments = new URL(runUrl).pathname.split("/").filter(Boolean);
419
+ const reposAt = segments.indexOf("repos");
420
+ const ownerAndRepo = reposAt === -1 ? [] : segments.slice(reposAt + 1, reposAt + 3);
421
+ if (ownerAndRepo.length !== 2) {
422
+ throw new Error(`unexpected run_url (expected /repos/<owner>/<repo>/...): ${runUrl}`);
423
+ }
424
+ return ownerAndRepo.join("/");
425
+ }
426
+
427
+ /** Absolute path of the raw job log cache file written by `getJobLog`. */
428
+ export function jobLogPath(repo: string, runId: string, jobId: number): string {
429
+ const { owner, repo: name } = splitRepo(repo);
430
+ return join(homedir(), ".cache", "pi", "github", "ci-logs", owner, name, runId, `${jobId}.log`);
431
+ }
432
+
428
433
  async function getJobLog(
429
- runId: string,
430
- jobId: number,
431
- effectiveRepo: string,
434
+ job: CiLogsJob,
432
435
  signal: AbortSignal | undefined,
433
436
  cwd: string | undefined,
434
437
  input?: unknown,
435
438
  ): Promise<string> {
436
- const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
437
- const cacheFile = join(cacheDir, `${jobId}.log`);
439
+ // The log download only accepts a job id, and the cache is keyed on the
440
+ // canonical repo/run from the job itself, not on the caller's `repo` string.
441
+ const repo = repoFromRunUrl(job.run_url);
442
+ const cacheFile = jobLogPath(repo, String(job.run_id), job.id);
443
+ const cacheDir = dirname(cacheFile);
438
444
 
439
- // 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
445
+ // 同一 cache 文件的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
440
446
  // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
441
- return seq.execute(`${runId}:${jobId}`, async () => {
447
+ return seq.execute(cacheFile, async () => {
442
448
  // Check file cache
443
449
  try {
444
450
  return await readFile(cacheFile, "utf8");
@@ -447,13 +453,14 @@ async function getJobLog(
447
453
  }
448
454
 
449
455
  // `gh api` refuses to print responses that contain terminal escape
450
- // sequences unless `--allow-escape-sequences` is passed. Job logs are a
451
- // binary zip, so without this flag the download always fails with
456
+ // sequences unless `--allow-escape-sequences` is passed. Job logs carry
457
+ // ANSI color codes, so without this flag the download always fails with
452
458
  // "the response contains terminal escape sequences; pass
453
- // --allow-escape-sequences to output it anyway". The ANSI escapes are
454
- // stripped later by `cleanStepOutput`, so there is no injection surface.
459
+ // --allow-escape-sequences to output it anyway". The raw bytes are kept
460
+ // as-is (the file is the log exactly as GitHub delivers it); the tool never
461
+ // echoes them, and the TUI strips ANSI when rendering tool results.
455
462
  const log = await ghExec(
456
- ["api", "--allow-escape-sequences", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`],
463
+ ["api", "--allow-escape-sequences", `/repos/${repo}/actions/jobs/${job.id}/logs`],
457
464
  {
458
465
  cwd,
459
466
  signal,
@@ -516,8 +523,14 @@ export function statusIcon(conclusion: string | null): string {
516
523
  }
517
524
  }
518
525
 
526
+ /** A step's line span in the raw job log: 0-based `start`, exclusive `end`. */
527
+ export interface StepSpan {
528
+ start: number;
529
+ end: number;
530
+ }
531
+
519
532
  /**
520
- * Extract step content from raw job log by matching step names to "Run " groups.
533
+ * Locate a job's steps in its raw log by matching step names to "Run " groups.
521
534
  *
522
535
  * Each top-level step emits a `##[group]Run <name>` / `##[group]Post Run <name>`
523
536
  * marker at depth 1. Composite actions emit their internal steps as *additional*
@@ -535,18 +548,14 @@ export function statusIcon(conclusion: string | null): string {
535
548
  * Explicitly named steps that lack a "Run " prefix (e.g. a step named
536
549
  * "Setup node" running actions/setup-node) are located between the previous
537
550
  * and next anchor's groups.
538
- * Steps that were skipped and never executed return null.
539
- *
540
- * Returns null if no matching group is found.
551
+ * Steps that were skipped and never executed get no span.
541
552
  */
542
- export function extractStepFromLog(
553
+ export function stepLineSpans(
543
554
  log: string,
544
- stepNumber: number,
545
555
  apiSteps: { number: number; name: string }[],
546
- ): string | null {
547
- if (apiSteps.every((s) => s.number !== stepNumber)) return null;
548
-
556
+ ): Map<number, StepSpan> {
549
557
  const lines = log.split("\n");
558
+ const spans = new Map<number, StepSpan>();
550
559
 
551
560
  // Collect depth-1 "Run "/"Post Run " groups in log order.
552
561
  const groups: { line: number; action: string }[] = [];
@@ -568,14 +577,6 @@ export function extractStepFromLog(
568
577
  }
569
578
  }
570
579
 
571
- // Step 1 ("Set up job"): everything before the first "Run "/"Post Run " group.
572
- if (stepNumber === 1) {
573
- return lines
574
- .slice(0, groups[0]?.line ?? lines.length)
575
- .join("\n")
576
- .trimEnd();
577
- }
578
-
579
580
  // API steps that produce a "Run "/"Post Run " log group, in step order.
580
581
  const runSteps = apiSteps
581
582
  .filter((s) => /^(Run |Post Run )/.test(s.name))
@@ -599,40 +600,67 @@ export function extractStepFromLog(
599
600
  .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
600
601
  .toSorted((a, b) => a.line - b.line);
601
602
 
602
- // Direct anchor hit: span from this anchor to the next one.
603
- const anchorIdx = anchors.findIndex((a) => a.stepNum === stepNumber);
604
- if (anchorIdx !== -1) {
605
- const start = anchors[anchorIdx].line;
606
- const end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
607
- return lines.slice(start, end).join("\n").trimEnd();
608
- }
603
+ for (const s of apiSteps) {
604
+ let start: number;
605
+ let end: number;
609
606
 
610
- // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits in
611
- // the gap between the previous and next anchors' groups. Take the first
612
- // unclaimed group in that span.
613
- const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
614
- (acc, a) => (a.stepNum < stepNumber ? a : acc),
615
- undefined,
616
- );
617
- const nextAnchor = anchors.find((a) => a.stepNum > stepNumber);
618
-
619
- const spanStart = prevAnchor ? prevAnchor.line + 1 : 0;
620
- const spanEnd = nextAnchor ? nextAnchor.line : lines.length;
607
+ if (s.number === 1) {
608
+ // Step 1 ("Set up job"): everything before the first "Run " group.
609
+ start = 0;
610
+ end = groups[0]?.line ?? lines.length;
611
+ } else {
612
+ const anchorIdx = anchors.findIndex((a) => a.stepNum === s.number);
613
+ if (anchorIdx === -1) {
614
+ // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits
615
+ // in the gap between the previous and next anchors' groups. Take the
616
+ // first unclaimed group in that span.
617
+ const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
618
+ (acc, a) => (a.stepNum < s.number ? a : acc),
619
+ undefined,
620
+ );
621
+ const nextAnchor = anchors.find((a) => a.stepNum > s.number);
622
+ const gapStart = prevAnchor ? prevAnchor.line + 1 : 0;
623
+ const gapEnd = nextAnchor ? nextAnchor.line : lines.length;
621
624
 
622
- for (const [gi, g] of groups.entries()) {
623
- if (used.has(gi)) continue;
624
- if (g.line >= spanStart && g.line < spanEnd) {
625
- return lines.slice(g.line, spanEnd).join("\n").trimEnd();
625
+ const gi = groups.findIndex(
626
+ (g, idx) => !used.has(idx) && g.line >= gapStart && g.line < gapEnd,
627
+ );
628
+ if (gi === -1) continue; // step never ran → no block in the log
629
+ start = groups[gi].line;
630
+ end = gapEnd;
631
+ } else {
632
+ // Direct anchor hit: span from this anchor to the next one.
633
+ start = anchors[anchorIdx].line;
634
+ end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
635
+ }
626
636
  }
637
+
638
+ // Drop trailing blank lines so the span slices to the step's own text.
639
+ while (end > start && lines[end - 1].trim() === "") end--;
640
+ spans.set(s.number, { start, end });
627
641
  }
628
642
 
629
- return null;
643
+ return spans;
644
+ }
645
+
646
+ /** Text of `stepNumber` in its raw log, or null when the step never ran. */
647
+ export function extractStepFromLog(
648
+ log: string,
649
+ stepNumber: number,
650
+ apiSteps: { number: number; name: string }[],
651
+ ): string | null {
652
+ const span = stepLineSpans(log, apiSteps).get(stepNumber);
653
+ if (span === undefined) return null;
654
+ return log.split("\n").slice(span.start, span.end).join("\n").trimEnd();
630
655
  }
631
656
 
632
657
  // ── ci-logs rendering (pure, testable) ──────────────────────────────────────
633
658
 
634
659
  export interface CiLogsJob {
635
660
  id: number;
661
+ run_id: number;
662
+ /** Canonical `api.github.com/repos/<owner>/<repo>/actions/runs/<id>`. */
663
+ run_url: string;
636
664
  name: string;
637
665
  status: string;
638
666
  conclusion: string | null;
@@ -644,481 +672,50 @@ export interface CiLogsResult {
644
672
  details: Record<string, unknown>;
645
673
  }
646
674
 
647
- /** GitHub Actions runner line prefix: `2026-08-05T16:35:50.8358826Z `. */
648
- const RUNNER_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z /;
649
- /** ANSI color escape sequences. */
650
- // eslint-disable-next-line no-control-regex -- intentional: matching raw ESC sequences in runner logs
651
- const ANSI_RE = /\u001B\[[0-9;]*m/g;
652
-
653
- /**
654
- * Strip the runner framing from a step's raw log, leaving the command's own
655
- * output as plain text: removes the per-line timestamp prefix, ANSI color
656
- * escapes and `##[group]` / `##[endgroup]` marker lines. `##[error]` /
657
- * `##[warning]` lines are kept — their message is part of the output.
658
- */
659
- export function cleanStepOutput(stepLog: string): string {
660
- return stepLog
661
- .split("\n")
662
- .map((line) =>
663
- line
664
- .replace(/^\uFEFF/, "") // UTF-8 BOM on the first line
665
- .replace(RUNNER_TIMESTAMP_RE, "")
666
- .replaceAll(ANSI_RE, "")
667
- .replace(/\r$/, "")
668
- .trimEnd(),
669
- )
670
- .filter((line) => !line.startsWith("##[group]") && !line.startsWith("##[endgroup]"))
671
- .join("\n")
672
- .trim();
673
- }
674
-
675
- /**
676
- * Strip terminal escape sequences and a leading UTF-8 BOM from a raw job log,
677
- * keeping everything else — timestamps, `##[group]` markers, blank lines —
678
- * intact. Used when writing a job's complete log to a file: complete, but
679
- * readable without ANSI garbage.
680
- */
681
- export function stripAnsi(text: string): string {
682
- return text.replace(/^\uFEFF/, "").replaceAll(ANSI_RE, "");
683
- }
684
-
685
- export interface StepLogParams {
686
- runId: string;
687
- job?: string;
688
- step: string;
689
- offset?: number;
690
- limit?: number;
691
- /** Return the complete, untruncated step output (ignores `offset`/`limit`). */
692
- full?: boolean;
693
- }
694
-
695
- /**
696
- * Render the result of `read-github-ci-logs` for a single step: the step's
697
- * complete log as plain text (no runner framing). `job` is required — a step
698
- * only exists inside a specific job. `offset`/`limit` control the returned
699
- * text. Pure — no network, no `gh`.
700
- */
701
- export async function renderStepLog(
702
- params: StepLogParams,
703
- jobs: CiLogsJob[],
704
- fetchJobLog: (jobId: number) => Promise<string>,
705
- onUpdate?: (msg: CiLogsResult) => void,
706
- ): Promise<CiLogsResult> {
707
- const { job, step, offset, limit, full } = params;
708
-
709
- if (!job) {
710
- return {
711
- content: [{ type: "text", text: "`job` is required when fetching a step's logs." }],
712
- details: {},
713
- };
714
- }
715
-
716
- const isNumeric = /^\d+$/.test(job);
717
- const targetJob = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
718
- if (!targetJob) {
719
- return {
720
- content: [
721
- {
722
- type: "text",
723
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
724
- },
725
- ],
726
- details: {},
727
- };
728
- }
729
-
730
- if (targetJob.status === "queued") {
731
- return {
732
- content: [
733
- {
734
- type: "text",
735
- text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
736
- },
737
- ],
738
- details: {},
739
- };
740
- }
741
-
742
- // Resolve step name → number
743
- const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
744
- if (!found) {
745
- return {
746
- content: [
747
- {
748
- type: "text",
749
- text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
750
- },
751
- ],
752
- details: {},
753
- };
754
- }
755
- const stepNum = found.number;
756
-
757
- if (stepNum < 1 || stepNum > targetJob.steps.length) {
758
- return {
759
- content: [
760
- {
761
- type: "text",
762
- text: `Step ${stepNum} out of range. Job "${targetJob.name}" has ${targetJob.steps.length} steps (1-${targetJob.steps.length}).`,
763
- },
764
- ],
765
- details: {},
766
- };
767
- }
768
-
769
- onUpdate?.({
770
- content: [{ type: "text", text: `Fetching logs for step ${stepNum}...` }],
771
- details: {},
772
- });
773
-
774
- const rawLog = await fetchJobLog(targetJob.id);
775
-
776
- const stepLog = extractStepFromLog(rawLog, stepNum, targetJob.steps);
777
- if (stepLog === null) {
778
- return {
779
- content: [
780
- {
781
- type: "text",
782
- 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.`,
783
- },
784
- ],
785
- details: {},
786
- };
787
- }
788
-
789
- const clean = cleanStepOutput(stepLog);
790
-
791
- // `full`: return the complete output, no truncation and no offset.
792
- if (full) {
793
- const fullLines = clean.split("\n").length;
794
- return {
795
- content: [{ type: "text", text: clean }],
796
- details: {
797
- summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: complete output (${fullLines} lines)`,
798
- truncated: false,
799
- full: true,
800
- job: {
801
- name: targetJob.name,
802
- conclusion: targetJob.conclusion,
803
- steps: stepsDetail(targetJob, new Set([stepNum])),
804
- },
805
- totalLines: fullLines,
806
- shownLines: fullLines,
807
- },
808
- };
809
- }
810
-
811
- // Apply offset on the cleaned text, then truncate.
812
- const totalLines = clean.split("\n").length;
813
- let logToShow = clean;
814
- let appliedOffset = false;
815
- if (offset !== undefined && offset > 1) {
816
- if (offset > totalLines) {
817
- return {
818
- content: [
819
- {
820
- type: "text",
821
- text: `Offset ${offset} exceeds step log length (${totalLines} lines).`,
822
- },
823
- ],
824
- details: {},
825
- };
826
- }
827
- logToShow = clean
828
- .split("\n")
829
- .slice(offset - 1)
830
- .join("\n");
831
- appliedOffset = true;
832
- }
833
-
834
- const maxLines = limit ?? 500;
835
- const maxBytes = 60 * 1024;
836
- const { text, truncated: tr } = truncate(logToShow, maxLines, maxBytes);
837
- const shownLines = text.split("\n").length;
838
-
839
- return {
840
- content: [{ type: "text", text }],
841
- details: {
842
- summary: `Step ${stepNum} — ${targetJob.name} / ${found.name}: ${shownLines} of ${totalLines} lines${tr ? " (truncated)" : ""}`,
843
- truncated: tr,
844
- job: {
845
- name: targetJob.name,
846
- conclusion: targetJob.conclusion,
847
- steps: stepsDetail(targetJob, new Set([stepNum])),
848
- },
849
- totalLines,
850
- shownLines,
851
- offset: appliedOffset ? offset : undefined,
852
- },
853
- };
854
- }
855
-
856
- export interface JobLogsParams {
857
- runId: string;
858
- job?: string;
859
- offset?: number;
860
- limit?: number;
861
- /** Expand every step's complete output (default: only failed steps, truncated). */
862
- full?: boolean;
863
- }
864
-
865
- export interface JobLogsStep {
675
+ /** One step of a job, indexed into the job's raw log file. */
676
+ export interface CiLogsStepIndex {
677
+ number: number;
866
678
  name: string;
867
- output?: string;
679
+ conclusion: string | null;
680
+ /** 1-based inclusive line range of this step's block in `log_file`. */
681
+ start_line?: number;
682
+ end_line?: number;
868
683
  }
869
684
 
870
- export interface JobLogsOutput {
685
+ /** A job's steps plus the raw log file holding their output. */
686
+ export interface CiLogsJobIndex {
871
687
  name: string;
872
- steps: JobLogsStep[];
688
+ id: number;
689
+ status: string;
690
+ conclusion: string | null;
691
+ log_file: string;
692
+ steps: CiLogsStepIndex[];
873
693
  }
874
694
 
875
695
  /**
876
- * Render the result of `read-github-ci-logs` without a `step`: a JSON array of
877
- * jobs `[{ name, steps: [{ name, output? }] }]`. Every step is listed by name;
878
- * only failed steps carry an `output` (their log as plain text). `job` is an
879
- * optional filter; `offset`/`limit` control the size of each `output` text.
880
- * Pure — no network, no `gh`.
696
+ * Index a job's steps into its raw log: every step that produced a log block
697
+ * gets the `[start_line, end_line]` range (1-based, inclusive) of that block in
698
+ * `log_file`; steps that never ran (skipped, or absent from the log) carry no
699
+ * range. The step content itself is not returned — the model reads it out of
700
+ * the file.
881
701
  */
882
- export async function renderJobLogs(
883
- params: JobLogsParams,
884
- jobs: CiLogsJob[],
885
- fetchJobLog: (jobId: number) => Promise<string>,
886
- ): Promise<CiLogsResult> {
887
- const { job, offset, limit, full } = params;
888
-
889
- if (jobs.length === 0) {
890
- return {
891
- content: [{ type: "text", text: `No jobs found for run ${params.runId}` }],
892
- details: {},
893
- };
894
- }
895
-
896
- let targetJobs = jobs;
897
- if (job) {
898
- const isNumeric = /^\d+$/.test(job);
899
- targetJobs = jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job);
900
- if (targetJobs.length === 0) {
901
- return {
902
- content: [
903
- {
904
- type: "text",
905
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
906
- },
907
- ],
908
- details: {},
909
- };
910
- }
911
- }
912
-
913
- const output: JobLogsOutput[] = [];
914
-
915
- for (const j of targetJobs) {
916
- const steps: JobLogsStep[] = [];
917
- let rawLog: string | null = null;
918
-
919
- for (const s of j.steps) {
920
- if (!full && s.conclusion !== "failure") {
921
- steps.push({ name: s.name });
922
- continue;
923
- }
924
-
925
- try {
926
- rawLog ??= await fetchJobLog(j.id);
927
- const stepLog = extractStepFromLog(rawLog, s.number, j.steps);
928
- if (!stepLog) {
929
- steps.push({ name: s.name });
930
- continue;
931
- }
932
-
933
- const clean = cleanStepOutput(stepLog);
934
-
935
- // `full`: every step carries its complete, untruncated output.
936
- if (full) {
937
- steps.push({ name: s.name, output: clean });
938
- continue;
939
- }
940
-
941
- const totalLines = clean.split("\n").length;
942
-
943
- // Apply offset on the cleaned text, then truncate.
944
- let logToShow = clean;
945
- if (offset !== undefined && offset > 1) {
946
- if (offset > totalLines) {
947
- steps.push({ name: s.name });
948
- continue;
949
- }
950
- logToShow = clean
951
- .split("\n")
952
- .slice(offset - 1)
953
- .join("\n");
954
- }
955
-
956
- const { text } = truncate(logToShow, limit ?? 500, 60 * 1024);
957
- steps.push({ name: s.name, ...(text && { output: text }) });
958
- } catch {
959
- // Log fetch failed — list the step without an output.
960
- steps.push({ name: s.name });
961
- }
962
- }
963
-
964
- output.push({ name: j.name, steps });
965
- }
966
-
967
- const totalJobs = output.length;
968
- const failedJobs = output.filter((j) => j.steps.some((s) => s.output !== undefined)).length;
969
- const expandedSteps = output.reduce(
970
- (acc, j) => acc + j.steps.filter((s) => s.output !== undefined).length,
971
- 0,
972
- );
973
-
702
+ export function jobLogIndex(job: CiLogsJob, rawLog: string): CiLogsJobIndex {
703
+ const spans = stepLineSpans(rawLog, job.steps);
974
704
  return {
975
- content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
976
- details: {
977
- summary: full
978
- ? `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${expandedSteps} step output${expandedSteps === 1 ? "" : "s"} expanded (full, untruncated)`
979
- : `${totalJobs} job${totalJobs > 1 ? "s" : ""}, ${failedJobs} failed, ${expandedSteps} failed step${expandedSteps > 1 ? "s" : ""}`,
980
- truncated: undefined,
981
- ...(full && { full: true }),
982
- jobs: targetJobs.map((j) => ({
983
- name: j.name,
984
- conclusion: j.conclusion,
985
- steps: stepsDetail(j, undefined),
986
- })),
987
- },
988
- };
989
- }
990
-
991
- // ── writing complete logs to a file ─────────────────────────────────────────
992
-
993
- export interface WriteLogFileParams {
994
- runId: string;
995
- job?: string;
996
- step?: string;
997
- outputFile: string;
998
- }
999
-
1000
- /**
1001
- * Write the complete log to a file and return metadata (path, line/byte
1002
- * counts) instead of the log content itself. With `step`: the step's cleaned
1003
- * output. Without `step`: the whole job's log, timestamps and `##[group]`
1004
- * markers kept but ANSI escapes stripped. `job` is required when the run has
1005
- * more than one job (a single-job run is used implicitly). Relative
1006
- * `outputFile` paths resolve against `cwd`.
1007
- */
1008
- export async function writeLogFile(
1009
- params: WriteLogFileParams,
1010
- jobs: CiLogsJob[],
1011
- fetchJobLog: (jobId: number) => Promise<string>,
1012
- cwd: string | undefined,
1013
- input: unknown,
1014
- ): Promise<CiLogsResult> {
1015
- const { job, step, outputFile } = params;
1016
-
1017
- const isNumeric = /^\d+$/.test(job ?? "");
1018
- const targetJobs = job ? jobs.filter((j) => (isNumeric ? String(j.id) : j.name) === job) : jobs;
1019
- if (targetJobs.length === 0) {
1020
- return {
1021
- content: [
1022
- {
1023
- type: "text",
1024
- text: `Job "${job}" not found. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1025
- },
1026
- ],
1027
- details: { input },
1028
- };
1029
- }
1030
- if (targetJobs.length > 1) {
1031
- return {
1032
- content: [
1033
- {
1034
- type: "text",
1035
- text: `Job "${job}" matches ${targetJobs.length} jobs. Specify a unique job name or id. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1036
- },
1037
- ],
1038
- details: { input },
1039
- };
1040
- }
1041
- const targetJob = targetJobs[0];
1042
-
1043
- if (targetJob.status === "queued") {
1044
- return {
1045
- content: [
1046
- {
1047
- type: "text",
1048
- text: `Job "${targetJob.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1049
- },
1050
- ],
1051
- details: { input },
1052
- };
1053
- }
1054
-
1055
- const rawLog = await fetchJobLog(targetJob.id);
1056
-
1057
- let content: string;
1058
- let what: string;
1059
- if (step === undefined) {
1060
- content = stripAnsi(rawLog);
1061
- what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1062
- } else {
1063
- const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
1064
- if (found === undefined) {
1065
- return {
1066
- content: [
1067
- {
1068
- type: "text",
1069
- text: `Step "${step}" not found. Available: ${targetJob.steps.map((s) => `${s.name} (${s.number})`).join(", ")}`,
1070
- },
1071
- ],
1072
- details: { input },
1073
- };
1074
- }
1075
- const stepLog = extractStepFromLog(rawLog, found.number, targetJob.steps);
1076
- if (stepLog === null) {
705
+ name: job.name,
706
+ id: job.id,
707
+ status: job.status,
708
+ conclusion: job.conclusion,
709
+ log_file: jobLogPath(repoFromRunUrl(job.run_url), String(job.run_id), job.id),
710
+ steps: job.steps.map((s) => {
711
+ const span = spans.get(s.number);
1077
712
  return {
1078
- content: [
1079
- {
1080
- type: "text",
1081
- text: `Could not extract step ${found.number} from job "${targetJob.name}" logs. The log may be malformed or empty.`,
1082
- },
1083
- ],
1084
- details: { input },
713
+ number: s.number,
714
+ name: s.name,
715
+ conclusion: s.conclusion,
716
+ ...(span && span.end > span.start && { start_line: span.start + 1, end_line: span.end }),
1085
717
  };
1086
- }
1087
- content = cleanStepOutput(stepLog);
1088
- what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
1089
- }
1090
-
1091
- const target = resolve(cwd ?? process.cwd(), outputFile);
1092
- await mkdir(dirname(target), { recursive: true });
1093
- await writeFile(target, content);
1094
-
1095
- const lines = content.split("\n").length;
1096
- const bytes = Buffer.byteLength(content, "utf8");
1097
-
1098
- return {
1099
- content: [
1100
- {
1101
- type: "text",
1102
- text:
1103
- `## CI log written to \`${target}\`\n\n` +
1104
- `- content: ${what}\n` +
1105
- `- ${lines} lines, ${bytes} bytes\n` +
1106
- `- run: ${params.runId}\n\n` +
1107
- `Read it with the \`read\` tool (use \`offset\`/\`limit\` for large files).`,
1108
- },
1109
- ],
1110
- details: {
1111
- outputFile: target,
1112
- lines,
1113
- bytes,
1114
- runId: params.runId,
1115
- job: {
1116
- name: targetJob.name,
1117
- id: targetJob.id,
1118
- conclusion: targetJob.conclusion,
1119
- },
1120
- input,
1121
- },
718
+ }),
1122
719
  };
1123
720
  }
1124
721
 
@@ -1856,49 +1453,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1856
1453
  name: "read-github-ci-logs",
1857
1454
  label: "GitHub CI Logs",
1858
1455
  description:
1859
- "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. Set full=true for complete untruncated outputs (every step when step is omitted; caution: very large outputs consume a lot of LLM context). Set output_file=/path to write the complete log to a file instead of returning it (requires job when the run has multiple jobs); the tool returns the file path to read.",
1456
+ "Download one GitHub Actions job's CI log and index its steps. Returns JSON {name, id, status, conclusion, log_file, steps:[{number, name, conclusion, start_line?, end_line?}]}: `log_file` is the job's complete raw log on disk (runner timestamps and ANSI kept, exactly as GitHub delivers it) and each step carries the 1-based inclusive line range of its block inside that file. Read the content out of the file yourself (read/grep with offset/limit) — it is not echoed back. Get `job` names/ids from read-github-workflow-jobs." +
1457
+ " Note: queued jobs have no logs yet; use watch-github-run to wait for completion.",
1860
1458
  promptSnippet: "Read GitHub CI logs",
1861
1459
  parameters: Type.Object({
1862
1460
  run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
1863
1461
  repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
1864
- job: Type.Optional(
1865
- Type.String({
1866
- description:
1867
- "Job name or ID. Optional filter when listing jobs; required when fetching a specific step's logs.",
1868
- }),
1869
- ),
1870
- step: Type.Optional(
1871
- Type.String({
1872
- description:
1873
- "Step name to fetch the complete log for. Requires `job`. Omit to list jobs/steps with failed step logs expanded.",
1874
- }),
1875
- ),
1876
- offset: Type.Optional(
1877
- Type.Number({
1878
- description:
1879
- "Line number to start each output text from (1-indexed). Useful for long outputs where the error is at the end.",
1880
- }),
1881
- ),
1882
- limit: Type.Optional(
1883
- Type.Number({
1884
- description: "Maximum number of lines per output text (default 500).",
1885
- }),
1886
- ),
1887
- full: Type.Optional(
1888
- Type.Boolean({
1889
- description:
1890
- "Return complete, untruncated output instead of the default 500-line/60KB cap. With `step`: that step's full output. Without `step`: every step's full output (not just failed ones). Ignored when `output_file` is set. Caution: very large outputs consume a lot of LLM context — prefer `output_file` for big logs.",
1891
- }),
1892
- ),
1893
- output_file: Type.Optional(
1894
- Type.String({
1895
- description:
1896
- "Write the complete log to this file instead of returning it (relative paths resolve against the working directory). With `step` (requires `job`): the step's cleaned output. Without `step`: requires `job` (or a run with a single job) and writes that job's full log — timestamps and group markers kept, ANSI escapes stripped. Returns the file path; read it with the `read` tool.",
1897
- }),
1898
- ),
1462
+ job: Type.String({ description: "Job name or job ID, from read-github-workflow-jobs." }),
1899
1463
  }),
1900
1464
  async execute(_id, params, signal, onUpdate, ctx) {
1901
- const { run_id, repo, job, step, offset, limit, full, output_file } = params;
1465
+ const { run_id, repo, job } = params;
1466
+ const runId = String(run_id);
1902
1467
 
1903
1468
  const pendant = subtitlePendant(params, "run_id");
1904
1469
  const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
@@ -1909,52 +1474,35 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1909
1474
  });
1910
1475
  const { jobs } = Value.Parse(jobsResponseSchema, JSON.parse(jobsOut));
1911
1476
 
1912
- const fetchJobLog = (jobId: number): Promise<string> =>
1913
- getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
1914
-
1915
- // ── Write the complete log to a file ───────────────────────────────
1916
- if (output_file !== undefined && output_file !== "") {
1917
- const result = await writeLogFile(
1918
- { runId: String(run_id), job, step, outputFile: output_file },
1919
- jobs,
1920
- fetchJobLog,
1921
- ctx.cwd,
1922
- params,
1477
+ const failure = (text: string): CiLogsResult => ({
1478
+ content: [{ type: "text", text }],
1479
+ details: { input: params, ...(pendant && { pendant }) },
1480
+ });
1481
+
1482
+ const isNumeric = /^\d+$/.test(job);
1483
+ const target = jobs.find((j) => (isNumeric ? String(j.id) : j.name) === job);
1484
+ if (target === undefined) {
1485
+ return failure(
1486
+ `Job "${job}" not found in run ${runId}. Available: ${jobs.map((j) => `${j.name} (id: ${j.id})`).join(", ")}`,
1923
1487
  );
1924
- return { ...result, details: { ...result.details, ...(pendant && { pendant }) } };
1925
1488
  }
1926
-
1927
- // ── Fetch a specific step's logs (requires `job`) ─────────────────
1928
- if (step !== undefined) {
1929
- onUpdate?.({
1930
- content: [{ type: "text", text: `Fetching job list...` }],
1931
- details: {},
1932
- });
1933
- const stepResult = await renderStepLog(
1934
- { runId: String(run_id), job, step, offset, limit, full },
1935
- jobs,
1936
- fetchJobLog,
1937
- onUpdate,
1489
+ if (target.status === "queued") {
1490
+ return failure(
1491
+ `Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
1938
1492
  );
1939
- return {
1940
- ...stepResult,
1941
- details: { ...stepResult.details, input: params, ...(pendant && { pendant }) },
1942
- };
1943
1493
  }
1944
1494
 
1945
- // ── List jobs/steps, with failed step logs expanded ────────────────
1946
1495
  onUpdate?.({
1947
- content: [{ type: "text", text: `Fetching job list...` }],
1496
+ content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
1948
1497
  details: {},
1949
1498
  });
1950
- const jobsResult = await renderJobLogs(
1951
- { runId: String(run_id), job, offset, limit, full },
1952
- jobs,
1953
- fetchJobLog,
1954
- );
1499
+
1500
+ const rawLog = await getJobLog(target, signal, ctx.cwd, params);
1501
+ const index = jobLogIndex(target, rawLog);
1502
+
1955
1503
  return {
1956
- ...jobsResult,
1957
- details: { ...jobsResult.details, input: params, ...(pendant && { pendant }) },
1504
+ content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
1505
+ details: { ...index, input: params, ...(pendant && { pendant }) },
1958
1506
  };
1959
1507
  },
1960
1508
  });