feat: Wire MQ lifecycle events to gt feed display (gt-lak31)
- Add MQ event types and logging in mrqueue/events.go - Have refinery emit merge_started, merged, merge_failed, merge_skipped events - Create MQEventSource to read from mq_events.jsonl - Add MultiSource to combine events from bd activity and MQ events - Add color coding: green for merged, red for failed - Update feed help with MQ event symbols Events are stored in .beads/mq_events.jsonl and displayed in the feed TUI with appropriate symbols and colors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
152
internal/mrqueue/events.go
Normal file
152
internal/mrqueue/events.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package mrqueue provides merge request queue storage and events.
|
||||
package mrqueue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType represents the type of MQ lifecycle event.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// EventMergeStarted indicates refinery began processing an MR.
|
||||
EventMergeStarted EventType = "merge_started"
|
||||
// EventMerged indicates an MR was successfully merged.
|
||||
EventMerged EventType = "merged"
|
||||
// EventMergeFailed indicates a merge failed (conflict, tests, etc.).
|
||||
EventMergeFailed EventType = "merge_failed"
|
||||
// EventMergeSkipped indicates an MR was skipped (already merged, etc.).
|
||||
EventMergeSkipped EventType = "merge_skipped"
|
||||
)
|
||||
|
||||
// Event represents a single MQ lifecycle event.
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type EventType `json:"type"`
|
||||
MRID string `json:"mr_id"`
|
||||
Branch string `json:"branch"`
|
||||
Target string `json:"target"`
|
||||
Worker string `json:"worker,omitempty"`
|
||||
SourceIssue string `json:"source_issue,omitempty"`
|
||||
Rig string `json:"rig,omitempty"`
|
||||
MergeCommit string `json:"merge_commit,omitempty"` // For merged events
|
||||
Reason string `json:"reason,omitempty"` // For failed/skipped events
|
||||
}
|
||||
|
||||
// EventLogger handles writing MQ events to the event log.
|
||||
type EventLogger struct {
|
||||
logPath string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewEventLogger creates a new EventLogger for the given beads directory.
|
||||
func NewEventLogger(beadsDir string) *EventLogger {
|
||||
return &EventLogger{
|
||||
logPath: filepath.Join(beadsDir, "mq_events.jsonl"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewEventLoggerFromRig creates an EventLogger for the given rig path.
|
||||
func NewEventLoggerFromRig(rigPath string) *EventLogger {
|
||||
return NewEventLogger(filepath.Join(rigPath, ".beads"))
|
||||
}
|
||||
|
||||
// LogEvent writes an event to the MQ event log.
|
||||
func (l *EventLogger) LogEvent(event Event) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Ensure timestamp is set
|
||||
if event.Timestamp.IsZero() {
|
||||
event.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Ensure log directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(l.logPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal event to JSON
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling event: %w", err)
|
||||
}
|
||||
|
||||
// Append to log file
|
||||
f, err := os.OpenFile(l.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening event log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogMergeStarted logs a merge_started event.
|
||||
func (l *EventLogger) LogMergeStarted(mr *MR) error {
|
||||
return l.LogEvent(Event{
|
||||
Type: EventMergeStarted,
|
||||
MRID: mr.ID,
|
||||
Branch: mr.Branch,
|
||||
Target: mr.Target,
|
||||
Worker: mr.Worker,
|
||||
SourceIssue: mr.SourceIssue,
|
||||
Rig: mr.Rig,
|
||||
})
|
||||
}
|
||||
|
||||
// LogMerged logs a merged event.
|
||||
func (l *EventLogger) LogMerged(mr *MR, mergeCommit string) error {
|
||||
return l.LogEvent(Event{
|
||||
Type: EventMerged,
|
||||
MRID: mr.ID,
|
||||
Branch: mr.Branch,
|
||||
Target: mr.Target,
|
||||
Worker: mr.Worker,
|
||||
SourceIssue: mr.SourceIssue,
|
||||
Rig: mr.Rig,
|
||||
MergeCommit: mergeCommit,
|
||||
})
|
||||
}
|
||||
|
||||
// LogMergeFailed logs a merge_failed event.
|
||||
func (l *EventLogger) LogMergeFailed(mr *MR, reason string) error {
|
||||
return l.LogEvent(Event{
|
||||
Type: EventMergeFailed,
|
||||
MRID: mr.ID,
|
||||
Branch: mr.Branch,
|
||||
Target: mr.Target,
|
||||
Worker: mr.Worker,
|
||||
SourceIssue: mr.SourceIssue,
|
||||
Rig: mr.Rig,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// LogMergeSkipped logs a merge_skipped event.
|
||||
func (l *EventLogger) LogMergeSkipped(mr *MR, reason string) error {
|
||||
return l.LogEvent(Event{
|
||||
Type: EventMergeSkipped,
|
||||
MRID: mr.ID,
|
||||
Branch: mr.Branch,
|
||||
Target: mr.Target,
|
||||
Worker: mr.Worker,
|
||||
SourceIssue: mr.SourceIssue,
|
||||
Rig: mr.Rig,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// LogPath returns the path to the event log file.
|
||||
func (l *EventLogger) LogPath() string {
|
||||
return l.logPath
|
||||
}
|
||||
114
internal/mrqueue/events_test.go
Normal file
114
internal/mrqueue/events_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package mrqueue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventLogger(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "mrqueue-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create beads dir: %v", err)
|
||||
}
|
||||
|
||||
logger := NewEventLogger(beadsDir)
|
||||
|
||||
// Test MR
|
||||
mr := &MR{
|
||||
ID: "mr-test-123",
|
||||
Branch: "polecat/test",
|
||||
Target: "main",
|
||||
SourceIssue: "gt-abc",
|
||||
Worker: "test-worker",
|
||||
Rig: "test-rig",
|
||||
}
|
||||
|
||||
// Log merge_started
|
||||
if err := logger.LogMergeStarted(mr); err != nil {
|
||||
t.Errorf("LogMergeStarted failed: %v", err)
|
||||
}
|
||||
|
||||
// Log merged
|
||||
if err := logger.LogMerged(mr, "abc123def456"); err != nil {
|
||||
t.Errorf("LogMerged failed: %v", err)
|
||||
}
|
||||
|
||||
// Log merge_failed
|
||||
if err := logger.LogMergeFailed(mr, "conflict in file.go"); err != nil {
|
||||
t.Errorf("LogMergeFailed failed: %v", err)
|
||||
}
|
||||
|
||||
// Log merge_skipped
|
||||
if err := logger.LogMergeSkipped(mr, "already merged"); err != nil {
|
||||
t.Errorf("LogMergeSkipped failed: %v", err)
|
||||
}
|
||||
|
||||
// Read and verify events
|
||||
logPath := logger.LogPath()
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
lines := splitLines(string(data))
|
||||
if len(lines) != 4 {
|
||||
t.Errorf("Expected 4 events, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Verify each event type
|
||||
expectedTypes := []EventType{EventMergeStarted, EventMerged, EventMergeFailed, EventMergeSkipped}
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var event Event
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Errorf("Failed to parse event %d: %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Type != expectedTypes[i] {
|
||||
t.Errorf("Event %d: expected type %s, got %s", i, expectedTypes[i], event.Type)
|
||||
}
|
||||
|
||||
if event.MRID != mr.ID {
|
||||
t.Errorf("Event %d: expected MR ID %s, got %s", i, mr.ID, event.MRID)
|
||||
}
|
||||
|
||||
if event.Branch != mr.Branch {
|
||||
t.Errorf("Event %d: expected branch %s, got %s", i, mr.Branch, event.Branch)
|
||||
}
|
||||
|
||||
// Check timestamp is recent
|
||||
if time.Since(event.Timestamp) > time.Minute {
|
||||
t.Errorf("Event %d: timestamp too old: %v", i, event.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
if start < i {
|
||||
lines = append(lines, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
183
internal/mrqueue/mrqueue.go
Normal file
183
internal/mrqueue/mrqueue.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package mrqueue provides merge request queue storage.
|
||||
// MRs are stored locally in .beads/mq/ and deleted after merge.
|
||||
// This avoids sync overhead for transient MR state.
|
||||
package mrqueue
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MR represents a merge request in the queue.
|
||||
type MR struct {
|
||||
ID string `json:"id"`
|
||||
Branch string `json:"branch"` // Source branch (e.g., "polecat/nux")
|
||||
Target string `json:"target"` // Target branch (e.g., "main")
|
||||
SourceIssue string `json:"source_issue"` // The work item being merged
|
||||
Worker string `json:"worker"` // Who did the work
|
||||
Rig string `json:"rig"` // Which rig
|
||||
Title string `json:"title"` // MR title
|
||||
Priority int `json:"priority"` // Priority (lower = higher priority)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Queue manages the MR storage.
|
||||
type Queue struct {
|
||||
dir string // .beads/mq/ directory
|
||||
}
|
||||
|
||||
// New creates a new MR queue for the given rig path.
|
||||
func New(rigPath string) *Queue {
|
||||
return &Queue{
|
||||
dir: filepath.Join(rigPath, ".beads", "mq"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromWorkdir creates a queue by finding the rig root from a working directory.
|
||||
func NewFromWorkdir(workdir string) (*Queue, error) {
|
||||
// Walk up to find .beads or rig root
|
||||
dir := workdir
|
||||
for {
|
||||
beadsDir := filepath.Join(dir, ".beads")
|
||||
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
||||
return &Queue{dir: filepath.Join(beadsDir, "mq")}, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, fmt.Errorf("could not find .beads directory from %s", workdir)
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDir creates the MQ directory if it doesn't exist.
|
||||
func (q *Queue) EnsureDir() error {
|
||||
return os.MkdirAll(q.dir, 0755)
|
||||
}
|
||||
|
||||
// generateID creates a unique MR ID.
|
||||
func generateID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("mr-%d-%s", time.Now().Unix(), hex.EncodeToString(b))
|
||||
}
|
||||
|
||||
// Submit adds a new MR to the queue.
|
||||
func (q *Queue) Submit(mr *MR) error {
|
||||
if err := q.EnsureDir(); err != nil {
|
||||
return fmt.Errorf("creating mq directory: %w", err)
|
||||
}
|
||||
|
||||
if mr.ID == "" {
|
||||
mr.ID = generateID()
|
||||
}
|
||||
if mr.CreatedAt.IsZero() {
|
||||
mr.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(mr, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling MR: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(q.dir, mr.ID+".json")
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("writing MR file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all pending MRs, sorted by priority then creation time.
|
||||
func (q *Queue) List() ([]*MR, error) {
|
||||
entries, err := os.ReadDir(q.dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // Empty queue
|
||||
}
|
||||
return nil, fmt.Errorf("reading mq directory: %w", err)
|
||||
}
|
||||
|
||||
var mrs []*MR
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
mr, err := q.load(filepath.Join(q.dir, entry.Name()))
|
||||
if err != nil {
|
||||
continue // Skip malformed files
|
||||
}
|
||||
mrs = append(mrs, mr)
|
||||
}
|
||||
|
||||
// Sort by priority (lower first), then by creation time (older first)
|
||||
sort.Slice(mrs, func(i, j int) bool {
|
||||
if mrs[i].Priority != mrs[j].Priority {
|
||||
return mrs[i].Priority < mrs[j].Priority
|
||||
}
|
||||
return mrs[i].CreatedAt.Before(mrs[j].CreatedAt)
|
||||
})
|
||||
|
||||
return mrs, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific MR by ID.
|
||||
func (q *Queue) Get(id string) (*MR, error) {
|
||||
path := filepath.Join(q.dir, id+".json")
|
||||
return q.load(path)
|
||||
}
|
||||
|
||||
// load reads an MR from a file path.
|
||||
func (q *Queue) load(path string) (*MR, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var mr MR
|
||||
if err := json.Unmarshal(data, &mr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mr, nil
|
||||
}
|
||||
|
||||
// Remove deletes an MR from the queue (after successful merge).
|
||||
func (q *Queue) Remove(id string) error {
|
||||
path := filepath.Join(q.dir, id+".json")
|
||||
err := os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Already removed
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Count returns the number of pending MRs.
|
||||
func (q *Queue) Count() int {
|
||||
entries, err := os.ReadDir(q.dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Dir returns the queue directory path.
|
||||
func (q *Queue) Dir() string {
|
||||
return q.dir
|
||||
}
|
||||
Reference in New Issue
Block a user