Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions cmd/preflight/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import (
type EventType string

const (
EventOperation EventType = "operation"
EventBuildStatus EventType = "build_status"
EventJobFailure EventType = "job_failure"
EventBuildSummary EventType = "build_summary"
EventTestFailure EventType = "test_failure"
EventOperation EventType = "operation"
EventBuildStatus EventType = "build_status"
EventJobFailure EventType = "job_failure"
EventJobRetryPassed EventType = "job_retry_passed"
EventBuildSummary EventType = "build_summary"
EventTestFailure EventType = "test_failure"
)

// Event is the single data model emitted by a preflight run.
Expand All @@ -39,7 +40,7 @@ type Event struct {

Jobs *watch.JobSummary `json:"jobs,omitempty"`

// Job is set for job_failure events.
// Job is set for job_failure and job_retry_passed events.
Job *buildkite.Job `json:"job,omitempty"`

// FailedJobs is set for build_summary events when the build failed. Contains hard-failed jobs only (soft failures excluded).
Expand Down
15 changes: 15 additions & 0 deletions cmd/preflight/job_presenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ func (p jobPresenter) PassedLine(j buildkite.Job) string {
return fmt.Sprintf("✔ %s", name)
}

func (p jobPresenter) RetryPassedLine(j buildkite.Job) string {
name := watch.NewFormattedJob(j).DisplayName()
return fmt.Sprintf("✔ %s passed on retry (attempt %d)", name, j.RetriesCount+1)
}

func (p jobPresenter) ColoredRetryPassedLine(j buildkite.Job) string {
emojiPrefix, textName := emoji.Split(watch.NewFormattedJob(j).DisplayName())
style := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
detail := fmt.Sprintf("passed on retry (attempt %d)", j.RetriesCount+1)
if emojiPrefix != "" {
return style.Render("✔ ") + emoji.Render(emojiPrefix) + " " + style.Render(fmt.Sprintf("%s %s", textName, detail))
}
return style.Render(fmt.Sprintf("✔ %s %s", textName, detail))
}

func (p jobPresenter) ColoredPassedLine(j buildkite.Job, style lipgloss.Style) string {
emojiPrefix, textName := emoji.Split(watch.NewFormattedJob(j).DisplayName())
if emojiPrefix != "" {
Expand Down
36 changes: 36 additions & 0 deletions cmd/preflight/job_presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,42 @@ func TestJobPresenter_PassedLine_WithEmoji(t *testing.T) {
}
}

func TestJobPresenter_RetryPassedLine(t *testing.T) {
line := jobPresenter{
pipeline: "buildkite/cli",
buildNumber: 42,
}.RetryPassedLine(buildkite.Job{ID: "retry-1", Name: "Lint", Type: "script", State: "passed", RetriesCount: 1})

assertStringContainsAll(t, line, []string{"✔ Lint", "passed on retry", "attempt 2"})
}

func TestJobPresenter_RetryPassedLine_MultipleRetries(t *testing.T) {
line := jobPresenter{
pipeline: "buildkite/cli",
buildNumber: 42,
}.RetryPassedLine(buildkite.Job{ID: "retry-2", Name: "Test", Type: "script", State: "passed", RetriesCount: 2})

assertStringContainsAll(t, line, []string{"✔ Test", "passed on retry", "attempt 3"})
}

func TestJobPresenter_ColoredRetryPassedLine(t *testing.T) {
line := jobPresenter{
pipeline: "buildkite/cli",
buildNumber: 42,
}.ColoredRetryPassedLine(buildkite.Job{ID: "retry-1", Name: "Lint", Type: "script", State: "passed", RetriesCount: 1})

assertStringContainsAll(t, line, []string{"✔", "Lint", "passed on retry", "attempt 2"})
}

func TestJobPresenter_ColoredRetryPassedLine_WithEmoji(t *testing.T) {
line := jobPresenter{
pipeline: "buildkite/cli",
buildNumber: 42,
}.ColoredRetryPassedLine(buildkite.Job{ID: "retry-1", Name: ":docker: Build image", Type: "script", State: "passed", RetriesCount: 1})

assertStringContainsAll(t, line, []string{"✔", "Build image", "passed on retry"})
}

func TestJobPresenter_ColoredLine(t *testing.T) {
startedAt := buildkite.Timestamp{Time: time.Now().Add(-90 * time.Second)}
finishedAt := buildkite.Timestamp{Time: time.Now().Add(-15 * time.Second)}
Expand Down
14 changes: 13 additions & 1 deletion cmd/preflight/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,18 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
return err
}
}
for _, retryPassed := range status.NewlyRetryPassed {
if err := renderer.Render(Event{
Type: EventJobRetryPassed,
Time: time.Now(),
PreflightID: preflightID.String(),
Pipeline: pipelineName,
BuildNumber: build.Number,
Job: &retryPassed,
}); err != nil {
return err
}
}
return renderer.Render(Event{
Type: EventBuildStatus,
Time: time.Now(),
Expand All @@ -194,7 +206,7 @@ func (c *PreflightCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error
BuildState: b.State,
Jobs: &status.Summary,
})
}, watch.WithTestTracking(func(newTestChanges []buildkite.BuildTest) error {
}, watch.WithRetriedJobs(), watch.WithTestTracking(func(newTestChanges []buildkite.BuildTest) error {
return renderer.Render(Event{
Type: EventTestFailure,
Time: time.Now(),
Expand Down
7 changes: 7 additions & 0 deletions cmd/preflight/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ func (r *plainRenderer) Render(e Event) error {
return err
}

case EventJobRetryPassed:
if e.Job != nil {
presenter := jobPresenter{pipeline: e.Pipeline, buildNumber: e.BuildNumber}
_, err := fmt.Fprintf(r.stdout, "%s%s\n", prefix, presenter.RetryPassedLine(*e.Job))
return err
}

case EventBuildSummary:
header := summaryHeader(e)
if _, err := fmt.Fprintf(r.stdout, "\n%s\n", header); err != nil {
Expand Down
66 changes: 66 additions & 0 deletions cmd/preflight/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,72 @@ func TestPlainRenderer_Render_JobFailure(t *testing.T) {
}
}

func TestPlainRenderer_Render_JobRetryPassed(t *testing.T) {
var out bytes.Buffer
r := newPlainRenderer(&out)

now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)
r.Render(Event{
Type: EventJobRetryPassed,
Time: now,
Job: &buildkite.Job{
ID: "retry-1",
Name: "Lint",
Type: "script",
State: "passed",
RetriesCount: 1,
},
})

got := out.String()
if !strings.Contains(got, "10:31:00") {
t.Fatalf("expected timestamp, got %q", got)
}
if !strings.Contains(got, "✔ Lint") {
t.Fatalf("expected check mark and job name, got %q", got)
}
if !strings.Contains(got, "passed on retry") {
t.Fatalf("expected retry text, got %q", got)
}
if !strings.Contains(got, "attempt 2") {
t.Fatalf("expected attempt count, got %q", got)
}
}

func TestJSONRenderer_Render_JobRetryPassed(t *testing.T) {
var out bytes.Buffer
r := newJSONRenderer(&out)

now := time.Date(2025, 1, 15, 10, 31, 0, 0, time.UTC)
r.Render(Event{
Type: EventJobRetryPassed,
Time: now,
PreflightID: "pfid-123",
Pipeline: "buildkite/cli",
BuildNumber: 42,
Job: &buildkite.Job{
ID: "retry-1",
Name: "Lint",
State: "passed",
RetriesCount: 1,
},
})

var got Event
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got.Type != EventJobRetryPassed {
t.Fatalf("expected type %q, got %q", EventJobRetryPassed, got.Type)
}
if got.Job == nil {
t.Fatal("expected job to be present")
}
if got.Job.RetriesCount != 1 {
t.Fatalf("expected retries count 1, got %d", got.Job.RetriesCount)
}
}

func TestPlainRenderer_Render_TestFailure(t *testing.T) {
var out bytes.Buffer
r := newPlainRenderer(&out)
Expand Down
7 changes: 7 additions & 0 deletions cmd/preflight/tty.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ func (m ttyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Printf("%s", m.hardwrapLine(line))
}

case EventJobRetryPassed:
if msg.Job != nil {
presenter := jobPresenter{pipeline: msg.Pipeline, buildNumber: msg.BuildNumber}
line := timestampPrefix(msg.Time) + presenter.ColoredRetryPassedLine(*msg.Job)
return m, tea.Printf("%s", m.hardwrapLine(line))
}

case EventBuildSummary:
// Print the summary via Printf (which scrolls it above the
// view) instead of rendering it through View(). Inline-image
Expand Down
47 changes: 34 additions & 13 deletions internal/build/watch/tracker.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import (

// trackedJob holds a job and its lifecycle state across polls.
type trackedJob struct {
Job buildkite.Job
PrevState string // state from previous poll, "" if first seen
Reported bool // true once surfaced to caller as failed
Job buildkite.Job
PrevState string // state from previous poll, "" if first seen
Reported bool // true once surfaced to caller as failed
RetryReported bool // true once surfaced to caller as retry-passed
}

// JobSummary aggregates job counts by high-level state.
Expand Down Expand Up @@ -54,11 +55,12 @@ func (s JobSummary) String() string {

// BuildStatus is the output of JobTracker.Update().
type BuildStatus struct {
NewlyFailed []buildkite.Job
Running []buildkite.Job
TotalRunning int
Summary JobSummary
Build buildkite.Build
NewlyFailed []buildkite.Job
NewlyRetryPassed []buildkite.Job
Running []buildkite.Job
TotalRunning int
Summary JobSummary
Build buildkite.Build
}

// JobTracker tracks job state changes across polls.
Expand Down Expand Up @@ -106,31 +108,50 @@ func (t *JobTracker) Update(b buildkite.Build) BuildStatus {
}
}

// Second pass: detect retry jobs that just reached passed.
for _, j := range b.Jobs {
if j.Type != "script" || j.State != "passed" || j.RetriesCount == 0 {
continue
}
tj := t.jobs[j.ID]
if tj == nil || tj.RetryReported {
continue
}
for _, orig := range t.jobs {
if orig.Job.RetriedInJobID == j.ID && orig.Reported {
status.NewlyRetryPassed = append(status.NewlyRetryPassed, j)
tj.RetryReported = true
break
}
}
}

status.Summary = t.summarize(b)
status.TotalRunning = len(running)
status.Running = running

return status
}

// PassedJobs returns all jobs that passed, sorted by start time.
// PassedJobs returns all non-superseded jobs that passed, sorted by start time.
func (t *JobTracker) PassedJobs() []buildkite.Job {
var result []buildkite.Job
for _, tj := range t.jobs {
if tj.Job.State == "passed" {
if tj.Job.State == "passed" && !tj.Job.Retried {
result = append(result, tj.Job)
}
}
sortJobsByStartTime(result)
return result
}

// FailedJobs returns all hard-failed jobs (excludes soft failures), sorted by start time.
// FailedJobs returns all hard-failed, non-superseded jobs (excludes soft failures),
// sorted by start time.
func (t *JobTracker) FailedJobs() []buildkite.Job {
var result []buildkite.Job
for _, tj := range t.jobs {
job := NewFormattedJob(tj.Job)
if job.IsFailed() && !job.IsSoftFailed() {
if job.IsFailed() && !job.IsSoftFailed() && !tj.Job.Retried {
result = append(result, tj.Job)
}
}
Expand Down Expand Up @@ -159,7 +180,7 @@ func sortJobsByStartTime(jobs []buildkite.Job) {
func (t *JobTracker) summarize(b buildkite.Build) JobSummary {
var s JobSummary
for _, j := range b.Jobs {
if j.Type != "script" {
if j.Type != "script" || j.Retried {
continue
}
job := NewFormattedJob(j)
Expand Down
Loading