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
+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+}