Commit a869da2
Eric Bower
·
2026-08-15 12:44:23 -0400 EDT
parent bfb4fa8
feat: --in-place and --summary-file flags
2 files changed,
+299,
-6
M
main.go
M
main.go
+143,
-6
1@@ -32,6 +32,13 @@ var tmplFS embed.FS
2 type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace
3
4 func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Workspace {
5+ if cfg != nil && cfg.InPlace {
6+ return &WorkspaceInPlace{
7+ Cfg: cfg,
8+ Logger: logger,
9+ Source: source,
10+ }
11+ }
12 if strings.HasSuffix(source, ".tar") {
13 return &WorkspaceTar{
14 Cfg: cfg,
15@@ -73,6 +80,8 @@ type Cfg struct {
16 IncludeRunning bool // emit running status updates in addition to terminal
17 HumanOutput bool // human-readable output instead of JSONL / slog
18 Wait bool // block until job completes, print history and summary
19+ InPlace bool // execute workspace in-place without rsyncing to /tmp
20+ SummaryFile string // write markdown summary to this file path
21 EnvVars envList // custom environment variables passed via -e / -env
22 SessionPrefix string // session prefix filter for monitor (default: "ci.")
23 }
24@@ -119,9 +128,13 @@ func NewCfg() (*Cfg, string, bool) {
25 var includeRunning bool
26 var human bool
27 var wait bool
28+ var inPlace bool
29+ var summaryFile string
30 flag.BoolVar(&includeRunning, "include-running", false, "emit running status updates in addition to terminal (default: terminal only)")
31 flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)")
32 flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary")
33+ flag.BoolVar(&inPlace, "in-place", false, "execute workspace in-place without rsyncing to /tmp")
34+ flag.StringVar(&summaryFile, "summary-file", "", "file path to write markdown job summary")
35
36 // Split args so the subcommand (first non-flag arg) doesn't block
37 // flags that appear after it: "pici runner --wait" works.
38@@ -147,6 +160,8 @@ func NewCfg() (*Cfg, string, bool) {
39 IncludeRunning: includeRunning,
40 HumanOutput: human,
41 Wait: wait,
42+ InPlace: inPlace,
43+ SummaryFile: summaryFile,
44 EnvVars: envVars,
45 SessionPrefix: sessionPrefix,
46 }, cmd, wantHelp
47@@ -270,6 +285,8 @@ SUBCOMMAND HELP
48
49 FLAGS
50 -e, -env <KEY=VAL> Set or override environment variable for pico.sh
51+ -in-place Execute workspace in-place without copying to /tmp
52+ -summary-file <path> Write markdown job summary table to file
53 -pk <path> SSH private key
54 -ck <path> SSH public certificate key
55 -artifact-dir <path> Artifact staging directory (default: /tmp/pici-artifacts)
56@@ -571,6 +588,39 @@ func (w *WorkspaceRsync) Checksum() string {
57 return w.checksum
58 }
59
60+type WorkspaceInPlace struct {
61+ Cfg *Cfg
62+ Logger *slog.Logger
63+ Source string
64+ checksum string
65+}
66+
67+func (w *WorkspaceInPlace) Setup() error {
68+ log := w.Logger.With("source", w.Source)
69+ log.Debug("using in-place workspace")
70+ cs, err := computeDirChecksum(w.Source)
71+ if err != nil {
72+ log.Error("compute workspace checksum", "err", err)
73+ } else {
74+ w.checksum = cs
75+ log.Debug("workspace checksum", "checksum", w.checksum)
76+ }
77+ return nil
78+}
79+
80+func (w *WorkspaceInPlace) Cleanup() error {
81+ // In-place workspace is not cleaned up
82+ return nil
83+}
84+
85+func (w *WorkspaceInPlace) GetDir() string {
86+ return w.Source
87+}
88+
89+func (w *WorkspaceInPlace) Checksum() string {
90+ return w.checksum
91+}
92+
93 func computeDirChecksum(dir string) (string, error) {
94 hasher := sha256.New()
95 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
96@@ -1140,6 +1190,12 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
97 fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
98 }
99
100+ if cfg.SummaryFile != "" {
101+ if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known); err != nil {
102+ log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
103+ }
104+ }
105+
106 if exitCode != 0 {
107 return &JobFailedError{ExitCode: exitCode}
108 }
109@@ -1147,6 +1203,74 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
110 return nil
111 }
112
113+func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState) error {
114+ if filePath == "" {
115+ return nil
116+ }
117+
118+ exitCode, status := resolveJobExitCode(jobSessions)
119+ _, _, duration := computeJobTiming(jobSessions)
120+ statusIcon := map[string]string{"success": "✅", "failed": "❌"}[status]
121+
122+ var sb strings.Builder
123+ sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
124+ sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
125+
126+ sb.WriteString("| Session | Status | Exit Code | Duration |\n")
127+ sb.WriteString("| :--- | :--- | :--- | :--- |\n")
128+
129+ for _, s := range jobSessions {
130+ state := known[s.Short]
131+ sStatus := "unknown"
132+ sExit := "—"
133+ sDur := "—"
134+ sIcon := "❓"
135+ if state != nil {
136+ sStatus = state.status
137+ sExit = state.exitCode
138+ if sExit == "" {
139+ sExit = "—"
140+ }
141+ sDur = state.duration
142+ if sDur == "" {
143+ sDur = "—"
144+ }
145+ sIcon = map[string]string{"running": "🚀", "success": "✅", "failed": "❌"}[sStatus]
146+ }
147+ sb.WriteString(fmt.Sprintf("| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur))
148+ }
149+ sb.WriteString("\n")
150+
151+ // Include failed session details
152+ hasFailed := false
153+ for _, s := range jobSessions {
154+ state := known[s.Short]
155+ if state == nil || state.status != "failed" {
156+ continue
157+ }
158+ if !hasFailed {
159+ sb.WriteString("### Failed Session Details\n\n")
160+ hasFailed = true
161+ }
162+ history, err := fetchHistoryPlain(s.Name)
163+ if err != nil {
164+ history = fmt.Sprintf("(history unavailable: %v)", err)
165+ }
166+ sb.WriteString(fmt.Sprintf("<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history)))
167+ }
168+
169+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
170+ if err != nil {
171+ return fmt.Errorf("open summary file: %w", err)
172+ }
173+ defer f.Close()
174+
175+ if _, err := f.WriteString(sb.String()); err != nil {
176+ return fmt.Errorf("write summary file: %w", err)
177+ }
178+ return nil
179+}
180+
181 // cleanSessionShort produces a readable short name from a full session name.
182 // ci.name.jobID.step.lint → lint
183 // ci.name.jobID.step.ci.name.jobID.runner → runner
184@@ -2419,11 +2543,20 @@ func runLocal(cfg *Cfg, dest string) error {
185 }
186 log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
187
188- // Set up workspace in temp directory
189- wk := &WorkspaceRsync{
190- Cfg: cfg,
191- Logger: log,
192- Source: cwd,
193+ // Set up workspace
194+ var wk Workspace
195+ if cfg.InPlace {
196+ wk = &WorkspaceInPlace{
197+ Cfg: cfg,
198+ Logger: log,
199+ Source: cwd,
200+ }
201+ } else {
202+ wk = &WorkspaceRsync{
203+ Cfg: cfg,
204+ Logger: log,
205+ Source: cwd,
206+ }
207 }
208
209 eng := &JobEngine{
210@@ -2444,7 +2577,11 @@ func runLocal(cfg *Cfg, dest string) error {
211 if err := eng.Setup(); err != nil {
212 return fmt.Errorf("workspace setup: %w", err)
213 }
214- fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
215+ if cfg.InPlace {
216+ fmt.Fprintf(os.Stdout, "📁 using in-place workspace directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
217+ } else {
218+ fmt.Fprintf(os.Stdout, "📦 syncing workspace to temp directory %s\n", eng.Wk.GetDir()) //nolint:errcheck
219+ }
220 log.Debug("workspace directory", "dir", eng.Wk.GetDir())
221
222 // Store event.json in artifact dir
+156,
-0
1@@ -1226,3 +1226,159 @@ exit 13
2 t.Errorf("expected exit code 13, got %d", jobFailedErr.ExitCode)
3 }
4 }
5+
6+func TestWorkspaceInPlace(t *testing.T) {
7+ tempDir := t.TempDir()
8+ testFile := filepath.Join(tempDir, "test.txt")
9+ if err := os.WriteFile(testFile, []byte("hello world"), 0644); err != nil {
10+ t.Fatal(err)
11+ }
12+
13+ cfg := &Cfg{
14+ InPlace: true,
15+ }
16+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
17+ wk := defaultWorkspaceFactory(cfg, logger, tempDir)
18+
19+ if _, ok := wk.(*WorkspaceInPlace); !ok {
20+ t.Fatalf("expected WorkspaceInPlace, got %T", wk)
21+ }
22+
23+ if err := wk.Setup(); err != nil {
24+ t.Fatalf("Setup failed: %v", err)
25+ }
26+
27+ if wk.GetDir() != tempDir {
28+ t.Errorf("expected GetDir() == %q, got %q", tempDir, wk.GetDir())
29+ }
30+
31+ if wk.Checksum() == "" {
32+ t.Errorf("expected non-empty checksum")
33+ }
34+
35+ if err := wk.Cleanup(); err != nil {
36+ t.Fatalf("Cleanup failed: %v", err)
37+ }
38+
39+ if _, err := os.Stat(testFile); os.IsNotExist(err) {
40+ t.Fatalf("expected test file to still exist after Cleanup() on in-place workspace")
41+ }
42+}
43+
44+func TestRunLocal_InPlace(t *testing.T) {
45+ if _, err := exec.LookPath("zmx"); err != nil {
46+ t.Skip("zmx not found, skipping test")
47+ }
48+
49+ tempDir := t.TempDir()
50+ origWd, _ := os.Getwd()
51+ defer func() { _ = os.Chdir(origWd) }()
52+ _ = os.Chdir(tempDir)
53+
54+ picoContent := `#!/usr/bin/env bash
55+set -e
56+echo "inplace marker" > marker.txt
57+`
58+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
59+ t.Fatal(err)
60+ }
61+
62+ artifactDir := t.TempDir()
63+ cfg := &Cfg{
64+ ArtifactDir: artifactDir,
65+ MonitorInterval: 100 * time.Millisecond,
66+ InPlace: true,
67+ }
68+
69+ err := runLocal(cfg, "")
70+ if err != nil {
71+ t.Fatalf("expected runLocal in-place to succeed, got %v", err)
72+ }
73+
74+ markerPath := filepath.Join(tempDir, "marker.txt")
75+ data, err := os.ReadFile(markerPath)
76+ if err != nil {
77+ t.Fatalf("expected marker.txt to be created in working directory: %v", err)
78+ }
79+ if !strings.Contains(string(data), "inplace marker") {
80+ t.Errorf("unexpected marker.txt content: %q", string(data))
81+ }
82+}
83+
84+func TestRunLocal_SummaryFile(t *testing.T) {
85+ if _, err := exec.LookPath("zmx"); err != nil {
86+ t.Skip("zmx not found, skipping test")
87+ }
88+
89+ tempDir := t.TempDir()
90+ origWd, _ := os.Getwd()
91+ defer func() { _ = os.Chdir(origWd) }()
92+ _ = os.Chdir(tempDir)
93+
94+ picoContent := `#!/usr/bin/env bash
95+echo "hello from step"
96+`
97+ if err := os.WriteFile("pico.sh", []byte(picoContent), 0755); err != nil {
98+ t.Fatal(err)
99+ }
100+
101+ artifactDir := t.TempDir()
102+ summaryFilePath := filepath.Join(tempDir, "summary.md")
103+ cfg := &Cfg{
104+ ArtifactDir: artifactDir,
105+ MonitorInterval: 100 * time.Millisecond,
106+ InPlace: true,
107+ SummaryFile: summaryFilePath,
108+ }
109+
110+ err := runLocal(cfg, "")
111+ if err != nil {
112+ t.Fatalf("expected runLocal to succeed, got %v", err)
113+ }
114+
115+ summaryBytes, err := os.ReadFile(summaryFilePath)
116+ if err != nil {
117+ t.Fatalf("expected summary file %s to exist: %v", summaryFilePath, err)
118+ }
119+
120+ summary := string(summaryBytes)
121+ if !strings.Contains(summary, "### Pici CI Summary:") {
122+ t.Errorf("expected summary to contain header, got:\n%s", summary)
123+ }
124+ if !strings.Contains(summary, "| Session | Status | Exit Code | Duration |") {
125+ t.Errorf("expected summary to contain table header, got:\n%s", summary)
126+ }
127+}
128+
129+func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
130+ tempFile := filepath.Join(t.TempDir(), "summary.md")
131+ sessions := []SessionInfo{
132+ {Short: "setup", Name: "local.repo.job.setup", ExitCode: "0"},
133+ {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
134+ }
135+ known := map[string]*sessionState{
136+ "setup": {status: "success", exitCode: "0", duration: "1.2s"},
137+ "failing-step": {status: "failed", exitCode: "42", duration: "3.4s"},
138+ }
139+
140+ err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known)
141+ if err != nil {
142+ t.Fatalf("writeSummaryMarkdown failed: %v", err)
143+ }
144+
145+ data, err := os.ReadFile(tempFile)
146+ if err != nil {
147+ t.Fatalf("read summary file: %v", err)
148+ }
149+ content := string(data)
150+
151+ if !strings.Contains(content, "### Pici CI Summary: `test-repo` (Job `job-123`)") {
152+ t.Errorf("missing header in content:\n%s", content)
153+ }
154+ if !strings.Contains(content, "| `failing-step` | ❌ failed | `42` | 3.4s |") {
155+ t.Errorf("missing table row in content:\n%s", content)
156+ }
157+ if !strings.Contains(content, "### Failed Session Details") {
158+ t.Errorf("missing failed details in content:\n%s", content)
159+ }
160+}