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