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