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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user