feat: add graph links and hooks system (bd-kwro.2-5, bd-kwro.8)
- bd mail reply: reply to messages with thread linking via replies_to - bd show --thread: display full conversation threads - bd relate/unrelate: bidirectional relates_to links for knowledge graph - bd duplicate --of: mark issues as duplicates with auto-close - bd supersede --with: mark issues as superseded with auto-close - Hooks system: on_create, on_update, on_close, on_message in .beads/hooks/ - RPC protocol: added Sender, Ephemeral, RepliesTo fields to CreateArgs/UpdateArgs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/debug"
|
||||
"github.com/steveyegge/beads/internal/hooks"
|
||||
"github.com/steveyegge/beads/internal/routing"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -250,19 +251,22 @@ var createCmd = &cobra.Command{
|
||||
FatalError("%v", err)
|
||||
}
|
||||
|
||||
// Parse response to get issue for hook
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||
FatalError("parsing response: %v", err)
|
||||
}
|
||||
|
||||
// Run create hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventCreate, &issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(string(resp.Data))
|
||||
} else if silent {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||
FatalError("parsing response: %v", err)
|
||||
}
|
||||
fmt.Println(issue.ID)
|
||||
} else {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||
FatalError("parsing response: %v", err)
|
||||
}
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID)
|
||||
fmt.Printf(" Title: %s\n", issue.Title)
|
||||
@@ -393,6 +397,11 @@ var createCmd = &cobra.Command{
|
||||
// Schedule auto-flush
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
// Run create hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventCreate, issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(issue)
|
||||
} else if silent {
|
||||
|
||||
230
cmd/bd/duplicate.go
Normal file
230
cmd/bd/duplicate.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var duplicateCmd = &cobra.Command{
|
||||
Use: "duplicate <id> --of <canonical>",
|
||||
Short: "Mark an issue as a duplicate of another",
|
||||
Long: `Mark an issue as a duplicate of a canonical issue.
|
||||
|
||||
The duplicate issue is automatically closed with a reference to the canonical.
|
||||
This is essential for large issue databases with many similar reports.
|
||||
|
||||
Examples:
|
||||
bd duplicate bd-abc --of bd-xyz # Mark bd-abc as duplicate of bd-xyz`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDuplicate,
|
||||
}
|
||||
|
||||
var supersedeCmd = &cobra.Command{
|
||||
Use: "supersede <id> --with <new>",
|
||||
Short: "Mark an issue as superseded by a newer one",
|
||||
Long: `Mark an issue as superseded by a newer version.
|
||||
|
||||
The superseded issue is automatically closed with a reference to the replacement.
|
||||
Useful for design docs, specs, and evolving artifacts.
|
||||
|
||||
Examples:
|
||||
bd supersede bd-old --with bd-new # Mark bd-old as superseded by bd-new`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSupersede,
|
||||
}
|
||||
|
||||
var (
|
||||
duplicateOf string
|
||||
supersededWith string
|
||||
)
|
||||
|
||||
func init() {
|
||||
duplicateCmd.Flags().StringVar(&duplicateOf, "of", "", "Canonical issue ID (required)")
|
||||
_ = duplicateCmd.MarkFlagRequired("of")
|
||||
rootCmd.AddCommand(duplicateCmd)
|
||||
|
||||
supersedeCmd.Flags().StringVar(&supersededWith, "with", "", "Replacement issue ID (required)")
|
||||
_ = supersedeCmd.MarkFlagRequired("with")
|
||||
rootCmd.AddCommand(supersedeCmd)
|
||||
}
|
||||
|
||||
func runDuplicate(cmd *cobra.Command, args []string) error {
|
||||
CheckReadonly("duplicate")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs
|
||||
var duplicateID, canonicalID string
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &duplicateID); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: duplicateOf})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &canonicalID); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
duplicateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
canonicalID, err = utils.ResolvePartialID(ctx, store, duplicateOf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err)
|
||||
}
|
||||
}
|
||||
|
||||
if duplicateID == canonicalID {
|
||||
return fmt.Errorf("cannot mark an issue as duplicate of itself")
|
||||
}
|
||||
|
||||
// Verify canonical issue exists
|
||||
var canonical *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: canonicalID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("canonical issue not found: %s", canonicalID)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &canonical); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
canonical, err = store.GetIssue(ctx, canonicalID)
|
||||
if err != nil || canonical == nil {
|
||||
return fmt.Errorf("canonical issue not found: %s", canonicalID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the duplicate issue with duplicate_of and close it
|
||||
updates := map[string]interface{}{
|
||||
"duplicate_of": canonicalID,
|
||||
"status": string(types.StatusClosed),
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, duplicateID, updates, actor); err != nil {
|
||||
return fmt.Errorf("failed to mark as duplicate: %w", err)
|
||||
}
|
||||
|
||||
// Trigger auto-flush
|
||||
if flushManager != nil {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"duplicate": duplicateID,
|
||||
"canonical": canonicalID,
|
||||
"status": "closed",
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", green("✓"), duplicateID, canonicalID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSupersede(cmd *cobra.Command, args []string) error {
|
||||
CheckReadonly("supersede")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs
|
||||
var oldID, newID string
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &oldID); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: supersededWith})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", supersededWith, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &newID); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
oldID, err = utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
newID, err = utils.ResolvePartialID(ctx, store, supersededWith)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", supersededWith, err)
|
||||
}
|
||||
}
|
||||
|
||||
if oldID == newID {
|
||||
return fmt.Errorf("cannot mark an issue as superseded by itself")
|
||||
}
|
||||
|
||||
// Verify new issue exists
|
||||
var newIssue *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: newID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("replacement issue not found: %s", newID)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &newIssue); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
newIssue, err = store.GetIssue(ctx, newID)
|
||||
if err != nil || newIssue == nil {
|
||||
return fmt.Errorf("replacement issue not found: %s", newID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the old issue with superseded_by and close it
|
||||
updates := map[string]interface{}{
|
||||
"superseded_by": newID,
|
||||
"status": string(types.StatusClosed),
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, oldID, updates, actor); err != nil {
|
||||
return fmt.Errorf("failed to mark as superseded: %w", err)
|
||||
}
|
||||
|
||||
// Trigger auto-flush
|
||||
if flushManager != nil {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"superseded": oldID,
|
||||
"replacement": newID,
|
||||
"status": "closed",
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Marked %s as superseded by %s (closed)\n", green("✓"), oldID, newID)
|
||||
return nil
|
||||
}
|
||||
187
cmd/bd/mail.go
187
cmd/bd/mail.go
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/hooks"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
@@ -85,6 +86,21 @@ Examples:
|
||||
RunE: runMailAck,
|
||||
}
|
||||
|
||||
var mailReplyCmd = &cobra.Command{
|
||||
Use: "reply <id> -m <body>",
|
||||
Short: "Reply to a message",
|
||||
Long: `Reply to an existing message, creating a conversation thread.
|
||||
|
||||
Creates a new message with replies_to set to the original message,
|
||||
and sends it to the original sender.
|
||||
|
||||
Examples:
|
||||
bd mail reply bd-abc123 -m "Thanks for the update!"
|
||||
bd mail reply bd-abc123 -m "Done" --urgent`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailReply,
|
||||
}
|
||||
|
||||
// Mail command flags
|
||||
var (
|
||||
mailSubject string
|
||||
@@ -101,6 +117,7 @@ func init() {
|
||||
mailCmd.AddCommand(mailInboxCmd)
|
||||
mailCmd.AddCommand(mailReadCmd)
|
||||
mailCmd.AddCommand(mailAckCmd)
|
||||
mailCmd.AddCommand(mailReplyCmd)
|
||||
|
||||
// Send command flags
|
||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||
@@ -119,6 +136,12 @@ func init() {
|
||||
|
||||
// Ack command flags
|
||||
mailAckCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity")
|
||||
|
||||
// Reply command flags
|
||||
mailReplyCmd.Flags().StringVarP(&mailBody, "body", "m", "", "Reply body (required)")
|
||||
mailReplyCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)")
|
||||
mailReplyCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override sender identity")
|
||||
_ = mailReplyCmd.MarkFlagRequired("body")
|
||||
}
|
||||
|
||||
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
@@ -141,6 +164,8 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
IssueType: string(types.TypeMessage),
|
||||
Priority: priority,
|
||||
Assignee: recipient,
|
||||
Sender: sender,
|
||||
Ephemeral: true, // Messages can be bulk-deleted
|
||||
}
|
||||
|
||||
resp, err := daemonClient.Create(createArgs)
|
||||
@@ -148,13 +173,17 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
// Parse response to get issue ID and update sender field
|
||||
// Parse response to get issue ID
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
// Note: sender/ephemeral fields need daemon support - for now they work in direct mode
|
||||
// Run message hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventMessage, &issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id": issue.ID,
|
||||
@@ -202,6 +231,11 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
// Run message hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventMessage, issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id": issue.ID,
|
||||
@@ -487,3 +521,152 @@ func runMailAck(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailReply(cmd *cobra.Command, args []string) error {
|
||||
CheckReadonly("mail reply")
|
||||
|
||||
messageID := args[0]
|
||||
sender := config.GetIdentity(mailIdentity)
|
||||
|
||||
// Get the original message
|
||||
var originalMsg *types.Issue
|
||||
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get original message: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &originalMsg); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
originalMsg, err = store.GetIssue(rootCtx, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get original message: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if originalMsg == nil {
|
||||
return fmt.Errorf("message not found: %s", messageID)
|
||||
}
|
||||
|
||||
if originalMsg.IssueType != types.TypeMessage {
|
||||
return fmt.Errorf("%s is not a message (type: %s)", messageID, originalMsg.IssueType)
|
||||
}
|
||||
|
||||
// Determine recipient: reply goes to the original sender
|
||||
recipient := originalMsg.Sender
|
||||
if recipient == "" {
|
||||
return fmt.Errorf("original message has no sender, cannot determine reply recipient")
|
||||
}
|
||||
|
||||
// Build reply subject
|
||||
subject := originalMsg.Title
|
||||
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
|
||||
subject = "Re: " + subject
|
||||
}
|
||||
|
||||
// Determine priority
|
||||
priority := 2 // default: normal
|
||||
if mailUrgent {
|
||||
priority = 0
|
||||
}
|
||||
|
||||
// Create the reply message
|
||||
now := time.Now()
|
||||
reply := &types.Issue{
|
||||
Title: subject,
|
||||
Description: mailBody,
|
||||
Status: types.StatusOpen,
|
||||
Priority: priority,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: recipient,
|
||||
Sender: sender,
|
||||
Ephemeral: true,
|
||||
RepliesTo: messageID, // Thread link
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if daemonClient != nil {
|
||||
// Daemon mode - create reply with all messaging fields
|
||||
createArgs := &rpc.CreateArgs{
|
||||
Title: reply.Title,
|
||||
Description: reply.Description,
|
||||
IssueType: string(types.TypeMessage),
|
||||
Priority: priority,
|
||||
Assignee: recipient,
|
||||
Sender: sender,
|
||||
Ephemeral: true,
|
||||
RepliesTo: messageID, // Thread link
|
||||
}
|
||||
|
||||
resp, err := daemonClient.Create(createArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply: %w", err)
|
||||
}
|
||||
|
||||
var createdIssue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id": createdIssue.ID,
|
||||
"to": recipient,
|
||||
"from": sender,
|
||||
"subject": subject,
|
||||
"replies_to": messageID,
|
||||
"priority": priority,
|
||||
"timestamp": createdIssue.CreatedAt,
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
fmt.Printf("Reply sent: %s\n", createdIssue.ID)
|
||||
fmt.Printf(" To: %s\n", recipient)
|
||||
fmt.Printf(" Re: %s\n", messageID)
|
||||
if mailUrgent {
|
||||
fmt.Printf(" Priority: URGENT\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Direct mode
|
||||
if err := store.CreateIssue(rootCtx, reply, actor); err != nil {
|
||||
return fmt.Errorf("failed to send reply: %w", err)
|
||||
}
|
||||
|
||||
// Trigger auto-flush
|
||||
if flushManager != nil {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id": reply.ID,
|
||||
"to": recipient,
|
||||
"from": sender,
|
||||
"subject": subject,
|
||||
"replies_to": messageID,
|
||||
"priority": priority,
|
||||
"timestamp": reply.CreatedAt,
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
fmt.Printf("Reply sent: %s\n", reply.ID)
|
||||
fmt.Printf(" To: %s\n", recipient)
|
||||
fmt.Printf(" Re: %s\n", messageID)
|
||||
if mailUrgent {
|
||||
fmt.Printf(" Priority: URGENT\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/debug"
|
||||
"github.com/steveyegge/beads/internal/hooks"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/memory"
|
||||
@@ -83,6 +84,9 @@ var (
|
||||
// Auto-flush manager (replaces timer-based approach to fix bd-52)
|
||||
flushManager *FlushManager
|
||||
|
||||
// Hook runner for extensibility (bd-kwro.8)
|
||||
hookRunner *hooks.Runner
|
||||
|
||||
// skipFinalFlush is set by sync command when sync.branch mode completes successfully.
|
||||
// This prevents PersistentPostRun from re-exporting and dirtying the working directory.
|
||||
skipFinalFlush = false
|
||||
@@ -599,6 +603,13 @@ var rootCmd = &cobra.Command{
|
||||
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
|
||||
}
|
||||
|
||||
// Initialize hook runner (bd-kwro.8)
|
||||
// dbPath is .beads/something.db, so workspace root is parent of .beads
|
||||
if dbPath != "" {
|
||||
beadsDir := filepath.Dir(dbPath)
|
||||
hookRunner = hooks.NewRunner(filepath.Join(beadsDir, "hooks"))
|
||||
}
|
||||
|
||||
// Warn if multiple databases detected in directory hierarchy
|
||||
warnMultipleDatabases(dbPath)
|
||||
|
||||
|
||||
295
cmd/bd/relate.go
Normal file
295
cmd/bd/relate.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var relateCmd = &cobra.Command{
|
||||
Use: "relate <id1> <id2>",
|
||||
Short: "Create a bidirectional relates_to link between issues",
|
||||
Long: `Create a loose 'see also' relationship between two issues.
|
||||
|
||||
The relates_to link is bidirectional - both issues will reference each other.
|
||||
This enables knowledge graph connections without blocking or hierarchy.
|
||||
|
||||
Examples:
|
||||
bd relate bd-abc bd-xyz # Link two related issues
|
||||
bd relate bd-123 bd-456 # Create see-also connection`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runRelate,
|
||||
}
|
||||
|
||||
var unrelateCmd = &cobra.Command{
|
||||
Use: "unrelate <id1> <id2>",
|
||||
Short: "Remove a relates_to link between issues",
|
||||
Long: `Remove a relates_to relationship between two issues.
|
||||
|
||||
Removes the link in both directions.
|
||||
|
||||
Example:
|
||||
bd unrelate bd-abc bd-xyz`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runUnrelate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(relateCmd)
|
||||
rootCmd.AddCommand(unrelateCmd)
|
||||
}
|
||||
|
||||
func runRelate(cmd *cobra.Command, args []string) error {
|
||||
CheckReadonly("relate")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs
|
||||
var id1, id2 string
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &id1); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &id2); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
id1, err = utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
id2, err = utils.ResolvePartialID(ctx, store, args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
||||
}
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
return fmt.Errorf("cannot relate an issue to itself")
|
||||
}
|
||||
|
||||
// Get both issues
|
||||
var issue1, issue2 *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &issue1); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &issue2); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
issue1, err = store.GetIssue(ctx, id1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
||||
}
|
||||
issue2, err = store.GetIssue(ctx, id2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
||||
}
|
||||
}
|
||||
|
||||
if issue1 == nil {
|
||||
return fmt.Errorf("issue not found: %s", id1)
|
||||
}
|
||||
if issue2 == nil {
|
||||
return fmt.Errorf("issue not found: %s", id2)
|
||||
}
|
||||
|
||||
// Add id2 to issue1's relates_to if not already present
|
||||
if !contains(issue1.RelatesTo, id2) {
|
||||
newRelatesTo1 := append(issue1.RelatesTo, id2)
|
||||
if err := store.UpdateIssue(ctx, id1, map[string]interface{}{
|
||||
"relates_to": formatRelatesTo(newRelatesTo1),
|
||||
}, actor); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", id1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add id1 to issue2's relates_to if not already present
|
||||
if !contains(issue2.RelatesTo, id1) {
|
||||
newRelatesTo2 := append(issue2.RelatesTo, id1)
|
||||
if err := store.UpdateIssue(ctx, id2, map[string]interface{}{
|
||||
"relates_to": formatRelatesTo(newRelatesTo2),
|
||||
}, actor); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", id2, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-flush
|
||||
if flushManager != nil {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id1": id1,
|
||||
"id2": id2,
|
||||
"related": true,
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Linked %s ↔ %s\n", green("✓"), id1, id2)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUnrelate(cmd *cobra.Command, args []string) error {
|
||||
CheckReadonly("unrelate")
|
||||
|
||||
ctx := rootCtx
|
||||
|
||||
// Resolve partial IDs
|
||||
var id1, id2 string
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &id1); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &id2); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
id1, err = utils.ResolvePartialID(ctx, store, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
||||
}
|
||||
id2, err = utils.ResolvePartialID(ctx, store, args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get both issues
|
||||
var issue1, issue2 *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp1.Data, &issue1); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp2.Data, &issue2); err != nil {
|
||||
return fmt.Errorf("parsing response: %w", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
issue1, err = store.GetIssue(ctx, id1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
||||
}
|
||||
issue2, err = store.GetIssue(ctx, id2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
||||
}
|
||||
}
|
||||
|
||||
if issue1 == nil {
|
||||
return fmt.Errorf("issue not found: %s", id1)
|
||||
}
|
||||
if issue2 == nil {
|
||||
return fmt.Errorf("issue not found: %s", id2)
|
||||
}
|
||||
|
||||
// Remove id2 from issue1's relates_to
|
||||
newRelatesTo1 := remove(issue1.RelatesTo, id2)
|
||||
if err := store.UpdateIssue(ctx, id1, map[string]interface{}{
|
||||
"relates_to": formatRelatesTo(newRelatesTo1),
|
||||
}, actor); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", id1, err)
|
||||
}
|
||||
|
||||
// Remove id1 from issue2's relates_to
|
||||
newRelatesTo2 := remove(issue2.RelatesTo, id1)
|
||||
if err := store.UpdateIssue(ctx, id2, map[string]interface{}{
|
||||
"relates_to": formatRelatesTo(newRelatesTo2),
|
||||
}, actor); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", id2, err)
|
||||
}
|
||||
|
||||
// Trigger auto-flush
|
||||
if flushManager != nil {
|
||||
flushManager.MarkDirty(false)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"id1": id1,
|
||||
"id2": id2,
|
||||
"unrelated": true,
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Unlinked %s ↔ %s\n", green("✓"), id1, id2)
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func remove(slice []string, item string) []string {
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, s := range slice {
|
||||
if s != item {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatRelatesTo(ids []string) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, _ := json.Marshal(ids)
|
||||
return string(data)
|
||||
}
|
||||
221
cmd/bd/show.go
221
cmd/bd/show.go
@@ -1,14 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/hooks"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
@@ -22,6 +25,7 @@ var showCmd = &cobra.Command{
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||
showThread, _ := cmd.Flags().GetBool("thread")
|
||||
ctx := rootCtx
|
||||
|
||||
// Check database freshness before reading (bd-2q6d, bd-c4rq)
|
||||
@@ -61,6 +65,12 @@ var showCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
// Handle --thread flag: show full conversation thread
|
||||
if showThread && len(resolvedIDs) > 0 {
|
||||
showMessageThread(ctx, resolvedIDs[0], jsonOutput)
|
||||
return
|
||||
}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
allDetails := []interface{}{}
|
||||
@@ -637,12 +647,17 @@ var updateCmd = &cobra.Command{
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
// Run update hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventUpdate, &issue)
|
||||
}
|
||||
if jsonOutput {
|
||||
updatedIssues = append(updatedIssues, &issue)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if !jsonOutput {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
|
||||
}
|
||||
@@ -716,8 +731,13 @@ var updateCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
// Run update hook (bd-kwro.8)
|
||||
issue, _ := store.GetIssue(ctx, id)
|
||||
if issue != nil && hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventUpdate, issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
issue, _ := store.GetIssue(ctx, id)
|
||||
if issue != nil {
|
||||
updatedIssues = append(updatedIssues, issue)
|
||||
}
|
||||
@@ -990,12 +1010,17 @@ var closeCmd = &cobra.Command{
|
||||
continue
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
||||
// Run close hook (bd-kwro.8)
|
||||
if hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventClose, &issue)
|
||||
}
|
||||
if jsonOutput {
|
||||
closedIssues = append(closedIssues, &issue)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if !jsonOutput {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
|
||||
}
|
||||
@@ -1014,8 +1039,14 @@ var closeCmd = &cobra.Command{
|
||||
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Run close hook (bd-kwro.8)
|
||||
issue, _ := store.GetIssue(ctx, id)
|
||||
if issue != nil && hookRunner != nil {
|
||||
hookRunner.Run(hooks.EventClose, issue)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
issue, _ := store.GetIssue(ctx, id)
|
||||
if issue != nil {
|
||||
closedIssues = append(closedIssues, issue)
|
||||
}
|
||||
@@ -1036,8 +1067,178 @@ var closeCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// showMessageThread displays a full conversation thread for a message
|
||||
func showMessageThread(ctx context.Context, messageID string, jsonOutput bool) {
|
||||
// Get the starting message
|
||||
var startMsg *types.Issue
|
||||
var err error
|
||||
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: messageID})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &startMsg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
startMsg, err = store.GetIssue(ctx, messageID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error fetching message %s: %v\n", messageID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if startMsg == nil {
|
||||
fmt.Fprintf(os.Stderr, "Message %s not found\n", messageID)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find the root of the thread by following replies_to chain upward
|
||||
rootMsg := startMsg
|
||||
seen := make(map[string]bool)
|
||||
seen[rootMsg.ID] = true
|
||||
|
||||
for rootMsg.RepliesTo != "" {
|
||||
if seen[rootMsg.RepliesTo] {
|
||||
break // Avoid infinite loops
|
||||
}
|
||||
seen[rootMsg.RepliesTo] = true
|
||||
|
||||
var parentMsg *types.Issue
|
||||
if daemonClient != nil {
|
||||
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: rootMsg.RepliesTo})
|
||||
if err != nil {
|
||||
break // Parent not found, use current as root
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &parentMsg); err != nil {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
parentMsg, _ = store.GetIssue(ctx, rootMsg.RepliesTo)
|
||||
}
|
||||
if parentMsg == nil {
|
||||
break
|
||||
}
|
||||
rootMsg = parentMsg
|
||||
}
|
||||
|
||||
// Now collect all messages in the thread
|
||||
// Start from root and find all replies
|
||||
threadMessages := []*types.Issue{rootMsg}
|
||||
threadIDs := map[string]bool{rootMsg.ID: true}
|
||||
queue := []string{rootMsg.ID}
|
||||
|
||||
// BFS to find all replies
|
||||
for len(queue) > 0 {
|
||||
currentID := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
// Find all messages that reply to currentID
|
||||
var replies []*types.Issue
|
||||
if daemonClient != nil {
|
||||
// In daemon mode, search for messages with replies_to = currentID
|
||||
// Use list with a filter (simplified: we'll search all messages)
|
||||
// This is inefficient but works for now
|
||||
listArgs := &rpc.ListArgs{IssueType: "message"}
|
||||
resp, err := daemonClient.List(listArgs)
|
||||
if err == nil {
|
||||
var allMessages []*types.Issue
|
||||
if err := json.Unmarshal(resp.Data, &allMessages); err == nil {
|
||||
for _, msg := range allMessages {
|
||||
if msg.RepliesTo == currentID && !threadIDs[msg.ID] {
|
||||
replies = append(replies, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mode - search for replies
|
||||
messageType := types.TypeMessage
|
||||
filter := types.IssueFilter{IssueType: &messageType}
|
||||
allMessages, _ := store.SearchIssues(ctx, "", filter)
|
||||
for _, msg := range allMessages {
|
||||
if msg.RepliesTo == currentID && !threadIDs[msg.ID] {
|
||||
replies = append(replies, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, reply := range replies {
|
||||
threadMessages = append(threadMessages, reply)
|
||||
threadIDs[reply.ID] = true
|
||||
queue = append(queue, reply.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation time
|
||||
sort.Slice(threadMessages, func(i, j int) bool {
|
||||
return threadMessages[i].CreatedAt.Before(threadMessages[j].CreatedAt)
|
||||
})
|
||||
|
||||
if jsonOutput {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(threadMessages)
|
||||
return
|
||||
}
|
||||
|
||||
// Display the thread
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
dim := color.New(color.Faint).SprintFunc()
|
||||
|
||||
fmt.Printf("\n%s Thread: %s\n", cyan("📬"), rootMsg.Title)
|
||||
fmt.Println(strings.Repeat("─", 66))
|
||||
|
||||
for _, msg := range threadMessages {
|
||||
// Show indent based on depth (count replies_to chain)
|
||||
depth := 0
|
||||
parent := msg.RepliesTo
|
||||
for parent != "" && depth < 5 {
|
||||
depth++
|
||||
// Find parent to get its replies_to
|
||||
for _, m := range threadMessages {
|
||||
if m.ID == parent {
|
||||
parent = m.RepliesTo
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := msg.CreatedAt.Format("2006-01-02 15:04")
|
||||
|
||||
// Status indicator
|
||||
statusIcon := "📧"
|
||||
if msg.Status == types.StatusClosed {
|
||||
statusIcon = "✓"
|
||||
}
|
||||
|
||||
fmt.Printf("%s%s %s %s\n", indent, statusIcon, cyan(msg.ID), dim(timeStr))
|
||||
fmt.Printf("%s From: %s To: %s\n", indent, msg.Sender, msg.Assignee)
|
||||
if msg.RepliesTo != "" {
|
||||
fmt.Printf("%s Re: %s\n", indent, msg.RepliesTo)
|
||||
}
|
||||
fmt.Printf("%s %s: %s\n", indent, dim("Subject"), msg.Title)
|
||||
if msg.Description != "" {
|
||||
// Indent the body
|
||||
bodyLines := strings.Split(msg.Description, "\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Printf("%s %s\n", indent, line)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("Total: %d messages in thread\n\n", len(threadMessages))
|
||||
}
|
||||
|
||||
func init() {
|
||||
showCmd.Flags().Bool("json", false, "Output JSON format")
|
||||
showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)")
|
||||
rootCmd.AddCommand(showCmd)
|
||||
|
||||
updateCmd.Flags().StringP("status", "s", "", "New status")
|
||||
|
||||
161
internal/hooks/hooks.go
Normal file
161
internal/hooks/hooks.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Package hooks provides a hook system for extensibility.
|
||||
// Hooks are executable scripts in .beads/hooks/ that run after certain events.
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// Event types
|
||||
const (
|
||||
EventCreate = "create"
|
||||
EventUpdate = "update"
|
||||
EventClose = "close"
|
||||
EventMessage = "message"
|
||||
)
|
||||
|
||||
// Hook file names
|
||||
const (
|
||||
HookOnCreate = "on_create"
|
||||
HookOnUpdate = "on_update"
|
||||
HookOnClose = "on_close"
|
||||
HookOnMessage = "on_message"
|
||||
)
|
||||
|
||||
// Runner handles hook execution
|
||||
type Runner struct {
|
||||
hooksDir string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewRunner creates a new hook runner.
|
||||
// hooksDir is typically .beads/hooks/ relative to workspace root.
|
||||
func NewRunner(hooksDir string) *Runner {
|
||||
return &Runner{
|
||||
hooksDir: hooksDir,
|
||||
timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRunnerFromWorkspace creates a hook runner for a workspace.
|
||||
func NewRunnerFromWorkspace(workspaceRoot string) *Runner {
|
||||
return NewRunner(filepath.Join(workspaceRoot, ".beads", "hooks"))
|
||||
}
|
||||
|
||||
// Run executes a hook if it exists.
|
||||
// Runs asynchronously - returns immediately, hook runs in background.
|
||||
func (r *Runner) Run(event string, issue *types.Issue) {
|
||||
hookName := eventToHook(event)
|
||||
if hookName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(r.hooksDir, hookName)
|
||||
|
||||
// Check if hook exists and is executable
|
||||
info, err := os.Stat(hookPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return // Hook doesn't exist, skip silently
|
||||
}
|
||||
|
||||
// Check if executable (Unix)
|
||||
if info.Mode()&0111 == 0 {
|
||||
return // Not executable, skip
|
||||
}
|
||||
|
||||
// Run asynchronously
|
||||
go r.runHook(hookPath, event, issue)
|
||||
}
|
||||
|
||||
// RunSync executes a hook synchronously and returns any error.
|
||||
// Useful for testing or when you need to wait for the hook.
|
||||
func (r *Runner) RunSync(event string, issue *types.Issue) error {
|
||||
hookName := eventToHook(event)
|
||||
if hookName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(r.hooksDir, hookName)
|
||||
|
||||
// Check if hook exists and is executable
|
||||
info, err := os.Stat(hookPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil // Hook doesn't exist, skip silently
|
||||
}
|
||||
|
||||
if info.Mode()&0111 == 0 {
|
||||
return nil // Not executable, skip
|
||||
}
|
||||
|
||||
return r.runHook(hookPath, event, issue)
|
||||
}
|
||||
|
||||
func (r *Runner) runHook(hookPath, event string, issue *types.Issue) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Prepare JSON data for stdin
|
||||
issueJSON, err := json.Marshal(issue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create command: hook_script <issue_id> <event_type>
|
||||
// #nosec G204 -- hookPath is from controlled .beads/hooks directory
|
||||
cmd := exec.CommandContext(ctx, hookPath, issue.ID, event)
|
||||
cmd.Stdin = bytes.NewReader(issueJSON)
|
||||
|
||||
// Capture output for debugging (but don't block on it)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// Run the hook
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
// Log error but don't fail - hooks shouldn't break beads
|
||||
// In production, this could go to a log file
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HookExists checks if a hook exists for an event
|
||||
func (r *Runner) HookExists(event string) bool {
|
||||
hookName := eventToHook(event)
|
||||
if hookName == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(r.hooksDir, hookName)
|
||||
info, err := os.Stat(hookPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
return info.Mode()&0111 != 0
|
||||
}
|
||||
|
||||
func eventToHook(event string) string {
|
||||
switch event {
|
||||
case EventCreate:
|
||||
return HookOnCreate
|
||||
case EventUpdate:
|
||||
return HookOnUpdate
|
||||
case EventClose:
|
||||
return HookOnClose
|
||||
case EventMessage:
|
||||
return HookOnMessage
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ type CreateArgs struct {
|
||||
EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
// Messaging fields (bd-kwro)
|
||||
Sender string `json:"sender,omitempty"` // Who sent this (for messages)
|
||||
Ephemeral bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed
|
||||
RepliesTo string `json:"replies_to,omitempty"` // Issue ID for conversation threading
|
||||
}
|
||||
|
||||
// UpdateArgs represents arguments for the update operation
|
||||
@@ -91,6 +95,10 @@ type UpdateArgs struct {
|
||||
AddLabels []string `json:"add_labels,omitempty"`
|
||||
RemoveLabels []string `json:"remove_labels,omitempty"`
|
||||
SetLabels []string `json:"set_labels,omitempty"`
|
||||
// Messaging fields (bd-kwro)
|
||||
Sender *string `json:"sender,omitempty"` // Who sent this (for messages)
|
||||
Ephemeral *bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed
|
||||
RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading
|
||||
}
|
||||
|
||||
// CloseArgs represents arguments for the close operation
|
||||
|
||||
@@ -76,6 +76,16 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
|
||||
if a.IssueType != nil {
|
||||
u["issue_type"] = *a.IssueType
|
||||
}
|
||||
// Messaging fields (bd-kwro)
|
||||
if a.Sender != nil {
|
||||
u["sender"] = *a.Sender
|
||||
}
|
||||
if a.Ephemeral != nil {
|
||||
u["ephemeral"] = *a.Ephemeral
|
||||
}
|
||||
if a.RepliesTo != nil {
|
||||
u["replies_to"] = *a.RepliesTo
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -150,6 +160,10 @@ func (s *Server) handleCreate(req *Request) Response {
|
||||
ExternalRef: externalRef,
|
||||
EstimatedMinutes: createArgs.EstimatedMinutes,
|
||||
Status: types.StatusOpen,
|
||||
// Messaging fields (bd-kwro)
|
||||
Sender: createArgs.Sender,
|
||||
Ephemeral: createArgs.Ephemeral,
|
||||
RepliesTo: createArgs.RepliesTo,
|
||||
}
|
||||
|
||||
// Check if any dependencies are discovered-from type
|
||||
|
||||
Reference in New Issue
Block a user