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

@@ -8,6 +8,7 @@ import (
"fmt"
"os/exec"
"strings"
"time"
)
// Common errors
@@ -601,6 +602,139 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
return result, nil
}
// AttachmentFields holds the attachment info for pinned beads.
// These fields track which molecule is attached to a handoff/pinned bead.
type AttachmentFields struct {
AttachedMolecule string // Root issue ID of the attached molecule
AttachedAt string // ISO 8601 timestamp when attached
}
// ParseAttachmentFields extracts attachment fields from an issue's description.
// Fields are expected as "key: value" lines. Returns nil if no attachment fields found.
func ParseAttachmentFields(issue *Issue) *AttachmentFields {
if issue == nil || issue.Description == "" {
return nil
}
fields := &AttachmentFields{}
hasFields := false
for _, line := range strings.Split(issue.Description, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Look for "key: value" pattern
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
if value == "" {
continue
}
// Map keys to fields (case-insensitive)
switch strings.ToLower(key) {
case "attached_molecule", "attached-molecule", "attachedmolecule":
fields.AttachedMolecule = value
hasFields = true
case "attached_at", "attached-at", "attachedat":
fields.AttachedAt = value
hasFields = true
}
}
if !hasFields {
return nil
}
return fields
}
// FormatAttachmentFields formats AttachmentFields as a string suitable for an issue description.
// Only non-empty fields are included.
func FormatAttachmentFields(fields *AttachmentFields) string {
if fields == nil {
return ""
}
var lines []string
if fields.AttachedMolecule != "" {
lines = append(lines, "attached_molecule: "+fields.AttachedMolecule)
}
if fields.AttachedAt != "" {
lines = append(lines, "attached_at: "+fields.AttachedAt)
}
return strings.Join(lines, "\n")
}
// SetAttachmentFields updates an issue's description with the given attachment fields.
// Existing attachment field lines are replaced; other content is preserved.
// Returns the new description string.
func SetAttachmentFields(issue *Issue, fields *AttachmentFields) string {
// Known attachment field keys (lowercase)
attachmentKeys := map[string]bool{
"attached_molecule": true,
"attached-molecule": true,
"attachedmolecule": true,
"attached_at": true,
"attached-at": true,
"attachedat": true,
}
// Collect non-attachment lines from existing description
var otherLines []string
if issue != nil && issue.Description != "" {
for _, line := range strings.Split(issue.Description, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
// Preserve blank lines in content
otherLines = append(otherLines, line)
continue
}
// Check if this is an attachment field line
colonIdx := strings.Index(trimmed, ":")
if colonIdx == -1 {
otherLines = append(otherLines, line)
continue
}
key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx]))
if !attachmentKeys[key] {
otherLines = append(otherLines, line)
}
// Skip attachment field lines - they'll be replaced
}
}
// Build new description: attachment fields first, then other content
formatted := FormatAttachmentFields(fields)
// Trim trailing blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[len(otherLines)-1]) == "" {
otherLines = otherLines[:len(otherLines)-1]
}
// Trim leading blank lines from other content
for len(otherLines) > 0 && strings.TrimSpace(otherLines[0]) == "" {
otherLines = otherLines[1:]
}
if formatted == "" {
return strings.Join(otherLines, "\n")
}
if len(otherLines) == 0 {
return formatted
}
return formatted + "\n\n" + strings.Join(otherLines, "\n")
}
// 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 {
@@ -780,3 +914,77 @@ func SetMRFields(issue *Issue, fields *MRFields) string {
return formatted + "\n\n" + strings.Join(otherLines, "\n")
}
// AttachMolecule attaches a molecule to a pinned bead by updating its description.
// The moleculeID is the root issue ID of the molecule to attach.
// Returns the updated issue.
func (b *Beads) AttachMolecule(pinnedBeadID, moleculeID string) (*Issue, error) {
// Fetch the pinned bead
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, fmt.Errorf("fetching pinned bead: %w", err)
}
if issue.Status != StatusPinned {
return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status)
}
// Build attachment fields with current timestamp
fields := &AttachmentFields{
AttachedMolecule: moleculeID,
AttachedAt: currentTimestamp(),
}
// Update description with attachment fields
newDesc := SetAttachmentFields(issue, fields)
// Update the issue
if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil {
return nil, fmt.Errorf("updating pinned bead: %w", err)
}
// Re-fetch to return updated state
return b.Show(pinnedBeadID)
}
// DetachMolecule removes molecule attachment from a pinned bead.
// Returns the updated issue.
func (b *Beads) DetachMolecule(pinnedBeadID string) (*Issue, error) {
// Fetch the pinned bead
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, fmt.Errorf("fetching pinned bead: %w", err)
}
// Check if there's anything to detach
if ParseAttachmentFields(issue) == nil {
return issue, nil // Nothing to detach
}
// Clear attachment fields by passing nil
newDesc := SetAttachmentFields(issue, nil)
// Update the issue
if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil {
return nil, fmt.Errorf("updating pinned bead: %w", err)
}
// Re-fetch to return updated state
return b.Show(pinnedBeadID)
}
// GetAttachment returns the attachment fields from a pinned bead.
// Returns nil if no molecule is attached.
func (b *Beads) GetAttachment(pinnedBeadID string) (*AttachmentFields, error) {
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, err
}
return ParseAttachmentFields(issue), nil
}
// currentTimestamp returns the current time in ISO 8601 format.
func currentTimestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}

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)
}
}