Merge branch 'main' into subtle-ux-improvements

Resolved modify/delete conflict: cmd/bd/mail.go was deleted on main
(mail functionality moved to Gas Town), accepting deletion.
This commit is contained in:
Steve Yegge
2025-12-20 23:26:35 -08:00
39 changed files with 1216 additions and 1652 deletions

View File

@@ -12,18 +12,16 @@ import (
// Event types
const (
EventCreate = "create"
EventUpdate = "update"
EventClose = "close"
EventMessage = "message"
EventCreate = "create"
EventUpdate = "update"
EventClose = "close"
)
// Hook file names
const (
HookOnCreate = "on_create"
HookOnUpdate = "on_update"
HookOnClose = "on_close"
HookOnMessage = "on_message"
HookOnCreate = "on_create"
HookOnUpdate = "on_update"
HookOnClose = "on_close"
)
// Runner handles hook execution
@@ -120,8 +118,6 @@ func eventToHook(event string) string {
return HookOnUpdate
case EventClose:
return HookOnClose
case EventMessage:
return HookOnMessage
default:
return ""
}

View File

@@ -44,7 +44,6 @@ func TestEventToHook(t *testing.T) {
{EventCreate, HookOnCreate},
{EventUpdate, HookOnUpdate},
{EventClose, HookOnClose},
{EventMessage, HookOnMessage},
{"unknown", ""},
{"", ""},
}
@@ -182,7 +181,7 @@ echo "$1 $2" > ` + outputFile
func TestRunSync_ReceivesJSON(t *testing.T) {
tmpDir := t.TempDir()
hookPath := filepath.Join(tmpDir, HookOnMessage)
hookPath := filepath.Join(tmpDir, HookOnCreate)
outputFile := filepath.Join(tmpDir, "stdin.txt")
// Create a hook that captures stdin
@@ -194,13 +193,12 @@ cat > ` + outputFile
runner := NewRunner(tmpDir)
issue := &types.Issue{
ID: "bd-msg",
Title: "Test Message",
Sender: "alice",
ID: "bd-test",
Title: "Test Issue",
Assignee: "bob",
}
err := runner.RunSync(EventMessage, issue)
err := runner.RunSync(EventCreate, issue)
if err != nil {
t.Errorf("RunSync returned error: %v", err)
}
@@ -380,7 +378,6 @@ func TestAllHookEvents(t *testing.T) {
{EventCreate, HookOnCreate},
{EventUpdate, HookOnUpdate},
{EventClose, HookOnClose},
{EventMessage, HookOnMessage},
}
for _, e := range events {

View File

@@ -554,6 +554,8 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
updates["notes"] = incoming.Notes
updates["closed_at"] = incoming.ClosedAt
// Pinned field (bd-7h5)
updates["pinned"] = incoming.Pinned
if incoming.Assignee != "" {
updates["assignee"] = incoming.Assignee
@@ -647,6 +649,8 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
updates["notes"] = incoming.Notes
updates["closed_at"] = incoming.ClosedAt
// Pinned field (bd-7h5)
updates["pinned"] = incoming.Pinned
if incoming.Assignee != "" {
updates["assignee"] = incoming.Assignee

View File

@@ -112,6 +112,15 @@ func (fc *fieldComparator) equalPriority(existing int, newVal interface{}) bool
return ok && int64(existing) == newPriority
}
func (fc *fieldComparator) equalBool(existingVal bool, newVal interface{}) bool {
switch t := newVal.(type) {
case bool:
return existingVal == t
default:
return false
}
}
func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, newVal interface{}) bool {
switch key {
case "title":
@@ -134,6 +143,8 @@ func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue,
return !fc.equalStr(existing.Assignee, newVal)
case "external_ref":
return !fc.equalPtrStr(existing.ExternalRef, newVal)
case "pinned":
return !fc.equalBool(existing.Pinned, newVal)
default:
return false
}

View File

@@ -121,7 +121,7 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed,
COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred,
COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone,
COALESCE(SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END), 0) as pinned
COALESCE(SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END), 0) as pinned
FROM issues
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues)
if err != nil {

View File

@@ -558,6 +558,8 @@ var allowedUpdateFields = map[string]bool{
// Messaging fields (bd-kwro)
"sender": true,
"ephemeral": true,
// Pinned field (bd-7h5)
"pinned": true,
// NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
// Use AddDependency() to create graph edges instead
}

View File

@@ -69,6 +69,23 @@ func FindMoleculesJSONLInDir(dbDir string) string {
return ""
}
// ResolveForWrite returns the path to write to, resolving symlinks.
// If path is a symlink, returns the resolved target path.
// If path doesn't exist, returns path unchanged (new file).
func ResolveForWrite(path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return path, nil
}
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return filepath.EvalSymlinks(path)
}
return path, nil
}
// CanonicalizePath converts a path to its canonical form by:
// 1. Converting to absolute path
// 2. Resolving symlinks

View File

@@ -179,3 +179,56 @@ func TestCanonicalizePathSymlink(t *testing.T) {
}
}
}
func TestResolveForWrite(t *testing.T) {
t.Run("regular file", func(t *testing.T) {
tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "regular.txt")
if err := os.WriteFile(file, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
got, err := ResolveForWrite(file)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != file {
t.Errorf("got %q, want %q", got, file)
}
})
t.Run("symlink", func(t *testing.T) {
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "target.txt")
if err := os.WriteFile(target, []byte("test"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(tmpDir, "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
got, err := ResolveForWrite(link)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Resolve target too - on macOS, /var is symlink to /private/var
wantTarget, _ := filepath.EvalSymlinks(target)
if got != wantTarget {
t.Errorf("got %q, want %q", got, wantTarget)
}
})
t.Run("non-existent", func(t *testing.T) {
tmpDir := t.TempDir()
newFile := filepath.Join(tmpDir, "new.txt")
got, err := ResolveForWrite(newFile)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != newFile {
t.Errorf("got %q, want %q", got, newFile)
}
})
}