feat(beads): add attachment fields for pinned beads (gt-rana.1)

Implement AttachmentFields to track molecule attachments on pinned/handoff beads:
- AttachedMolecule: root issue ID of attached molecule
- AttachedAt: timestamp when attached

API:
- AttachMolecule(pinnedBeadID, moleculeID) - attach
- DetachMolecule(pinnedBeadID) - detach
- GetAttachment(pinnedBeadID) - query
- ParseAttachmentFields(issue) - parse from description
- FormatAttachmentFields(fields) - format for description

Includes comprehensive tests for parsing and API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-21 15:50:45 -08:00
parent 9c2adb9734
commit 2e59c002e8
3 changed files with 624 additions and 0 deletions

View File

@@ -626,3 +626,258 @@ Also see http://localhost:8080/api`,
t.Error("branch field was not set")
}
}
// TestParseAttachmentFields tests parsing attachment fields from issue descriptions.
func TestParseAttachmentFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
wantNil bool
wantFields *AttachmentFields
}{
{
name: "nil issue",
issue: nil,
wantNil: true,
},
{
name: "empty description",
issue: &Issue{Description: ""},
wantNil: true,
},
{
name: "no attachment fields",
issue: &Issue{Description: "This is just plain text\nwith no attachment markers"},
wantNil: true,
},
{
name: "both fields",
issue: &Issue{
Description: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
},
{
name: "only molecule",
issue: &Issue{
Description: `attached_molecule: mol-abc`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-abc",
},
},
{
name: "mixed with other content",
issue: &Issue{
Description: `attached_molecule: mol-def
attached_at: 2025-12-21T10:00:00Z
This is a handoff bead for the polecat.
Keep working on the current task.`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-def",
AttachedAt: "2025-12-21T10:00:00Z",
},
},
{
name: "alternate key formats (hyphen)",
issue: &Issue{
Description: `attached-molecule: mol-ghi
attached-at: 2025-12-21T12:00:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-ghi",
AttachedAt: "2025-12-21T12:00:00Z",
},
},
{
name: "case insensitive",
issue: &Issue{
Description: `Attached_Molecule: mol-jkl
ATTACHED_AT: 2025-12-21T14:00:00Z`,
},
wantFields: &AttachmentFields{
AttachedMolecule: "mol-jkl",
AttachedAt: "2025-12-21T14:00:00Z",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := ParseAttachmentFields(tt.issue)
if tt.wantNil {
if fields != nil {
t.Errorf("ParseAttachmentFields() = %+v, want nil", fields)
}
return
}
if fields == nil {
t.Fatal("ParseAttachmentFields() = nil, want non-nil")
}
if fields.AttachedMolecule != tt.wantFields.AttachedMolecule {
t.Errorf("AttachedMolecule = %q, want %q", fields.AttachedMolecule, tt.wantFields.AttachedMolecule)
}
if fields.AttachedAt != tt.wantFields.AttachedAt {
t.Errorf("AttachedAt = %q, want %q", fields.AttachedAt, tt.wantFields.AttachedAt)
}
})
}
}
// TestFormatAttachmentFields tests formatting attachment fields to string.
func TestFormatAttachmentFields(t *testing.T) {
tests := []struct {
name string
fields *AttachmentFields
want string
}{
{
name: "nil fields",
fields: nil,
want: "",
},
{
name: "empty fields",
fields: &AttachmentFields{},
want: "",
},
{
name: "both fields",
fields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
{
name: "only molecule",
fields: &AttachmentFields{
AttachedMolecule: "mol-abc",
},
want: "attached_molecule: mol-abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAttachmentFields(tt.fields)
if got != tt.want {
t.Errorf("FormatAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestSetAttachmentFields tests updating issue descriptions with attachment fields.
func TestSetAttachmentFields(t *testing.T) {
tests := []struct {
name string
issue *Issue
fields *AttachmentFields
want string
}{
{
name: "nil issue",
issue: nil,
fields: &AttachmentFields{
AttachedMolecule: "mol-xyz",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-xyz
attached_at: 2025-12-21T15:30:00Z`,
},
{
name: "empty description",
issue: &Issue{Description: ""},
fields: &AttachmentFields{
AttachedMolecule: "mol-abc",
AttachedAt: "2025-12-21T10:00:00Z",
},
want: `attached_molecule: mol-abc
attached_at: 2025-12-21T10:00:00Z`,
},
{
name: "preserve prose content",
issue: &Issue{Description: "This is a handoff bead description.\n\nKeep working on the task."},
fields: &AttachmentFields{
AttachedMolecule: "mol-def",
},
want: `attached_molecule: mol-def
This is a handoff bead description.
Keep working on the task.`,
},
{
name: "replace existing fields",
issue: &Issue{
Description: `attached_molecule: mol-old
attached_at: 2025-12-20T10:00:00Z
Some existing prose content.`,
},
fields: &AttachmentFields{
AttachedMolecule: "mol-new",
AttachedAt: "2025-12-21T15:30:00Z",
},
want: `attached_molecule: mol-new
attached_at: 2025-12-21T15:30:00Z
Some existing prose content.`,
},
{
name: "nil fields clears attachment",
issue: &Issue{Description: "attached_molecule: mol-old\nattached_at: 2025-12-20T10:00:00Z\n\nKeep this text."},
fields: nil,
want: "Keep this text.",
},
{
name: "empty fields clears attachment",
issue: &Issue{Description: "attached_molecule: mol-old\n\nKeep this text."},
fields: &AttachmentFields{},
want: "Keep this text.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SetAttachmentFields(tt.issue, tt.fields)
if got != tt.want {
t.Errorf("SetAttachmentFields() =\n%q\nwant\n%q", got, tt.want)
}
})
}
}
// TestAttachmentFieldsRoundTrip tests that parse/format round-trips correctly.
func TestAttachmentFieldsRoundTrip(t *testing.T) {
original := &AttachmentFields{
AttachedMolecule: "mol-roundtrip",
AttachedAt: "2025-12-21T15:30:00Z",
}
// Format to string
formatted := FormatAttachmentFields(original)
// Parse back
issue := &Issue{Description: formatted}
parsed := ParseAttachmentFields(issue)
if parsed == nil {
t.Fatal("round-trip parse returned nil")
}
if *parsed != *original {
t.Errorf("round-trip mismatch:\ngot %+v\nwant %+v", parsed, original)
}
}