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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.beads/
.claude/
.runtime/
CLAUDE.local.md
coverage.txt
/bin/
Expand Down
97 changes: 97 additions & 0 deletions cmd/wl/cmd_herald.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"fmt"
"io"
"time"

"github.com/gastownhall/wasteland/internal/commons"
"github.com/gastownhall/wasteland/internal/herald"
"github.com/gastownhall/wasteland/internal/xdg"
"github.com/spf13/cobra"
)

func newHeraldCmd(stdout, stderr io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "herald",
Short: "Watch the wanted board for changes",
Long: `Herald polls the wanted board and reports new, removed, or status-changed items.

On first run it snapshots the current board. Subsequent runs diff against the
saved state and print changes. Use --watch to poll continuously.

State is persisted as JSON in the XDG data directory.

Examples:
wl herald # one-shot diff since last run
wl herald --watch # poll every 60s (Ctrl-C to stop)
wl herald --interval 30s --watch`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
watch, _ := cmd.Flags().GetBool("watch")
intervalStr, _ := cmd.Flags().GetString("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid --interval: %w", err)
}
return runHerald(cmd, stdout, stderr, watch, interval)
},
}
cmd.Flags().Bool("watch", false, "Poll continuously instead of one-shot")
cmd.Flags().String("interval", "60s", "Poll interval (e.g. 30s, 2m)")
return cmd
}

func runHerald(cmd *cobra.Command, stdout, stderr io.Writer, watch bool, interval time.Duration) error {
cfg, err := resolveWasteland(cmd)
if err != nil {
return err
}
db, err := openDBFromConfig(cfg)
if err != nil {
return err
}

lister := &wantedBoardLister{db: db}
notifier := &herald.LogNotifier{Printf: func(format string, args ...any) {
fmt.Fprintf(stdout, format, args...)
}}
statePath := herald.DefaultStatePath(xdg.DataDir())
store := herald.NewStateStore(statePath)

if !watch {
return herald.Poll(lister, notifier, store)
}

fmt.Fprintf(stderr, "Watching wanted board (every %s, Ctrl-C to stop)...\n", interval)
for {
if err := herald.Poll(lister, notifier, store); err != nil {
fmt.Fprintf(stderr, "herald: poll error: %v\n", err)
}
time.Sleep(interval)
}
}

// wantedBoardLister adapts the commons browse query into the herald.Lister interface.
type wantedBoardLister struct {
db commons.DB
}

func (l *wantedBoardLister) ListItems() ([]herald.Item, error) {
summaries, err := commons.BrowseWanted(l.db, commons.BrowseFilter{
Limit: 500,
Sort: commons.SortNewest,
})
if err != nil {
return nil, err
}
items := make([]herald.Item, len(summaries))
for i, s := range summaries {
items[i] = herald.Item{
ID: s.ID,
Title: s.Title,
Status: s.Status,
}
}
return items, nil
}
82 changes: 82 additions & 0 deletions cmd/wl/cmd_herald_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"bytes"
"strings"
"testing"

"github.com/gastownhall/wasteland/internal/commons"
"github.com/gastownhall/wasteland/internal/federation"
)

// csvDB returns a fixed CSV response for Query calls.
type csvDB struct {
noopDB
csv string
}

func (d *csvDB) Query(string, string) (string, error) { return d.csv, nil }

func TestRunHerald_OneShotFirstRun(t *testing.T) {
saveWasteland(t)
t.Setenv("XDG_DATA_HOME", t.TempDir())

db := &csvDB{csv: "id,title,project,type,priority,posted_by,claimed_by,status,effort_level\nw-1,Fix bug,,task,1,alice,,open,medium\n"}
oldDBFromConfig := openDBFromConfig
openDBFromConfig = func(*federation.Config) (commons.DB, error) { return db, nil }
t.Cleanup(func() { openDBFromConfig = oldDBFromConfig })

var stdout, stderr bytes.Buffer
cmd := wastelandCmd()
err := runHerald(cmd, &stdout, &stderr, false, 0)
if err != nil {
t.Fatalf("runHerald() error: %v", err)
}

got := stdout.String()
if !strings.Contains(got, "[+]") {
t.Errorf("expected [+] for new item, got %q", got)
}
if !strings.Contains(got, "w-1") {
t.Errorf("expected item ID w-1, got %q", got)
}
}

func TestRunHerald_OneShotNoChanges(t *testing.T) {
saveWasteland(t)
t.Setenv("XDG_DATA_HOME", t.TempDir())

db := &csvDB{csv: "id,title,project,type,priority,posted_by,claimed_by,status,effort_level\nw-1,Fix bug,,task,1,alice,,open,medium\n"}
oldDBFromConfig := openDBFromConfig
openDBFromConfig = func(*federation.Config) (commons.DB, error) { return db, nil }
t.Cleanup(func() { openDBFromConfig = oldDBFromConfig })

var stdout, stderr bytes.Buffer
cmd := wastelandCmd()

// First run seeds state.
if err := runHerald(cmd, &stdout, &stderr, false, 0); err != nil {
t.Fatalf("first poll: %v", err)
}

// Second run should produce no output.
stdout.Reset()
if err := runHerald(cmd, &stdout, &stderr, false, 0); err != nil {
t.Fatalf("second poll: %v", err)
}
if stdout.String() != "" {
t.Errorf("expected no output on second poll, got %q", stdout.String())
}
}

func TestRunHerald_NotJoined(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())

var stdout, stderr bytes.Buffer
cmd := wastelandCmd()
err := runHerald(cmd, &stdout, &stderr, false, 0)
if err == nil {
t.Fatal("expected error when not joined")
}
}
1 change: 1 addition & 0 deletions cmd/wl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
newDoctorCmd(stdout, stderr),
newLeaderboardCmd(stdout, stderr),
newProfileCmd(stdout, stderr),
newHeraldCmd(stdout, stderr),
newVersionCmd(stdout),
)
if inferGateEnabled() {
Expand Down
181 changes: 181 additions & 0 deletions internal/herald/herald.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Package herald watches the wanted board and notifies on changes.
//
// Herald polls the wanted board via a Lister, diffs against persisted state,
// and dispatches Change events through a pluggable Notifier interface.
package herald

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)

// Item is a snapshot of a wanted board entry for diffing purposes.
type Item struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}

// ChangeKind describes what changed between polls.
type ChangeKind string

// Change kind constants.
const (
Added ChangeKind = "added"
Removed ChangeKind = "removed"
StatusChanged ChangeKind = "status_changed"
)

// Change describes a single wanted-board mutation detected by a poll.
type Change struct {
Kind ChangeKind `json:"kind"`
Item Item `json:"item"`
OldStatus string `json:"old_status,omitempty"` // only for StatusChanged
}

// Lister fetches the current wanted board snapshot.
type Lister interface {
ListItems() ([]Item, error)
}

// Notifier receives change notifications.
type Notifier interface {
Notify(changes []Change) error
}

// StateStore persists the last-seen snapshot to disk as JSON.
type StateStore struct {
path string
}

// NewStateStore creates a StateStore that reads/writes state at the given path.
func NewStateStore(path string) *StateStore {
return &StateStore{path: path}
}

// DefaultStatePath returns the default state file path under XDG data dir.
func DefaultStatePath(dataDir string) string {
return filepath.Join(dataDir, "herald-state.json")
}

// state is the on-disk format.
type state struct {
Items []Item `json:"items"`
UpdatedAt time.Time `json:"updated_at"`
}

// Load reads persisted state. Returns nil items (not error) if file missing.
func (s *StateStore) Load() ([]Item, error) {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading herald state: %w", err)
}
var st state
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parsing herald state: %w", err)
}
return st.Items, nil
}

// Save persists the current snapshot.
func (s *StateStore) Save(items []Item) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("creating herald state dir: %w", err)
}
st := state{Items: items, UpdatedAt: time.Now().UTC()}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("marshaling herald state: %w", err)
}
return os.WriteFile(s.path, data, 0o644)
}

// Diff computes changes between old and new snapshots.
func Diff(old, cur []Item) []Change {
oldMap := make(map[string]Item, len(old))
for _, item := range old {
oldMap[item.ID] = item
}
curMap := make(map[string]Item, len(cur))
for _, item := range cur {
curMap[item.ID] = item
}

var changes []Change

// Detect added and status-changed items.
for _, item := range cur {
prev, existed := oldMap[item.ID]
if !existed {
changes = append(changes, Change{Kind: Added, Item: item})
} else if prev.Status != item.Status {
changes = append(changes, Change{
Kind: StatusChanged,
Item: item,
OldStatus: prev.Status,
})
}
}

// Detect removed items.
for _, item := range old {
if _, exists := curMap[item.ID]; !exists {
changes = append(changes, Change{Kind: Removed, Item: item})
}
}

return changes
}

// Poll performs a single poll cycle: list → diff → notify → save.
func Poll(lister Lister, notifier Notifier, store *StateStore) error {
current, err := lister.ListItems()
if err != nil {
return fmt.Errorf("listing wanted board: %w", err)
}

previous, err := store.Load()
if err != nil {
return fmt.Errorf("loading state: %w", err)
}

changes := Diff(previous, current)

if len(changes) > 0 {
if err := notifier.Notify(changes); err != nil {
return fmt.Errorf("notifying: %w", err)
}
}

if err := store.Save(current); err != nil {
return fmt.Errorf("saving state: %w", err)
}

return nil
}

// LogNotifier writes changes to an io.Writer (e.g. os.Stdout).
type LogNotifier struct {
Printf func(format string, args ...any)
}

// Notify prints each change.
func (n *LogNotifier) Notify(changes []Change) error {
for _, c := range changes {
switch c.Kind {
case Added:
n.Printf("[+] %s: %s (%s)\n", c.Item.ID, c.Item.Title, c.Item.Status)
case Removed:
n.Printf("[-] %s: %s\n", c.Item.ID, c.Item.Title)
case StatusChanged:
n.Printf("[~] %s: %s (%s → %s)\n", c.Item.ID, c.Item.Title, c.OldStatus, c.Item.Status)
}
}
return nil
}
Loading