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/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/debug"
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
|
"github.com/steveyegge/beads/internal/hooks"
|
||||||
"github.com/steveyegge/beads/internal/routing"
|
"github.com/steveyegge/beads/internal/routing"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -250,19 +251,22 @@ var createCmd = &cobra.Command{
|
|||||||
FatalError("%v", err)
|
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 {
|
if jsonOutput {
|
||||||
fmt.Println(string(resp.Data))
|
fmt.Println(string(resp.Data))
|
||||||
} else if silent {
|
} 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)
|
fmt.Println(issue.ID)
|
||||||
} else {
|
} 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()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID)
|
fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID)
|
||||||
fmt.Printf(" Title: %s\n", issue.Title)
|
fmt.Printf(" Title: %s\n", issue.Title)
|
||||||
@@ -393,6 +397,11 @@ var createCmd = &cobra.Command{
|
|||||||
// Schedule auto-flush
|
// Schedule auto-flush
|
||||||
markDirtyAndScheduleFlush()
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
// Run create hook (bd-kwro.8)
|
||||||
|
if hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventCreate, issue)
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(issue)
|
outputJSON(issue)
|
||||||
} else if silent {
|
} 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/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
|
"github.com/steveyegge/beads/internal/hooks"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
@@ -85,6 +86,21 @@ Examples:
|
|||||||
RunE: runMailAck,
|
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
|
// Mail command flags
|
||||||
var (
|
var (
|
||||||
mailSubject string
|
mailSubject string
|
||||||
@@ -101,6 +117,7 @@ func init() {
|
|||||||
mailCmd.AddCommand(mailInboxCmd)
|
mailCmd.AddCommand(mailInboxCmd)
|
||||||
mailCmd.AddCommand(mailReadCmd)
|
mailCmd.AddCommand(mailReadCmd)
|
||||||
mailCmd.AddCommand(mailAckCmd)
|
mailCmd.AddCommand(mailAckCmd)
|
||||||
|
mailCmd.AddCommand(mailReplyCmd)
|
||||||
|
|
||||||
// Send command flags
|
// Send command flags
|
||||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||||
@@ -119,6 +136,12 @@ func init() {
|
|||||||
|
|
||||||
// Ack command flags
|
// Ack command flags
|
||||||
mailAckCmd.Flags().StringVar(&mailIdentity, "identity", "", "Override identity")
|
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 {
|
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||||
@@ -141,6 +164,8 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
IssueType: string(types.TypeMessage),
|
IssueType: string(types.TypeMessage),
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
Assignee: recipient,
|
Assignee: recipient,
|
||||||
|
Sender: sender,
|
||||||
|
Ephemeral: true, // Messages can be bulk-deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := daemonClient.Create(createArgs)
|
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)
|
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
|
var issue types.Issue
|
||||||
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
||||||
return fmt.Errorf("parsing response: %w", err)
|
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 {
|
if jsonOutput {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": issue.ID,
|
"id": issue.ID,
|
||||||
@@ -202,6 +231,11 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
flushManager.MarkDirty(false)
|
flushManager.MarkDirty(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run message hook (bd-kwro.8)
|
||||||
|
if hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventMessage, issue)
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": issue.ID,
|
"id": issue.ID,
|
||||||
@@ -487,3 +521,152 @@ func runMailAck(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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/beads"
|
||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/debug"
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
|
"github.com/steveyegge/beads/internal/hooks"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/storage/memory"
|
"github.com/steveyegge/beads/internal/storage/memory"
|
||||||
@@ -83,6 +84,9 @@ var (
|
|||||||
// Auto-flush manager (replaces timer-based approach to fix bd-52)
|
// Auto-flush manager (replaces timer-based approach to fix bd-52)
|
||||||
flushManager *FlushManager
|
flushManager *FlushManager
|
||||||
|
|
||||||
|
// Hook runner for extensibility (bd-kwro.8)
|
||||||
|
hookRunner *hooks.Runner
|
||||||
|
|
||||||
// skipFinalFlush is set by sync command when sync.branch mode completes successfully.
|
// skipFinalFlush is set by sync command when sync.branch mode completes successfully.
|
||||||
// This prevents PersistentPostRun from re-exporting and dirtying the working directory.
|
// This prevents PersistentPostRun from re-exporting and dirtying the working directory.
|
||||||
skipFinalFlush = false
|
skipFinalFlush = false
|
||||||
@@ -599,6 +603,13 @@ var rootCmd = &cobra.Command{
|
|||||||
flushManager = NewFlushManager(autoFlushEnabled, getDebounceDuration())
|
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
|
// Warn if multiple databases detected in directory hierarchy
|
||||||
warnMultipleDatabases(dbPath)
|
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)
|
||||||
|
}
|
||||||
213
cmd/bd/show.go
213
cmd/bd/show.go
@@ -1,14 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/hooks"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -22,6 +25,7 @@ var showCmd = &cobra.Command{
|
|||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
showThread, _ := cmd.Flags().GetBool("thread")
|
||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
|
|
||||||
// Check database freshness before reading (bd-2q6d, bd-c4rq)
|
// 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 daemon is running, use RPC
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
allDetails := []interface{}{}
|
allDetails := []interface{}{}
|
||||||
@@ -637,12 +647,17 @@ var updateCmd = &cobra.Command{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
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)
|
updatedIssues = append(updatedIssues, &issue)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if !jsonOutput {
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
|
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
|
||||||
}
|
}
|
||||||
@@ -716,8 +731,13 @@ var updateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
// Run update hook (bd-kwro.8)
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
issue, _ := store.GetIssue(ctx, id)
|
||||||
|
if issue != nil && hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventUpdate, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
if issue != nil {
|
if issue != nil {
|
||||||
updatedIssues = append(updatedIssues, issue)
|
updatedIssues = append(updatedIssues, issue)
|
||||||
}
|
}
|
||||||
@@ -990,12 +1010,17 @@ var closeCmd = &cobra.Command{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
if err := json.Unmarshal(resp.Data, &issue); err == nil {
|
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)
|
closedIssues = append(closedIssues, &issue)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if !jsonOutput {
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
|
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)
|
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if jsonOutput {
|
|
||||||
|
// Run close hook (bd-kwro.8)
|
||||||
issue, _ := store.GetIssue(ctx, id)
|
issue, _ := store.GetIssue(ctx, id)
|
||||||
|
if issue != nil && hookRunner != nil {
|
||||||
|
hookRunner.Run(hooks.EventClose, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
if issue != nil {
|
if issue != nil {
|
||||||
closedIssues = append(closedIssues, issue)
|
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() {
|
func init() {
|
||||||
showCmd.Flags().Bool("json", false, "Output JSON format")
|
showCmd.Flags().Bool("json", false, "Output JSON format")
|
||||||
|
showCmd.Flags().Bool("thread", false, "Show full conversation thread (for messages)")
|
||||||
rootCmd.AddCommand(showCmd)
|
rootCmd.AddCommand(showCmd)
|
||||||
|
|
||||||
updateCmd.Flags().StringP("status", "s", "", "New status")
|
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
|
EstimatedMinutes *int `json:"estimated_minutes,omitempty"` // Time estimate in minutes
|
||||||
Labels []string `json:"labels,omitempty"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
Dependencies []string `json:"dependencies,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
|
// UpdateArgs represents arguments for the update operation
|
||||||
@@ -91,6 +95,10 @@ type UpdateArgs struct {
|
|||||||
AddLabels []string `json:"add_labels,omitempty"`
|
AddLabels []string `json:"add_labels,omitempty"`
|
||||||
RemoveLabels []string `json:"remove_labels,omitempty"`
|
RemoveLabels []string `json:"remove_labels,omitempty"`
|
||||||
SetLabels []string `json:"set_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
|
// CloseArgs represents arguments for the close operation
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
|
|||||||
if a.IssueType != nil {
|
if a.IssueType != nil {
|
||||||
u["issue_type"] = *a.IssueType
|
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
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,10 @@ func (s *Server) handleCreate(req *Request) Response {
|
|||||||
ExternalRef: externalRef,
|
ExternalRef: externalRef,
|
||||||
EstimatedMinutes: createArgs.EstimatedMinutes,
|
EstimatedMinutes: createArgs.EstimatedMinutes,
|
||||||
Status: types.StatusOpen,
|
Status: types.StatusOpen,
|
||||||
|
// Messaging fields (bd-kwro)
|
||||||
|
Sender: createArgs.Sender,
|
||||||
|
Ephemeral: createArgs.Ephemeral,
|
||||||
|
RepliesTo: createArgs.RepliesTo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any dependencies are discovered-from type
|
// Check if any dependencies are discovered-from type
|
||||||
|
|||||||
Reference in New Issue
Block a user