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        info!("Submitting stack entry {} as pull request", entry_id);
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        info!("Ensuring branch '{}' is pushed to remote", entry.branch);
69
70        // Push the entry branch - fail fast if this fails
71        git_repo.push(&entry.branch).map_err(|e| {
72            CascadeError::bitbucket(format!(
73                "Failed to push branch '{}': {}. Cannot create PR without remote branch. \
74                Try manually pushing with: git push origin {}",
75                entry.branch, e, entry.branch
76            ))
77        })?;
78
79        info!("✅ Successfully pushed branch '{}' to remote", entry.branch);
80
81        // Mark as pushed in metadata
82        if let Some(commit_meta) = self
83            .stack_manager
84            .get_repository_metadata()
85            .commits
86            .get(&entry.commit_hash)
87        {
88            let mut updated_meta = commit_meta.clone();
89            updated_meta.mark_pushed();
90            // Note: This would require making mark_pushed public and updating the metadata
91            // For now, we'll track this as a future enhancement
92        }
93
94        // Determine target branch (parent entry's branch or stack base)
95        let target_branch = self.get_target_branch(stack, entry)?;
96
97        // Ensure target branch is also pushed to remote (if it's not the base branch)
98        if target_branch != stack.base_branch {
99            info!(
100                "Ensuring target branch '{}' is pushed to remote",
101                target_branch
102            );
103
104            // Push target branch - fail fast if this fails
105            git_repo.push(&target_branch).map_err(|e| {
106                CascadeError::bitbucket(format!(
107                    "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
108                    Try manually pushing with: git push origin {target_branch}"
109                ))
110            })?;
111
112            info!(
113                "✅ Successfully pushed target branch '{}' to remote",
114                target_branch
115            );
116        }
117
118        // Create pull request
119        let pr_request =
120            self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
121
122        let pr = match self.pr_manager.create_pull_request(pr_request).await {
123            Ok(pr) => pr,
124            Err(e) => {
125                return Err(CascadeError::bitbucket(format!(
126                    "Failed to create pull request for branch '{}' -> '{}': {}. \
127                    Ensure both branches exist in the remote repository. \
128                    You can manually push with: git push origin {}",
129                    entry.branch, target_branch, e, entry.branch
130                )));
131            }
132        };
133
134        // Update stack manager with PR information
135        self.stack_manager
136            .submit_entry(stack_id, entry_id, pr.id.to_string())?;
137
138        info!("Created pull request #{} for entry {}", pr.id, entry_id);
139        Ok(pr)
140    }
141
142    /// Check the status of all pull requests in a stack
143    pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
144        let stack = self
145            .stack_manager
146            .get_stack(stack_id)
147            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
148
149        let mut status = StackSubmissionStatus {
150            stack_name: stack.name.clone(),
151            total_entries: stack.entries.len(),
152            submitted_entries: 0,
153            open_prs: 0,
154            merged_prs: 0,
155            declined_prs: 0,
156            pull_requests: Vec::new(),
157            enhanced_statuses: Vec::new(),
158        };
159
160        for entry in &stack.entries {
161            if let Some(pr_id_str) = &entry.pull_request_id {
162                status.submitted_entries += 1;
163
164                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
165                    match self.pr_manager.get_pull_request(pr_id).await {
166                        Ok(pr) => {
167                            match pr.state {
168                                PullRequestState::Open => status.open_prs += 1,
169                                PullRequestState::Merged => status.merged_prs += 1,
170                                PullRequestState::Declined => status.declined_prs += 1,
171                            }
172                            status.pull_requests.push(pr);
173                        }
174                        Err(e) => {
175                            warn!("Failed to get pull request #{}: {}", pr_id, e);
176                        }
177                    }
178                }
179            }
180        }
181
182        Ok(status)
183    }
184
185    /// List all pull requests for the repository
186    pub async fn list_pull_requests(
187        &self,
188        state: Option<PullRequestState>,
189    ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
190        self.pr_manager.list_pull_requests(state).await
191    }
192
193    /// Get the target branch for a stack entry
194    fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
195        // For the first entry (bottom of stack), target is the base branch
196        if let Some(first_entry) = stack.entries.first() {
197            if entry.id == first_entry.id {
198                return Ok(stack.base_branch.clone());
199            }
200        }
201
202        // For other entries, find the parent entry's branch
203        let entry_index = stack
204            .entries
205            .iter()
206            .position(|e| e.id == entry.id)
207            .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
208
209        if entry_index == 0 {
210            Ok(stack.base_branch.clone())
211        } else {
212            Ok(stack.entries[entry_index - 1].branch.clone())
213        }
214    }
215
216    /// Create a pull request request object
217    fn create_pr_request(
218        &self,
219        _stack: &Stack,
220        entry: &StackEntry,
221        target_branch: &str,
222        title: Option<String>,
223        description: Option<String>,
224        draft: bool,
225    ) -> Result<CreatePullRequestRequest> {
226        let bitbucket_config = self.config.bitbucket.as_ref()
227            .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
228
229        let repository = Repository {
230            id: 0, // This will be filled by the API
231            name: bitbucket_config.repo.clone(),
232            slug: bitbucket_config.repo.clone(),
233            scm_id: "git".to_string(),
234            state: "AVAILABLE".to_string(),
235            status_message: "Available".to_string(),
236            forkable: true,
237            project: Project {
238                id: 0,
239                key: bitbucket_config.project.clone(),
240                name: bitbucket_config.project.clone(),
241                description: None,
242                public: false,
243                project_type: "NORMAL".to_string(),
244            },
245            public: false,
246        };
247
248        let from_ref = PullRequestRef {
249            id: format!("refs/heads/{}", entry.branch),
250            display_id: entry.branch.clone(),
251            latest_commit: entry.commit_hash.clone(),
252            repository: repository.clone(),
253        };
254
255        let to_ref = PullRequestRef {
256            id: format!("refs/heads/{target_branch}"),
257            display_id: target_branch.to_string(),
258            latest_commit: "".to_string(), // This will be filled by the API
259            repository,
260        };
261
262        let title = title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
263
264        let description = description.or_else(|| {
265            if entry.message.lines().count() > 1 {
266                Some(entry.message.lines().skip(1).collect::<Vec<_>>().join("\n"))
267            } else {
268                None
269            }
270        });
271
272        Ok(CreatePullRequestRequest {
273            title,
274            description,
275            from_ref,
276            to_ref,
277            draft: if draft { Some(true) } else { None },
278        })
279    }
280
281    /// Update pull requests after a rebase using smart force push strategy
282    /// This preserves all review history by updating existing branches instead of creating new ones
283    pub async fn update_prs_after_rebase(
284        &mut self,
285        stack_id: &Uuid,
286        branch_mapping: &HashMap<String, String>,
287    ) -> Result<Vec<String>> {
288        info!(
289            "Updating pull requests after rebase for stack {} using smart force push",
290            stack_id
291        );
292
293        let stack = self
294            .stack_manager
295            .get_stack(stack_id)
296            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
297            .clone();
298
299        let mut updated_branches = Vec::new();
300
301        for entry in &stack.entries {
302            // Check if this entry has an existing PR and was remapped to a new branch
303            if let (Some(pr_id_str), Some(new_branch)) =
304                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
305            {
306                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
307                    info!(
308                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
309                        pr_id, entry.id, entry.branch, new_branch
310                    );
311
312                    // Get the existing PR to understand its current state
313                    match self.pr_manager.get_pull_request(pr_id).await {
314                        Ok(_existing_pr) => {
315                            // Force push the new branch content to the old branch name
316                            // This preserves the PR while updating its contents
317                            match self
318                                .stack_manager
319                                .git_repo()
320                                .force_push_branch(&entry.branch, new_branch)
321                            {
322                                Ok(_) => {
323                                    info!(
324                                        "✅ Successfully force-pushed {} to preserve PR #{}",
325                                        entry.branch, pr_id
326                                    );
327
328                                    // Add a comment explaining the rebase
329                                    let rebase_comment = format!(
330                                        "🔄 **Automatic rebase completed**\n\n\
331                                        This PR has been automatically rebased to incorporate the latest changes.\n\
332                                        - Original commits: `{}`\n\
333                                        - New base: Latest main branch\n\
334                                        - All review history and comments are preserved\n\n\
335                                        The changes in this PR remain the same - only the base has been updated.",
336                                        &entry.commit_hash[..8]
337                                    );
338
339                                    if let Err(e) =
340                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
341                                    {
342                                        warn!(
343                                            "Failed to add rebase comment to PR #{}: {}",
344                                            pr_id, e
345                                        );
346                                    }
347
348                                    updated_branches.push(format!(
349                                        "PR #{}: {} (preserved)",
350                                        pr_id, entry.branch
351                                    ));
352                                }
353                                Err(e) => {
354                                    error!("Failed to force push {}: {}", entry.branch, e);
355                                    // Fall back to creating a comment about the issue
356                                    let error_comment = format!(
357                                        "⚠️ **Rebase Update Issue**\n\n\
358                                        The automatic rebase completed, but updating this PR failed.\n\
359                                        You may need to manually update this branch.\n\
360                                        Error: {e}"
361                                    );
362
363                                    if let Err(e2) =
364                                        self.pr_manager.add_comment(pr_id, &error_comment).await
365                                    {
366                                        warn!(
367                                            "Failed to add error comment to PR #{}: {}",
368                                            pr_id, e2
369                                        );
370                                    }
371                                }
372                            }
373                        }
374                        Err(e) => {
375                            warn!("Could not retrieve PR #{}: {}", pr_id, e);
376                        }
377                    }
378                }
379            } else if branch_mapping.contains_key(&entry.branch) {
380                // This entry was remapped but doesn't have a PR yet
381                info!(
382                    "Entry {} was remapped but has no PR - no action needed",
383                    entry.id
384                );
385            }
386        }
387
388        if !updated_branches.is_empty() {
389            info!(
390                "Successfully updated {} PRs using smart force push strategy",
391                updated_branches.len()
392            );
393        }
394
395        Ok(updated_branches)
396    }
397
398    /// Check the enhanced status of all pull requests in a stack
399    pub async fn check_enhanced_stack_status(
400        &self,
401        stack_id: &Uuid,
402    ) -> Result<StackSubmissionStatus> {
403        let stack = self
404            .stack_manager
405            .get_stack(stack_id)
406            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
407
408        let mut status = StackSubmissionStatus {
409            stack_name: stack.name.clone(),
410            total_entries: stack.entries.len(),
411            submitted_entries: 0,
412            open_prs: 0,
413            merged_prs: 0,
414            declined_prs: 0,
415            pull_requests: Vec::new(),
416            enhanced_statuses: Vec::new(),
417        };
418
419        for entry in &stack.entries {
420            if let Some(pr_id_str) = &entry.pull_request_id {
421                status.submitted_entries += 1;
422
423                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
424                    // Get enhanced status instead of basic PR
425                    match self.pr_manager.get_pull_request_status(pr_id).await {
426                        Ok(enhanced_status) => {
427                            match enhanced_status.pr.state {
428                                crate::bitbucket::pull_request::PullRequestState::Open => {
429                                    status.open_prs += 1
430                                }
431                                crate::bitbucket::pull_request::PullRequestState::Merged => {
432                                    status.merged_prs += 1
433                                }
434                                crate::bitbucket::pull_request::PullRequestState::Declined => {
435                                    status.declined_prs += 1
436                                }
437                            }
438                            status.pull_requests.push(enhanced_status.pr.clone());
439                            status.enhanced_statuses.push(enhanced_status);
440                        }
441                        Err(e) => {
442                            warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
443                            // Fallback to basic PR info
444                            match self.pr_manager.get_pull_request(pr_id).await {
445                                Ok(pr) => {
446                                    match pr.state {
447                                        crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
448                                        crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
449                                        crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
450                                    }
451                                    status.pull_requests.push(pr);
452                                }
453                                Err(e2) => {
454                                    warn!("Failed to get basic PR #{}: {}", pr_id, e2);
455                                }
456                            }
457                        }
458                    }
459                }
460            }
461        }
462
463        Ok(status)
464    }
465}
466
467/// Status of stack submission with enhanced mergability information
468#[derive(Debug)]
469pub struct StackSubmissionStatus {
470    pub stack_name: String,
471    pub total_entries: usize,
472    pub submitted_entries: usize,
473    pub open_prs: usize,
474    pub merged_prs: usize,
475    pub declined_prs: usize,
476    pub pull_requests: Vec<PullRequest>,
477    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
478}
479
480impl StackSubmissionStatus {
481    /// Calculate completion percentage
482    pub fn completion_percentage(&self) -> f64 {
483        if self.total_entries == 0 {
484            0.0
485        } else {
486            (self.merged_prs as f64 / self.total_entries as f64) * 100.0
487        }
488    }
489
490    /// Check if all entries are submitted
491    pub fn all_submitted(&self) -> bool {
492        self.submitted_entries == self.total_entries
493    }
494
495    /// Check if all PRs are merged
496    pub fn all_merged(&self) -> bool {
497        self.merged_prs == self.total_entries
498    }
499}