@trim21/personal-pi-extensions 0.0.130 → 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.
- package/package.json +1 -1
- package/src/gh-readonly.ts +413 -336
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -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
|
-
*
|
|
433
|
-
* at depth 1.
|
|
434
|
-
*
|
|
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
|
-
*
|
|
437
|
-
* "Run "
|
|
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
|
|
440
|
-
* Steps
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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
|
|
1172
|
+
content: [{ type: "text", text: `Fetching job list...` }],
|
|
900
1173
|
details: {},
|
|
901
1174
|
});
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
// ──
|
|
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
|
|