Merge main, keeping main's manager.go and our FailureType tests
This commit is contained in:
@@ -58,10 +58,12 @@ type IssueDep struct {
|
||||
|
||||
// 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
|
||||
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
|
||||
Assignee string // filter by assignee (e.g., "gastown/Toast")
|
||||
NoAssignee bool // filter for issues with no assignee
|
||||
}
|
||||
|
||||
// CreateOptions specifies options for creating an issue.
|
||||
@@ -164,6 +166,12 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
||||
if opts.Parent != "" {
|
||||
args = append(args, "--parent="+opts.Parent)
|
||||
}
|
||||
if opts.Assignee != "" {
|
||||
args = append(args, "--assignee="+opts.Assignee)
|
||||
}
|
||||
if opts.NoAssignee {
|
||||
args = append(args, "--no-assignee")
|
||||
}
|
||||
|
||||
out, err := b.run(args...)
|
||||
if err != nil {
|
||||
@@ -178,6 +186,47 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// ListByAssignee returns all issues assigned to a specific assignee.
|
||||
// The assignee is typically in the format "rig/polecatName" (e.g., "gastown/Toast").
|
||||
func (b *Beads) ListByAssignee(assignee string) ([]*Issue, error) {
|
||||
return b.List(ListOptions{
|
||||
Status: "all", // Include both open and closed for state derivation
|
||||
Assignee: assignee,
|
||||
Priority: -1, // No priority filter
|
||||
})
|
||||
}
|
||||
|
||||
// GetAssignedIssue returns the first open issue assigned to the given assignee.
|
||||
// Returns nil if no open issue is assigned.
|
||||
func (b *Beads) GetAssignedIssue(assignee string) (*Issue, error) {
|
||||
issues, err := b.List(ListOptions{
|
||||
Status: "open",
|
||||
Assignee: assignee,
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Also check in_progress status explicitly
|
||||
if len(issues) == 0 {
|
||||
issues, err = b.List(ListOptions{
|
||||
Status: "in_progress",
|
||||
Assignee: assignee,
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return issues[0], nil
|
||||
}
|
||||
|
||||
// Ready returns issues that are ready to work (not blocked).
|
||||
func (b *Beads) Ready() ([]*Issue, error) {
|
||||
out, err := b.run("ready", "--json")
|
||||
@@ -193,6 +242,22 @@ func (b *Beads) Ready() ([]*Issue, error) {
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// ReadyWithType returns ready issues filtered by type.
|
||||
// Uses bd ready --type flag for server-side filtering (gt-ktf3).
|
||||
func (b *Beads) ReadyWithType(issueType string) ([]*Issue, error) {
|
||||
out, err := b.run("ready", "--json", "--type", issueType, "-n", "100")
|
||||
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")
|
||||
@@ -400,6 +465,88 @@ func (b *Beads) IsBeadsRepo() bool {
|
||||
return err == nil || !errors.Is(err, ErrNotARepo)
|
||||
}
|
||||
|
||||
// StatusPinned is the status for pinned beads that never get closed.
|
||||
const StatusPinned = "pinned"
|
||||
|
||||
// HandoffBeadTitle returns the well-known title for a role's handoff bead.
|
||||
func HandoffBeadTitle(role string) string {
|
||||
return role + " Handoff"
|
||||
}
|
||||
|
||||
// FindHandoffBead finds the pinned handoff bead for a role by title.
|
||||
// Returns nil if not found (not an error).
|
||||
func (b *Beads) FindHandoffBead(role string) (*Issue, error) {
|
||||
issues, err := b.List(ListOptions{Status: StatusPinned, Priority: -1})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing pinned issues: %w", err)
|
||||
}
|
||||
|
||||
targetTitle := HandoffBeadTitle(role)
|
||||
for _, issue := range issues {
|
||||
if issue.Title == targetTitle {
|
||||
return issue, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetOrCreateHandoffBead returns the handoff bead for a role, creating it if needed.
|
||||
func (b *Beads) GetOrCreateHandoffBead(role string) (*Issue, error) {
|
||||
// Check if it exists
|
||||
existing, err := b.FindHandoffBead(role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Create new handoff bead
|
||||
issue, err := b.Create(CreateOptions{
|
||||
Title: HandoffBeadTitle(role),
|
||||
Type: "task",
|
||||
Priority: 2,
|
||||
Description: "", // Empty until first handoff
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating handoff bead: %w", err)
|
||||
}
|
||||
|
||||
// Update to pinned status
|
||||
status := StatusPinned
|
||||
if err := b.Update(issue.ID, UpdateOptions{Status: &status}); err != nil {
|
||||
return nil, fmt.Errorf("setting handoff bead to pinned: %w", err)
|
||||
}
|
||||
|
||||
// Re-fetch to get updated status
|
||||
return b.Show(issue.ID)
|
||||
}
|
||||
|
||||
// UpdateHandoffContent updates the handoff bead's description with new content.
|
||||
func (b *Beads) UpdateHandoffContent(role, content string) error {
|
||||
issue, err := b.GetOrCreateHandoffBead(role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &content})
|
||||
}
|
||||
|
||||
// ClearHandoffContent clears the handoff bead's description.
|
||||
func (b *Beads) ClearHandoffContent(role string) error {
|
||||
issue, err := b.FindHandoffBead(role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if issue == nil {
|
||||
return nil // Nothing to clear
|
||||
}
|
||||
|
||||
empty := ""
|
||||
return b.Update(issue.ID, UpdateOptions{Description: &empty})
|
||||
}
|
||||
|
||||
// MRFields holds the structured fields for a merge-request issue.
|
||||
// These fields are stored as key: value lines in the issue description.
|
||||
type MRFields struct {
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestIsBeadsRepo(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
b := New(tmpDir)
|
||||
// This should return false since there's no .beads directory
|
||||
|
||||
138
internal/beads/builtin_molecules.go
Normal file
138
internal/beads/builtin_molecules.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Package beads provides a wrapper for the bd (beads) CLI.
|
||||
package beads
|
||||
|
||||
// BuiltinMolecule defines a built-in molecule template.
|
||||
type BuiltinMolecule struct {
|
||||
ID string // Well-known ID (e.g., "mol-engineer-in-box")
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
// BuiltinMolecules returns all built-in molecule definitions.
|
||||
func BuiltinMolecules() []BuiltinMolecule {
|
||||
return []BuiltinMolecule{
|
||||
EngineerInBoxMolecule(),
|
||||
QuickFixMolecule(),
|
||||
ResearchMolecule(),
|
||||
}
|
||||
}
|
||||
|
||||
// EngineerInBoxMolecule returns the engineer-in-box molecule definition.
|
||||
// This is a full workflow from design to merge.
|
||||
func EngineerInBoxMolecule() BuiltinMolecule {
|
||||
return BuiltinMolecule{
|
||||
ID: "mol-engineer-in-box",
|
||||
Title: "Engineer in a Box",
|
||||
Description: `Full workflow from design to merge.
|
||||
|
||||
## Step: design
|
||||
Think carefully about architecture. Consider:
|
||||
- Existing patterns in the codebase
|
||||
- Trade-offs between approaches
|
||||
- Testability and maintainability
|
||||
|
||||
Write a brief design summary before proceeding.
|
||||
|
||||
## Step: implement
|
||||
Write the code. Follow codebase conventions.
|
||||
Needs: design
|
||||
|
||||
## Step: review
|
||||
Self-review the changes. Look for:
|
||||
- Bugs and edge cases
|
||||
- Style issues
|
||||
- Missing error handling
|
||||
Needs: implement
|
||||
|
||||
## Step: test
|
||||
Write and run tests. Cover happy path and edge cases.
|
||||
Fix any failures before proceeding.
|
||||
Needs: implement
|
||||
|
||||
## Step: submit
|
||||
Submit for merge via refinery.
|
||||
Needs: review, test`,
|
||||
}
|
||||
}
|
||||
|
||||
// QuickFixMolecule returns the quick-fix molecule definition.
|
||||
// This is a fast path for small changes.
|
||||
func QuickFixMolecule() BuiltinMolecule {
|
||||
return BuiltinMolecule{
|
||||
ID: "mol-quick-fix",
|
||||
Title: "Quick Fix",
|
||||
Description: `Fast path for small changes.
|
||||
|
||||
## Step: implement
|
||||
Make the fix. Keep it focused.
|
||||
|
||||
## Step: test
|
||||
Run relevant tests. Fix any regressions.
|
||||
Needs: implement
|
||||
|
||||
## Step: submit
|
||||
Submit for merge.
|
||||
Needs: test`,
|
||||
}
|
||||
}
|
||||
|
||||
// ResearchMolecule returns the research molecule definition.
|
||||
// This is an investigation workflow.
|
||||
func ResearchMolecule() BuiltinMolecule {
|
||||
return BuiltinMolecule{
|
||||
ID: "mol-research",
|
||||
Title: "Research",
|
||||
Description: `Investigation workflow.
|
||||
|
||||
## Step: investigate
|
||||
Explore the question. Search code, read docs,
|
||||
understand context. Take notes.
|
||||
|
||||
## Step: document
|
||||
Write up findings. Include:
|
||||
- What you learned
|
||||
- Recommendations
|
||||
- Open questions
|
||||
Needs: investigate`,
|
||||
}
|
||||
}
|
||||
|
||||
// SeedBuiltinMolecules creates all built-in molecules in the beads database.
|
||||
// It skips molecules that already exist (by title match).
|
||||
// Returns the number of molecules created.
|
||||
func (b *Beads) SeedBuiltinMolecules() (int, error) {
|
||||
molecules := BuiltinMolecules()
|
||||
created := 0
|
||||
|
||||
// Get existing molecules to avoid duplicates
|
||||
existing, err := b.List(ListOptions{Type: "molecule", Priority: -1})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Build map of existing molecule titles
|
||||
existingTitles := make(map[string]bool)
|
||||
for _, issue := range existing {
|
||||
existingTitles[issue.Title] = true
|
||||
}
|
||||
|
||||
// Create each molecule if it doesn't exist
|
||||
for _, mol := range molecules {
|
||||
if existingTitles[mol.Title] {
|
||||
continue // Already exists
|
||||
}
|
||||
|
||||
_, err := b.Create(CreateOptions{
|
||||
Title: mol.Title,
|
||||
Type: "molecule",
|
||||
Priority: 2, // Medium priority
|
||||
Description: mol.Description,
|
||||
})
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
143
internal/beads/builtin_molecules_test.go
Normal file
143
internal/beads/builtin_molecules_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package beads
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuiltinMolecules(t *testing.T) {
|
||||
molecules := BuiltinMolecules()
|
||||
|
||||
if len(molecules) != 3 {
|
||||
t.Errorf("expected 3 built-in molecules, got %d", len(molecules))
|
||||
}
|
||||
|
||||
// Verify each molecule can be parsed and validated
|
||||
for _, mol := range molecules {
|
||||
t.Run(mol.Title, func(t *testing.T) {
|
||||
// Check required fields
|
||||
if mol.ID == "" {
|
||||
t.Error("molecule missing ID")
|
||||
}
|
||||
if mol.Title == "" {
|
||||
t.Error("molecule missing Title")
|
||||
}
|
||||
if mol.Description == "" {
|
||||
t.Error("molecule missing Description")
|
||||
}
|
||||
|
||||
// Parse the molecule steps
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse molecule steps: %v", err)
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
t.Error("molecule has no steps")
|
||||
}
|
||||
|
||||
// Validate the molecule as if it were an issue
|
||||
issue := &Issue{
|
||||
Type: "molecule",
|
||||
Title: mol.Title,
|
||||
Description: mol.Description,
|
||||
}
|
||||
|
||||
if err := ValidateMolecule(issue); err != nil {
|
||||
t.Errorf("molecule validation failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineerInBoxMolecule(t *testing.T) {
|
||||
mol := EngineerInBoxMolecule()
|
||||
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Should have 5 steps: design, implement, review, test, submit
|
||||
if len(steps) != 5 {
|
||||
t.Errorf("expected 5 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
// Verify step refs
|
||||
expectedRefs := []string{"design", "implement", "review", "test", "submit"}
|
||||
for i, expected := range expectedRefs {
|
||||
if steps[i].Ref != expected {
|
||||
t.Errorf("step %d: expected ref %q, got %q", i, expected, steps[i].Ref)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify dependencies
|
||||
// design has no deps
|
||||
if len(steps[0].Needs) != 0 {
|
||||
t.Errorf("design should have no deps, got %v", steps[0].Needs)
|
||||
}
|
||||
|
||||
// implement needs design
|
||||
if len(steps[1].Needs) != 1 || steps[1].Needs[0] != "design" {
|
||||
t.Errorf("implement should need design, got %v", steps[1].Needs)
|
||||
}
|
||||
|
||||
// review needs implement
|
||||
if len(steps[2].Needs) != 1 || steps[2].Needs[0] != "implement" {
|
||||
t.Errorf("review should need implement, got %v", steps[2].Needs)
|
||||
}
|
||||
|
||||
// test needs implement
|
||||
if len(steps[3].Needs) != 1 || steps[3].Needs[0] != "implement" {
|
||||
t.Errorf("test should need implement, got %v", steps[3].Needs)
|
||||
}
|
||||
|
||||
// submit needs review and test
|
||||
if len(steps[4].Needs) != 2 {
|
||||
t.Errorf("submit should need 2 deps, got %v", steps[4].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFixMolecule(t *testing.T) {
|
||||
mol := QuickFixMolecule()
|
||||
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Should have 3 steps: implement, test, submit
|
||||
if len(steps) != 3 {
|
||||
t.Errorf("expected 3 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
expectedRefs := []string{"implement", "test", "submit"}
|
||||
for i, expected := range expectedRefs {
|
||||
if steps[i].Ref != expected {
|
||||
t.Errorf("step %d: expected ref %q, got %q", i, expected, steps[i].Ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchMolecule(t *testing.T) {
|
||||
mol := ResearchMolecule()
|
||||
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 steps: investigate, document
|
||||
if len(steps) != 2 {
|
||||
t.Errorf("expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
expectedRefs := []string{"investigate", "document"}
|
||||
for i, expected := range expectedRefs {
|
||||
if steps[i].Ref != expected {
|
||||
t.Errorf("step %d: expected ref %q, got %q", i, expected, steps[i].Ref)
|
||||
}
|
||||
}
|
||||
|
||||
// document needs investigate
|
||||
if len(steps[1].Needs) != 1 || steps[1].Needs[0] != "investigate" {
|
||||
t.Errorf("document should need investigate, got %v", steps[1].Needs)
|
||||
}
|
||||
}
|
||||
305
internal/beads/molecule.go
Normal file
305
internal/beads/molecule.go
Normal file
@@ -0,0 +1,305 @@
|
||||
// Package beads molecule support - composable workflow templates.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MoleculeStep represents a parsed step from a molecule definition.
|
||||
type MoleculeStep struct {
|
||||
Ref string // Step reference (from "## Step: <ref>")
|
||||
Title string // Step title (first non-empty line or ref)
|
||||
Instructions string // Prose instructions for this step
|
||||
Needs []string // Step refs this step depends on
|
||||
Tier string // Optional tier hint: haiku, sonnet, opus
|
||||
}
|
||||
|
||||
// stepHeaderRegex matches "## Step: <ref>" with optional whitespace.
|
||||
var stepHeaderRegex = regexp.MustCompile(`(?i)^##\s*Step:\s*(\S+)\s*$`)
|
||||
|
||||
// needsLineRegex matches "Needs: step1, step2, ..." lines.
|
||||
var needsLineRegex = regexp.MustCompile(`(?i)^Needs:\s*(.+)$`)
|
||||
|
||||
// tierLineRegex matches "Tier: haiku|sonnet|opus" lines.
|
||||
var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`)
|
||||
|
||||
// templateVarRegex matches {{variable}} placeholders.
|
||||
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
|
||||
|
||||
// ParseMoleculeSteps extracts step definitions from a molecule's description.
|
||||
//
|
||||
// The expected format is:
|
||||
//
|
||||
// ## Step: <ref>
|
||||
// <prose instructions>
|
||||
// Needs: <step>, <step> # optional
|
||||
// Tier: haiku|sonnet|opus # optional
|
||||
//
|
||||
// Returns an empty slice if no steps are found.
|
||||
func ParseMoleculeSteps(description string) ([]MoleculeStep, error) {
|
||||
if description == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(description, "\n")
|
||||
var steps []MoleculeStep
|
||||
var currentStep *MoleculeStep
|
||||
var contentLines []string
|
||||
|
||||
// Helper to finalize current step
|
||||
finalizeStep := func() {
|
||||
if currentStep == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Process content lines to extract Needs/Tier and build instructions
|
||||
var instructionLines []string
|
||||
for _, line := range contentLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for Needs: line
|
||||
if matches := needsLineRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
deps := strings.Split(matches[1], ",")
|
||||
for _, dep := range deps {
|
||||
dep = strings.TrimSpace(dep)
|
||||
if dep != "" {
|
||||
currentStep.Needs = append(currentStep.Needs, dep)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for Tier: line
|
||||
if matches := tierLineRegex.FindStringSubmatch(trimmed); matches != nil {
|
||||
currentStep.Tier = strings.ToLower(matches[1])
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular instruction line
|
||||
instructionLines = append(instructionLines, line)
|
||||
}
|
||||
|
||||
// Build instructions, trimming leading/trailing blank lines
|
||||
currentStep.Instructions = strings.TrimSpace(strings.Join(instructionLines, "\n"))
|
||||
|
||||
// Set title from first non-empty line of instructions, or use ref
|
||||
if currentStep.Instructions != "" {
|
||||
firstLine := strings.SplitN(currentStep.Instructions, "\n", 2)[0]
|
||||
currentStep.Title = strings.TrimSpace(firstLine)
|
||||
}
|
||||
if currentStep.Title == "" {
|
||||
currentStep.Title = currentStep.Ref
|
||||
}
|
||||
|
||||
steps = append(steps, *currentStep)
|
||||
currentStep = nil
|
||||
contentLines = nil
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
// Check for step header
|
||||
if matches := stepHeaderRegex.FindStringSubmatch(line); matches != nil {
|
||||
// Finalize previous step if any
|
||||
finalizeStep()
|
||||
|
||||
// Start new step
|
||||
currentStep = &MoleculeStep{
|
||||
Ref: matches[1],
|
||||
}
|
||||
contentLines = nil
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content lines if we're in a step
|
||||
if currentStep != nil {
|
||||
contentLines = append(contentLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize last step
|
||||
finalizeStep()
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// ExpandTemplateVars replaces {{variable}} placeholders in text using the provided context map.
|
||||
// Unknown variables are left as-is.
|
||||
func ExpandTemplateVars(text string, ctx map[string]string) string {
|
||||
if ctx == nil {
|
||||
return text
|
||||
}
|
||||
|
||||
return templateVarRegex.ReplaceAllStringFunc(text, func(match string) string {
|
||||
// Extract variable name from {{name}}
|
||||
varName := match[2 : len(match)-2]
|
||||
if value, ok := ctx[varName]; ok {
|
||||
return value
|
||||
}
|
||||
return match // Leave unknown variables as-is
|
||||
})
|
||||
}
|
||||
|
||||
// InstantiateOptions configures molecule instantiation behavior.
|
||||
type InstantiateOptions struct {
|
||||
// Context map for {{variable}} substitution
|
||||
Context map[string]string
|
||||
}
|
||||
|
||||
// InstantiateMolecule creates child issues from a molecule template.
|
||||
//
|
||||
// For each step in the molecule, this creates:
|
||||
// - A child issue with ID "{parent.ID}.{step.Ref}"
|
||||
// - Title from step title
|
||||
// - Description from step instructions (with template vars expanded)
|
||||
// - Type: task
|
||||
// - Priority: inherited from parent
|
||||
// - Dependencies wired according to Needs: declarations
|
||||
//
|
||||
// The function is atomic via bd CLI - either all issues are created or none.
|
||||
// Returns the created step issues.
|
||||
func (b *Beads) InstantiateMolecule(mol *Issue, parent *Issue, opts InstantiateOptions) ([]*Issue, error) {
|
||||
if mol == nil {
|
||||
return nil, fmt.Errorf("molecule issue is nil")
|
||||
}
|
||||
if parent == nil {
|
||||
return nil, fmt.Errorf("parent issue is nil")
|
||||
}
|
||||
|
||||
// Parse steps from molecule
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing molecule steps: %w", err)
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return nil, fmt.Errorf("molecule has no steps defined")
|
||||
}
|
||||
|
||||
// Build map of step ref -> step for dependency validation
|
||||
stepMap := make(map[string]*MoleculeStep)
|
||||
for i := range steps {
|
||||
stepMap[steps[i].Ref] = &steps[i]
|
||||
}
|
||||
|
||||
// Validate all Needs references exist
|
||||
for _, step := range steps {
|
||||
for _, need := range step.Needs {
|
||||
if _, ok := stepMap[need]; !ok {
|
||||
return nil, fmt.Errorf("step %q depends on unknown step %q", step.Ref, need)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create child issues for each step
|
||||
var createdIssues []*Issue
|
||||
stepIssueIDs := make(map[string]string) // step ref -> issue ID
|
||||
|
||||
for _, step := range steps {
|
||||
// Expand template variables in instructions
|
||||
instructions := step.Instructions
|
||||
if opts.Context != nil {
|
||||
instructions = ExpandTemplateVars(instructions, opts.Context)
|
||||
}
|
||||
|
||||
// Build description with provenance metadata
|
||||
description := instructions
|
||||
if description != "" {
|
||||
description += "\n\n"
|
||||
}
|
||||
description += fmt.Sprintf("instantiated_from: %s\nstep: %s", mol.ID, step.Ref)
|
||||
if step.Tier != "" {
|
||||
description += fmt.Sprintf("\ntier: %s", step.Tier)
|
||||
}
|
||||
|
||||
// Create the child issue
|
||||
childOpts := CreateOptions{
|
||||
Title: step.Title,
|
||||
Type: "task",
|
||||
Priority: parent.Priority,
|
||||
Description: description,
|
||||
Parent: parent.ID,
|
||||
}
|
||||
|
||||
child, err := b.Create(childOpts)
|
||||
if err != nil {
|
||||
// Attempt to clean up created issues on failure
|
||||
for _, created := range createdIssues {
|
||||
_ = b.Close(created.ID)
|
||||
}
|
||||
return nil, fmt.Errorf("creating step %q: %w", step.Ref, err)
|
||||
}
|
||||
|
||||
createdIssues = append(createdIssues, child)
|
||||
stepIssueIDs[step.Ref] = child.ID
|
||||
}
|
||||
|
||||
// Wire inter-step dependencies based on Needs: declarations
|
||||
for _, step := range steps {
|
||||
if len(step.Needs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
childID := stepIssueIDs[step.Ref]
|
||||
for _, need := range step.Needs {
|
||||
dependsOnID := stepIssueIDs[need]
|
||||
if err := b.AddDependency(childID, dependsOnID); err != nil {
|
||||
// Log but don't fail - the issues are created
|
||||
// This is non-atomic but bd CLI doesn't support transactions
|
||||
return createdIssues, fmt.Errorf("adding dependency %s -> %s: %w", childID, dependsOnID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createdIssues, nil
|
||||
}
|
||||
|
||||
// ValidateMolecule checks if an issue is a valid molecule definition.
|
||||
// Returns an error describing the problem, or nil if valid.
|
||||
func ValidateMolecule(mol *Issue) error {
|
||||
if mol == nil {
|
||||
return fmt.Errorf("molecule is nil")
|
||||
}
|
||||
|
||||
if mol.Type != "molecule" {
|
||||
return fmt.Errorf("issue type is %q, expected molecule", mol.Type)
|
||||
}
|
||||
|
||||
steps, err := ParseMoleculeSteps(mol.Description)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing steps: %w", err)
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return fmt.Errorf("molecule has no steps defined")
|
||||
}
|
||||
|
||||
// Build step map for reference validation
|
||||
stepMap := make(map[string]bool)
|
||||
for _, step := range steps {
|
||||
if step.Ref == "" {
|
||||
return fmt.Errorf("step has empty ref")
|
||||
}
|
||||
if stepMap[step.Ref] {
|
||||
return fmt.Errorf("duplicate step ref: %s", step.Ref)
|
||||
}
|
||||
stepMap[step.Ref] = true
|
||||
}
|
||||
|
||||
// Validate Needs references
|
||||
for _, step := range steps {
|
||||
for _, need := range step.Needs {
|
||||
if !stepMap[need] {
|
||||
return fmt.Errorf("step %q depends on unknown step %q", step.Ref, need)
|
||||
}
|
||||
if need == step.Ref {
|
||||
return fmt.Errorf("step %q has self-dependency", step.Ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Detect cycles in dependency graph
|
||||
|
||||
return nil
|
||||
}
|
||||
491
internal/beads/molecule_test.go
Normal file
491
internal/beads/molecule_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMoleculeSteps_EmptyDescription(t *testing.T) {
|
||||
steps, err := ParseMoleculeSteps("")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 0 {
|
||||
t.Errorf("expected 0 steps, got %d", len(steps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_NoSteps(t *testing.T) {
|
||||
desc := `This is a molecule description without any steps.
|
||||
Just some prose text.`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 0 {
|
||||
t.Errorf("expected 0 steps, got %d", len(steps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_SingleStep(t *testing.T) {
|
||||
desc := `## Step: implement
|
||||
Write the code carefully.
|
||||
Follow existing patterns.`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected 1 step, got %d", len(steps))
|
||||
}
|
||||
|
||||
step := steps[0]
|
||||
if step.Ref != "implement" {
|
||||
t.Errorf("Ref = %q, want implement", step.Ref)
|
||||
}
|
||||
if step.Title != "Write the code carefully." {
|
||||
t.Errorf("Title = %q, want 'Write the code carefully.'", step.Title)
|
||||
}
|
||||
if step.Instructions != "Write the code carefully.\nFollow existing patterns." {
|
||||
t.Errorf("Instructions = %q", step.Instructions)
|
||||
}
|
||||
if len(step.Needs) != 0 {
|
||||
t.Errorf("Needs = %v, want empty", step.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_MultipleSteps(t *testing.T) {
|
||||
desc := `This workflow takes a task through multiple stages.
|
||||
|
||||
## Step: design
|
||||
Think about architecture and patterns.
|
||||
Consider edge cases.
|
||||
|
||||
## Step: implement
|
||||
Write the implementation.
|
||||
Needs: design
|
||||
|
||||
## Step: test
|
||||
Write comprehensive tests.
|
||||
Needs: implement
|
||||
|
||||
## Step: submit
|
||||
Submit for review.
|
||||
Needs: implement, test`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 4 {
|
||||
t.Fatalf("expected 4 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
// Check design step
|
||||
if steps[0].Ref != "design" {
|
||||
t.Errorf("step[0].Ref = %q, want design", steps[0].Ref)
|
||||
}
|
||||
if len(steps[0].Needs) != 0 {
|
||||
t.Errorf("step[0].Needs = %v, want empty", steps[0].Needs)
|
||||
}
|
||||
|
||||
// Check implement step
|
||||
if steps[1].Ref != "implement" {
|
||||
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
||||
}
|
||||
if !reflect.DeepEqual(steps[1].Needs, []string{"design"}) {
|
||||
t.Errorf("step[1].Needs = %v, want [design]", steps[1].Needs)
|
||||
}
|
||||
|
||||
// Check test step
|
||||
if steps[2].Ref != "test" {
|
||||
t.Errorf("step[2].Ref = %q, want test", steps[2].Ref)
|
||||
}
|
||||
if !reflect.DeepEqual(steps[2].Needs, []string{"implement"}) {
|
||||
t.Errorf("step[2].Needs = %v, want [implement]", steps[2].Needs)
|
||||
}
|
||||
|
||||
// Check submit step with multiple dependencies
|
||||
if steps[3].Ref != "submit" {
|
||||
t.Errorf("step[3].Ref = %q, want submit", steps[3].Ref)
|
||||
}
|
||||
if !reflect.DeepEqual(steps[3].Needs, []string{"implement", "test"}) {
|
||||
t.Errorf("step[3].Needs = %v, want [implement, test]", steps[3].Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_WithTier(t *testing.T) {
|
||||
desc := `## Step: quick-task
|
||||
Do something simple.
|
||||
Tier: haiku
|
||||
|
||||
## Step: complex-task
|
||||
Do something complex.
|
||||
Needs: quick-task
|
||||
Tier: opus`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
if steps[0].Tier != "haiku" {
|
||||
t.Errorf("step[0].Tier = %q, want haiku", steps[0].Tier)
|
||||
}
|
||||
if steps[1].Tier != "opus" {
|
||||
t.Errorf("step[1].Tier = %q, want opus", steps[1].Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_CaseInsensitive(t *testing.T) {
|
||||
desc := `## STEP: Design
|
||||
Plan the work.
|
||||
NEEDS: nothing
|
||||
TIER: SONNET
|
||||
|
||||
## step: implement
|
||||
Write code.
|
||||
needs: Design
|
||||
tier: Haiku`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
// Note: refs preserve original case
|
||||
if steps[0].Ref != "Design" {
|
||||
t.Errorf("step[0].Ref = %q, want Design", steps[0].Ref)
|
||||
}
|
||||
if steps[0].Tier != "sonnet" {
|
||||
t.Errorf("step[0].Tier = %q, want sonnet", steps[0].Tier)
|
||||
}
|
||||
|
||||
if steps[1].Ref != "implement" {
|
||||
t.Errorf("step[1].Ref = %q, want implement", steps[1].Ref)
|
||||
}
|
||||
if steps[1].Tier != "haiku" {
|
||||
t.Errorf("step[1].Tier = %q, want haiku", steps[1].Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_EngineerInBox(t *testing.T) {
|
||||
// The canonical example from the design doc
|
||||
desc := `This workflow takes a task from design to merge.
|
||||
|
||||
## Step: design
|
||||
Think carefully about architecture. Consider existing patterns,
|
||||
trade-offs, testability.
|
||||
|
||||
## Step: implement
|
||||
Write clean code. Follow codebase conventions.
|
||||
Needs: design
|
||||
|
||||
## Step: review
|
||||
Review for bugs, edge cases, style issues.
|
||||
Needs: implement
|
||||
|
||||
## Step: test
|
||||
Write and run tests. Cover happy path and edge cases.
|
||||
Needs: implement
|
||||
|
||||
## Step: submit
|
||||
Submit for merge via refinery.
|
||||
Needs: review, test`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 5 {
|
||||
t.Fatalf("expected 5 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
expected := []struct {
|
||||
ref string
|
||||
needs []string
|
||||
}{
|
||||
{"design", nil},
|
||||
{"implement", []string{"design"}},
|
||||
{"review", []string{"implement"}},
|
||||
{"test", []string{"implement"}},
|
||||
{"submit", []string{"review", "test"}},
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
if steps[i].Ref != exp.ref {
|
||||
t.Errorf("step[%d].Ref = %q, want %q", i, steps[i].Ref, exp.ref)
|
||||
}
|
||||
if exp.needs == nil {
|
||||
if len(steps[i].Needs) != 0 {
|
||||
t.Errorf("step[%d].Needs = %v, want empty", i, steps[i].Needs)
|
||||
}
|
||||
} else if !reflect.DeepEqual(steps[i].Needs, exp.needs) {
|
||||
t.Errorf("step[%d].Needs = %v, want %v", i, steps[i].Needs, exp.needs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTemplateVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
ctx map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no variables",
|
||||
text: "Just plain text",
|
||||
ctx: map[string]string{"foo": "bar"},
|
||||
want: "Just plain text",
|
||||
},
|
||||
{
|
||||
name: "single variable",
|
||||
text: "Implement {{feature_name}} feature",
|
||||
ctx: map[string]string{"feature_name": "authentication"},
|
||||
want: "Implement authentication feature",
|
||||
},
|
||||
{
|
||||
name: "multiple variables",
|
||||
text: "Implement {{feature}} in {{file}}",
|
||||
ctx: map[string]string{"feature": "login", "file": "auth.go"},
|
||||
want: "Implement login in auth.go",
|
||||
},
|
||||
{
|
||||
name: "unknown variable left as-is",
|
||||
text: "Value is {{unknown}}",
|
||||
ctx: map[string]string{"known": "value"},
|
||||
want: "Value is {{unknown}}",
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
text: "Value is {{var}}",
|
||||
ctx: nil,
|
||||
want: "Value is {{var}}",
|
||||
},
|
||||
{
|
||||
name: "empty context",
|
||||
text: "Value is {{var}}",
|
||||
ctx: map[string]string{},
|
||||
want: "Value is {{var}}",
|
||||
},
|
||||
{
|
||||
name: "repeated variable",
|
||||
text: "{{x}} and {{x}} again",
|
||||
ctx: map[string]string{"x": "foo"},
|
||||
want: "foo and foo again",
|
||||
},
|
||||
{
|
||||
name: "multiline",
|
||||
text: "First line with {{a}}.\nSecond line with {{b}}.",
|
||||
ctx: map[string]string{"a": "alpha", "b": "beta"},
|
||||
want: "First line with alpha.\nSecond line with beta.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExpandTemplateVars(tt.text, tt.ctx)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExpandTemplateVars() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_WithTemplateVars(t *testing.T) {
|
||||
desc := `## Step: implement
|
||||
Implement {{feature_name}} in {{target_file}}.
|
||||
Follow the existing patterns.`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected 1 step, got %d", len(steps))
|
||||
}
|
||||
|
||||
// Template vars should be preserved in parsed instructions
|
||||
if steps[0].Instructions != "Implement {{feature_name}} in {{target_file}}.\nFollow the existing patterns." {
|
||||
t.Errorf("Instructions = %q", steps[0].Instructions)
|
||||
}
|
||||
|
||||
// Now expand them
|
||||
expanded := ExpandTemplateVars(steps[0].Instructions, map[string]string{
|
||||
"feature_name": "user auth",
|
||||
"target_file": "auth.go",
|
||||
})
|
||||
|
||||
if expanded != "Implement user auth in auth.go.\nFollow the existing patterns." {
|
||||
t.Errorf("expanded = %q", expanded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_Valid(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "mol-xyz",
|
||||
Type: "molecule",
|
||||
Description: `## Step: design
|
||||
Plan the work.
|
||||
|
||||
## Step: implement
|
||||
Write code.
|
||||
Needs: design`,
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateMolecule() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_WrongType(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "task-xyz",
|
||||
Type: "task",
|
||||
Description: `## Step: design\nPlan.`,
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule() = nil, want error for wrong type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_NoSteps(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "mol-xyz",
|
||||
Type: "molecule",
|
||||
Description: "Just some description without steps.",
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule() = nil, want error for no steps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_DuplicateRef(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "mol-xyz",
|
||||
Type: "molecule",
|
||||
Description: `## Step: design
|
||||
Plan the work.
|
||||
|
||||
## Step: design
|
||||
Plan again.`,
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule() = nil, want error for duplicate ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_UnknownDependency(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "mol-xyz",
|
||||
Type: "molecule",
|
||||
Description: `## Step: implement
|
||||
Write code.
|
||||
Needs: nonexistent`,
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule() = nil, want error for unknown dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_SelfDependency(t *testing.T) {
|
||||
mol := &Issue{
|
||||
ID: "mol-xyz",
|
||||
Type: "molecule",
|
||||
Description: `## Step: implement
|
||||
Write code.
|
||||
Needs: implement`,
|
||||
}
|
||||
|
||||
err := ValidateMolecule(mol)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule() = nil, want error for self-dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMolecule_Nil(t *testing.T) {
|
||||
err := ValidateMolecule(nil)
|
||||
if err == nil {
|
||||
t.Error("ValidateMolecule(nil) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_WhitespaceHandling(t *testing.T) {
|
||||
desc := `## Step: spaced
|
||||
Indented instructions.
|
||||
|
||||
More indented content.
|
||||
|
||||
Needs: dep1 , dep2 ,dep3
|
||||
Tier: opus `
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected 1 step, got %d", len(steps))
|
||||
}
|
||||
|
||||
// Ref preserves original (though trimmed)
|
||||
if steps[0].Ref != "spaced" {
|
||||
t.Errorf("Ref = %q, want spaced", steps[0].Ref)
|
||||
}
|
||||
|
||||
// Dependencies should be trimmed
|
||||
expectedDeps := []string{"dep1", "dep2", "dep3"}
|
||||
if !reflect.DeepEqual(steps[0].Needs, expectedDeps) {
|
||||
t.Errorf("Needs = %v, want %v", steps[0].Needs, expectedDeps)
|
||||
}
|
||||
|
||||
// Tier should be lowercase and trimmed
|
||||
if steps[0].Tier != "opus" {
|
||||
t.Errorf("Tier = %q, want opus", steps[0].Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoleculeSteps_EmptyInstructions(t *testing.T) {
|
||||
desc := `## Step: empty
|
||||
|
||||
## Step: next
|
||||
Has content.`
|
||||
|
||||
steps, err := ParseMoleculeSteps(desc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("expected 2 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
// First step has empty instructions, title defaults to ref
|
||||
if steps[0].Instructions != "" {
|
||||
t.Errorf("step[0].Instructions = %q, want empty", steps[0].Instructions)
|
||||
}
|
||||
if steps[0].Title != "empty" {
|
||||
t.Errorf("step[0].Title = %q, want empty", steps[0].Title)
|
||||
}
|
||||
|
||||
// Second step has content
|
||||
if steps[1].Instructions != "Has content." {
|
||||
t.Errorf("step[1].Instructions = %q", steps[1].Instructions)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user