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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ up without restarting.
|---|---|
| `enter` | open the logs |
| `p` | the pull request this run built |
| `a` | send a desktop notification when this run finishes — press again to stop |
| `Q` | re-run — this pipeline, this branch |
| `N` | new run — asks which branch |
| `C` | cancel a run that has not finished |
Expand All @@ -358,6 +359,12 @@ Capitals because the lower-case letters are taken in views reachable from
here: `r` refreshes everywhere, `c` replies on a pull request the build list
links to.

A watched run is checked every twenty seconds for as long as boardwalk is
open, wherever you have gone since, and the header counts how many are being
watched. The notification says how it ended and, for a failure, the first task
that failed. On macOS it goes through `terminal-notifier` if installed, else
`osascript`; on Linux through `notify-send`.

### Logs

| key | |
Expand All @@ -366,6 +373,7 @@ links to.
| `e` | cut the pane down to its errors, and back |
| `g` / `G` | top / bottom |
| `t` | show / hide timestamps |
| `a` | send a desktop notification when the build finishes — press again to stop |
| `r` | refresh |
| `y` | copy the build id |
| `o` | open the build in the browser |
Expand Down
69 changes: 69 additions & 0 deletions internal/notify/notify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Package notify puts a message on the desktop.
//
// It is built the way the clipboard and browser actions in internal/ui are: a
// pure list of candidate commands per platform, tried in order, the first one
// on PATH winning. That keeps every platform's behaviour readable from a test
// on whichever machine the test runs.
package notify

import (
"fmt"
"os/exec"
"runtime"
"strings"
)

// Send posts a desktop notification.
func Send(title, message string) error {
return run(candidates(runtime.GOOS, title, message), exec.LookPath)
}

// candidates are the commands that can post the notification, best first.
//
// On macOS terminal-notifier goes ahead of osascript when it is installed: a
// notification posted through osascript belongs to Script Editor, which is
// what it is listed under in System Settings and what clicking it opens.
func candidates(goos, title, message string) [][]string {
switch goos {
case "darwin":
script := fmt.Sprintf("display notification %s with title %s",
appleString(message), appleString(title))
return [][]string{
{"terminal-notifier", "-title", title, "-message", message, "-group", "boardwalk"},
{"osascript", "-e", script},
}
case "windows":
return nil
default:
return [][]string{{"notify-send", "-a", "boardwalk", title, message}}
}
}

// run runs the first candidate on PATH and waits for it. A missing command and
// a command that refuses are different errors: the first names what to
// install, the second is the command's own complaint.
func run(candidates [][]string, lookPath func(string) (string, error)) error {
if len(candidates) == 0 {
return fmt.Errorf("desktop notifications are not supported on %s", runtime.GOOS)
}
var names []string
for _, argv := range candidates {
names = append(names, argv[0])
if _, err := lookPath(argv[0]); err != nil {
continue
}
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
if err != nil && len(out) > 0 {
return fmt.Errorf("%s: %w: %s", argv[0], err, strings.TrimSpace(string(out)))
}
return err
}
return fmt.Errorf("none of these are installed: %s", strings.Join(names, ", "))
}

// appleString quotes s as an AppleScript string literal.
func appleString(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
56 changes: 56 additions & 0 deletions internal/notify/notify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package notify

import (
"errors"
"reflect"
"strings"
"testing"
)

func TestCandidatesOnMacPreferTerminalNotifier(t *testing.T) {
got := candidates("darwin", "boardwalk", "api #12")
if len(got) != 2 || got[0][0] != "terminal-notifier" || got[1][0] != "osascript" {
t.Fatalf("candidates = %v, want terminal-notifier then osascript", got)
}
want := []string{"terminal-notifier", "-title", "boardwalk", "-message", "api #12", "-group", "boardwalk"}
if !reflect.DeepEqual(got[0], want) {
t.Errorf("terminal-notifier argv = %v, want %v", got[0], want)
}
}

func TestCandidatesOnMacEscapeTheScript(t *testing.T) {
script := candidates("darwin", `say "hi"`, `a\b "c"`)[1][2]
want := `display notification "a\\b \"c\"" with title "say \"hi\""`
if script != want {
t.Errorf("script = %s, want %s", script, want)
}
}

func TestCandidatesOnLinuxUseNotifySend(t *testing.T) {
got := candidates("linux", "t", "m")
want := [][]string{{"notify-send", "-a", "boardwalk", "t", "m"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("candidates = %v, want %v", got, want)
}
}

func TestCandidatesOnWindowsAreNone(t *testing.T) {
if got := candidates("windows", "t", "m"); len(got) != 0 {
t.Errorf("candidates = %v, want none", got)
}
}

func TestRunNamesWhatToInstall(t *testing.T) {
missing := func(string) (string, error) { return "", errors.New("not found") }
err := run(candidates("darwin", "t", "m"), missing)
if err == nil || !strings.Contains(err.Error(), "terminal-notifier, osascript") {
t.Errorf("err = %v, want it to name both commands", err)
}
}

func TestRunWithNoCandidatesSaysUnsupported(t *testing.T) {
err := run(nil, nil)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Errorf("err = %v, want not supported", err)
}
}
9 changes: 8 additions & 1 deletion internal/ui/builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,13 @@ func (m *Builds) Update(msg tea.Msg) (View, tea.Cmd) {
m.status, m.failed = "looking for the pull request…", false
return m, tea.Batch(m.fetchPullRequestLink(r.Build), m.work.begin(1))

case keyWatch.Help().Key:
r, ok := row.(buildRow)
if !hasRow || !ok {
return m, nil
}
return m, func() tea.Msg { return WatchBuildMsg{Build: r.Build} }

case "r":
m.status, m.failed = "refreshing…", false
return m, m.refresh()
Expand Down Expand Up @@ -672,7 +679,7 @@ func (m *Builds) Keys() help.KeyMap {
// and ? off the end of it at ordinary widths, which is the defect
// TestEveryViewsShortHelpSurvivesOrdinaryWidths exists to catch.
short := []key.Binding{keyLogs, keyLinkedPR}
full := []key.Binding{keyLogs, keyLinkedPR, keyRerun, keyQueue, keyCancelBuild}
full := []key.Binding{keyLogs, keyLinkedPR, keyWatch, keyRerun, keyQueue, keyCancelBuild}
return listKeys(short, full...)
}

Expand Down
199 changes: 199 additions & 0 deletions internal/ui/buildwatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package ui

import (
"fmt"
"sort"
"strings"
"time"

"github.com/JacobAtchley/boardwalk/internal/azdo"
"github.com/JacobAtchley/boardwalk/internal/notify"
tea "github.com/charmbracelet/bubbletea"
)

// watchInterval is how often watched builds are checked. A build takes
// minutes; learning it finished twenty seconds late costs nothing, and the
// check is one request per watched build.
const watchInterval = 20 * time.Second

// WatchBuildMsg asks Root to start watching a build, or to stop if it already
// is. A view sends it rather than keeping the watch itself because a watch has
// to outlive the view it was started from: the point is to stop looking at the
// build.
type WatchBuildMsg struct{ Build azdo.Build }

// buildWatchTickMsg is the watch's own clock.
type buildWatchTickMsg struct{}

// watchOutcome is one watched build that has finished, and what happened when
// the desktop was told.
type watchOutcome struct {
Build azdo.Build
Failures []azdo.Failure
SendErr error
}

// buildWatchResultMsg is what one pass over the watched builds found.
type buildWatchResultMsg struct {
Finished []watchOutcome
Errs []error
}

// buildWatch is the set of builds Root is waiting on, and the seams it talks
// to the world through. fetch, timeline and send are fields rather than calls
// on the client so a test can drive the whole cycle without a server or a
// desktop, the same way gateRuns takes its fetch.
type buildWatch struct {
watched map[int]azdo.Build
ticking bool
interval time.Duration

fetch func(id int) (azdo.Build, error)
timeline func(id int) (azdo.Progress, []azdo.Record, error)
send func(title, message string) error
}

func newBuildWatch(c *azdo.Client) *buildWatch {
return &buildWatch{
watched: map[int]azdo.Build{},
interval: watchInterval,
fetch: func(id int) (azdo.Build, error) { return c.BuildByID(id) },
timeline: func(id int) (azdo.Progress, []azdo.Record, error) { return c.Timeline(id) },
send: notify.Send,
}
}

// toggle starts or stops watching b, returning the status line to show and
// the tick to start, if this is the first build being watched.
func (w *buildWatch) toggle(b azdo.Build) (StatusMsg, tea.Cmd) {
label := buildLabel(b)
if _, ok := w.watched[b.ID]; ok {
delete(w.watched, b.ID)
return StatusMsg{Text: "stopped watching " + label}, nil
}
if b.Status.Done() {
return StatusMsg{Text: fmt.Sprintf("%s has already %s", label, b.Status)}, nil
}
w.watched[b.ID] = b
status := StatusMsg{Text: fmt.Sprintf("watching %s — you'll get a notification when it finishes", label)}
if w.ticking {
return status, nil
}
w.ticking = true
return status, w.tick()
}

func (w *buildWatch) tick() tea.Cmd {
return tea.Tick(w.interval, func(time.Time) tea.Msg { return buildWatchTickMsg{} })
}

// poll checks every watched build once, off the update loop: it makes a
// request per build and runs the notifier, and neither belongs on the goroutine
// that paints the screen. The ids are copied out first so the command never
// reads the map while Update writes it.
func (w *buildWatch) poll() tea.Cmd {
if len(w.watched) == 0 {
w.ticking = false
return nil
}
ids := make([]int, 0, len(w.watched))
for id := range w.watched {
ids = append(ids, id)
}
sort.Ints(ids)

fetch, timeline, send := w.fetch, w.timeline, w.send
return func() tea.Msg {
var msg buildWatchResultMsg
for _, id := range ids {
b, err := fetch(id)
if err != nil {
msg.Errs = append(msg.Errs, fmt.Errorf("could not check build %d: %w", id, err))
continue
}
if !b.Status.Done() {
continue
}
var failures []azdo.Failure
if b.Status == azdo.StatusFailed || b.Status == azdo.StatusPartial {
// A timeline that will not load still leaves a finished build
// worth announcing; it just cannot say where it broke.
if _, records, err := timeline(id); err == nil {
failures = azdo.Failures(records)
}
}
title, body := watchMessage(b, failures)
msg.Finished = append(msg.Finished, watchOutcome{Build: b, Failures: failures, SendErr: send(title, body)})
}
return msg
}
}

// apply drops the builds that finished, and schedules the next pass while any
// are left. A build whose check failed stays watched and is retried.
func (w *buildWatch) apply(msg buildWatchResultMsg) (StatusMsg, tea.Cmd) {
var parts []string
var failed bool
for _, o := range msg.Finished {
delete(w.watched, o.Build.ID)
parts = append(parts, watchSummary(o.Build, o.Failures))
if o.SendErr != nil {
parts = append(parts, "could not notify: "+o.SendErr.Error())
failed = true
}
}
for _, err := range msg.Errs {
parts = append(parts, err.Error())
failed = true
}

var next tea.Cmd
if len(w.watched) > 0 {
next = w.tick()
} else {
w.ticking = false
}
return StatusMsg{Text: strings.Join(parts, "; "), Err: failed}, next
}

// watchMessage is the notification for a finished build.
func watchMessage(b azdo.Build, failures []azdo.Failure) (title, body string) {
title = "boardwalk · build " + watchOutcomeWord(b.Status)
body = buildLabel(b)
if b.SourceBranch != "" {
body += " · " + shortRef(b.SourceBranch)
}
if len(failures) > 0 {
f := failures[0]
body += "\nfailed at " + f.Task
if len(f.Errors) > 0 {
body += ": " + truncate(f.Errors[0], 120)
}
}
return title, body
}

// watchSummary is the status line for a finished build.
func watchSummary(b azdo.Build, failures []azdo.Failure) string {
label := buildLabel(b)
switch b.Status {
case azdo.StatusSucceeded:
return "✓ " + label + " succeeded"
case azdo.StatusFailed, azdo.StatusPartial:
line := "✗ " + label + " " + watchOutcomeWord(b.Status)
if len(failures) > 0 {
line += " at " + failures[0].Task
}
return line
default:
return label + " " + b.Status.String()
}
}

// watchOutcomeWord is how a finished build's status reads in a sentence.
func watchOutcomeWord(s azdo.BuildStatus) string {
if s == azdo.StatusPartial {
return "partially succeeded"
}
return s.String()
}
Loading
Loading