refactor: Split beads.go into focused files
- beads.go (512 lines): Core types and bd CLI wrapper operations - fields.go (327 lines): AttachmentFields and MRFields parsing/formatting - handoff.go (218 lines): Handoff bead ops, ClearMail, molecule attach/detach - audit.go (98 lines): Detach audit logging No functional changes - just reorganization for maintainability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
98
internal/beads/audit.go
Normal file
98
internal/beads/audit.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Package beads provides audit logging for molecule operations.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DetachAuditEntry represents an audit log entry for a detach operation.
|
||||
type DetachAuditEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Operation string `json:"operation"` // "detach", "burn", "squash"
|
||||
PinnedBeadID string `json:"pinned_bead_id"`
|
||||
DetachedMolecule string `json:"detached_molecule"`
|
||||
DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach
|
||||
Reason string `json:"reason,omitempty"` // Optional reason for detach
|
||||
PreviousState string `json:"previous_state,omitempty"`
|
||||
}
|
||||
|
||||
// DetachOptions specifies optional context for a detach operation.
|
||||
type DetachOptions struct {
|
||||
Operation string // "detach", "burn", "squash" - defaults to "detach"
|
||||
Agent string // Who is performing the detach
|
||||
Reason string // Optional reason for the detach
|
||||
}
|
||||
|
||||
// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation.
|
||||
// Returns the updated issue.
|
||||
func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) {
|
||||
// Fetch the pinned bead first to get previous state
|
||||
issue, err := b.Show(pinnedBeadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching pinned bead: %w", err)
|
||||
}
|
||||
|
||||
// Get current attachment info for audit
|
||||
attachment := ParseAttachmentFields(issue)
|
||||
if attachment == nil {
|
||||
return issue, nil // Nothing to detach
|
||||
}
|
||||
|
||||
// Log the detach operation
|
||||
operation := opts.Operation
|
||||
if operation == "" {
|
||||
operation = "detach"
|
||||
}
|
||||
entry := DetachAuditEntry{
|
||||
Timestamp: currentTimestamp(),
|
||||
Operation: operation,
|
||||
PinnedBeadID: pinnedBeadID,
|
||||
DetachedMolecule: attachment.AttachedMolecule,
|
||||
DetachedBy: opts.Agent,
|
||||
Reason: opts.Reason,
|
||||
PreviousState: issue.Status,
|
||||
}
|
||||
if err := b.LogDetachAudit(entry); err != nil {
|
||||
// Log error but don't fail the detach operation
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// LogDetachAudit appends an audit entry to the audit log file.
|
||||
// The audit log is stored in .beads/audit.log as JSONL format.
|
||||
func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error {
|
||||
auditPath := filepath.Join(b.workDir, ".beads", "audit.log")
|
||||
|
||||
// Marshal entry to JSON
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling audit entry: %w", err)
|
||||
}
|
||||
|
||||
// Append to audit log file
|
||||
f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening audit log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing audit entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -511,626 +510,3 @@ func (b *Beads) IsBeadsRepo() bool {
|
||||
_, err := b.run("list", "--limit=1")
|
||||
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
|
||||
Actor: role,
|
||||
})
|
||||
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})
|
||||
}
|
||||
|
||||
// ClearMailResult contains statistics from a ClearMail operation.
|
||||
type ClearMailResult struct {
|
||||
Closed int // Number of messages closed
|
||||
Cleared int // Number of pinned messages cleared (content removed)
|
||||
}
|
||||
|
||||
// ClearMail closes or clears all open messages.
|
||||
// Non-pinned messages are closed with the given reason.
|
||||
// Pinned messages have their description cleared but remain open.
|
||||
func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
|
||||
// List all open messages
|
||||
issues, err := b.List(ListOptions{
|
||||
Status: "open",
|
||||
Type: "message",
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing messages: %w", err)
|
||||
}
|
||||
|
||||
result := &ClearMailResult{}
|
||||
|
||||
// Separate pinned from non-pinned
|
||||
var toClose []string
|
||||
var toClear []*Issue
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.Status == StatusPinned {
|
||||
toClear = append(toClear, issue)
|
||||
} else {
|
||||
toClose = append(toClose, issue.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Close non-pinned messages in batch
|
||||
if len(toClose) > 0 {
|
||||
if err := b.CloseWithReason(reason, toClose...); err != nil {
|
||||
return nil, fmt.Errorf("closing messages: %w", err)
|
||||
}
|
||||
result.Closed = len(toClose)
|
||||
}
|
||||
|
||||
// Clear pinned messages
|
||||
empty := ""
|
||||
for _, issue := range toClear {
|
||||
if err := b.Update(issue.ID, UpdateOptions{Description: &empty}); err != nil {
|
||||
return nil, fmt.Errorf("clearing pinned message %s: %w", issue.ID, err)
|
||||
}
|
||||
result.Cleared++
|
||||
}
|
||||
|
||||
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
|
||||
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
||||
}
|
||||
|
||||
// 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
|
||||
case "attached_args", "attached-args", "attachedargs":
|
||||
fields.AttachedArgs = 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)
|
||||
}
|
||||
if fields.AttachedArgs != "" {
|
||||
lines = append(lines, "attached_args: "+fields.AttachedArgs)
|
||||
}
|
||||
|
||||
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,
|
||||
"attached_args": true,
|
||||
"attached-args": true,
|
||||
"attachedargs": 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 {
|
||||
Branch string // Source branch name (e.g., "polecat/Nux/gt-xyz")
|
||||
Target string // Target branch (e.g., "main" or "integration/gt-epic")
|
||||
SourceIssue string // The work item being merged (e.g., "gt-xyz")
|
||||
Worker string // Who did the work
|
||||
Rig string // Which rig
|
||||
MergeCommit string // SHA of merge commit (set on close)
|
||||
CloseReason string // Reason for closing: merged, rejected, conflict, superseded
|
||||
}
|
||||
|
||||
// ParseMRFields extracts structured merge-request fields from an issue's description.
|
||||
// Fields are expected as "key: value" lines, with optional prose text mixed in.
|
||||
// Returns nil if no MR fields are found.
|
||||
func ParseMRFields(issue *Issue) *MRFields {
|
||||
if issue == nil || issue.Description == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := &MRFields{}
|
||||
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 "branch":
|
||||
fields.Branch = value
|
||||
hasFields = true
|
||||
case "target":
|
||||
fields.Target = value
|
||||
hasFields = true
|
||||
case "source_issue", "source-issue", "sourceissue":
|
||||
fields.SourceIssue = value
|
||||
hasFields = true
|
||||
case "worker":
|
||||
fields.Worker = value
|
||||
hasFields = true
|
||||
case "rig":
|
||||
fields.Rig = value
|
||||
hasFields = true
|
||||
case "merge_commit", "merge-commit", "mergecommit":
|
||||
fields.MergeCommit = value
|
||||
hasFields = true
|
||||
case "close_reason", "close-reason", "closereason":
|
||||
fields.CloseReason = value
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFields {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// FormatMRFields formats MRFields as a string suitable for an issue description.
|
||||
// Only non-empty fields are included.
|
||||
func FormatMRFields(fields *MRFields) string {
|
||||
if fields == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
if fields.Branch != "" {
|
||||
lines = append(lines, "branch: "+fields.Branch)
|
||||
}
|
||||
if fields.Target != "" {
|
||||
lines = append(lines, "target: "+fields.Target)
|
||||
}
|
||||
if fields.SourceIssue != "" {
|
||||
lines = append(lines, "source_issue: "+fields.SourceIssue)
|
||||
}
|
||||
if fields.Worker != "" {
|
||||
lines = append(lines, "worker: "+fields.Worker)
|
||||
}
|
||||
if fields.Rig != "" {
|
||||
lines = append(lines, "rig: "+fields.Rig)
|
||||
}
|
||||
if fields.MergeCommit != "" {
|
||||
lines = append(lines, "merge_commit: "+fields.MergeCommit)
|
||||
}
|
||||
if fields.CloseReason != "" {
|
||||
lines = append(lines, "close_reason: "+fields.CloseReason)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// SetMRFields updates an issue's description with the given MR fields.
|
||||
// Existing MR field lines are replaced; other content is preserved.
|
||||
// Returns the new description string.
|
||||
func SetMRFields(issue *Issue, fields *MRFields) string {
|
||||
if issue == nil {
|
||||
return FormatMRFields(fields)
|
||||
}
|
||||
|
||||
// Known MR field keys (lowercase)
|
||||
mrKeys := map[string]bool{
|
||||
"branch": true,
|
||||
"target": true,
|
||||
"source_issue": true,
|
||||
"source-issue": true,
|
||||
"sourceissue": true,
|
||||
"worker": true,
|
||||
"rig": true,
|
||||
"merge_commit": true,
|
||||
"merge-commit": true,
|
||||
"mergecommit": true,
|
||||
"close_reason": true,
|
||||
"close-reason": true,
|
||||
"closereason": true,
|
||||
}
|
||||
|
||||
// Collect non-MR lines from existing description
|
||||
var otherLines []string
|
||||
if 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 MR field line
|
||||
colonIdx := strings.Index(trimmed, ":")
|
||||
if colonIdx == -1 {
|
||||
otherLines = append(otherLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx]))
|
||||
if !mrKeys[key] {
|
||||
otherLines = append(otherLines, line)
|
||||
}
|
||||
// Skip MR field lines - they'll be replaced
|
||||
}
|
||||
}
|
||||
|
||||
// Build new description: MR fields first, then other content
|
||||
formatted := FormatMRFields(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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// DetachAuditEntry represents an audit log entry for a detach operation.
|
||||
type DetachAuditEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Operation string `json:"operation"` // "detach", "burn", "squash"
|
||||
PinnedBeadID string `json:"pinned_bead_id"`
|
||||
DetachedMolecule string `json:"detached_molecule"`
|
||||
DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach
|
||||
Reason string `json:"reason,omitempty"` // Optional reason for detach
|
||||
PreviousState string `json:"previous_state,omitempty"`
|
||||
}
|
||||
|
||||
// DetachOptions specifies optional context for a detach operation.
|
||||
type DetachOptions struct {
|
||||
Operation string // "detach", "burn", "squash" - defaults to "detach"
|
||||
Agent string // Who is performing the detach
|
||||
Reason string // Optional reason for the detach
|
||||
}
|
||||
|
||||
// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation.
|
||||
// Returns the updated issue.
|
||||
func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) {
|
||||
// Fetch the pinned bead first to get previous state
|
||||
issue, err := b.Show(pinnedBeadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching pinned bead: %w", err)
|
||||
}
|
||||
|
||||
// Get current attachment info for audit
|
||||
attachment := ParseAttachmentFields(issue)
|
||||
if attachment == nil {
|
||||
return issue, nil // Nothing to detach
|
||||
}
|
||||
|
||||
// Log the detach operation
|
||||
operation := opts.Operation
|
||||
if operation == "" {
|
||||
operation = "detach"
|
||||
}
|
||||
entry := DetachAuditEntry{
|
||||
Timestamp: currentTimestamp(),
|
||||
Operation: operation,
|
||||
PinnedBeadID: pinnedBeadID,
|
||||
DetachedMolecule: attachment.AttachedMolecule,
|
||||
DetachedBy: opts.Agent,
|
||||
Reason: opts.Reason,
|
||||
PreviousState: issue.Status,
|
||||
}
|
||||
if err := b.LogDetachAudit(entry); err != nil {
|
||||
// Log error but don't fail the detach operation
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// LogDetachAudit appends an audit entry to the audit log file.
|
||||
// The audit log is stored in .beads/audit.log as JSONL format.
|
||||
func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error {
|
||||
auditPath := filepath.Join(b.workDir, ".beads", "audit.log")
|
||||
|
||||
// Marshal entry to JSON
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling audit entry: %w", err)
|
||||
}
|
||||
|
||||
// Append to audit log file
|
||||
f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening audit log: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing audit entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
327
internal/beads/fields.go
Normal file
327
internal/beads/fields.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// Package beads provides field parsing utilities for structured issue descriptions.
|
||||
package beads
|
||||
|
||||
import "strings"
|
||||
|
||||
// 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
|
||||
AttachedArgs string // Natural language args passed via gt sling --args (no-tmux mode)
|
||||
}
|
||||
|
||||
// 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
|
||||
case "attached_args", "attached-args", "attachedargs":
|
||||
fields.AttachedArgs = 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)
|
||||
}
|
||||
if fields.AttachedArgs != "" {
|
||||
lines = append(lines, "attached_args: "+fields.AttachedArgs)
|
||||
}
|
||||
|
||||
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,
|
||||
"attached_args": true,
|
||||
"attached-args": true,
|
||||
"attachedargs": 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 {
|
||||
Branch string // Source branch name (e.g., "polecat/Nux/gt-xyz")
|
||||
Target string // Target branch (e.g., "main" or "integration/gt-epic")
|
||||
SourceIssue string // The work item being merged (e.g., "gt-xyz")
|
||||
Worker string // Who did the work
|
||||
Rig string // Which rig
|
||||
MergeCommit string // SHA of merge commit (set on close)
|
||||
CloseReason string // Reason for closing: merged, rejected, conflict, superseded
|
||||
}
|
||||
|
||||
// ParseMRFields extracts structured merge-request fields from an issue's description.
|
||||
// Fields are expected as "key: value" lines, with optional prose text mixed in.
|
||||
// Returns nil if no MR fields are found.
|
||||
func ParseMRFields(issue *Issue) *MRFields {
|
||||
if issue == nil || issue.Description == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := &MRFields{}
|
||||
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 "branch":
|
||||
fields.Branch = value
|
||||
hasFields = true
|
||||
case "target":
|
||||
fields.Target = value
|
||||
hasFields = true
|
||||
case "source_issue", "source-issue", "sourceissue":
|
||||
fields.SourceIssue = value
|
||||
hasFields = true
|
||||
case "worker":
|
||||
fields.Worker = value
|
||||
hasFields = true
|
||||
case "rig":
|
||||
fields.Rig = value
|
||||
hasFields = true
|
||||
case "merge_commit", "merge-commit", "mergecommit":
|
||||
fields.MergeCommit = value
|
||||
hasFields = true
|
||||
case "close_reason", "close-reason", "closereason":
|
||||
fields.CloseReason = value
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasFields {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// FormatMRFields formats MRFields as a string suitable for an issue description.
|
||||
// Only non-empty fields are included.
|
||||
func FormatMRFields(fields *MRFields) string {
|
||||
if fields == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
if fields.Branch != "" {
|
||||
lines = append(lines, "branch: "+fields.Branch)
|
||||
}
|
||||
if fields.Target != "" {
|
||||
lines = append(lines, "target: "+fields.Target)
|
||||
}
|
||||
if fields.SourceIssue != "" {
|
||||
lines = append(lines, "source_issue: "+fields.SourceIssue)
|
||||
}
|
||||
if fields.Worker != "" {
|
||||
lines = append(lines, "worker: "+fields.Worker)
|
||||
}
|
||||
if fields.Rig != "" {
|
||||
lines = append(lines, "rig: "+fields.Rig)
|
||||
}
|
||||
if fields.MergeCommit != "" {
|
||||
lines = append(lines, "merge_commit: "+fields.MergeCommit)
|
||||
}
|
||||
if fields.CloseReason != "" {
|
||||
lines = append(lines, "close_reason: "+fields.CloseReason)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// SetMRFields updates an issue's description with the given MR fields.
|
||||
// Existing MR field lines are replaced; other content is preserved.
|
||||
// Returns the new description string.
|
||||
func SetMRFields(issue *Issue, fields *MRFields) string {
|
||||
if issue == nil {
|
||||
return FormatMRFields(fields)
|
||||
}
|
||||
|
||||
// Known MR field keys (lowercase)
|
||||
mrKeys := map[string]bool{
|
||||
"branch": true,
|
||||
"target": true,
|
||||
"source_issue": true,
|
||||
"source-issue": true,
|
||||
"sourceissue": true,
|
||||
"worker": true,
|
||||
"rig": true,
|
||||
"merge_commit": true,
|
||||
"merge-commit": true,
|
||||
"mergecommit": true,
|
||||
"close_reason": true,
|
||||
"close-reason": true,
|
||||
"closereason": true,
|
||||
}
|
||||
|
||||
// Collect non-MR lines from existing description
|
||||
var otherLines []string
|
||||
if 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 MR field line
|
||||
colonIdx := strings.Index(trimmed, ":")
|
||||
if colonIdx == -1 {
|
||||
otherLines = append(otherLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(strings.TrimSpace(trimmed[:colonIdx]))
|
||||
if !mrKeys[key] {
|
||||
otherLines = append(otherLines, line)
|
||||
}
|
||||
// Skip MR field lines - they'll be replaced
|
||||
}
|
||||
}
|
||||
|
||||
// Build new description: MR fields first, then other content
|
||||
formatted := FormatMRFields(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")
|
||||
}
|
||||
218
internal/beads/handoff.go
Normal file
218
internal/beads/handoff.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Package beads provides handoff bead operations for agent workflow management.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
Actor: role,
|
||||
})
|
||||
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})
|
||||
}
|
||||
|
||||
// ClearMailResult contains statistics from a ClearMail operation.
|
||||
type ClearMailResult struct {
|
||||
Closed int // Number of messages closed
|
||||
Cleared int // Number of pinned messages cleared (content removed)
|
||||
}
|
||||
|
||||
// ClearMail closes or clears all open messages.
|
||||
// Non-pinned messages are closed with the given reason.
|
||||
// Pinned messages have their description cleared but remain open.
|
||||
func (b *Beads) ClearMail(reason string) (*ClearMailResult, error) {
|
||||
// List all open messages
|
||||
issues, err := b.List(ListOptions{
|
||||
Status: "open",
|
||||
Type: "message",
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing messages: %w", err)
|
||||
}
|
||||
|
||||
result := &ClearMailResult{}
|
||||
|
||||
// Separate pinned from non-pinned
|
||||
var toClose []string
|
||||
var toClear []*Issue
|
||||
|
||||
for _, issue := range issues {
|
||||
if issue.Status == StatusPinned {
|
||||
toClear = append(toClear, issue)
|
||||
} else {
|
||||
toClose = append(toClose, issue.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Close non-pinned messages in batch
|
||||
if len(toClose) > 0 {
|
||||
if err := b.CloseWithReason(reason, toClose...); err != nil {
|
||||
return nil, fmt.Errorf("closing messages: %w", err)
|
||||
}
|
||||
result.Closed = len(toClose)
|
||||
}
|
||||
|
||||
// Clear pinned messages
|
||||
empty := ""
|
||||
for _, issue := range toClear {
|
||||
if err := b.Update(issue.ID, UpdateOptions{Description: &empty}); err != nil {
|
||||
return nil, fmt.Errorf("clearing pinned message %s: %w", issue.ID, err)
|
||||
}
|
||||
result.Cleared++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user