feat(convoy): add --owner flag for targeted completion notifications

Add --owner flag to gt convoy create to track who requested a convoy.
Owner receives completion notification when convoy closes (in addition
to any --notify subscribers). Notifications are de-duplicated if owner
and notify are the same address.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rictus
2026-01-12 19:47:14 -08:00
committed by beads/crew/emma
parent 58207a00ec
commit 392ff1d31b

View File

@@ -62,6 +62,7 @@ func looksLikeIssueID(s string) bool {
var ( var (
convoyMolecule string convoyMolecule string
convoyNotify string convoyNotify string
convoyOwner string
convoyStatusJSON bool convoyStatusJSON bool
convoyListJSON bool convoyListJSON bool
convoyListStatus string convoyListStatus string
@@ -121,10 +122,15 @@ var convoyCreateCmd = &cobra.Command{
The convoy is created in town-level beads (hq-* prefix) and can track The convoy is created in town-level beads (hq-* prefix) and can track
issues across any rig. issues across any rig.
The --owner flag specifies who requested the convoy (receives completion
notification by default). If not specified, defaults to created_by.
The --notify flag adds additional subscribers beyond the owner.
Examples: Examples:
gt convoy create "Deploy v2.0" gt-abc bd-xyz gt convoy create "Deploy v2.0" gt-abc bd-xyz
gt convoy create "Release prep" gt-abc --notify # defaults to mayor/ gt convoy create "Release prep" gt-abc --notify # defaults to mayor/
gt convoy create "Release prep" gt-abc --notify ops/ # notify ops/ gt convoy create "Release prep" gt-abc --notify ops/ # notify ops/
gt convoy create "Feature rollout" gt-a gt-b --owner mayor/ --notify ops/
gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`, gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`,
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
RunE: runConvoyCreate, RunE: runConvoyCreate,
@@ -225,7 +231,8 @@ Examples:
func init() { func init() {
// Create flags // Create flags
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID") convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Address to notify on completion (default: mayor/ if flag used without value)") convoyCreateCmd.Flags().StringVar(&convoyOwner, "owner", "", "Owner who requested convoy (gets completion notification)")
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Additional address to notify on completion (default: mayor/ if flag used without value)")
convoyCreateCmd.Flags().Lookup("notify").NoOptDefVal = "mayor/" convoyCreateCmd.Flags().Lookup("notify").NoOptDefVal = "mayor/"
// Status flags // Status flags
@@ -291,6 +298,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
// Create convoy issue in town beads // Create convoy issue in town beads
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues)) description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
if convoyOwner != "" {
description += fmt.Sprintf("\nOwner: %s", convoyOwner)
}
if convoyNotify != "" { if convoyNotify != "" {
description += fmt.Sprintf("\nNotify: %s", convoyNotify) description += fmt.Sprintf("\nNotify: %s", convoyNotify)
} }
@@ -345,6 +355,9 @@ func runConvoyCreate(cmd *cobra.Command, args []string) error {
if len(trackedIssues) > 0 { if len(trackedIssues) > 0 {
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", ")) fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
} }
if convoyOwner != "" {
fmt.Printf(" Owner: %s\n", convoyOwner)
}
if convoyNotify != "" { if convoyNotify != "" {
fmt.Printf(" Notify: %s\n", convoyNotify) fmt.Printf(" Notify: %s\n", convoyNotify)
} }
@@ -786,9 +799,9 @@ func checkAndCloseCompletedConvoys(townBeads string) ([]struct{ ID, Title string
return closed, nil return closed, nil
} }
// notifyConvoyCompletion sends a notification if the convoy has a notify address. // notifyConvoyCompletion sends notifications to owner and any notify addresses.
func notifyConvoyCompletion(townBeads, convoyID, title string) { func notifyConvoyCompletion(townBeads, convoyID, title string) {
// Get convoy description to find notify address // Get convoy description to find owner and notify addresses
showArgs := []string{"show", convoyID, "--json"} showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...) showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads showCmd.Dir = townBeads
@@ -806,20 +819,26 @@ func notifyConvoyCompletion(townBeads, convoyID, title string) {
return return
} }
// Parse notify address from description // Parse owner and notify addresses from description
desc := convoys[0].Description desc := convoys[0].Description
notified := make(map[string]bool) // Track who we've notified to avoid duplicates
for _, line := range strings.Split(desc, "\n") { for _, line := range strings.Split(desc, "\n") {
if strings.HasPrefix(line, "Notify: ") { var addr string
addr := strings.TrimPrefix(line, "Notify: ") if strings.HasPrefix(line, "Owner: ") {
if addr != "" { addr = strings.TrimPrefix(line, "Owner: ")
// Send notification via gt mail } else if strings.HasPrefix(line, "Notify: ") {
mailArgs := []string{"mail", "send", addr, addr = strings.TrimPrefix(line, "Notify: ")
"-s", fmt.Sprintf("🚚 Convoy landed: %s", title), }
"-m", fmt.Sprintf("Convoy %s has completed.\n\nAll tracked issues are now closed.", convoyID)}
mailCmd := exec.Command("gt", mailArgs...) if addr != "" && !notified[addr] {
_ = mailCmd.Run() // Best effort, ignore errors // Send notification via gt mail
} mailArgs := []string{"mail", "send", addr,
break "-s", fmt.Sprintf("🚚 Convoy landed: %s", title),
"-m", fmt.Sprintf("Convoy %s has completed.\n\nAll tracked issues are now closed.", convoyID)}
mailCmd := exec.Command("gt", mailArgs...)
_ = mailCmd.Run() // Best effort, ignore errors
notified[addr] = true
} }
} }
} }