diff --git a/cmd/bd/flags.go b/cmd/bd/flags.go index fccdfb97..2ee05d4d 100644 --- a/cmd/bd/flags.go +++ b/cmd/bd/flags.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "github.com/spf13/cobra" @@ -13,18 +14,57 @@ func registerCommonIssueFlags(cmd *cobra.Command) { cmd.Flags().StringP("description", "d", "", "Issue description") cmd.Flags().String("body", "", "Alias for --description (GitHub CLI convention)") _ = cmd.Flags().MarkHidden("body") // Hidden alias for agent/CLI ergonomics + cmd.Flags().String("body-file", "", "Read description from file (use - for stdin)") + cmd.Flags().String("description-file", "", "Alias for --body-file") + _ = cmd.Flags().MarkHidden("description-file") // Hidden alias cmd.Flags().String("design", "", "Design notes") cmd.Flags().String("acceptance", "", "Acceptance criteria") cmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") } -// getDescriptionFlag retrieves the description value, checking both --description and --body. -// Returns the value and whether either flag was explicitly changed. +// getDescriptionFlag retrieves the description value, checking --body-file, --description-file, +// --description, and --body (in that order of precedence). +// Returns the value and whether any flag was explicitly changed. func getDescriptionFlag(cmd *cobra.Command) (string, bool) { + bodyFileChanged := cmd.Flags().Changed("body-file") + descFileChanged := cmd.Flags().Changed("description-file") descChanged := cmd.Flags().Changed("description") bodyChanged := cmd.Flags().Changed("body") - // Error if both are specified with different values + // Check for conflicting file flags + if bodyFileChanged && descFileChanged { + bodyFile, _ := cmd.Flags().GetString("body-file") + descFile, _ := cmd.Flags().GetString("description-file") + if bodyFile != descFile { + fmt.Fprintf(os.Stderr, "Error: cannot specify both --body-file and --description-file with different values\n") + os.Exit(1) + } + } + + // File flags take precedence over string flags + if bodyFileChanged || descFileChanged { + var filePath string + if bodyFileChanged { + filePath, _ = cmd.Flags().GetString("body-file") + } else { + filePath, _ = cmd.Flags().GetString("description-file") + } + + // Error if both file and string flags are specified + if descChanged || bodyChanged { + fmt.Fprintf(os.Stderr, "Error: cannot specify both --body-file and --description/--body\n") + os.Exit(1) + } + + content, err := readBodyFile(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading body file: %v\n", err) + os.Exit(1) + } + return content, true + } + + // Error if both description and body are specified with different values if descChanged && bodyChanged { desc, _ := cmd.Flags().GetString("description") body, _ := cmd.Flags().GetString("body") @@ -46,6 +86,30 @@ func getDescriptionFlag(cmd *cobra.Command) (string, bool) { return desc, descChanged } +// readBodyFile reads the description content from a file. +// If filePath is "-", reads from stdin. +func readBodyFile(filePath string) (string, error) { + var reader io.Reader + + if filePath == "-" { + reader = os.Stdin + } else { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + reader = file + } + + content, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + + return string(content), nil +} + // registerPriorityFlag registers the priority flag with a specific default value. func registerPriorityFlag(cmd *cobra.Command, defaultVal string) { cmd.Flags().StringP("priority", "p", defaultVal, "Priority (0-4 or P0-P4, 0=highest)") diff --git a/cmd/bd/import.go b/cmd/bd/import.go index 75dc2918..ddacfc61 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -97,6 +97,8 @@ NOTE: Import requires direct database access and does not work with daemon mode. orphanHandling, _ := cmd.Flags().GetString("orphan-handling") force, _ := cmd.Flags().GetBool("force") protectLeftSnapshot, _ := cmd.Flags().GetBool("protect-left-snapshot") + noGitHistory, _ := cmd.Flags().GetBool("no-git-history") + _ = noGitHistory // Accepted for compatibility with bd sync subprocess calls // Check if stdin is being used interactively (not piped) if input == "" && term.IsTerminal(int(os.Stdin.Fd())) { @@ -777,6 +779,7 @@ func init() { importCmd.Flags().String("orphan-handling", "", "How to handle missing parent issues: strict/resurrect/skip/allow (default: use config or 'allow')") importCmd.Flags().Bool("force", false, "Force metadata update even when database is already in sync with JSONL") importCmd.Flags().Bool("protect-left-snapshot", false, "Protect issues in left snapshot from git-history-backfill (bd-sync-deletion fix)") + importCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (passed by bd sync)") importCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output import statistics in JSON format") rootCmd.AddCommand(importCmd) } diff --git a/cmd/bd/mail.go b/cmd/bd/mail.go index 90f9fc42..d2873aa8 100644 --- a/cmd/bd/mail.go +++ b/cmd/bd/mail.go @@ -482,12 +482,20 @@ func runMailAck(cmd *cobra.Command, args []string) error { errors = append(errors, fmt.Sprintf("%s: %v", messageID, err)) continue } + // Fire close hook for GGT notifications (daemon mode) + if hookRunner != nil { + hookRunner.Run(hooks.EventClose, issue) + } } else { // Direct mode - use CloseIssue for proper close handling if err := store.CloseIssue(rootCtx, messageID, "acknowledged", actor); err != nil { errors = append(errors, fmt.Sprintf("%s: %v", messageID, err)) continue } + // Fire close hook for GGT notifications (direct mode) + if hookRunner != nil { + hookRunner.Run(hooks.EventClose, issue) + } } acked = append(acked, messageID) @@ -646,6 +654,11 @@ func runMailReply(cmd *cobra.Command, args []string) error { flushManager.MarkDirty(false) } + // Fire message hook for GGT notifications + if hookRunner != nil { + hookRunner.Run(hooks.EventMessage, reply) + } + if jsonOutput { result := map[string]interface{}{ "id": reply.ID,