Merge polecat/Nux: beads CLI wrapper (gt-u1j.13)

This commit is contained in:
Steve Yegge
2025-12-17 14:33:07 -08:00
3 changed files with 745 additions and 196 deletions

364
internal/beads/beads.go Normal file
View File

@@ -0,0 +1,364 @@
// Package beads provides a wrapper for the bd (beads) CLI.
package beads
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
)
// Common errors
var (
ErrNotInstalled = errors.New("bd not installed: run 'pip install beads-cli' or see https://github.com/anthropics/beads")
ErrNotARepo = errors.New("not a beads repository (no .beads directory found)")
ErrSyncConflict = errors.New("beads sync conflict")
ErrNotFound = errors.New("issue not found")
)
// Issue represents a beads issue.
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority int `json:"priority"`
Type string `json:"issue_type"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ClosedAt string `json:"closed_at,omitempty"`
Parent string `json:"parent,omitempty"`
Assignee string `json:"assignee,omitempty"`
Children []string `json:"children,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Blocks []string `json:"blocks,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"`
// Counts from list output
DependencyCount int `json:"dependency_count,omitempty"`
DependentCount int `json:"dependent_count,omitempty"`
BlockedByCount int `json:"blocked_by_count,omitempty"`
// Detailed dependency info from show output
Dependencies []IssueDep `json:"dependencies,omitempty"`
Dependents []IssueDep `json:"dependents,omitempty"`
}
// IssueDep represents a dependency or dependent issue with its relation.
type IssueDep struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
Type string `json:"issue_type"`
DependencyType string `json:"dependency_type,omitempty"`
}
// ListOptions specifies filters for listing issues.
type ListOptions struct {
Status string // "open", "closed", "all"
Type string // "task", "bug", "feature", "epic"
Priority int // 0-4, -1 for no filter
Parent string // filter by parent ID
}
// CreateOptions specifies options for creating an issue.
type CreateOptions struct {
Title string
Type string // "task", "bug", "feature", "epic"
Priority int // 0-4
Description string
Parent string
}
// UpdateOptions specifies options for updating an issue.
type UpdateOptions struct {
Title *string
Status *string
Priority *int
Description *string
Assignee *string
}
// SyncStatus represents the sync status of the beads repository.
type SyncStatus struct {
Branch string
Ahead int
Behind int
Conflicts []string
}
// Beads wraps bd CLI operations for a working directory.
type Beads struct {
workDir string
}
// New creates a new Beads wrapper for the given directory.
func New(workDir string) *Beads {
return &Beads{workDir: workDir}
}
// run executes a bd command and returns stdout.
func (b *Beads) run(args ...string) ([]byte, error) {
cmd := exec.Command("bd", args...)
cmd.Dir = b.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return nil, b.wrapError(err, stderr.String(), args)
}
return stdout.Bytes(), nil
}
// wrapError wraps bd errors with context.
func (b *Beads) wrapError(err error, stderr string, args []string) error {
stderr = strings.TrimSpace(stderr)
// Check for bd not installed
if execErr, ok := err.(*exec.Error); ok && errors.Is(execErr.Err, exec.ErrNotFound) {
return ErrNotInstalled
}
// Detect specific error types from stderr
if strings.Contains(stderr, "not a beads repository") ||
strings.Contains(stderr, "No .beads directory") ||
strings.Contains(stderr, ".beads") && strings.Contains(stderr, "not found") {
return ErrNotARepo
}
if strings.Contains(stderr, "sync conflict") || strings.Contains(stderr, "CONFLICT") {
return ErrSyncConflict
}
if strings.Contains(stderr, "not found") || strings.Contains(stderr, "Issue not found") {
return ErrNotFound
}
if stderr != "" {
return fmt.Errorf("bd %s: %s", strings.Join(args, " "), stderr)
}
return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err)
}
// List returns issues matching the given options.
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
args := []string{"list", "--json"}
if opts.Status != "" {
args = append(args, "--status="+opts.Status)
}
if opts.Type != "" {
args = append(args, "--type="+opts.Type)
}
if opts.Priority >= 0 {
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
}
if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// Ready returns issues that are ready to work (not blocked).
func (b *Beads) Ready() ([]*Issue, error) {
out, err := b.run("ready", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd ready output: %w", err)
}
return issues, nil
}
// Show returns detailed information about an issue.
func (b *Beads) Show(id string) (*Issue, error) {
out, err := b.run("show", id, "--json")
if err != nil {
return nil, err
}
// bd show --json returns an array with one element
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd show output: %w", err)
}
if len(issues) == 0 {
return nil, ErrNotFound
}
return issues[0], nil
}
// Blocked returns issues that are blocked by dependencies.
func (b *Beads) Blocked() ([]*Issue, error) {
out, err := b.run("blocked", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd blocked output: %w", err)
}
return issues, nil
}
// Create creates a new issue and returns it.
func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
args := []string{"create", "--json"}
if opts.Title != "" {
args = append(args, "--title="+opts.Title)
}
if opts.Type != "" {
args = append(args, "--type="+opts.Type)
}
if opts.Priority >= 0 {
args = append(args, fmt.Sprintf("--priority=%d", opts.Priority))
}
if opts.Description != "" {
args = append(args, "--description="+opts.Description)
}
if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issue Issue
if err := json.Unmarshal(out, &issue); err != nil {
return nil, fmt.Errorf("parsing bd create output: %w", err)
}
return &issue, nil
}
// Update updates an existing issue.
func (b *Beads) Update(id string, opts UpdateOptions) error {
args := []string{"update", id}
if opts.Title != nil {
args = append(args, "--title="+*opts.Title)
}
if opts.Status != nil {
args = append(args, "--status="+*opts.Status)
}
if opts.Priority != nil {
args = append(args, fmt.Sprintf("--priority=%d", *opts.Priority))
}
if opts.Description != nil {
args = append(args, "--description="+*opts.Description)
}
if opts.Assignee != nil {
args = append(args, "--assignee="+*opts.Assignee)
}
_, err := b.run(args...)
return err
}
// Close closes one or more issues.
func (b *Beads) Close(ids ...string) error {
if len(ids) == 0 {
return nil
}
args := append([]string{"close"}, ids...)
_, err := b.run(args...)
return err
}
// CloseWithReason closes one or more issues with a reason.
func (b *Beads) CloseWithReason(reason string, ids ...string) error {
if len(ids) == 0 {
return nil
}
args := append([]string{"close"}, ids...)
args = append(args, "--reason="+reason)
_, err := b.run(args...)
return err
}
// AddDependency adds a dependency: issue depends on dependsOn.
func (b *Beads) AddDependency(issue, dependsOn string) error {
_, err := b.run("dep", "add", issue, dependsOn)
return err
}
// RemoveDependency removes a dependency.
func (b *Beads) RemoveDependency(issue, dependsOn string) error {
_, err := b.run("dep", "remove", issue, dependsOn)
return err
}
// Sync syncs beads with remote.
func (b *Beads) Sync() error {
_, err := b.run("sync")
return err
}
// SyncFromMain syncs beads updates from main branch.
func (b *Beads) SyncFromMain() error {
_, err := b.run("sync", "--from-main")
return err
}
// SyncStatus returns the sync status without performing a sync.
func (b *Beads) SyncStatus() (*SyncStatus, error) {
out, err := b.run("sync", "--status", "--json")
if err != nil {
// If sync branch doesn't exist, return empty status
if strings.Contains(err.Error(), "does not exist") {
return &SyncStatus{}, nil
}
return nil, err
}
var status SyncStatus
if err := json.Unmarshal(out, &status); err != nil {
return nil, fmt.Errorf("parsing bd sync status output: %w", err)
}
return &status, nil
}
// Stats returns repository statistics.
func (b *Beads) Stats() (string, error) {
out, err := b.run("stats")
if err != nil {
return "", err
}
return string(out), nil
}
// IsBeadsRepo checks if the working directory is a beads repository.
func (b *Beads) IsBeadsRepo() bool {
_, err := b.run("list", "--limit=1")
return err == nil || !errors.Is(err, ErrNotARepo)
}

View File

@@ -0,0 +1,185 @@
package beads
import (
"os"
"path/filepath"
"testing"
)
// TestNew verifies the constructor.
func TestNew(t *testing.T) {
b := New("/some/path")
if b == nil {
t.Fatal("New returned nil")
}
if b.workDir != "/some/path" {
t.Errorf("workDir = %q, want /some/path", b.workDir)
}
}
// TestListOptions verifies ListOptions defaults.
func TestListOptions(t *testing.T) {
opts := ListOptions{
Status: "open",
Type: "task",
Priority: 1,
}
if opts.Status != "open" {
t.Errorf("Status = %q, want open", opts.Status)
}
}
// TestCreateOptions verifies CreateOptions fields.
func TestCreateOptions(t *testing.T) {
opts := CreateOptions{
Title: "Test issue",
Type: "task",
Priority: 2,
Description: "A test description",
Parent: "gt-abc",
}
if opts.Title != "Test issue" {
t.Errorf("Title = %q, want 'Test issue'", opts.Title)
}
if opts.Parent != "gt-abc" {
t.Errorf("Parent = %q, want gt-abc", opts.Parent)
}
}
// TestUpdateOptions verifies UpdateOptions pointer fields.
func TestUpdateOptions(t *testing.T) {
status := "in_progress"
priority := 1
opts := UpdateOptions{
Status: &status,
Priority: &priority,
}
if *opts.Status != "in_progress" {
t.Errorf("Status = %q, want in_progress", *opts.Status)
}
if *opts.Priority != 1 {
t.Errorf("Priority = %d, want 1", *opts.Priority)
}
}
// TestIsBeadsRepo tests repository detection.
func TestIsBeadsRepo(t *testing.T) {
// Test with a non-beads directory
tmpDir, err := os.MkdirTemp("", "beads-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
b := New(tmpDir)
// This should return false since there's no .beads directory
// and bd list will fail
if b.IsBeadsRepo() {
// This might pass if bd handles missing .beads gracefully
t.Log("IsBeadsRepo returned true for non-beads directory (bd might initialize)")
}
}
// TestWrapError tests error wrapping.
func TestWrapError(t *testing.T) {
b := New("/test")
tests := []struct {
stderr string
wantErr error
wantNil bool
}{
{"not a beads repository", ErrNotARepo, false},
{"No .beads directory found", ErrNotARepo, false},
{".beads directory not found", ErrNotARepo, false},
{"sync conflict detected", ErrSyncConflict, false},
{"CONFLICT in file.md", ErrSyncConflict, false},
{"Issue not found: gt-xyz", ErrNotFound, false},
{"gt-xyz not found", ErrNotFound, false},
}
for _, tt := range tests {
err := b.wrapError(nil, tt.stderr, []string{"test"})
if tt.wantNil {
if err != nil {
t.Errorf("wrapError(%q) = %v, want nil", tt.stderr, err)
}
} else {
if err != tt.wantErr {
t.Errorf("wrapError(%q) = %v, want %v", tt.stderr, err, tt.wantErr)
}
}
}
}
// Integration test that runs against real bd if available
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// Find a beads repo (use current directory if it has .beads)
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// Walk up to find .beads
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, ".beads")); err == nil {
break
}
parent := filepath.Dir(dir)
if parent == dir {
t.Skip("no .beads directory found in path")
}
dir = parent
}
b := New(dir)
// Test List
t.Run("List", func(t *testing.T) {
issues, err := b.List(ListOptions{Status: "open"})
if err != nil {
t.Fatalf("List failed: %v", err)
}
t.Logf("Found %d open issues", len(issues))
})
// Test Ready
t.Run("Ready", func(t *testing.T) {
issues, err := b.Ready()
if err != nil {
t.Fatalf("Ready failed: %v", err)
}
t.Logf("Found %d ready issues", len(issues))
})
// Test Blocked
t.Run("Blocked", func(t *testing.T) {
issues, err := b.Blocked()
if err != nil {
t.Fatalf("Blocked failed: %v", err)
}
t.Logf("Found %d blocked issues", len(issues))
})
// Test Show (if we have issues)
t.Run("Show", func(t *testing.T) {
issues, err := b.List(ListOptions{})
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(issues) == 0 {
t.Skip("no issues to show")
}
issue, err := b.Show(issues[0].ID)
if err != nil {
t.Fatalf("Show(%s) failed: %v", issues[0].ID, err)
}
t.Logf("Showed issue: %s - %s", issue.ID, issue.Title)
})
}