From e23b3b19551a6f63291ce89d22b3d1c8577da266 Mon Sep 17 00:00:00 2001 From: beads/crew/wolf Date: Tue, 30 Dec 2025 21:18:59 -0800 Subject: [PATCH] Restore convoy add command and feed deduplication logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accidentally included staged reverts in previous commit. Restoring: - convoyAddCmd for 'gt convoy add' command - Event filtering/deduplication in feed model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/convoy.go | 95 ++++++++++++++++++++++++++++++++++++++ internal/tui/feed/model.go | 17 +++++++ 2 files changed, 112 insertions(+) diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index 57e33f8e..4b6e30a7 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -107,6 +107,20 @@ Examples: RunE: runConvoyList, } +var convoyAddCmd = &cobra.Command{ + Use: "add [issue-id...]", + Short: "Add issues to an existing convoy", + Long: `Add issues to an existing convoy. + +If the convoy is closed, it will be automatically reopened. + +Examples: + gt convoy add hq-cv-abc gt-new-issue + gt convoy add hq-cv-abc gt-issue1 gt-issue2 gt-issue3`, + Args: cobra.MinimumNArgs(2), + RunE: runConvoyAdd, +} + func init() { // Create flags convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID") @@ -124,6 +138,7 @@ func init() { convoyCmd.AddCommand(convoyCreateCmd) convoyCmd.AddCommand(convoyStatusCmd) convoyCmd.AddCommand(convoyListCmd) + convoyCmd.AddCommand(convoyAddCmd) rootCmd.AddCommand(convoyCmd) } @@ -222,6 +237,86 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error { return nil } +func runConvoyAdd(cmd *cobra.Command, args []string) error { + convoyID := args[0] + issuesToAdd := args[1:] + + townBeads, err := getTownBeadsDir() + if err != nil { + return err + } + + // Validate convoy exists and get its status + showArgs := []string{"show", convoyID, "--json"} + showCmd := exec.Command("bd", showArgs...) + showCmd.Dir = townBeads + var stdout bytes.Buffer + showCmd.Stdout = &stdout + + if err := showCmd.Run(); err != nil { + return fmt.Errorf("convoy '%s' not found", convoyID) + } + + var convoys []struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Type string `json:"issue_type"` + } + if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil { + return fmt.Errorf("parsing convoy data: %w", err) + } + + if len(convoys) == 0 { + return fmt.Errorf("convoy '%s' not found", convoyID) + } + + convoy := convoys[0] + + // Verify it's actually a convoy type + if convoy.Type != "convoy" { + return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type) + } + + // If convoy is closed, reopen it + reopened := false + if convoy.Status == "closed" { + reopenArgs := []string{"update", convoyID, "--status=open"} + reopenCmd := exec.Command("bd", reopenArgs...) + reopenCmd.Dir = townBeads + if err := reopenCmd.Run(); err != nil { + return fmt.Errorf("couldn't reopen convoy: %w", err) + } + reopened = true + fmt.Printf("%s Reopened convoy %s\n", style.Bold.Render("↺"), convoyID) + } + + // Add 'tracks' relations for each issue + addedCount := 0 + for _, issueID := range issuesToAdd { + depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"} + depCmd := exec.Command("bd", depArgs...) + depCmd.Dir = townBeads + + if err := depCmd.Run(); err != nil { + style.PrintWarning("couldn't add %s: %v", issueID, err) + } else { + addedCount++ + } + } + + // Output + if reopened { + fmt.Println() + } + fmt.Printf("%s Added %d issue(s) to convoy 🚚 %s\n", style.Bold.Render("✓"), addedCount, convoyID) + if addedCount > 0 { + fmt.Printf(" Issues: %s\n", strings.Join(issuesToAdd[:addedCount], ", ")) + } + + return nil +} + func runConvoyStatus(cmd *cobra.Command, args []string) error { townBeads, err := getTownBeadsDir() if err != nil { diff --git a/internal/tui/feed/model.go b/internal/tui/feed/model.go index 24bb737e..d4d491cb 100644 --- a/internal/tui/feed/model.go +++ b/internal/tui/feed/model.go @@ -281,6 +281,11 @@ func (m *Model) addEvent(e Event) { } } + // Filter out events with empty bead IDs (malformed mutations) + if e.Type == "update" && e.Target == "" { + return + } + // Filter out noisy agent session updates from the event feed. // Agent session molecules (like gt-gastown-crew-joe) update frequently // for status tracking. These updates are visible in the agent tree, @@ -293,6 +298,18 @@ func (m *Model) addEvent(e Event) { return } + // Deduplicate rapid updates to the same bead within 2 seconds. + // This prevents spam when multiple deps/labels are added to one issue. + if e.Type == "update" && e.Target != "" && len(m.events) > 0 { + lastEvent := m.events[len(m.events)-1] + if lastEvent.Type == "update" && lastEvent.Target == e.Target { + // Same bead updated within 2 seconds - skip duplicate + if e.Time.Sub(lastEvent.Time) < 2*time.Second { + return + } + } + } + // Add to event feed m.events = append(m.events, e)