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:
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common errors
|
// Common errors
|
||||||
@@ -601,6 +602,139 @@ func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
|
|||||||
return result, nil
|
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.
|
// MRFields holds the structured fields for a merge-request issue.
|
||||||
// These fields are stored as key: value lines in the issue description.
|
// These fields are stored as key: value lines in the issue description.
|
||||||
type MRFields struct {
|
type MRFields struct {
|
||||||
@@ -780,3 +914,77 @@ func SetMRFields(issue *Issue, fields *MRFields) string {
|
|||||||
|
|
||||||
return formatted + "\n\n" + strings.Join(otherLines, "\n")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -626,3 +626,258 @@ Also see http://localhost:8080/api`,
|
|||||||
t.Error("branch field was not set")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,44 @@ Example:
|
|||||||
RunE: runMoleculeProgress,
|
RunE: runMoleculeProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var moleculeAttachCmd = &cobra.Command{
|
||||||
|
Use: "attach <pinned-bead-id> <molecule-id>",
|
||||||
|
Short: "Attach a molecule to a pinned bead",
|
||||||
|
Long: `Attach a molecule to a pinned/handoff bead.
|
||||||
|
|
||||||
|
This records which molecule an agent is currently working on. The attachment
|
||||||
|
is stored in the pinned bead's description and visible via 'bd show'.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
gt molecule attach gt-abc mol-xyz`,
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: runMoleculeAttach,
|
||||||
|
}
|
||||||
|
|
||||||
|
var moleculeDetachCmd = &cobra.Command{
|
||||||
|
Use: "detach <pinned-bead-id>",
|
||||||
|
Short: "Detach molecule from a pinned bead",
|
||||||
|
Long: `Remove molecule attachment from a pinned/handoff bead.
|
||||||
|
|
||||||
|
This clears the attached_molecule and attached_at fields from the bead.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
gt molecule detach gt-abc`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMoleculeDetach,
|
||||||
|
}
|
||||||
|
|
||||||
|
var moleculeAttachmentCmd = &cobra.Command{
|
||||||
|
Use: "attachment <pinned-bead-id>",
|
||||||
|
Short: "Show attachment status of a pinned bead",
|
||||||
|
Long: `Show which molecule is attached to a pinned bead.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
gt molecule attachment gt-abc`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runMoleculeAttachment,
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// List flags
|
// List flags
|
||||||
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
moleculeListCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||||
@@ -154,6 +192,9 @@ func init() {
|
|||||||
// Progress flags
|
// Progress flags
|
||||||
moleculeProgressCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
moleculeProgressCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
// Attachment flags
|
||||||
|
moleculeAttachmentCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
moleculeCmd.AddCommand(moleculeListCmd)
|
moleculeCmd.AddCommand(moleculeListCmd)
|
||||||
moleculeCmd.AddCommand(moleculeShowCmd)
|
moleculeCmd.AddCommand(moleculeShowCmd)
|
||||||
@@ -162,6 +203,9 @@ func init() {
|
|||||||
moleculeCmd.AddCommand(moleculeInstancesCmd)
|
moleculeCmd.AddCommand(moleculeInstancesCmd)
|
||||||
moleculeCmd.AddCommand(moleculeExportCmd)
|
moleculeCmd.AddCommand(moleculeExportCmd)
|
||||||
moleculeCmd.AddCommand(moleculeProgressCmd)
|
moleculeCmd.AddCommand(moleculeProgressCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeAttachCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeDetachCmd)
|
||||||
|
moleculeCmd.AddCommand(moleculeAttachmentCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(moleculeCmd)
|
rootCmd.AddCommand(moleculeCmd)
|
||||||
}
|
}
|
||||||
@@ -818,3 +862,120 @@ func extractMoleculeID(description string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runMoleculeAttach(cmd *cobra.Command, args []string) error {
|
||||||
|
pinnedBeadID := args[0]
|
||||||
|
moleculeID := args[1]
|
||||||
|
|
||||||
|
workDir, err := findLocalBeadsDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := beads.New(workDir)
|
||||||
|
|
||||||
|
// Attach the molecule
|
||||||
|
issue, err := b.AttachMolecule(pinnedBeadID, moleculeID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("attaching molecule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment := beads.ParseAttachmentFields(issue)
|
||||||
|
fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, pinnedBeadID)
|
||||||
|
if attachment != nil && attachment.AttachedAt != "" {
|
||||||
|
fmt.Printf(" attached_at: %s\n", attachment.AttachedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMoleculeDetach(cmd *cobra.Command, args []string) error {
|
||||||
|
pinnedBeadID := args[0]
|
||||||
|
|
||||||
|
workDir, err := findLocalBeadsDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := beads.New(workDir)
|
||||||
|
|
||||||
|
// Check current attachment first
|
||||||
|
attachment, err := b.GetAttachment(pinnedBeadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking attachment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachment == nil {
|
||||||
|
fmt.Printf("%s No molecule attached to %s\n", style.Dim.Render("ℹ"), pinnedBeadID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMolecule := attachment.AttachedMolecule
|
||||||
|
|
||||||
|
// Detach the molecule
|
||||||
|
_, err = b.DetachMolecule(pinnedBeadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("detaching molecule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Detached %s from %s\n", style.Bold.Render("✓"), previousMolecule, pinnedBeadID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMoleculeAttachment(cmd *cobra.Command, args []string) error {
|
||||||
|
pinnedBeadID := args[0]
|
||||||
|
|
||||||
|
workDir, err := findLocalBeadsDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a beads workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := beads.New(workDir)
|
||||||
|
|
||||||
|
// Get the issue
|
||||||
|
issue, err := b.Show(pinnedBeadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting issue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment := beads.ParseAttachmentFields(issue)
|
||||||
|
|
||||||
|
if moleculeJSON {
|
||||||
|
type attachmentOutput struct {
|
||||||
|
IssueID string `json:"issue_id"`
|
||||||
|
IssueTitle string `json:"issue_title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AttachedMolecule string `json:"attached_molecule,omitempty"`
|
||||||
|
AttachedAt string `json:"attached_at,omitempty"`
|
||||||
|
}
|
||||||
|
out := attachmentOutput{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
IssueTitle: issue.Title,
|
||||||
|
Status: issue.Status,
|
||||||
|
}
|
||||||
|
if attachment != nil {
|
||||||
|
out.AttachedMolecule = attachment.AttachedMolecule
|
||||||
|
out.AttachedAt = attachment.AttachedAt
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("\n%s: %s\n", style.Bold.Render(issue.ID), issue.Title)
|
||||||
|
fmt.Printf("Status: %s\n", issue.Status)
|
||||||
|
|
||||||
|
if attachment == nil || attachment.AttachedMolecule == "" {
|
||||||
|
fmt.Printf("\n%s\n", style.Dim.Render("No molecule attached"))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\n%s\n", style.Bold.Render("Attached Molecule:"))
|
||||||
|
fmt.Printf(" ID: %s\n", attachment.AttachedMolecule)
|
||||||
|
if attachment.AttachedAt != "" {
|
||||||
|
fmt.Printf(" Attached at: %s\n", attachment.AttachedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user