cascade_cli/bitbucket/
integration.rs

1use crate::bitbucket::client::BitbucketClient;
2use crate::bitbucket::pull_request::{
3    CreatePullRequestRequest, Project, PullRequest, PullRequestManager, PullRequestRef,
4    PullRequestState, Repository,
5};
6use crate::config::CascadeConfig;
7use crate::errors::{CascadeError, Result};
8use crate::stack::{Stack, StackEntry, StackManager};
9use std::collections::HashMap;
10use tracing::{error, info, warn};
11use uuid::Uuid;
12
13/// High-level integration between stacks and Bitbucket
14pub struct BitbucketIntegration {
15    stack_manager: StackManager,
16    pr_manager: PullRequestManager,
17    config: CascadeConfig,
18}
19
20impl BitbucketIntegration {
21    /// Create a new Bitbucket integration
22    pub fn new(stack_manager: StackManager, config: CascadeConfig) -> Result<Self> {
23        let bitbucket_config = config
24            .bitbucket
25            .as_ref()
26            .ok_or_else(|| CascadeError::config("Bitbucket configuration not found"))?;
27
28        let client = BitbucketClient::new(bitbucket_config)?;
29        let pr_manager = PullRequestManager::new(client);
30
31        Ok(Self {
32            stack_manager,
33            pr_manager,
34            config,
35        })
36    }
37
38    /// Submit a single stack entry as a pull request
39    pub async fn submit_entry(
40        &mut self,
41        stack_id: &Uuid,
42        entry_id: &Uuid,
43        title: Option<String>,
44        description: Option<String>,
45        draft: bool,
46    ) -> Result<PullRequest> {
47        let stack = self
48            .stack_manager
49            .get_stack(stack_id)
50            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
51
52        let entry = stack
53            .get_entry(entry_id)
54            .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
55
56        // Submitting stack entry as pull request
57
58        // 🆕 VALIDATE GIT INTEGRITY BEFORE SUBMISSION
59        if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
60            return Err(CascadeError::validation(format!(
61                "Cannot submit entry from corrupted stack '{}':\n{}",
62                stack.name, integrity_error
63            )));
64        }
65
66        // Push branch to remote if not already pushed
67        let git_repo = self.stack_manager.git_repo();
68        // Push the entry branch - fail fast if this fails
69        git_repo
70            .push(&entry.branch)
71            .map_err(|e| CascadeError::bitbucket(format!("Cannot create PR: {e}")))?;
72
73        // Branch pushed successfully
74
75        // Mark as pushed in metadata
76        if let Some(commit_meta) = self
77            .stack_manager
78            .get_repository_metadata()
79            .commits
80            .get(&entry.commit_hash)
81        {
82            let mut updated_meta = commit_meta.clone();
83            updated_meta.mark_pushed();
84            // Note: This would require making mark_pushed public and updating the metadata
85            // For now, we'll track this as a future enhancement
86        }
87
88        // Determine target branch (parent entry's branch or stack base)
89        let target_branch = self.get_target_branch(stack, entry)?;
90
91        // Ensure target branch is also pushed to remote (if it's not the base branch)
92        if target_branch != stack.base_branch {
93            info!(
94                "Ensuring target branch '{}' is pushed to remote",
95                target_branch
96            );
97
98            // Push target branch - fail fast if this fails
99            git_repo.push(&target_branch).map_err(|e| {
100                CascadeError::bitbucket(format!(
101                    "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
102                    Try manually pushing with: git push origin {target_branch}"
103                ))
104            })?;
105
106            info!(
107                "✅ Successfully pushed target branch '{}' to remote",
108                target_branch
109            );
110        }
111
112        // Create pull request
113        let pr_request =
114            self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
115
116        let pr = match self.pr_manager.create_pull_request(pr_request).await {
117            Ok(pr) => pr,
118            Err(e) => {
119                return Err(CascadeError::bitbucket(format!(
120                    "Failed to create pull request for branch '{}' -> '{}': {}. \
121                    Ensure both branches exist in the remote repository. \
122                    You can manually push with: git push origin {}",
123                    entry.branch, target_branch, e, entry.branch
124                )));
125            }
126        };
127
128        // Update stack manager with PR information
129        self.stack_manager
130            .submit_entry(stack_id, entry_id, pr.id.to_string())?;
131
132        info!("Created pull request #{} for entry {}", pr.id, entry_id);
133        Ok(pr)
134    }
135
136    /// Check the status of all pull requests in a stack
137    pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
138        let stack = self
139            .stack_manager
140            .get_stack(stack_id)
141            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
142
143        let mut status = StackSubmissionStatus {
144            stack_name: stack.name.clone(),
145            total_entries: stack.entries.len(),
146            submitted_entries: 0,
147            open_prs: 0,
148            merged_prs: 0,
149            declined_prs: 0,
150            pull_requests: Vec::new(),
151            enhanced_statuses: Vec::new(),
152        };
153
154        for entry in &stack.entries {
155            if let Some(pr_id_str) = &entry.pull_request_id {
156                status.submitted_entries += 1;
157
158                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
159                    match self.pr_manager.get_pull_request(pr_id).await {
160                        Ok(pr) => {
161                            match pr.state {
162                                PullRequestState::Open => status.open_prs += 1,
163                                PullRequestState::Merged => status.merged_prs += 1,
164                                PullRequestState::Declined => status.declined_prs += 1,
165                            }
166                            status.pull_requests.push(pr);
167                        }
168                        Err(e) => {
169                            warn!("Failed to get pull request #{}: {}", pr_id, e);
170                        }
171                    }
172                }
173            }
174        }
175
176        Ok(status)
177    }
178
179    /// List all pull requests for the repository
180    pub async fn list_pull_requests(
181        &self,
182        state: Option<PullRequestState>,
183    ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
184        self.pr_manager.list_pull_requests(state).await
185    }
186
187    /// Get the target branch for a stack entry
188    fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
189        // For the first entry (bottom of stack), target is the base branch
190        if let Some(first_entry) = stack.entries.first() {
191            if entry.id == first_entry.id {
192                return Ok(stack.base_branch.clone());
193            }
194        }
195
196        // For other entries, find the parent entry's branch
197        let entry_index = stack
198            .entries
199            .iter()
200            .position(|e| e.id == entry.id)
201            .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
202
203        if entry_index == 0 {
204            Ok(stack.base_branch.clone())
205        } else {
206            Ok(stack.entries[entry_index - 1].branch.clone())
207        }
208    }
209
210    /// Create a pull request request object
211    fn create_pr_request(
212        &self,
213        stack: &Stack,
214        entry: &StackEntry,
215        target_branch: &str,
216        title: Option<String>,
217        description: Option<String>,
218        draft: bool,
219    ) -> Result<CreatePullRequestRequest> {
220        let bitbucket_config = self.config.bitbucket.as_ref()
221            .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
222
223        let repository = Repository {
224            id: 0, // This will be filled by the API
225            name: bitbucket_config.repo.clone(),
226            slug: bitbucket_config.repo.clone(),
227            scm_id: "git".to_string(),
228            state: "AVAILABLE".to_string(),
229            status_message: "Available".to_string(),
230            forkable: true,
231            project: Project {
232                id: 0,
233                key: bitbucket_config.project.clone(),
234                name: bitbucket_config.project.clone(),
235                description: None,
236                public: false,
237                project_type: "NORMAL".to_string(),
238            },
239            public: false,
240        };
241
242        let from_ref = PullRequestRef {
243            id: format!("refs/heads/{}", entry.branch),
244            display_id: entry.branch.clone(),
245            latest_commit: entry.commit_hash.clone(),
246            repository: repository.clone(),
247        };
248
249        let to_ref = PullRequestRef {
250            id: format!("refs/heads/{target_branch}"),
251            display_id: target_branch.to_string(),
252            latest_commit: "".to_string(), // This will be filled by the API
253            repository,
254        };
255
256        let mut title =
257            title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
258
259        // Add [DRAFT] prefix for draft PRs
260        if draft && !title.starts_with("[DRAFT]") {
261            title = format!("[DRAFT] {title}");
262        }
263
264        let description = {
265            // Priority order: 1) Template (if configured), 2) User description, 3) Commit message body, 4) None
266            if let Some(template) = &self.config.cascade.pr_description_template {
267                Some(template.clone()) // Always use template if configured
268            } else if let Some(desc) = description {
269                Some(desc) // Use provided description if no template
270            } else if entry.message.lines().count() > 1 {
271                // Fallback to commit message body if no template and no description
272                Some(
273                    entry
274                        .message
275                        .lines()
276                        .skip(1)
277                        .collect::<Vec<_>>()
278                        .join("\n")
279                        .trim()
280                        .to_string(),
281                )
282            } else {
283                None
284            }
285        };
286
287        // Add stack hierarchy footer to description
288        let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
289
290        Ok(CreatePullRequestRequest {
291            title,
292            description: description_with_footer,
293            from_ref,
294            to_ref,
295            draft, // Explicitly set true or false for Bitbucket Server
296        })
297    }
298
299    /// Generate a beautiful stack hierarchy footer for PR descriptions
300    fn add_stack_hierarchy_footer(
301        &self,
302        description: Option<String>,
303        stack: &Stack,
304        current_entry: &StackEntry,
305    ) -> Result<Option<String>> {
306        let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
307
308        let footer = format!(
309            "\n\n---\n\n## 📚 Stack Information\n\n{}\n\n<sub>Generated by [Cascade CLI](https://github.com/your-org/cascade-cli) • Stack: **{}**</sub>",
310            hierarchy,
311            stack.name
312        );
313
314        match description {
315            Some(desc) => Ok(Some(format!("{desc}{footer}"))),
316            None => Ok(Some(footer.trim_start_matches('\n').to_string())),
317        }
318    }
319
320    /// Generate a visual hierarchy showing the stack structure
321    fn generate_stack_hierarchy(
322        &self,
323        stack: &Stack,
324        current_entry: &StackEntry,
325    ) -> Result<String> {
326        let mut hierarchy = String::new();
327
328        // Add stack overview
329        hierarchy.push_str(&format!("**Stack:** `{}`\n", stack.name));
330        hierarchy.push_str(&format!("**Base Branch:** `{}`\n", stack.base_branch));
331        hierarchy.push_str(&format!("**Total Entries:** {}\n\n", stack.entries.len()));
332
333        // Add visual tree
334        hierarchy.push_str("### 🌳 Stack Hierarchy\n\n");
335        hierarchy.push_str("```\n");
336
337        // Base branch
338        hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
339
340        // Stack entries with visual connections
341        for (index, entry) in stack.entries.iter().enumerate() {
342            let is_current = entry.id == current_entry.id;
343            let is_last = index == stack.entries.len() - 1;
344
345            // Visual tree connector
346            let connector = if is_last { "└── " } else { "├── " };
347
348            // Entry indicator
349            let indicator = if is_current {
350                "👈 YOU ARE HERE"
351            } else if entry.pull_request_id.is_some() {
352                "✅ PR Created"
353            } else {
354                "⏳ Not Submitted"
355            };
356
357            // PR link if available
358            let pr_info = if let Some(pr_id) = &entry.pull_request_id {
359                format!(" (PR #{pr_id})")
360            } else {
361                String::new()
362            };
363
364            hierarchy.push_str(&format!(
365                "{}🔗 {}{} {} {}\n",
366                connector,
367                entry.branch,
368                pr_info,
369                if is_current { "◀" } else { " " },
370                indicator
371            ));
372        }
373
374        hierarchy.push_str("```\n\n");
375
376        // Add current entry details
377        hierarchy.push_str("### 📋 Current Entry Details\n\n");
378        hierarchy.push_str(&format!("- **Branch:** `{}`\n", current_entry.branch));
379        hierarchy.push_str(&format!(
380            "- **Commit:** `{}`\n",
381            &current_entry.commit_hash[0..8]
382        ));
383
384        if let Some(pr_id) = &current_entry.pull_request_id {
385            hierarchy.push_str(&format!("- **Pull Request:** #{pr_id}\n"));
386        }
387
388        // Add navigation help
389        hierarchy.push_str("\n### 🧭 Navigation\n\n");
390        hierarchy.push_str("- **Previous Entry:** ");
391
392        if let Some(prev_index) = stack
393            .entries
394            .iter()
395            .position(|e| e.id == current_entry.id)
396            .and_then(|i| i.checked_sub(1))
397        {
398            let prev_entry = &stack.entries[prev_index];
399            if let Some(pr_id) = &prev_entry.pull_request_id {
400                // You would construct the actual Bitbucket URL here
401                hierarchy.push_str(&format!("`{}` (PR #{})", prev_entry.branch, pr_id));
402            } else {
403                hierarchy.push_str(&format!("`{}` (not submitted)", prev_entry.branch));
404            }
405        } else {
406            hierarchy.push_str(&format!("`{}` (base branch)", stack.base_branch));
407        }
408
409        hierarchy.push('\n');
410        hierarchy.push_str("- **Next Entry:** ");
411
412        if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
413            if current_index + 1 < stack.entries.len() {
414                let next_entry = &stack.entries[current_index + 1];
415                if let Some(pr_id) = &next_entry.pull_request_id {
416                    hierarchy.push_str(&format!("`{}` (PR #{})", next_entry.branch, pr_id));
417                } else {
418                    hierarchy.push_str(&format!("`{}` (not submitted)", next_entry.branch));
419                }
420            } else {
421                hierarchy.push_str("*This is the top of the stack*");
422            }
423        } else {
424            hierarchy.push_str("*Unknown*");
425        }
426
427        Ok(hierarchy)
428    }
429
430    /// Update pull requests after a rebase using smart force push strategy
431    /// This preserves all review history by updating existing branches instead of creating new ones
432    pub async fn update_prs_after_rebase(
433        &mut self,
434        stack_id: &Uuid,
435        branch_mapping: &HashMap<String, String>,
436    ) -> Result<Vec<String>> {
437        info!(
438            "Updating pull requests after rebase for stack {} using smart force push",
439            stack_id
440        );
441
442        let stack = self
443            .stack_manager
444            .get_stack(stack_id)
445            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
446            .clone();
447
448        let mut updated_branches = Vec::new();
449
450        for entry in &stack.entries {
451            // Check if this entry has an existing PR and was remapped to a new branch
452            if let (Some(pr_id_str), Some(new_branch)) =
453                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
454            {
455                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
456                    info!(
457                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
458                        pr_id, entry.id, entry.branch, new_branch
459                    );
460
461                    // Get the existing PR to understand its current state
462                    match self.pr_manager.get_pull_request(pr_id).await {
463                        Ok(_existing_pr) => {
464                            // Force push the new branch content to the old branch name
465                            // This preserves the PR while updating its contents
466                            match self
467                                .stack_manager
468                                .git_repo()
469                                .force_push_branch(&entry.branch, new_branch)
470                            {
471                                Ok(_) => {
472                                    info!(
473                                        "✅ Successfully force-pushed {} to preserve PR #{}",
474                                        entry.branch, pr_id
475                                    );
476
477                                    // Add a comment explaining the rebase
478                                    let rebase_comment = format!(
479                                        "🔄 **Automatic rebase completed**\n\n\
480                                        This PR has been automatically rebased to incorporate the latest changes.\n\
481                                        - Original commits: `{}`\n\
482                                        - New base: Latest main branch\n\
483                                        - All review history and comments are preserved\n\n\
484                                        The changes in this PR remain the same - only the base has been updated.",
485                                        &entry.commit_hash[..8]
486                                    );
487
488                                    if let Err(e) =
489                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
490                                    {
491                                        warn!(
492                                            "Failed to add rebase comment to PR #{}: {}",
493                                            pr_id, e
494                                        );
495                                    }
496
497                                    updated_branches.push(format!(
498                                        "PR #{}: {} (preserved)",
499                                        pr_id, entry.branch
500                                    ));
501                                }
502                                Err(e) => {
503                                    error!("Failed to force push {}: {}", entry.branch, e);
504                                    // Fall back to creating a comment about the issue
505                                    let error_comment = format!(
506                                        "⚠️ **Rebase Update Issue**\n\n\
507                                        The automatic rebase completed, but updating this PR failed.\n\
508                                        You may need to manually update this branch.\n\
509                                        Error: {e}"
510                                    );
511
512                                    if let Err(e2) =
513                                        self.pr_manager.add_comment(pr_id, &error_comment).await
514                                    {
515                                        warn!(
516                                            "Failed to add error comment to PR #{}: {}",
517                                            pr_id, e2
518                                        );
519                                    }
520                                }
521                            }
522                        }
523                        Err(e) => {
524                            warn!("Could not retrieve PR #{}: {}", pr_id, e);
525                        }
526                    }
527                }
528            } else if branch_mapping.contains_key(&entry.branch) {
529                // This entry was remapped but doesn't have a PR yet
530                info!(
531                    "Entry {} was remapped but has no PR - no action needed",
532                    entry.id
533                );
534            }
535        }
536
537        if !updated_branches.is_empty() {
538            info!(
539                "Successfully updated {} PRs using smart force push strategy",
540                updated_branches.len()
541            );
542        }
543
544        Ok(updated_branches)
545    }
546
547    /// Check the enhanced status of all pull requests in a stack
548    pub async fn check_enhanced_stack_status(
549        &self,
550        stack_id: &Uuid,
551    ) -> Result<StackSubmissionStatus> {
552        let stack = self
553            .stack_manager
554            .get_stack(stack_id)
555            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
556
557        let mut status = StackSubmissionStatus {
558            stack_name: stack.name.clone(),
559            total_entries: stack.entries.len(),
560            submitted_entries: 0,
561            open_prs: 0,
562            merged_prs: 0,
563            declined_prs: 0,
564            pull_requests: Vec::new(),
565            enhanced_statuses: Vec::new(),
566        };
567
568        for entry in &stack.entries {
569            if let Some(pr_id_str) = &entry.pull_request_id {
570                status.submitted_entries += 1;
571
572                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
573                    // Get enhanced status instead of basic PR
574                    match self.pr_manager.get_pull_request_status(pr_id).await {
575                        Ok(enhanced_status) => {
576                            match enhanced_status.pr.state {
577                                crate::bitbucket::pull_request::PullRequestState::Open => {
578                                    status.open_prs += 1
579                                }
580                                crate::bitbucket::pull_request::PullRequestState::Merged => {
581                                    status.merged_prs += 1
582                                }
583                                crate::bitbucket::pull_request::PullRequestState::Declined => {
584                                    status.declined_prs += 1
585                                }
586                            }
587                            status.pull_requests.push(enhanced_status.pr.clone());
588                            status.enhanced_statuses.push(enhanced_status);
589                        }
590                        Err(e) => {
591                            warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
592                            // Fallback to basic PR info
593                            match self.pr_manager.get_pull_request(pr_id).await {
594                                Ok(pr) => {
595                                    match pr.state {
596                                        crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
597                                        crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
598                                        crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
599                                    }
600                                    status.pull_requests.push(pr);
601                                }
602                                Err(e2) => {
603                                    warn!("Failed to get basic PR #{}: {}", pr_id, e2);
604                                }
605                            }
606                        }
607                    }
608                }
609            }
610        }
611
612        Ok(status)
613    }
614}
615
616/// Status of stack submission with enhanced mergability information
617#[derive(Debug)]
618pub struct StackSubmissionStatus {
619    pub stack_name: String,
620    pub total_entries: usize,
621    pub submitted_entries: usize,
622    pub open_prs: usize,
623    pub merged_prs: usize,
624    pub declined_prs: usize,
625    pub pull_requests: Vec<PullRequest>,
626    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
627}
628
629impl StackSubmissionStatus {
630    /// Calculate completion percentage
631    pub fn completion_percentage(&self) -> f64 {
632        if self.total_entries == 0 {
633            0.0
634        } else {
635            (self.merged_prs as f64 / self.total_entries as f64) * 100.0
636        }
637    }
638
639    /// Check if all entries are submitted
640    pub fn all_submitted(&self) -> bool {
641        self.submitted_entries == self.total_entries
642    }
643
644    /// Check if all PRs are merged
645    pub fn all_merged(&self) -> bool {
646        self.merged_prs == self.total_entries
647    }
648}