From dc931a08078b6b6712505e1209a5bc56873f79d5 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 30 Jul 2026 20:28:29 -0400 Subject: [PATCH] pipelines: standardize cli output --- internal/cli/pipeline_list.go | 53 ++++++++++++------------------ internal/cli/pipeline_list_test.go | 12 ++++++- internal/cli/pipeline_view.go | 19 ++++++++++- internal/cli/pipeline_view_test.go | 2 +- internal/cli/rows.go | 5 +-- 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/internal/cli/pipeline_list.go b/internal/cli/pipeline_list.go index d4ede27..a193dbb 100644 --- a/internal/cli/pipeline_list.go +++ b/internal/cli/pipeline_list.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "charm.land/lipgloss/v2" "github.com/alyraffauf/tg/internal/app" "github.com/spf13/cobra" ) @@ -41,7 +42,7 @@ func renderPipelineList(writer io.Writer, pipelines []app.Pipeline) { pipeline.ID, shortCommit(pipeline.Commit), pipelineTrigger(pipeline.Trigger), - pipelineStatusSummary(pipeline.Workflows), + pipelineStatusSummary(writer, pipeline.Workflows), shortDate(pipeline.CreatedAt), }) } @@ -57,44 +58,35 @@ func shortCommit(commit string) string { func pipelineTrigger(trigger map[string]any) string { triggerType, _ := trigger["$type"].(string) + ref, _ := trigger["ref"].(string) switch triggerType { case "sh.tangled.ci.trigger#push": - return joinTriggerDetails("push", triggerString(trigger, "ref")) + if ref == "" { + return "push" + } + return "push " + ref case "sh.tangled.ci.trigger#pullRequest": - sourceBranch := triggerString(trigger, "sourceBranch") - targetBranch := triggerString(trigger, "targetBranch") + sourceBranch, _ := trigger["sourceBranch"].(string) + targetBranch, _ := trigger["targetBranch"].(string) if sourceBranch != "" && targetBranch != "" { return "pull request " + sourceBranch + " → " + targetBranch } return "pull request" case "sh.tangled.ci.trigger#manual": - return joinTriggerDetails("manual", triggerString(trigger, "ref")) + if ref == "" { + return "manual" + } + return "manual " + ref default: return "unknown" } } -func triggerString(trigger map[string]any, key string) string { - value, _ := trigger[key].(string) - return value -} - -func joinTriggerDetails(kind, detail string) string { - if detail == "" { - return kind - } - return kind + " " + detail -} - -func pipelineStatusSummary(workflows []app.PipelineWorkflow) string { +func pipelineStatusSummary(writer io.Writer, workflows []app.PipelineWorkflow) string { counts := make(map[string]int) for _, workflow := range workflows { counts[workflow.Status]++ } - return strings.Join(statusSummaryParts(counts), " · ") -} - -func statusSummaryParts(counts map[string]int) []string { statuses := []struct { name string symbol string @@ -111,18 +103,15 @@ func statusSummaryParts(counts map[string]int) []string { parts := make([]string, 0, len(statuses)) for _, status := range statuses { if count := counts[status.name]; count > 0 { - parts = append(parts, status.symbol+" "+pluralizeStatus(count, status.label)) + part := status.symbol + " " + strconv.Itoa(count) + " " + status.label + if terminalColor := workflowStatusColor(status.name); isTerminal(writer) && terminalColor != nil { + part = lipgloss.NewStyle().Foreground(terminalColor).Render(part) + } + parts = append(parts, part) } } if len(parts) == 0 { - return []string{"unknown"} - } - return parts -} - -func pluralizeStatus(count int, label string) string { - if count == 1 { - return "1 " + label + return "unknown" } - return strconv.Itoa(count) + " " + label + return strings.Join(parts, " · ") } diff --git a/internal/cli/pipeline_list_test.go b/internal/cli/pipeline_list_test.go index 6353c68..53fe95d 100644 --- a/internal/cli/pipeline_list_test.go +++ b/internal/cli/pipeline_list_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "io" "strings" "testing" @@ -32,16 +33,25 @@ func TestRenderPipelineList(t *testing.T) { } func TestPipelineStatusSummary(t *testing.T) { + originalIsTerminal := isTerminal + t.Cleanup(func() { isTerminal = originalIsTerminal }) + workflows := []app.PipelineWorkflow{ {Name: "test", Status: "success"}, {Name: "lint", Status: "running"}, {Name: "build", Status: "failed"}, } - got := pipelineStatusSummary(workflows) + got := pipelineStatusSummary(&bytes.Buffer{}, workflows) const want = "✗ 1 failed · ● 1 running · ✓ 1 passed" if got != want { t.Fatalf("pipelineStatusSummary() = %q, want %q", got, want) } + + isTerminal = func(io.Writer) bool { return true } + colored := pipelineStatusSummary(&bytes.Buffer{}, workflows) + if !strings.Contains(colored, "\x1b[") { + t.Fatalf("terminal summary has no color: %q", colored) + } } func TestPipelineTrigger(t *testing.T) { diff --git a/internal/cli/pipeline_view.go b/internal/cli/pipeline_view.go index 136b3db..2943b27 100644 --- a/internal/cli/pipeline_view.go +++ b/internal/cli/pipeline_view.go @@ -57,7 +57,7 @@ func renderPipelineWorkflows(writer io.Writer, workflows []app.PipelineWorkflow) for _, workflow := range workflows { rows = append(rows, []string{ workflow.Name, - workflow.Status, + workflowStatusLabel(workflow.Status), workflow.StartedAt, workflow.FinishedAt, workflow.Error, @@ -66,3 +66,20 @@ func renderPipelineWorkflows(writer io.Writer, workflows []app.PipelineWorkflow) fmt.Fprintln(writer) renderTable(writer, []string{"WORKFLOW", "STATUS", "STARTED", "FINISHED", "ERROR"}, rows, "No workflows found.") } + +func workflowStatusLabel(status string) string { + switch status { + case "success": + return "✓ success" + case "failed", "timeout": + return "✗ " + status + case "running": + return "● running" + case "pending": + return "○ pending" + case "cancelled": + return "⊘ cancelled" + default: + return status + } +} diff --git a/internal/cli/pipeline_view_test.go b/internal/cli/pipeline_view_test.go index 3d329bf..3f4a262 100644 --- a/internal/cli/pipeline_view_test.go +++ b/internal/cli/pipeline_view_test.go @@ -22,7 +22,7 @@ func TestRenderPipelineDetail(t *testing.T) { var output bytes.Buffer renderPipelineDetail(&output, pipeline) - for _, expected := range []string{"ID:", "pipeline-123", "Trigger:", "manual main", "WORKFLOW", "exit status 1"} { + for _, expected := range []string{"ID:", "pipeline-123", "Trigger:", "manual main", "WORKFLOW", "✗ failed", "exit status 1"} { if !strings.Contains(output.String(), expected) { t.Errorf("output missing %q:\n%s", expected, output.String()) } diff --git a/internal/cli/rows.go b/internal/cli/rows.go index 4c5a5ef..971780d 100644 --- a/internal/cli/rows.go +++ b/internal/cli/rows.go @@ -136,14 +136,15 @@ func styleTableCell(style lipgloss.Style, header []string, rows [][]string, row, } case "title", "account": return style.Bold(true) - case "did", "rkey", "method", "updated": + case "did", "id", "rkey", "method", "updated": return style.Faint(true) } return style } func workflowStatusColor(status string) color.Color { - switch strings.ToLower(status) { + status = strings.TrimLeft(strings.ToLower(status), "✓✗●○⊘ ") + switch status { case "success": return lipgloss.Green case "failed", "timeout": -- 2.51.2