Files
gastownhall__beads/issue_lifecycle_external_test.go
Julian Knutsen b92442d1a3 feat(storage): one guarded issue-lifecycle surface for the CLI and linked consumers (#5191)
Adds issueops.Lifecycle — Create, Update, Close, Reopen — reached from the storage handle via
store.IssueLifecycle(), implemented once and shared by the CLI and linked library consumers. Three backends
implement it and a shared conformance suite asserts them against one spec. The bd CLI's create, update,
close and reopen now route through it.

Fixes several defects that predate this change and became visible once the write paths were forced to
agree: swallowed metadata-edit failures, a claim applied before field validation, missing value validation
on title/priority/estimate, silently dropped create edges to missing targets, infra-typed creates landing in
the wrong table, a dropped SourceRepo, closed_by_session written but never read, and a compound update
writing twice.

Behavior changes needing release notes: create with an occupied explicit id now fails instead of silently
overwriting; a compound update is one atomic operation with one hook and one version commit; reparenting
replaces all parent edges; reopening something not closed reports nothing to do.
2026-07-31 11:26:38 -07:00

124 lines
4.2 KiB
Go

package beads_test
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
"github.com/steveyegge/beads"
"github.com/steveyegge/beads/internal/hooks"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/dolt"
"github.com/steveyegge/beads/internal/telemetry"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/issueops"
)
// TestIssueLifecycleLayersHooksOutsideTelemetry pins the decorator order the
// accessor produces against the order cmd/bd assembles the chain in
// (storage_chain.go: telemetry first, hooks outermost). Each decorator layers
// itself onto the inner result, so the lifecycle comes back stacked the same
// way the store is.
func TestIssueLifecycleLayersHooksOutsideTelemetry(t *testing.T) {
t.Setenv("BD_OTEL_STDOUT", "true")
instrumented, ok := telemetry.WrapStorage(&dolt.DoltStore{}).(*telemetry.InstrumentedStorage)
if !ok {
t.Fatal("WrapStorage() did not create InstrumentedStorage")
}
lifecycle, err := storage.NewHookFiringStore(instrumented, nil).IssueLifecycle()
if err != nil {
t.Fatalf("IssueLifecycle() error = %v", err)
}
if got := reflect.TypeOf(lifecycle).String(); got != "*storage.hookIssueOperations" {
t.Fatalf("outer layer = %s, want the hook wrapper", got)
}
}
// TestIssueLifecycleExposesTypedUnsupportedError pins the typed error a backend
// returns when it cannot serve guarded mutations, reachable through the public
// alias without importing internal/storage.
func TestIssueLifecycleExposesTypedUnsupportedError(t *testing.T) {
lifecycle, err := (*dolt.DoltStore)(nil).IssueLifecycle()
if lifecycle != nil {
t.Fatalf("IssueLifecycle() lifecycle = %T, want nil", lifecycle)
}
var unsupported *beads.ErrUnsupported
if !errors.As(err, &unsupported) {
t.Fatalf("IssueLifecycle() error = %v, want *beads.ErrUnsupported", err)
}
}
func TestHookDecoratedIssueOperationsCreateFiresReverseDependencyUpdate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("hook script execution is not supported on Windows")
}
skipIfNoDoltServer(t)
ctx := t.Context()
root := t.TempDir()
store, err := beads.Open(ctx, filepath.Join(root, "store"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatal(err)
}
source := &types.Issue{ID: "test-hook-source", Title: "source", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
if err := store.CreateIssue(ctx, source, "seed"); err != nil {
t.Fatal(err)
}
rawStore, ok := store.(storage.DoltStorage)
if !ok {
t.Fatalf("opened store = %T, want DoltStorage", store)
}
hooksDir := filepath.Join(root, "hooks")
if err := os.Mkdir(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "events")
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s %%s\\n' \"$1\" \"$2\" >> %q\n", output)
for _, name := range []string{hooks.HookOnCreate, hooks.HookOnUpdate} {
if err := os.WriteFile(filepath.Join(hooksDir, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
operations, err := storage.NewHookFiringStore(rawStore, hooks.NewRunner(hooksDir)).IssueLifecycle()
if err != nil {
t.Fatal(err)
}
result, err := operations.Create(ctx, issueops.CreateRequest{
Actor: "writer",
ForceIDPrefix: true,
Issue: &types.Issue{Title: "created", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
Dependencies: []issueops.CreateDependency{{TargetID: source.ID, Type: types.DepRelatesTo, Reverse: true, Metadata: `{"key":"value"}`, ThreadID: "thread"}},
})
if err != nil {
t.Fatal(err)
}
if len(result.Issue.Dependencies) != 0 {
t.Fatalf("create result dependencies = %#v, want no outgoing reverse dependency", result.Issue.Dependencies)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(output)
events := string(data)
if strings.Contains(events, result.Issue.ID+" create") && strings.Contains(events, source.ID+" update") {
return
}
time.Sleep(10 * time.Millisecond)
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatalf("read hook events: %v", err)
}
t.Fatalf("hook events = %q, want create for %s and update for %s", data, result.Issue.ID, source.ID)
}