package main import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/types" ) // JiraSyncStats tracks statistics for a Jira sync operation. type JiraSyncStats struct { Pulled int `json:"pulled"` Pushed int `json:"pushed"` Created int `json:"created"` Updated int `json:"updated"` Skipped int `json:"skipped"` Errors int `json:"errors"` Conflicts int `json:"conflicts"` } // JiraSyncResult represents the result of a Jira sync operation. type JiraSyncResult struct { Success bool `json:"success"` Stats JiraSyncStats `json:"stats"` LastSync string `json:"last_sync,omitempty"` Error string `json:"error,omitempty"` Warnings []string `json:"warnings,omitempty"` } var jiraCmd = &cobra.Command{ Use: "jira", GroupID: "advanced", Short: "Jira integration commands", Long: `Synchronize issues between beads and Jira. Configuration: bd config set jira.url "https://company.atlassian.net" bd config set jira.project "PROJ" bd config set jira.api_token "YOUR_TOKEN" bd config set jira.username "your_email@company.com" # For Jira Cloud Environment variables (alternative to config): JIRA_API_TOKEN - Jira API token JIRA_USERNAME - Jira username/email Examples: bd jira sync --pull # Import issues from Jira bd jira sync --push # Export issues to Jira bd jira sync # Bidirectional sync (pull then push) bd jira sync --dry-run # Preview sync without changes bd jira status # Show sync status`, } var jiraSyncCmd = &cobra.Command{ Use: "sync", Short: "Synchronize issues with Jira", Long: `Synchronize issues between beads and Jira. Modes: --pull Import issues from Jira into beads --push Export issues from beads to Jira (no flags) Bidirectional sync: pull then push, with conflict resolution Conflict Resolution: By default, newer timestamp wins. Override with: --prefer-local Always prefer local beads version --prefer-jira Always prefer Jira version Examples: bd jira sync --pull # Import from Jira bd jira sync --push --create-only # Push new issues only bd jira sync --dry-run # Preview without changes bd jira sync --prefer-local # Bidirectional, local wins`, Run: func(cmd *cobra.Command, args []string) { // Flag errors are unlikely but check one to ensure cobra is working pull, _ := cmd.Flags().GetBool("pull") push, _ := cmd.Flags().GetBool("push") dryRun, _ := cmd.Flags().GetBool("dry-run") preferLocal, _ := cmd.Flags().GetBool("prefer-local") preferJira, _ := cmd.Flags().GetBool("prefer-jira") createOnly, _ := cmd.Flags().GetBool("create-only") updateRefs, _ := cmd.Flags().GetBool("update-refs") state, _ := cmd.Flags().GetString("state") // Block writes in readonly mode (sync modifies data) if !dryRun { CheckReadonly("jira sync") } // Validate conflicting flags if preferLocal && preferJira { fmt.Fprintf(os.Stderr, "Error: cannot use both --prefer-local and --prefer-jira\n") os.Exit(1) } // Ensure store is available if err := ensureStoreActive(); err != nil { fmt.Fprintf(os.Stderr, "Error: database not available: %v\n", err) os.Exit(1) } // Ensure we have Jira configuration if err := validateJiraConfig(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Default mode: bidirectional (pull then push) if !pull && !push { pull = true push = true } ctx := rootCtx result := &JiraSyncResult{Success: true} // Step 1: Pull from Jira if pull { if dryRun { fmt.Println("→ [DRY RUN] Would pull issues from Jira") } else { fmt.Println("→ Pulling issues from Jira...") } pullStats, err := doPullFromJira(ctx, dryRun, state) if err != nil { result.Success = false result.Error = err.Error() if jsonOutput { outputJSON(result) } else { fmt.Fprintf(os.Stderr, "Error pulling from Jira: %v\n", err) } os.Exit(1) } result.Stats.Pulled = pullStats.Created + pullStats.Updated result.Stats.Created += pullStats.Created result.Stats.Updated += pullStats.Updated result.Stats.Skipped += pullStats.Skipped if !dryRun { fmt.Printf("✓ Pulled %d issues (%d created, %d updated)\n", result.Stats.Pulled, pullStats.Created, pullStats.Updated) } } // Step 2: Handle conflicts (if bidirectional) if pull && push && !dryRun { conflicts, err := detectJiraConflicts(ctx) if err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("conflict detection failed: %v", err)) } else if len(conflicts) > 0 { result.Stats.Conflicts = len(conflicts) if preferLocal { fmt.Printf("→ Resolving %d conflicts (preferring local)\n", len(conflicts)) // Local wins - no action needed, push will overwrite } else if preferJira { fmt.Printf("→ Resolving %d conflicts (preferring Jira)\n", len(conflicts)) // Jira wins - re-import conflicting issues if err := reimportConflicts(ctx, conflicts); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("conflict resolution failed: %v", err)) } } else { // Default: timestamp-based (newer wins) fmt.Printf("→ Resolving %d conflicts (newer wins)\n", len(conflicts)) if err := resolveConflictsByTimestamp(ctx, conflicts); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("conflict resolution failed: %v", err)) } } } } // Step 3: Push to Jira if push { if dryRun { fmt.Println("→ [DRY RUN] Would push issues to Jira") } else { fmt.Println("→ Pushing issues to Jira...") } pushStats, err := doPushToJira(ctx, dryRun, createOnly, updateRefs) if err != nil { result.Success = false result.Error = err.Error() if jsonOutput { outputJSON(result) } else { fmt.Fprintf(os.Stderr, "Error pushing to Jira: %v\n", err) } os.Exit(1) } result.Stats.Pushed = pushStats.Created + pushStats.Updated result.Stats.Created += pushStats.Created result.Stats.Updated += pushStats.Updated result.Stats.Skipped += pushStats.Skipped result.Stats.Errors += pushStats.Errors if !dryRun { fmt.Printf("✓ Pushed %d issues (%d created, %d updated)\n", result.Stats.Pushed, pushStats.Created, pushStats.Updated) } } // Update last sync timestamp if !dryRun && result.Success { result.LastSync = time.Now().Format(time.RFC3339) if err := store.SetConfig(ctx, "jira.last_sync", result.LastSync); err != nil { result.Warnings = append(result.Warnings, fmt.Sprintf("failed to update last_sync: %v", err)) } } // Output result if jsonOutput { outputJSON(result) } else if dryRun { fmt.Println("\n✓ Dry run complete (no changes made)") } else { fmt.Println("\n✓ Jira sync complete") if len(result.Warnings) > 0 { fmt.Println("\nWarnings:") for _, w := range result.Warnings { fmt.Printf(" - %s\n", w) } } } }, } var jiraStatusCmd = &cobra.Command{ Use: "status", Short: "Show Jira sync status", Long: `Show the current Jira sync status, including: - Last sync timestamp - Configuration status - Number of issues with Jira links - Issues pending push (no external_ref)`, Run: func(cmd *cobra.Command, args []string) { ctx := rootCtx // Ensure store is available if err := ensureStoreActive(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Get configuration jiraURL, _ := store.GetConfig(ctx, "jira.url") jiraProject, _ := store.GetConfig(ctx, "jira.project") lastSync, _ := store.GetConfig(ctx, "jira.last_sync") // Check if configured configured := jiraURL != "" && jiraProject != "" // Count issues with Jira links allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } withJiraRef := 0 pendingPush := 0 for _, issue := range allIssues { if issue.ExternalRef != nil && isJiraExternalRef(*issue.ExternalRef, jiraURL) { withJiraRef++ } else if issue.ExternalRef == nil { // Only count issues without any external_ref as pending push pendingPush++ } // Issues with non-Jira external_ref are not counted in either category } if jsonOutput { outputJSON(map[string]interface{}{ "configured": configured, "jira_url": jiraURL, "jira_project": jiraProject, "last_sync": lastSync, "total_issues": len(allIssues), "with_jira_ref": withJiraRef, "pending_push": pendingPush, }) return } fmt.Println("Jira Sync Status") fmt.Println("================") fmt.Println() if !configured { fmt.Println("Status: Not configured") fmt.Println() fmt.Println("To configure Jira integration:") fmt.Println(" bd config set jira.url \"https://company.atlassian.net\"") fmt.Println(" bd config set jira.project \"PROJ\"") fmt.Println(" bd config set jira.api_token \"YOUR_TOKEN\"") fmt.Println(" bd config set jira.username \"your@email.com\"") return } fmt.Printf("Jira URL: %s\n", jiraURL) fmt.Printf("Project: %s\n", jiraProject) if lastSync != "" { fmt.Printf("Last Sync: %s\n", lastSync) } else { fmt.Println("Last Sync: Never") } fmt.Println() fmt.Printf("Total Issues: %d\n", len(allIssues)) fmt.Printf("With Jira: %d\n", withJiraRef) fmt.Printf("Local Only: %d\n", pendingPush) if pendingPush > 0 { fmt.Println() fmt.Printf("Run 'bd jira sync --push' to push %d local issue(s) to Jira\n", pendingPush) } }, } func init() { // Sync command flags jiraSyncCmd.Flags().Bool("pull", false, "Pull issues from Jira") jiraSyncCmd.Flags().Bool("push", false, "Push issues to Jira") jiraSyncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes") jiraSyncCmd.Flags().Bool("prefer-local", false, "Prefer local version on conflicts") jiraSyncCmd.Flags().Bool("prefer-jira", false, "Prefer Jira version on conflicts") jiraSyncCmd.Flags().Bool("create-only", false, "Only create new issues, don't update existing") jiraSyncCmd.Flags().Bool("update-refs", true, "Update external_ref after creating Jira issues") jiraSyncCmd.Flags().String("state", "all", "Issue state to sync: open, closed, all") jiraCmd.AddCommand(jiraSyncCmd) jiraCmd.AddCommand(jiraStatusCmd) rootCmd.AddCommand(jiraCmd) } // validateJiraConfig checks that required Jira configuration is present. func validateJiraConfig() error { if err := ensureStoreActive(); err != nil { return fmt.Errorf("database not available: %w", err) } ctx := rootCtx jiraURL, _ := store.GetConfig(ctx, "jira.url") jiraProject, _ := store.GetConfig(ctx, "jira.project") if jiraURL == "" { return fmt.Errorf("jira.url not configured\nRun: bd config set jira.url \"https://company.atlassian.net\"") } if jiraProject == "" { return fmt.Errorf("jira.project not configured\nRun: bd config set jira.project \"PROJ\"") } // Check for API token (from config or env) apiToken, _ := store.GetConfig(ctx, "jira.api_token") if apiToken == "" && os.Getenv("JIRA_API_TOKEN") == "" { return fmt.Errorf("Jira API token not configured\nRun: bd config set jira.api_token \"YOUR_TOKEN\"\nOr: export JIRA_API_TOKEN=YOUR_TOKEN") } return nil } // PullStats tracks pull operation statistics. type PullStats struct { Created int Updated int Skipped int } // doPullFromJira imports issues from Jira using the Python script. func doPullFromJira(ctx context.Context, dryRun bool, state string) (*PullStats, error) { stats := &PullStats{} // Find the Python script scriptPath, err := findJiraScript("jira2jsonl.py") if err != nil { return stats, fmt.Errorf("jira2jsonl.py not found: %w", err) } // Build command args := []string{scriptPath, "--from-config"} if state != "" && state != "all" { args = append(args, "--state", state) } // Run Python script to get JSONL output cmd := exec.CommandContext(ctx, "python3", args...) cmd.Stderr = os.Stderr output, err := cmd.Output() if err != nil { return stats, fmt.Errorf("failed to fetch from Jira: %w", err) } if dryRun { // Count issues in output scanner := bufio.NewScanner(strings.NewReader(string(output))) count := 0 for scanner.Scan() { if strings.TrimSpace(scanner.Text()) != "" { count++ } } fmt.Printf(" Would import %d issues from Jira\n", count) return stats, nil } // Parse JSONL and import scanner := bufio.NewScanner(strings.NewReader(string(output))) var issues []*types.Issue for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var issue types.Issue if err := json.Unmarshal([]byte(line), &issue); err != nil { return stats, fmt.Errorf("failed to parse issue JSON: %w", err) } issues = append(issues, &issue) } if err := scanner.Err(); err != nil { return stats, fmt.Errorf("failed to read JSONL: %w", err) } // Import issues using shared logic opts := ImportOptions{ DryRun: false, SkipUpdate: false, } result, err := importIssuesCore(ctx, dbPath, store, issues, opts) if err != nil { return stats, fmt.Errorf("import failed: %w", err) } stats.Created = result.Created stats.Updated = result.Updated stats.Skipped = result.Skipped return stats, nil } // PushStats tracks push operation statistics. type PushStats struct { Created int Updated int Skipped int Errors int } // doPushToJira exports issues to Jira using the Python script. func doPushToJira(ctx context.Context, dryRun bool, createOnly bool, updateRefs bool) (*PushStats, error) { stats := &PushStats{} // Find the Python script scriptPath, err := findJiraScript("jsonl2jira.py") if err != nil { return stats, fmt.Errorf("jsonl2jira.py not found: %w", err) } // Get all issues issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { return stats, fmt.Errorf("failed to get issues: %w", err) } // Sort by ID for consistent output sort.Slice(issues, func(i, j int) bool { return issues[i].ID < issues[j].ID }) // Generate JSONL for export var jsonlLines []string for _, issue := range issues { data, err := json.Marshal(issue) if err != nil { return stats, fmt.Errorf("failed to encode issue %s: %w", issue.ID, err) } jsonlLines = append(jsonlLines, string(data)) } jsonlContent := strings.Join(jsonlLines, "\n") // Build command args := []string{scriptPath, "--from-config"} if dryRun { args = append(args, "--dry-run") } if createOnly { args = append(args, "--create-only") } if updateRefs { args = append(args, "--update-refs") } cmd := exec.CommandContext(ctx, "python3", args...) cmd.Stdin = strings.NewReader(jsonlContent) cmd.Stderr = os.Stderr output, err := cmd.Output() if err != nil { return stats, fmt.Errorf("failed to push to Jira: %w", err) } // Parse output for statistics and external_ref updates scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // Parse mapping output: {"bd_id": "...", "jira_key": "...", "external_ref": "..."} var mapping struct { BDID string `json:"bd_id"` JiraKey string `json:"jira_key"` ExternalRef string `json:"external_ref"` } if err := json.Unmarshal([]byte(line), &mapping); err == nil && mapping.BDID != "" { stats.Created++ // Update external_ref if requested if updateRefs && !dryRun && mapping.ExternalRef != "" { updates := map[string]interface{}{ "external_ref": mapping.ExternalRef, } if err := store.UpdateIssue(ctx, mapping.BDID, updates, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to update external_ref for %s: %v\n", mapping.BDID, err) stats.Errors++ } } } } return stats, nil } // findJiraScript locates the Jira Python script. func findJiraScript(name string) (string, error) { // Check common locations locations := []string{ // Relative to current working directory filepath.Join("examples", "jira-import", name), // Relative to executable "", } // Add executable-relative path if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) locations = append(locations, filepath.Join(exeDir, "examples", "jira-import", name)) locations = append(locations, filepath.Join(exeDir, "..", "examples", "jira-import", name)) } // Check BEADS_DIR or current .beads location if beadsDir := findBeadsDir(); beadsDir != "" { repoRoot := filepath.Dir(beadsDir) locations = append(locations, filepath.Join(repoRoot, "examples", "jira-import", name)) } for _, loc := range locations { if loc == "" { continue } if _, err := os.Stat(loc); err == nil { absPath, err := filepath.Abs(loc) if err == nil { return absPath, nil } return loc, nil } } return "", fmt.Errorf("script not found: %s (looked in examples/jira-import/)", name) } // JiraConflict represents a conflict between local and Jira versions. type JiraConflict struct { IssueID string LocalUpdated time.Time JiraUpdated time.Time JiraExternalRef string } // detectJiraConflicts finds issues that have been modified both locally and in Jira. func detectJiraConflicts(ctx context.Context) ([]JiraConflict, error) { // Get last sync time lastSyncStr, _ := store.GetConfig(ctx, "jira.last_sync") if lastSyncStr == "" { // No previous sync - no conflicts possible return nil, nil } lastSync, err := time.Parse(time.RFC3339, lastSyncStr) if err != nil { return nil, fmt.Errorf("invalid last_sync timestamp: %w", err) } // Get all issues with Jira refs that were updated since last sync allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { return nil, err } // Get jiraURL for validation jiraURL, _ := store.GetConfig(ctx, "jira.url") var conflicts []JiraConflict for _, issue := range allIssues { if issue.ExternalRef == nil || !isJiraExternalRef(*issue.ExternalRef, jiraURL) { continue } // Check if updated since last sync if issue.UpdatedAt.After(lastSync) { // This is a potential conflict - for now, mark as conflict // TODO: In a full implementation, we'd fetch the Jira issue and compare timestamps conflicts = append(conflicts, JiraConflict{ IssueID: issue.ID, LocalUpdated: issue.UpdatedAt, JiraExternalRef: *issue.ExternalRef, }) } } return conflicts, nil } // reimportConflicts re-imports conflicting issues from Jira (Jira wins). // NOTE: This is a placeholder - full implementation requires fetching individual // issues from Jira API and updating local copies. func reimportConflicts(_ context.Context, conflicts []JiraConflict) error { if len(conflicts) == 0 { return nil } fmt.Fprintf(os.Stderr, "Warning: conflict resolution (--prefer-jira) not fully implemented\n") fmt.Fprintf(os.Stderr, " %d issue(s) may have conflicts that need manual review:\n", len(conflicts)) for _, c := range conflicts { fmt.Fprintf(os.Stderr, " - %s (local updated: %s)\n", c.IssueID, c.LocalUpdated.Format(time.RFC3339)) } return nil } // resolveConflictsByTimestamp resolves conflicts by keeping the newer version. // NOTE: This is a placeholder - full implementation requires fetching Jira // timestamps and comparing with local timestamps. func resolveConflictsByTimestamp(_ context.Context, conflicts []JiraConflict) error { if len(conflicts) == 0 { return nil } fmt.Fprintf(os.Stderr, "Warning: timestamp-based conflict resolution not fully implemented\n") fmt.Fprintf(os.Stderr, " %d issue(s) may have conflicts - local version will be pushed:\n", len(conflicts)) for _, c := range conflicts { fmt.Fprintf(os.Stderr, " - %s\n", c.IssueID) } return nil } // isJiraExternalRef checks if an external_ref URL matches the configured Jira instance. // It validates both the URL structure (/browse/PROJECT-123) and optionally the host. func isJiraExternalRef(externalRef, jiraURL string) bool { // Must contain /browse/ pattern if !strings.Contains(externalRef, "/browse/") { return false } // If jiraURL is provided, validate the host matches if jiraURL != "" { jiraURL = strings.TrimSuffix(jiraURL, "/") if !strings.HasPrefix(externalRef, jiraURL) { return false } } return true }