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