Ephemeral issues should never be exported to issues.jsonl. They exist only in SQLite and are shared via .beads/redirect pointers. This prevents "zombie" issues from resurrecting after mol squash deletes them. Changes: - Filter ephemeral issues in autoflush, export, and multirepo_export - Add --summary flag to bd mol squash for agent-provided summaries - Fix DeleteIssue to also remove comments (missing cascade) - Add tests for ephemeral filtering and comment deletion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
808 lines
22 KiB
Go
808 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestParseDistillVar(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
varFlag string
|
|
searchableText string
|
|
wantFind string
|
|
wantVar string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "spawn-style: variable=value",
|
|
varFlag: "branch=feature-auth",
|
|
searchableText: "Implement feature-auth login flow",
|
|
wantFind: "feature-auth",
|
|
wantVar: "branch",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "substitution-style: value=variable",
|
|
varFlag: "feature-auth=branch",
|
|
searchableText: "Implement feature-auth login flow",
|
|
wantFind: "feature-auth",
|
|
wantVar: "branch",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "spawn-style with version number",
|
|
varFlag: "version=1.2.3",
|
|
searchableText: "Release version 1.2.3 to production",
|
|
wantFind: "1.2.3",
|
|
wantVar: "version",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "both found - prefers spawn-style",
|
|
varFlag: "api=api",
|
|
searchableText: "The api endpoint uses api keys",
|
|
wantFind: "api",
|
|
wantVar: "api",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "neither found - error",
|
|
varFlag: "foo=bar",
|
|
searchableText: "Nothing matches here",
|
|
wantFind: "",
|
|
wantVar: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty left side - error",
|
|
varFlag: "=value",
|
|
searchableText: "Some text with value",
|
|
wantFind: "",
|
|
wantVar: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "empty right side - error",
|
|
varFlag: "value=",
|
|
searchableText: "Some text with value",
|
|
wantFind: "",
|
|
wantVar: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no equals sign - error",
|
|
varFlag: "noequals",
|
|
searchableText: "Some text",
|
|
wantFind: "",
|
|
wantVar: "",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "value with equals sign",
|
|
varFlag: "env=KEY=VALUE",
|
|
searchableText: "Set KEY=VALUE in config",
|
|
wantFind: "KEY=VALUE",
|
|
wantVar: "env",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "partial match in longer word - finds it",
|
|
varFlag: "name=auth",
|
|
searchableText: "authentication module",
|
|
wantFind: "auth",
|
|
wantVar: "name",
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotFind, gotVar, err := parseDistillVar(tt.varFlag, tt.searchableText)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseDistillVar() expected error, got none")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("parseDistillVar() unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if gotFind != tt.wantFind {
|
|
t.Errorf("parseDistillVar() find = %q, want %q", gotFind, tt.wantFind)
|
|
}
|
|
if gotVar != tt.wantVar {
|
|
t.Errorf("parseDistillVar() var = %q, want %q", gotVar, tt.wantVar)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCollectSubgraphText(t *testing.T) {
|
|
// Create a simple subgraph for testing
|
|
subgraph := &MoleculeSubgraph{
|
|
Issues: []*types.Issue{
|
|
{
|
|
Title: "Epic: Feature Auth",
|
|
Description: "Implement authentication",
|
|
Design: "Use OAuth2",
|
|
},
|
|
{
|
|
Title: "Add login endpoint",
|
|
Notes: "See RFC 6749",
|
|
},
|
|
},
|
|
}
|
|
|
|
text := collectSubgraphText(subgraph)
|
|
|
|
// Verify all fields are included
|
|
expected := []string{
|
|
"Epic: Feature Auth",
|
|
"Implement authentication",
|
|
"Use OAuth2",
|
|
"Add login endpoint",
|
|
"See RFC 6749",
|
|
}
|
|
|
|
for _, exp := range expected {
|
|
if !strings.Contains(text, exp) {
|
|
t.Errorf("collectSubgraphText() missing %q", exp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsProto(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
labels []string
|
|
want bool
|
|
}{
|
|
{"with template label", []string{"template", "other"}, true},
|
|
{"template only", []string{"template"}, true},
|
|
{"no template label", []string{"bug", "feature"}, false},
|
|
{"empty labels", []string{}, false},
|
|
{"nil labels", nil, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
issue := &types.Issue{Labels: tt.labels}
|
|
got := isProto(issue)
|
|
if got != tt.want {
|
|
t.Errorf("isProto() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOperandType(t *testing.T) {
|
|
if got := operandType(true); got != "proto" {
|
|
t.Errorf("operandType(true) = %q, want %q", got, "proto")
|
|
}
|
|
if got := operandType(false); got != "molecule" {
|
|
t.Errorf("operandType(false) = %q, want %q", got, "molecule")
|
|
}
|
|
}
|
|
|
|
func TestMinPriority(t *testing.T) {
|
|
tests := []struct {
|
|
a, b, want int
|
|
}{
|
|
{1, 2, 1},
|
|
{2, 1, 1},
|
|
{0, 3, 0},
|
|
{3, 3, 3},
|
|
}
|
|
for _, tt := range tests {
|
|
got := minPriority(tt.a, tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("minPriority(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBondProtoProto(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create two protos
|
|
protoA := &types.Issue{
|
|
Title: "Proto A",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
Labels: []string{MoleculeLabel},
|
|
}
|
|
protoB := &types.Issue{
|
|
Title: "Proto B",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeEpic,
|
|
Labels: []string{MoleculeLabel},
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, protoA, "test"); err != nil {
|
|
t.Fatalf("Failed to create protoA: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, protoB, "test"); err != nil {
|
|
t.Fatalf("Failed to create protoB: %v", err)
|
|
}
|
|
|
|
// Test sequential bond
|
|
result, err := bondProtoProto(ctx, store, protoA, protoB, types.BondTypeSequential, "", "test")
|
|
if err != nil {
|
|
t.Fatalf("bondProtoProto failed: %v", err)
|
|
}
|
|
|
|
if result.ResultType != "compound_proto" {
|
|
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_proto")
|
|
}
|
|
if result.BondType != types.BondTypeSequential {
|
|
t.Errorf("BondType = %q, want %q", result.BondType, types.BondTypeSequential)
|
|
}
|
|
|
|
// Verify compound was created
|
|
compound, err := store.GetIssue(ctx, result.ResultID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get compound: %v", err)
|
|
}
|
|
if !isProto(compound) {
|
|
t.Errorf("Compound should be a proto (have template label), got labels: %v", compound.Labels)
|
|
}
|
|
if compound.Priority != 1 {
|
|
t.Errorf("Compound priority = %d, want %d (min of 1,2)", compound.Priority, 1)
|
|
}
|
|
|
|
// Verify dependencies exist (protoA depends on compound via parent-child)
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, protoA.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get deps for protoA: %v", err)
|
|
}
|
|
foundParentChild := false
|
|
for _, dep := range deps {
|
|
if dep.ID == compound.ID && dep.DependencyType == types.DepParentChild {
|
|
foundParentChild = true
|
|
}
|
|
}
|
|
if !foundParentChild {
|
|
t.Error("Expected parent-child dependency from protoA to compound")
|
|
}
|
|
}
|
|
|
|
func TestBondProtoMol(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create a proto with a child issue
|
|
proto := &types.Issue{
|
|
Title: "Proto: {{name}}",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
Labels: []string{MoleculeLabel},
|
|
}
|
|
if err := store.CreateIssue(ctx, proto, "test"); err != nil {
|
|
t.Fatalf("Failed to create proto: %v", err)
|
|
}
|
|
|
|
protoChild := &types.Issue{
|
|
Title: "Step 1 for {{name}}",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Labels: []string{MoleculeLabel},
|
|
}
|
|
if err := store.CreateIssue(ctx, protoChild, "test"); err != nil {
|
|
t.Fatalf("Failed to create proto child: %v", err)
|
|
}
|
|
|
|
// Add parent-child dependency
|
|
if err := store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: protoChild.ID,
|
|
DependsOnID: proto.ID,
|
|
Type: types.DepParentChild,
|
|
}, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
// Create a molecule (existing epic)
|
|
mol := &types.Issue{
|
|
Title: "Existing Work",
|
|
Status: types.StatusInProgress,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := store.CreateIssue(ctx, mol, "test"); err != nil {
|
|
t.Fatalf("Failed to create molecule: %v", err)
|
|
}
|
|
|
|
// Bond proto to molecule
|
|
vars := map[string]string{"name": "auth-feature"}
|
|
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "test")
|
|
if err != nil {
|
|
t.Fatalf("bondProtoMol failed: %v", err)
|
|
}
|
|
|
|
if result.ResultType != "compound_molecule" {
|
|
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_molecule")
|
|
}
|
|
if result.Spawned != 2 {
|
|
t.Errorf("Spawned = %d, want 2", result.Spawned)
|
|
}
|
|
if result.ResultID != mol.ID {
|
|
t.Errorf("ResultID = %q, want %q (original molecule)", result.ResultID, mol.ID)
|
|
}
|
|
}
|
|
|
|
func TestBondMolMol(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create two molecules
|
|
molA := &types.Issue{
|
|
Title: "Molecule A",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
molB := &types.Issue{
|
|
Title: "Molecule B",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, molA, "test"); err != nil {
|
|
t.Fatalf("Failed to create molA: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, molB, "test"); err != nil {
|
|
t.Fatalf("Failed to create molB: %v", err)
|
|
}
|
|
|
|
// Test sequential bond
|
|
result, err := bondMolMol(ctx, store, molA, molB, types.BondTypeSequential, "test")
|
|
if err != nil {
|
|
t.Fatalf("bondMolMol failed: %v", err)
|
|
}
|
|
|
|
if result.ResultType != "compound_molecule" {
|
|
t.Errorf("ResultType = %q, want %q", result.ResultType, "compound_molecule")
|
|
}
|
|
if result.ResultID != molA.ID {
|
|
t.Errorf("ResultID = %q, want %q", result.ResultID, molA.ID)
|
|
}
|
|
|
|
// Verify dependency: B blocks on A
|
|
deps, err := store.GetDependenciesWithMetadata(ctx, molB.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get deps for molB: %v", err)
|
|
}
|
|
foundBlocks := false
|
|
for _, dep := range deps {
|
|
if dep.ID == molA.ID && dep.DependencyType == types.DepBlocks {
|
|
foundBlocks = true
|
|
}
|
|
}
|
|
if !foundBlocks {
|
|
t.Error("Expected blocks dependency from molB to molA for sequential bond")
|
|
}
|
|
|
|
// Test parallel bond (create new molecules)
|
|
molC := &types.Issue{
|
|
Title: "Molecule C",
|
|
Status: types.StatusOpen,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
molD := &types.Issue{
|
|
Title: "Molecule D",
|
|
Status: types.StatusOpen,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := store.CreateIssue(ctx, molC, "test"); err != nil {
|
|
t.Fatalf("Failed to create molC: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, molD, "test"); err != nil {
|
|
t.Fatalf("Failed to create molD: %v", err)
|
|
}
|
|
|
|
result2, err := bondMolMol(ctx, store, molC, molD, types.BondTypeParallel, "test")
|
|
if err != nil {
|
|
t.Fatalf("bondMolMol parallel failed: %v", err)
|
|
}
|
|
|
|
// Verify parent-child dependency for parallel
|
|
deps2, err := store.GetDependenciesWithMetadata(ctx, molD.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get deps for molD: %v", err)
|
|
}
|
|
foundParentChild := false
|
|
for _, dep := range deps2 {
|
|
if dep.ID == molC.ID && dep.DependencyType == types.DepParentChild {
|
|
foundParentChild = true
|
|
}
|
|
}
|
|
if !foundParentChild {
|
|
t.Errorf("Expected parent-child dependency for parallel bond, result: %+v", result2)
|
|
}
|
|
}
|
|
|
|
func TestSquashMolecule(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
s, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create a molecule (root issue)
|
|
root := &types.Issue{
|
|
Title: "Test Molecule",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := s.CreateIssue(ctx, root, "test"); err != nil {
|
|
t.Fatalf("Failed to create root: %v", err)
|
|
}
|
|
|
|
// Create ephemeral children
|
|
child1 := &types.Issue{
|
|
Title: "Step 1: Design",
|
|
Description: "Design the architecture",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
CloseReason: "Completed design",
|
|
}
|
|
child2 := &types.Issue{
|
|
Title: "Step 2: Implement",
|
|
Description: "Build the feature",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
CloseReason: "Code merged",
|
|
}
|
|
|
|
if err := s.CreateIssue(ctx, child1, "test"); err != nil {
|
|
t.Fatalf("Failed to create child1: %v", err)
|
|
}
|
|
if err := s.CreateIssue(ctx, child2, "test"); err != nil {
|
|
t.Fatalf("Failed to create child2: %v", err)
|
|
}
|
|
|
|
// Add parent-child dependencies
|
|
if err := s.AddDependency(ctx, &types.Dependency{
|
|
IssueID: child1.ID,
|
|
DependsOnID: root.ID,
|
|
Type: types.DepParentChild,
|
|
}, "test"); err != nil {
|
|
t.Fatalf("Failed to add child1 dependency: %v", err)
|
|
}
|
|
if err := s.AddDependency(ctx, &types.Dependency{
|
|
IssueID: child2.ID,
|
|
DependsOnID: root.ID,
|
|
Type: types.DepParentChild,
|
|
}, "test"); err != nil {
|
|
t.Fatalf("Failed to add child2 dependency: %v", err)
|
|
}
|
|
|
|
// Test squash with keep-children
|
|
children := []*types.Issue{child1, child2}
|
|
result, err := squashMolecule(ctx, s, root, children, true, "", "test")
|
|
if err != nil {
|
|
t.Fatalf("squashMolecule failed: %v", err)
|
|
}
|
|
|
|
if result.SquashedCount != 2 {
|
|
t.Errorf("SquashedCount = %d, want 2", result.SquashedCount)
|
|
}
|
|
if result.DeletedCount != 0 {
|
|
t.Errorf("DeletedCount = %d, want 0 (keep-children)", result.DeletedCount)
|
|
}
|
|
if !result.KeptChildren {
|
|
t.Error("KeptChildren should be true")
|
|
}
|
|
|
|
// Verify digest was created
|
|
digest, err := s.GetIssue(ctx, result.DigestID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get digest: %v", err)
|
|
}
|
|
if digest.Ephemeral {
|
|
t.Error("Digest should NOT be ephemeral")
|
|
}
|
|
if digest.Status != types.StatusClosed {
|
|
t.Errorf("Digest status = %v, want closed", digest.Status)
|
|
}
|
|
if !strings.Contains(digest.Description, "Step 1: Design") {
|
|
t.Error("Digest should contain child titles")
|
|
}
|
|
if !strings.Contains(digest.Description, "Completed design") {
|
|
t.Error("Digest should contain close reasons")
|
|
}
|
|
|
|
// Children should still exist
|
|
c1, err := s.GetIssue(ctx, child1.ID)
|
|
if err != nil || c1 == nil {
|
|
t.Error("Child1 should still exist with keep-children")
|
|
}
|
|
}
|
|
|
|
func TestSquashMoleculeWithDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
s, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create a molecule with ephemeral children
|
|
root := &types.Issue{
|
|
Title: "Delete Test Molecule",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := s.CreateIssue(ctx, root, "test"); err != nil {
|
|
t.Fatalf("Failed to create root: %v", err)
|
|
}
|
|
|
|
child := &types.Issue{
|
|
Title: "Ephemeral Step",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
}
|
|
if err := s.CreateIssue(ctx, child, "test"); err != nil {
|
|
t.Fatalf("Failed to create child: %v", err)
|
|
}
|
|
if err := s.AddDependency(ctx, &types.Dependency{
|
|
IssueID: child.ID,
|
|
DependsOnID: root.ID,
|
|
Type: types.DepParentChild,
|
|
}, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
// Squash with delete (keepChildren=false)
|
|
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, false, "", "test")
|
|
if err != nil {
|
|
t.Fatalf("squashMolecule failed: %v", err)
|
|
}
|
|
|
|
if result.DeletedCount != 1 {
|
|
t.Errorf("DeletedCount = %d, want 1", result.DeletedCount)
|
|
}
|
|
|
|
// Child should be deleted
|
|
c, err := s.GetIssue(ctx, child.ID)
|
|
if err == nil && c != nil {
|
|
t.Error("Child should have been deleted")
|
|
}
|
|
|
|
// Digest should exist
|
|
digest, err := s.GetIssue(ctx, result.DigestID)
|
|
if err != nil || digest == nil {
|
|
t.Error("Digest should exist after squash")
|
|
}
|
|
}
|
|
|
|
func TestGenerateDigest(t *testing.T) {
|
|
root := &types.Issue{
|
|
Title: "Test Molecule",
|
|
}
|
|
children := []*types.Issue{
|
|
{
|
|
Title: "Step 1",
|
|
Description: "First step description",
|
|
Status: types.StatusClosed,
|
|
CloseReason: "Done",
|
|
},
|
|
{
|
|
Title: "Step 2",
|
|
Description: "Second step description that is longer",
|
|
Status: types.StatusInProgress,
|
|
},
|
|
}
|
|
|
|
digest := generateDigest(root, children)
|
|
|
|
// Verify structure
|
|
if !strings.Contains(digest, "## Molecule Execution Summary") {
|
|
t.Error("Digest should have summary header")
|
|
}
|
|
if !strings.Contains(digest, "Test Molecule") {
|
|
t.Error("Digest should contain molecule title")
|
|
}
|
|
if !strings.Contains(digest, "**Steps**: 2") {
|
|
t.Error("Digest should show step count")
|
|
}
|
|
if !strings.Contains(digest, "**Completed**: 1/2") {
|
|
t.Error("Digest should show completion stats")
|
|
}
|
|
if !strings.Contains(digest, "**In Progress**: 1") {
|
|
t.Error("Digest should show in-progress count")
|
|
}
|
|
if !strings.Contains(digest, "Step 1") {
|
|
t.Error("Digest should list step titles")
|
|
}
|
|
if !strings.Contains(digest, "*Outcome: Done*") {
|
|
t.Error("Digest should include close reasons")
|
|
}
|
|
}
|
|
|
|
// TestSquashMoleculeWithAgentSummary verifies that agent-provided summaries are used
|
|
func TestSquashMoleculeWithAgentSummary(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
s, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create a molecule with ephemeral child
|
|
root := &types.Issue{
|
|
Title: "Agent Summary Test",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := s.CreateIssue(ctx, root, "test"); err != nil {
|
|
t.Fatalf("Failed to create root: %v", err)
|
|
}
|
|
|
|
child := &types.Issue{
|
|
Title: "Ephemeral Step",
|
|
Description: "This should NOT appear in digest",
|
|
Status: types.StatusClosed,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
CloseReason: "Done",
|
|
}
|
|
if err := s.CreateIssue(ctx, child, "test"); err != nil {
|
|
t.Fatalf("Failed to create child: %v", err)
|
|
}
|
|
if err := s.AddDependency(ctx, &types.Dependency{
|
|
IssueID: child.ID,
|
|
DependsOnID: root.ID,
|
|
Type: types.DepParentChild,
|
|
}, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
// Squash with agent-provided summary
|
|
agentSummary := "## AI-Generated Summary\n\nThe agent completed the task successfully."
|
|
result, err := squashMolecule(ctx, s, root, []*types.Issue{child}, true, agentSummary, "test")
|
|
if err != nil {
|
|
t.Fatalf("squashMolecule failed: %v", err)
|
|
}
|
|
|
|
// Verify digest uses agent summary, not auto-generated content
|
|
digest, err := s.GetIssue(ctx, result.DigestID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get digest: %v", err)
|
|
}
|
|
|
|
if digest.Description != agentSummary {
|
|
t.Errorf("Digest should use agent summary.\nGot: %s\nWant: %s", digest.Description, agentSummary)
|
|
}
|
|
|
|
// Verify auto-generated content is NOT present
|
|
if strings.Contains(digest.Description, "Ephemeral Step") {
|
|
t.Error("Digest should NOT contain auto-generated content when agent summary provided")
|
|
}
|
|
}
|
|
|
|
// TestEphemeralFilteringFromExport verifies that ephemeral issues are filtered
|
|
// from JSONL export (bd-687g). Ephemeral issues should only exist in SQLite,
|
|
// not in issues.jsonl, to prevent "zombie" resurrection after mol squash.
|
|
func TestEphemeralFilteringFromExport(t *testing.T) {
|
|
ctx := context.Background()
|
|
dbPath := t.TempDir() + "/test.db"
|
|
s, err := sqlite.New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer s.Close()
|
|
if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set config: %v", err)
|
|
}
|
|
|
|
// Create a mix of ephemeral and non-ephemeral issues
|
|
normalIssue := &types.Issue{
|
|
Title: "Normal Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: false,
|
|
}
|
|
ephemeralIssue := &types.Issue{
|
|
Title: "Ephemeral Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Ephemeral: true,
|
|
}
|
|
|
|
if err := s.CreateIssue(ctx, normalIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create normal issue: %v", err)
|
|
}
|
|
if err := s.CreateIssue(ctx, ephemeralIssue, "test"); err != nil {
|
|
t.Fatalf("Failed to create ephemeral issue: %v", err)
|
|
}
|
|
|
|
// Get all issues from DB - should include both
|
|
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to search issues: %v", err)
|
|
}
|
|
if len(allIssues) != 2 {
|
|
t.Fatalf("Expected 2 issues in DB, got %d", len(allIssues))
|
|
}
|
|
|
|
// Filter ephemeral issues (simulating export behavior)
|
|
exportableIssues := make([]*types.Issue, 0)
|
|
for _, issue := range allIssues {
|
|
if !issue.Ephemeral {
|
|
exportableIssues = append(exportableIssues, issue)
|
|
}
|
|
}
|
|
|
|
// Should only have the non-ephemeral issue
|
|
if len(exportableIssues) != 1 {
|
|
t.Errorf("Expected 1 exportable issue, got %d", len(exportableIssues))
|
|
}
|
|
if exportableIssues[0].ID != normalIssue.ID {
|
|
t.Errorf("Expected normal issue %s, got %s", normalIssue.ID, exportableIssues[0].ID)
|
|
}
|
|
}
|