@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.
- package/package.json +1 -1
- package/src/gh-readonly.ts +159 -611
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
// 同一
|
|
445
|
+
// 同一 cache 文件的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
|
|
440
446
|
// 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
|
|
441
|
-
return seq.execute(
|
|
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
|
|
451
|
-
//
|
|
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
|
|
454
|
-
//
|
|
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/${
|
|
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
|
-
*
|
|
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
|
|
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
|
|
553
|
+
export function stepLineSpans(
|
|
543
554
|
log: string,
|
|
544
|
-
stepNumber: number,
|
|
545
555
|
apiSteps: { number: number; name: string }[],
|
|
546
|
-
):
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
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
|
-
/**
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
+
/** A job's steps plus the raw log file holding their output. */
|
|
686
|
+
export interface CiLogsJobIndex {
|
|
871
687
|
name: string;
|
|
872
|
-
|
|
688
|
+
id: number;
|
|
689
|
+
status: string;
|
|
690
|
+
conclusion: string | null;
|
|
691
|
+
log_file: string;
|
|
692
|
+
steps: CiLogsStepIndex[];
|
|
873
693
|
}
|
|
874
694
|
|
|
875
695
|
/**
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
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
|
|
883
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
"
|
|
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.
|
|
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
|
|
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
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
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
|
|
1496
|
+
content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
|
|
1948
1497
|
details: {},
|
|
1949
1498
|
});
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
);
|
|
1499
|
+
|
|
1500
|
+
const rawLog = await getJobLog(target, signal, ctx.cwd, params);
|
|
1501
|
+
const index = jobLogIndex(target, rawLog);
|
|
1502
|
+
|
|
1955
1503
|
return {
|
|
1956
|
-
|
|
1957
|
-
details: { ...
|
|
1504
|
+
content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
|
|
1505
|
+
details: { ...index, input: params, ...(pendant && { pendant }) },
|
|
1958
1506
|
};
|
|
1959
1507
|
},
|
|
1960
1508
|
});
|