From 249be20598b6c461a5b81a3a01e9f4d0cf03ae03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:51:58 +0000 Subject: [PATCH 01/13] Initial plan From 40c2424fa82be47ef98f338a8da0bfcd8d2566a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:59:42 +0000 Subject: [PATCH 02/13] feat: add progress bar and spinner for long-running CLI operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New cmd/progress.go: progressBar struct (in-place fill bar with \r), countdown() for deterministic sleeps, spinWhile() for indeterminate waits (braille spinner). Constants progressLineWidth/progressBarWidth ensure consistent rendering across all callers. - cmd/helpers.go: waitForReady, waitForReadyAny, waitForMigration now show an animated [████░░░░] progress bar instead of printing a new line per attempt. - cmd/deploy_local.go: 30s MySQL init sleep replaced with countdown bar. - cmd/deploy_azure.go: DeployBicep wrapped with spinWhile(); MySQL 30s sleep replaced with countdown(). - cmd/configure_projects.go: triggerAndPoll uses in-place \r updates with renderBar for task progress. Agent-Logs-Url: https://github.com/DevExpGbb/gh-devlake/sessions/5882daaa-5962-4400-851a-c131c3cb23f9 Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_projects.go | 26 ++++++--- cmd/deploy_azure.go | 11 ++-- cmd/deploy_local.go | 4 +- cmd/helpers.go | 18 ++++--- cmd/progress.go | 111 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 cmd/progress.go diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index cc39506..560f2cb 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -442,29 +442,41 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout fmt.Println(" Monitoring progress...") deadline := time.Now().Add(timeout) + start := time.Now() + + // clearLine erases the current in-place status line so subsequent output + // (completion banners, error messages) appears on a clean line. + clearLine := func() { + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + } + ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for range ticker.C { p, err := client.GetPipeline(pipeline.ID) + elapsed := time.Since(start).Truncate(time.Second) + if err != nil { - elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) - fmt.Printf(" [%s] Could not check status...\n", elapsed) + fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed)) } else { - elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) - fmt.Printf(" [%s] Status: %s | Tasks: %d/%d\n", elapsed, p.Status, p.FinishedTasks, p.TotalTasks) + bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) + fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed)) switch p.Status { case "TASK_COMPLETED": - fmt.Println(" \u2705 Data sync completed!") + clearLine() + fmt.Println(" ✅ Data sync completed!") return nil case "TASK_FAILED": - return fmt.Errorf("pipeline failed \u2014 check DevLake logs") + clearLine() + return fmt.Errorf("pipeline failed — check DevLake logs") } } if time.Now().After(deadline) { - fmt.Println(" \u26a0\ufe0f Monitoring timed out. Pipeline is still running.") + clearLine() + fmt.Println(" ⚠️ Monitoring timed out. Pipeline is still running.") fmt.Printf(" Check status: GET /pipelines/%d\n", pipeline.ID) return nil } diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..28cac4a 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -244,8 +244,7 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { if err := azure.MySQLStart(mysqlName, azureRG); err != nil { fmt.Printf(" ⚠️ Could not start MySQL: %v\n", err) } else { - fmt.Println(" Waiting 30s for MySQL...") - time.Sleep(30 * time.Second) + countdown(30, "waiting for MySQL") fmt.Println(" ✅ MySQL started") } } else if state != "" { @@ -287,7 +286,12 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { params["acrName"] = acrName } - deployment, err := azure.DeployBicep(azureRG, templatePath, params) + var deployment *azure.DeploymentOutput + err = spinWhile("Deploying Azure resources via Bicep (this takes several minutes)", func() error { + var innerErr error + deployment, innerErr = azure.DeployBicep(azureRG, templatePath, params) + return innerErr + }) if err != nil { return fmt.Errorf("Bicep deployment failed: %w", err) } @@ -303,7 +307,6 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { backendReady := waitForReady(deployment.BackendEndpoint, 30, 10*time.Second) == nil if backendReady { - fmt.Println(" ✅ Backend is responding!") fmt.Println("\n🔄 Triggering database migration...") httpClient := &http.Client{Timeout: 5 * time.Second} resp, err := httpClient.Get(deployment.BackendEndpoint + "/proceed-db-migration") diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 917282c..8e513a1 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -531,8 +531,8 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e backendURLCandidates := []string{"http://localhost:8080", "http://localhost:8085"} fmt.Println("\n⏳ Waiting for DevLake to be ready...") - fmt.Println(" Giving MySQL time to initialize (this takes ~30s on first run)...") - time.Sleep(30 * time.Second) + fmt.Println(" Giving MySQL time to initialize...") + countdown(30, "MySQL initializing") backendURL, err := waitForReadyAny(backendURLCandidates, 36, 10*time.Second) if err != nil { diff --git a/cmd/helpers.go b/cmd/helpers.go index edfe91a..dc82638 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -178,18 +178,20 @@ func deduplicateResults(results []ConnSetupResult) []ConnSetupResult { // maxAttempts is exhausted. interval is the pause between attempts. func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "waiting for DevLake") resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ DevLake is responding!") + bar.done("✅ DevLake is responding!") return nil } } - fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts) time.Sleep(interval) } + bar.clear() return fmt.Errorf("DevLake not ready after %d attempts — check logs", maxAttempts) } @@ -198,22 +200,24 @@ func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error // internal/devlake/discovery.go which checks both 8080 and 8085. func waitForReadyAny(baseURLs []string, maxAttempts int, interval time.Duration) (string, error) { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "waiting for DevLake") for _, baseURL := range baseURLs { resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ DevLake is responding!") + bar.done("✅ DevLake is responding!") return baseURL, nil } } } if attempt < maxAttempts { - fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts) time.Sleep(interval) } } + bar.clear() return "", fmt.Errorf("timed out after %d attempts", maxAttempts) } @@ -221,18 +225,20 @@ func waitForReadyAny(baseURLs []string, maxAttempts int, interval time.Duration) // During migration the API returns 428 (Precondition Required). func waitForMigration(baseURL string, maxAttempts int, interval time.Duration) error { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "migrating database") resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ Migration complete!") + bar.done("✅ Migration complete!") return nil } } - fmt.Printf(" Migrating... (%d/%d)\n", attempt, maxAttempts) time.Sleep(interval) } + bar.clear() return fmt.Errorf("migration did not complete after %d attempts", maxAttempts) } diff --git a/cmd/progress.go b/cmd/progress.go new file mode 100644 index 0000000..981245f --- /dev/null +++ b/cmd/progress.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "strings" + "time" +) + +// ── Progress bar ───────────────────────────────────────────────── + +// progressLineWidth is the terminal column count used to clear in-place status +// lines written with \r. Must be wide enough to overwrite the longest possible +// progress or spinner line. +const progressLineWidth = 72 + +// progressBarWidth is the number of block characters in every progress bar. +const progressBarWidth = 24 + +// progressBar renders an in-place terminal progress bar using \r. +// Create with newProgressBar, call update to redraw, and done to finish. +type progressBar struct { + total int + width int + start time.Time +} + +func newProgressBar(total int) *progressBar { + return &progressBar{total: total, width: progressBarWidth, start: time.Now()} +} + +// renderBar returns a [████░░░░] string representing current/total progress. +// Rounding is applied so early progress is visible even at low percentages. +func renderBar(current, total, width int) string { + if total <= 0 { + return "[" + strings.Repeat("░", width) + "]" + } + // Use rounding division to avoid premature completion or invisible early progress. + filled := (width*current + total/2) / total + if filled > width { + filled = width + } + return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" +} + +// update redraws the progress bar at position current. +// It uses \r to overwrite the current line in the terminal. +func (p *progressBar) update(current int, label string) { + bar := renderBar(current, p.total, p.width) + elapsed := time.Since(p.start).Truncate(time.Second) + fmt.Printf("\r %s %2d/%-2d %s (%s elapsed) ", bar, current, p.total, label, elapsed) +} + +// clear erases the progress bar line and returns the cursor to column 0. +func (p *progressBar) clear() { + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) +} + +// done clears the bar and prints a completion message. +func (p *progressBar) done(msg string) { + p.clear() + fmt.Println(" " + msg) +} + +// countdown shows a progress bar that ticks every second for n seconds, +// then clears the bar. Used for deterministic sleeps where the duration +// is known upfront (e.g. "Giving MySQL time to initialize"). +func countdown(n int, label string) { + bar := newProgressBar(n) + for i := 0; i <= n; i++ { + bar.update(i, label) + if i < n { + time.Sleep(time.Second) + } + } + bar.clear() +} + +// ── Spinner ─────────────────────────────────────────────────────── + +// spinChars is a rotating set of characters for indeterminate waits. +var spinChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// spinWhile runs fn in a goroutine and displays a spinner until it completes. +// On success it prints the elapsed time; on failure it clears the spinner line +// so the caller's error message lands on a clean line. +// Returns fn's error. +func spinWhile(label string, fn func() error) error { + done := make(chan error, 1) + go func() { done <- fn() }() + + start := time.Now() + i := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case err := <-done: + elapsed := time.Since(start).Truncate(time.Second) + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + if err == nil { + fmt.Printf(" ✅ Done (%s)\n", elapsed) + } + return err + case <-ticker.C: + elapsed := time.Since(start).Truncate(time.Second) + fmt.Printf("\r %s %s (%s elapsed) ", spinChars[i%len(spinChars)], label, elapsed) + i++ + } + } +} From 0afbada7f63afe95b7139c9aaa405cb456281b99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 16:14:08 +0000 Subject: [PATCH 03/13] Fix progress/spinner rendering: use ANSI erase-line instead of fixed-width spaces Replace strings.Repeat(" ", progressLineWidth) clears with \033[2K (ANSI erase entire line) in progress.go and configure_projects.go. This correctly erases lines of any length regardless of label width, fixing garbled output for long labels like the Azure Bicep spinner. Also removes the now-unneeded progressLineWidth constant and trailing-space padding from all update/spinner print calls. Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_projects.go | 6 +++--- cmd/progress.go | 15 +++++---------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 560f2cb..542ad6e 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -447,7 +447,7 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout // clearLine erases the current in-place status line so subsequent output // (completion banners, error messages) appears on a clean line. clearLine := func() { - fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + fmt.Printf("\r\033[2K") } ticker := time.NewTicker(10 * time.Second) @@ -458,10 +458,10 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout elapsed := time.Since(start).Truncate(time.Second) if err != nil { - fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed)) + fmt.Printf("\r\033[2K ⚠️ Could not check status (%s elapsed)", elapsed) } else { bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) - fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed)) + fmt.Printf("\r\033[2K %s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) switch p.Status { case "TASK_COMPLETED": diff --git a/cmd/progress.go b/cmd/progress.go index 981245f..73a1b3d 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -8,11 +8,6 @@ import ( // ── Progress bar ───────────────────────────────────────────────── -// progressLineWidth is the terminal column count used to clear in-place status -// lines written with \r. Must be wide enough to overwrite the longest possible -// progress or spinner line. -const progressLineWidth = 72 - // progressBarWidth is the number of block characters in every progress bar. const progressBarWidth = 24 @@ -43,16 +38,16 @@ func renderBar(current, total, width int) string { } // update redraws the progress bar at position current. -// It uses \r to overwrite the current line in the terminal. +// It uses \r and the ANSI erase-line sequence to overwrite the current line. func (p *progressBar) update(current int, label string) { bar := renderBar(current, p.total, p.width) elapsed := time.Since(p.start).Truncate(time.Second) - fmt.Printf("\r %s %2d/%-2d %s (%s elapsed) ", bar, current, p.total, label, elapsed) + fmt.Printf("\r\033[2K %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed) } // clear erases the progress bar line and returns the cursor to column 0. func (p *progressBar) clear() { - fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + fmt.Printf("\r\033[2K") } // done clears the bar and prints a completion message. @@ -97,14 +92,14 @@ func spinWhile(label string, fn func() error) error { select { case err := <-done: elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + fmt.Printf("\r\033[2K") if err == nil { fmt.Printf(" ✅ Done (%s)\n", elapsed) } return err case <-ticker.C: elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r %s %s (%s elapsed) ", spinChars[i%len(spinChars)], label, elapsed) + fmt.Printf("\r\033[2K %s %s (%s elapsed)", spinChars[i%len(spinChars)], label, elapsed) i++ } } From ef655a5716dcbd2536795f0c3bb99e5bf81b4b94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:14:13 +0000 Subject: [PATCH 04/13] Fix progress rendering: add truncatePad, pad/truncate all status lines to progressLineWidth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add progressLineWidth = 72 constant and add a rune-safe truncatePad helper that pads short lines with spaces and truncates long lines with "…". Apply it to update(), spinWhile ticker, and configure_projects.go status lines so every in-place write occupies exactly the target column width. Replace ANSI \033[2K clears with matching space-overwrite clears (reliable since every update now writes exactly progressLineWidth chars). Truncate configure_projects pipeline status to progressLineWidth-3 runes (accounting for the 3-space prefix) to prevent overflow on terminals of width 80. Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_projects.go | 8 +++++--- cmd/progress.go | 32 +++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 542ad6e..73710c0 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -447,7 +447,7 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout // clearLine erases the current in-place status line so subsequent output // (completion banners, error messages) appears on a clean line. clearLine := func() { - fmt.Printf("\r\033[2K") + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) } ticker := time.NewTicker(10 * time.Second) @@ -458,10 +458,12 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout elapsed := time.Since(start).Truncate(time.Second) if err != nil { - fmt.Printf("\r\033[2K ⚠️ Could not check status (%s elapsed)", elapsed) + line := fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed) + fmt.Printf("\r %s", truncatePad(line, progressLineWidth-3)) } else { bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) - fmt.Printf("\r\033[2K %s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) + line := fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) + fmt.Printf("\r %s", truncatePad(line, progressLineWidth-3)) switch p.Status { case "TASK_COMPLETED": diff --git a/cmd/progress.go b/cmd/progress.go index 73a1b3d..4828d0f 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -8,9 +8,27 @@ import ( // ── Progress bar ───────────────────────────────────────────────── +// progressLineWidth is the number of terminal columns each in-place status +// line occupies. Every update() and spinner tick writes exactly this many +// visible characters so that clear() (which overwrites the same width with +// spaces) always fully erases the previous line. +const progressLineWidth = 72 + // progressBarWidth is the number of block characters in every progress bar. const progressBarWidth = 24 +// truncatePad returns s padded with spaces to exactly width runes, +// or truncated to (width-1) runes followed by "…" if s is longer. +// Width is measured in runes to handle multi-byte characters correctly. +func truncatePad(s string, width int) string { + runes := []rune(s) + n := len(runes) + if n > width { + return string(runes[:width-1]) + "…" + } + return s + strings.Repeat(" ", width-n) +} + // progressBar renders an in-place terminal progress bar using \r. // Create with newProgressBar, call update to redraw, and done to finish. type progressBar struct { @@ -38,16 +56,19 @@ func renderBar(current, total, width int) string { } // update redraws the progress bar at position current. -// It uses \r and the ANSI erase-line sequence to overwrite the current line. +// It uses \r to overwrite the current line in the terminal. +// The line is always padded or truncated to exactly progressLineWidth +// characters so clear() reliably erases it. func (p *progressBar) update(current int, label string) { bar := renderBar(current, p.total, p.width) elapsed := time.Since(p.start).Truncate(time.Second) - fmt.Printf("\r\033[2K %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed) + line := fmt.Sprintf(" %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed) + fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) } // clear erases the progress bar line and returns the cursor to column 0. func (p *progressBar) clear() { - fmt.Printf("\r\033[2K") + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) } // done clears the bar and prints a completion message. @@ -92,14 +113,15 @@ func spinWhile(label string, fn func() error) error { select { case err := <-done: elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r\033[2K") + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) if err == nil { fmt.Printf(" ✅ Done (%s)\n", elapsed) } return err case <-ticker.C: elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r\033[2K %s %s (%s elapsed)", spinChars[i%len(spinChars)], label, elapsed) + line := fmt.Sprintf(" %s %s (%s elapsed)", spinChars[i%len(spinChars)], label, elapsed) + fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) i++ } } From d8b7121404912f1a596af2c4c3c16d455c077a5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:15:36 +0000 Subject: [PATCH 05/13] Extract statusLineIndent = 3 constant to eliminate magic numbers in configure_projects.go Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_projects.go | 4 ++-- cmd/progress.go | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 73710c0..4e3d0f6 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -459,11 +459,11 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout if err != nil { line := fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed) - fmt.Printf("\r %s", truncatePad(line, progressLineWidth-3)) + fmt.Printf("\r %s", truncatePad(line, progressLineWidth-statusLineIndent)) } else { bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) line := fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) - fmt.Printf("\r %s", truncatePad(line, progressLineWidth-3)) + fmt.Printf("\r %s", truncatePad(line, progressLineWidth-statusLineIndent)) switch p.Status { case "TASK_COMPLETED": diff --git a/cmd/progress.go b/cmd/progress.go index 4828d0f..5a80936 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -17,6 +17,12 @@ const progressLineWidth = 72 // progressBarWidth is the number of block characters in every progress bar. const progressBarWidth = 24 +// statusLineIndent is the number of leading spaces printed before each +// in-place status line (e.g. " [bar] ..."). clear() must overwrite the +// full progressLineWidth columns; status content is truncated to +// progressLineWidth-statusLineIndent to fit within that budget. +const statusLineIndent = 3 + // truncatePad returns s padded with spaces to exactly width runes, // or truncated to (width-1) runes followed by "…" if s is longer. // Width is measured in runes to handle multi-byte characters correctly. From 3548856831207e0255949388a9d7a916cd1b69a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 09:57:05 +0000 Subject: [PATCH 06/13] Initial plan: address three review findings in progress.go Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- go.mod | 2 ++ go.sum | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/go.mod b/go.mod index 3f105c3..47f5c6a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( ) require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect golang.org/x/sys v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index 63acd8d..2d511d0 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= From fe576d344d4b4e786528468accccec21502cd994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 09:59:16 +0000 Subject: [PATCH 07/13] Fix progress rendering: use go-runewidth, derive indent from constant, add tests Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/configure_projects.go | 4 +- cmd/progress.go | 42 ++++++---- cmd/progress_test.go | 162 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 cmd/progress_test.go diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index 4e3d0f6..3d1ae8a 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -459,11 +459,11 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout if err != nil { line := fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed) - fmt.Printf("\r %s", truncatePad(line, progressLineWidth-statusLineIndent)) + fmt.Printf("\r%s%s", statusLinePrefix, truncatePad(line, progressLineWidth-statusLineIndent)) } else { bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) line := fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) - fmt.Printf("\r %s", truncatePad(line, progressLineWidth-statusLineIndent)) + fmt.Printf("\r%s%s", statusLinePrefix, truncatePad(line, progressLineWidth-statusLineIndent)) switch p.Status { case "TASK_COMPLETED": diff --git a/cmd/progress.go b/cmd/progress.go index 5a80936..edf3339 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -4,14 +4,18 @@ import ( "fmt" "strings" "time" + + "github.com/mattn/go-runewidth" ) // ── Progress bar ───────────────────────────────────────────────── -// progressLineWidth is the number of terminal columns each in-place status -// line occupies. Every update() and spinner tick writes exactly this many -// visible characters so that clear() (which overwrites the same width with +// progressLineWidth is the number of terminal display columns each in-place +// status line occupies. Every update() and spinner tick writes exactly this +// many display columns so that clear() (which overwrites the same width with // spaces) always fully erases the previous line. +// Width is measured in display columns, not runes, so that wide/emoji +// characters (display width 2) are accounted for correctly. const progressLineWidth = 72 // progressBarWidth is the number of block characters in every progress bar. @@ -23,16 +27,22 @@ const progressBarWidth = 24 // progressLineWidth-statusLineIndent to fit within that budget. const statusLineIndent = 3 -// truncatePad returns s padded with spaces to exactly width runes, -// or truncated to (width-1) runes followed by "…" if s is longer. -// Width is measured in runes to handle multi-byte characters correctly. +// statusLinePrefix is the leading whitespace derived from statusLineIndent. +// It is used by update(), done(), and spinWhile() to ensure the prefix always +// matches the indent budget used for truncation. +var statusLinePrefix = strings.Repeat(" ", statusLineIndent) + +// truncatePad returns s padded with spaces to exactly width display columns, +// or truncated to (width-1) display columns followed by "…" if s is wider. +// Width is measured in terminal display columns using go-runewidth so that +// wide characters (e.g. CJK, emoji) and multi-byte sequences are handled +// correctly. func truncatePad(s string, width int) string { - runes := []rune(s) - n := len(runes) - if n > width { - return string(runes[:width-1]) + "…" + sw := runewidth.StringWidth(s) + if sw > width { + return runewidth.Truncate(s, width, "…") } - return s + strings.Repeat(" ", width-n) + return s + strings.Repeat(" ", width-sw) } // progressBar renders an in-place terminal progress bar using \r. @@ -64,11 +74,11 @@ func renderBar(current, total, width int) string { // update redraws the progress bar at position current. // It uses \r to overwrite the current line in the terminal. // The line is always padded or truncated to exactly progressLineWidth -// characters so clear() reliably erases it. +// display columns so clear() reliably erases it. func (p *progressBar) update(current int, label string) { bar := renderBar(current, p.total, p.width) elapsed := time.Since(p.start).Truncate(time.Second) - line := fmt.Sprintf(" %s %2d/%-2d %s (%s elapsed)", bar, current, p.total, label, elapsed) + line := fmt.Sprintf("%s%s %2d/%-2d %s (%s elapsed)", statusLinePrefix, bar, current, p.total, label, elapsed) fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) } @@ -80,7 +90,7 @@ func (p *progressBar) clear() { // done clears the bar and prints a completion message. func (p *progressBar) done(msg string) { p.clear() - fmt.Println(" " + msg) + fmt.Println(statusLinePrefix + msg) } // countdown shows a progress bar that ticks every second for n seconds, @@ -121,12 +131,12 @@ func spinWhile(label string, fn func() error) error { elapsed := time.Since(start).Truncate(time.Second) fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) if err == nil { - fmt.Printf(" ✅ Done (%s)\n", elapsed) + fmt.Printf("%s✅ Done (%s)\n", statusLinePrefix, elapsed) } return err case <-ticker.C: elapsed := time.Since(start).Truncate(time.Second) - line := fmt.Sprintf(" %s %s (%s elapsed)", spinChars[i%len(spinChars)], label, elapsed) + line := fmt.Sprintf("%s%s %s (%s elapsed)", statusLinePrefix, spinChars[i%len(spinChars)], label, elapsed) fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) i++ } diff --git a/cmd/progress_test.go b/cmd/progress_test.go new file mode 100644 index 0000000..1a3e98e --- /dev/null +++ b/cmd/progress_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "strings" + "testing" +) + +// TestTruncatePad checks the display-width-aware pad/truncate helper. +func TestTruncatePad(t *testing.T) { + tests := []struct { + name string + s string + width int + want string + }{ + { + name: "exact width — no change", + s: "hello", + width: 5, + want: "hello", + }, + { + name: "shorter — padded with spaces", + s: "hi", + width: 5, + want: "hi ", + }, + { + name: "empty string — all spaces", + s: "", + width: 4, + want: " ", + }, + { + name: "longer ASCII — truncated with ellipsis", + s: "hello world", + width: 7, + want: "hello …", // 6 ASCII + 1 ellipsis = 7 display cols + }, + { + name: "multi-byte rune — truncated at rune boundary", + s: "héllo", + width: 3, + want: "hé…", // 'h'(1) + 'é'(1) + '…'(1) = 3 display cols + }, + { + name: "wide CJK char — counts as 2 display columns", + s: "日本語", // each char is display width 2 → total 6 cols + width: 5, + want: "日本…", // "日"(2)+"本"(2) = 4 < 5, next would be 6 > 5, so truncate to 4 and add "…" → 5 cols + }, + { + name: "width 1 — single ellipsis", + s: "hello", + width: 1, + want: "…", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := truncatePad(tc.s, tc.width) + if got != tc.want { + t.Errorf("truncatePad(%q, %d) = %q; want %q", tc.s, tc.width, got, tc.want) + } + }) + } +} + +// TestRenderBar checks the progress bar rendering logic. +func TestRenderBar(t *testing.T) { + tests := []struct { + name string + current int + total int + width int + want string + }{ + { + name: "total zero — all empty", + current: 0, + total: 0, + width: 4, + want: "[░░░░]", + }, + { + name: "total negative — all empty", + current: 5, + total: -1, + width: 4, + want: "[░░░░]", + }, + { + name: "current zero — all empty", + current: 0, + total: 10, + width: 4, + want: "[░░░░]", + }, + { + name: "current equals total — all filled", + current: 10, + total: 10, + width: 4, + want: "[████]", + }, + { + name: "current exceeds total — clamped to full", + current: 15, + total: 10, + width: 4, + want: "[████]", + }, + { + name: "half progress — half filled", + current: 5, + total: 10, + width: 4, + want: "[██░░]", + }, + { + name: "rounding: 1/3 of width=3 rounds to 1 filled", + current: 1, + total: 3, + width: 3, + want: "[█░░]", + }, + { + name: "width 1 full", + current: 1, + total: 1, + width: 1, + want: "[█]", + }, + { + name: "width 1 empty", + current: 0, + total: 1, + width: 1, + want: "[░]", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := renderBar(tc.current, tc.total, tc.width) + if got != tc.want { + t.Errorf("renderBar(%d, %d, %d) = %q; want %q", + tc.current, tc.total, tc.width, got, tc.want) + } + }) + } +} + +// TestStatusLinePrefix verifies statusLinePrefix matches statusLineIndent. +func TestStatusLinePrefix(t *testing.T) { + if len(statusLinePrefix) != statusLineIndent { + t.Errorf("statusLinePrefix len = %d; want statusLineIndent = %d", + len(statusLinePrefix), statusLineIndent) + } + if statusLinePrefix != strings.Repeat(" ", statusLineIndent) { + t.Errorf("statusLinePrefix = %q; want %d spaces", statusLinePrefix, statusLineIndent) + } +} From 4b0ae88e1597fd91a154f2c9c27af5d13ae3ee60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 10:00:25 +0000 Subject: [PATCH 08/13] Apply remaining changes Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 47f5c6a..d106cd9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/DevExpGBB/gh-devlake go 1.22 require ( + github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 golang.org/x/term v0.25.0 @@ -11,6 +12,5 @@ require ( require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect golang.org/x/sys v0.26.0 // indirect ) From e79a54bc92fea93f9429b2d8da755458ec7db2de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:23:07 +0000 Subject: [PATCH 09/13] Handle progress edge cases Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/progress.go | 18 ++++++++++++++++-- cmd/progress_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/cmd/progress.go b/cmd/progress.go index edf3339..da5146b 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -63,9 +63,16 @@ func renderBar(current, total, width int) string { if total <= 0 { return "[" + strings.Repeat("░", width) + "]" } + if current < 0 { + current = 0 + } else if current > total { + current = total + } // Use rounding division to avoid premature completion or invisible early progress. filled := (width*current + total/2) / total - if filled > width { + if filled < 0 { + filled = 0 + } else if filled > width { filled = width } return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" @@ -118,7 +125,14 @@ var spinChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", // Returns fn's error. func spinWhile(label string, fn func() error) error { done := make(chan error, 1) - go func() { done <- fn() }() + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic in spinner task: %v", r) + } + }() + done <- fn() + }() start := time.Now() i := 0 diff --git a/cmd/progress_test.go b/cmd/progress_test.go index 1a3e98e..275a23f 100644 --- a/cmd/progress_test.go +++ b/cmd/progress_test.go @@ -3,6 +3,7 @@ package cmd import ( "strings" "testing" + "time" ) // TestTruncatePad checks the display-width-aware pad/truncate helper. @@ -96,6 +97,13 @@ func TestRenderBar(t *testing.T) { width: 4, want: "[░░░░]", }, + { + name: "current negative — clamped to empty", + current: -1, + total: 10, + width: 4, + want: "[░░░░]", + }, { name: "current equals total — all filled", current: 10, @@ -160,3 +168,28 @@ func TestStatusLinePrefix(t *testing.T) { t.Errorf("statusLinePrefix = %q; want %d spaces", statusLinePrefix, statusLineIndent) } } + +func TestSpinWhileRecover(t *testing.T) { + errCh := make(chan error, 1) + go func() { + var err error + _ = captureStdout(func() { + err = spinWhile("label", func() error { + panic("boom") + }) + }) + errCh <- err + }() + + select { + case err := <-errCh: + if err == nil { + t.Fatal("spinWhile returned nil error after panic") + } + if !strings.Contains(err.Error(), "panic in spinner task: boom") { + t.Fatalf("spinWhile error = %q; want panic message", err) + } + case <-time.After(time.Second): + t.Fatal("spinWhile did not return after panic") + } +} From 372b31b26d8a390f53a340667a60f0acc6fb177d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:24:27 +0000 Subject: [PATCH 10/13] Trim redundant progress branch Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/progress.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/progress.go b/cmd/progress.go index da5146b..adb9018 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -70,9 +70,7 @@ func renderBar(current, total, width int) string { } // Use rounding division to avoid premature completion or invisible early progress. filled := (width*current + total/2) / total - if filled < 0 { - filled = 0 - } else if filled > width { + if filled > width { filled = width } return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" From a4ed38aa54f88828fcc6227adb0d7b06b0a225cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:25:22 +0000 Subject: [PATCH 11/13] Clarify progress helper tests Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/progress.go | 11 ++++++----- cmd/progress_test.go | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/progress.go b/cmd/progress.go index adb9018..17a0d58 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -63,13 +63,14 @@ func renderBar(current, total, width int) string { if total <= 0 { return "[" + strings.Repeat("░", width) + "]" } - if current < 0 { - current = 0 - } else if current > total { - current = total + clampedCurrent := current + if clampedCurrent < 0 { + clampedCurrent = 0 + } else if clampedCurrent > total { + clampedCurrent = total } // Use rounding division to avoid premature completion or invisible early progress. - filled := (width*current + total/2) / total + filled := (width*clampedCurrent + total/2) / total if filled > width { filled = width } diff --git a/cmd/progress_test.go b/cmd/progress_test.go index 275a23f..038f574 100644 --- a/cmd/progress_test.go +++ b/cmd/progress_test.go @@ -173,7 +173,7 @@ func TestSpinWhileRecover(t *testing.T) { errCh := make(chan error, 1) go func() { var err error - _ = captureStdout(func() { + captureStdout(func() { err = spinWhile("label", func() error { panic("boom") }) From 095002cc12a4df055ab1bf5ec71188e021eb27da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 11:26:17 +0000 Subject: [PATCH 12/13] Simplify spinner panic test Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/progress_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/progress_test.go b/cmd/progress_test.go index 038f574..0536b0b 100644 --- a/cmd/progress_test.go +++ b/cmd/progress_test.go @@ -172,13 +172,11 @@ func TestStatusLinePrefix(t *testing.T) { func TestSpinWhileRecover(t *testing.T) { errCh := make(chan error, 1) go func() { - var err error captureStdout(func() { - err = spinWhile("label", func() error { + errCh <- spinWhile("label", func() error { panic("boom") }) }) - errCh <- err }() select { From 9efbc8b431ab1800a2fddad964fc9403566f8712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 13:46:48 +0000 Subject: [PATCH 13/13] Fix review feedback for progress output safety and TTY behavior Co-authored-by: ewega <26189114+ewega@users.noreply.github.com> --- cmd/progress.go | 126 +++++++++++++++++++++++++++++++++++++------ cmd/progress_test.go | 68 +++++++++++++++++++++++ go.mod | 2 - go.sum | 4 -- 4 files changed, 178 insertions(+), 22 deletions(-) diff --git a/cmd/progress.go b/cmd/progress.go index 17a0d58..239e558 100644 --- a/cmd/progress.go +++ b/cmd/progress.go @@ -2,10 +2,13 @@ package cmd import ( "fmt" + "io" + "os" "strings" "time" + "unicode" - "github.com/mattn/go-runewidth" + "golang.org/x/term" ) // ── Progress bar ───────────────────────────────────────────────── @@ -34,27 +37,100 @@ var statusLinePrefix = strings.Repeat(" ", statusLineIndent) // truncatePad returns s padded with spaces to exactly width display columns, // or truncated to (width-1) display columns followed by "…" if s is wider. -// Width is measured in terminal display columns using go-runewidth so that -// wide characters (e.g. CJK, emoji) and multi-byte sequences are handled -// correctly. func truncatePad(s string, width int) string { - sw := runewidth.StringWidth(s) + sw := stringDisplayWidth(s) if sw > width { - return runewidth.Truncate(s, width, "…") + s = truncateDisplayWidth(s, width) + sw = stringDisplayWidth(s) } return s + strings.Repeat(" ", width-sw) } +func stringDisplayWidth(s string) int { + width := 0 + for _, r := range s { + width += runeDisplayWidth(r) + } + return width +} + +func runeDisplayWidth(r rune) int { + switch { + case r == 0: + return 0 + case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): + return 0 + // Approximate wcwidth-style "wide" runes (East Asian wide/fullwidth + emoji). + case r >= 0x1100 && (r <= 0x115F || + r == 0x2329 || r == 0x232A || + (0x2E80 <= r && r <= 0xA4CF && r != 0x303F) || + (0xAC00 <= r && r <= 0xD7A3) || + (0xF900 <= r && r <= 0xFAFF) || + (0xFE10 <= r && r <= 0xFE19) || + (0xFE30 <= r && r <= 0xFE6F) || + (0xFF00 <= r && r <= 0xFF60) || + (0xFFE0 <= r && r <= 0xFFE6) || + (0x1F300 <= r && r <= 0x1FAFF)): + return 2 + default: + return 1 + } +} + +func truncateDisplayWidth(s string, width int) string { + if width <= 0 { + return "" + } + if width == 1 { + return "…" + } + + limit := width - 1 + var b strings.Builder + used := 0 + for _, r := range s { + rw := runeDisplayWidth(r) + if used+rw > limit { + break + } + b.WriteRune(r) + used += rw + } + b.WriteRune('…') + return b.String() +} + // progressBar renders an in-place terminal progress bar using \r. // Create with newProgressBar, call update to redraw, and done to finish. type progressBar struct { - total int - width int - start time.Time + total int + width int + start time.Time + out io.Writer + interactive bool +} + +var progressWriter = func() io.Writer { + if outputJSON { + return os.Stderr + } + return os.Stdout +} + +var progressWriterIsTerminal = func(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) } func newProgressBar(total int) *progressBar { - return &progressBar{total: total, width: progressBarWidth, start: time.Now()} + out := progressWriter() + return &progressBar{ + total: total, + width: progressBarWidth, + start: time.Now(), + out: out, + interactive: progressWriterIsTerminal(out), + } } // renderBar returns a [████░░░░] string representing current/total progress. @@ -85,18 +161,25 @@ func (p *progressBar) update(current int, label string) { bar := renderBar(current, p.total, p.width) elapsed := time.Since(p.start).Truncate(time.Second) line := fmt.Sprintf("%s%s %2d/%-2d %s (%s elapsed)", statusLinePrefix, bar, current, p.total, label, elapsed) - fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) + if p.interactive { + fmt.Fprintf(p.out, "\r%s", truncatePad(line, progressLineWidth)) + return + } + fmt.Fprintln(p.out, line) } // clear erases the progress bar line and returns the cursor to column 0. func (p *progressBar) clear() { - fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + if !p.interactive { + return + } + fmt.Fprintf(p.out, "\r%s\r", strings.Repeat(" ", progressLineWidth)) } // done clears the bar and prints a completion message. func (p *progressBar) done(msg string) { p.clear() - fmt.Println(statusLinePrefix + msg) + fmt.Fprintln(p.out, statusLinePrefix+msg) } // countdown shows a progress bar that ticks every second for n seconds, @@ -123,6 +206,8 @@ var spinChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", // so the caller's error message lands on a clean line. // Returns fn's error. func spinWhile(label string, fn func() error) error { + out := progressWriter() + interactive := progressWriterIsTerminal(out) done := make(chan error, 1) go func() { defer func() { @@ -134,6 +219,15 @@ func spinWhile(label string, fn func() error) error { }() start := time.Now() + if !interactive { + err := <-done + if err == nil { + elapsed := time.Since(start).Truncate(time.Second) + fmt.Fprintf(out, "%s✅ Done (%s)\n", statusLinePrefix, elapsed) + } + return err + } + i := 0 ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -142,15 +236,15 @@ func spinWhile(label string, fn func() error) error { select { case err := <-done: elapsed := time.Since(start).Truncate(time.Second) - fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", progressLineWidth)) if err == nil { - fmt.Printf("%s✅ Done (%s)\n", statusLinePrefix, elapsed) + fmt.Fprintf(out, "%s✅ Done (%s)\n", statusLinePrefix, elapsed) } return err case <-ticker.C: elapsed := time.Since(start).Truncate(time.Second) line := fmt.Sprintf("%s%s %s (%s elapsed)", statusLinePrefix, spinChars[i%len(spinChars)], label, elapsed) - fmt.Printf("\r%s", truncatePad(line, progressLineWidth)) + fmt.Fprintf(out, "\r%s", truncatePad(line, progressLineWidth)) i++ } } diff --git a/cmd/progress_test.go b/cmd/progress_test.go index 0536b0b..ee32cf5 100644 --- a/cmd/progress_test.go +++ b/cmd/progress_test.go @@ -1,6 +1,11 @@ package cmd import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" "strings" "testing" "time" @@ -50,6 +55,12 @@ func TestTruncatePad(t *testing.T) { width: 5, want: "日本…", // "日"(2)+"本"(2) = 4 < 5, next would be 6 > 5, so truncate to 4 and add "…" → 5 cols }, + { + name: "fullwidth latin char — counts as 2 display columns", + s: "ABC", // fullwidth A (2) + B (1) + C (1) = 4 + width: 3, + want: "A…", + }, { name: "width 1 — single ellipsis", s: "hello", @@ -191,3 +202,60 @@ func TestSpinWhileRecover(t *testing.T) { t.Fatal("spinWhile did not return after panic") } } + +func TestProgressBarNonInteractiveNoCarriageReturns(t *testing.T) { + origWriter := progressWriter + origIsTerminal := progressWriterIsTerminal + t.Cleanup(func() { + progressWriter = origWriter + progressWriterIsTerminal = origIsTerminal + }) + + var buf bytes.Buffer + progressWriter = func() io.Writer { return &buf } + progressWriterIsTerminal = func(io.Writer) bool { return false } + + bar := newProgressBar(3) + bar.update(1, "waiting") + bar.clear() + bar.done("done") + + out := buf.String() + if strings.Contains(out, "\r") { + t.Fatalf("non-interactive progress output should not contain carriage returns: %q", out) + } + if !strings.Contains(out, "done\n") { + t.Fatalf("expected completion line in output, got %q", out) + } +} + +func TestWaitForReadyJSONModeKeepsStdoutClean(t *testing.T) { + origJSON := outputJSON + outputJSON = true + t.Cleanup(func() { outputJSON = origJSON }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = origStdout }) + t.Cleanup(func() { _ = r.Close() }) + + if err := waitForReady(srv.URL, 1, time.Millisecond); err != nil { + t.Fatalf("waitForReady returned error: %v", err) + } + + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + if got := buf.String(); got != "" { + t.Fatalf("stdout should be empty in JSON mode, got %q", got) + } +} diff --git a/go.mod b/go.mod index d106cd9..3f105c3 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,12 @@ module github.com/DevExpGBB/gh-devlake go 1.22 require ( - github.com/mattn/go-runewidth v0.0.23 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 golang.org/x/term v0.25.0 ) require ( - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect golang.org/x/sys v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index 2d511d0..63acd8d 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,6 @@ -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=