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().unwrap();
159
160        let repository = Repository {
161            id: 0, // This will be filled by the API
162            name: bitbucket_config.repo.clone(),
163            slug: bitbucket_config.repo.clone(),
164            scm_id: "git".to_string(),
165            state: "AVAILABLE".to_string(),
166            status_message: "Available".to_string(),
167            forkable: true,
168            project: Project {
169                id: 0,
170                key: bitbucket_config.project.clone(),
171                name: bitbucket_config.project.clone(),
172                description: None,
173                public: false,
174                project_type: "NORMAL".to_string(),
175            },
176            public: false,
177        };
178
179        let from_ref = PullRequestRef {
180            id: format!("refs/heads/{}", entry.branch),
181            display_id: entry.branch.clone(),
182            latest_commit: entry.commit_hash.clone(),
183            repository: repository.clone(),
184        };
185
186        let to_ref = PullRequestRef {
187            id: format!("refs/heads/{target_branch}"),
188            display_id: target_branch.to_string(),
189            latest_commit: "".to_string(), // This will be filled by the API
190            repository,
191        };
192
193        let title = title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
194
195        let description = description.or_else(|| {
196            if entry.message.lines().count() > 1 {
197                Some(entry.message.lines().skip(1).collect::<Vec<_>>().join("\n"))
198            } else {
199                None
200            }
201        });
202
203        Ok(CreatePullRequestRequest {
204            title,
205            description,
206            from_ref,
207            to_ref,
208            draft: if draft { Some(true) } else { None },
209        })
210    }
211
212    /// Update pull requests after a rebase using smart force push strategy
213    /// This preserves all review history by updating existing branches instead of creating new ones
214    pub async fn update_prs_after_rebase(
215        &mut self,
216        stack_id: &Uuid,
217        branch_mapping: &HashMap<String, String>,
218    ) -> Result<Vec<String>> {
219        info!(
220            "Updating pull requests after rebase for stack {} using smart force push",
221            stack_id
222        );
223
224        let stack = self
225            .stack_manager
226            .get_stack(stack_id)
227            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
228            .clone();
229
230        let mut updated_branches = Vec::new();
231
232        for entry in &stack.entries {
233            // Check if this entry has an existing PR and was remapped to a new branch
234            if let (Some(pr_id_str), Some(new_branch)) =
235                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
236            {
237                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
238                    info!(
239                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
240                        pr_id, entry.id, entry.branch, new_branch
241                    );
242
243                    // Get the existing PR to understand its current state
244                    match self.pr_manager.get_pull_request(pr_id).await {
245                        Ok(_existing_pr) => {
246                            // Force push the new branch content to the old branch name
247                            // This preserves the PR while updating its contents
248                            match self
249                                .stack_manager
250                                .git_repo()
251                                .force_push_branch(&entry.branch, new_branch)
252                            {
253                                Ok(_) => {
254                                    info!(
255                                        "✅ Successfully force-pushed {} to preserve PR #{}",
256                                        entry.branch, pr_id
257                                    );
258
259                                    // Add a comment explaining the rebase
260                                    let rebase_comment = format!(
261                                        "🔄 **Automatic rebase completed**\n\n\
262                                        This PR has been automatically rebased to incorporate the latest changes.\n\
263                                        - Original commits: `{}`\n\
264                                        - New base: Latest main branch\n\
265                                        - All review history and comments are preserved\n\n\
266                                        The changes in this PR remain the same - only the base has been updated.",
267                                        &entry.commit_hash[..8]
268                                    );
269
270                                    if let Err(e) =
271                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
272                                    {
273                                        warn!(
274                                            "Failed to add rebase comment to PR #{}: {}",
275                                            pr_id, e
276                                        );
277                                    }
278
279                                    updated_branches.push(format!(
280                                        "PR #{}: {} (preserved)",
281                                        pr_id, entry.branch
282                                    ));
283                                }
284                                Err(e) => {
285                                    error!("Failed to force push {}: {}", entry.branch, e);
286                                    // Fall back to creating a comment about the issue
287                                    let error_comment = format!(
288                                        "⚠️ **Rebase Update Issue**\n\n\
289                                        The automatic rebase completed, but updating this PR failed.\n\
290                                        You may need to manually update this branch.\n\
291                                        Error: {e}"
292                                    );
293
294                                    if let Err(e2) =
295                                        self.pr_manager.add_comment(pr_id, &error_comment).await
296                                    {
297                                        warn!(
298                                            "Failed to add error comment to PR #{}: {}",
299                                            pr_id, e2
300                                        );
301                                    }
302                                }
303                            }
304                        }
305                        Err(e) => {
306                            warn!("Could not retrieve PR #{}: {}", pr_id, e);
307                        }
308                    }
309                }
310            } else if branch_mapping.contains_key(&entry.branch) {
311                // This entry was remapped but doesn't have a PR yet
312                info!(
313                    "Entry {} was remapped but has no PR - no action needed",
314                    entry.id
315                );
316            }
317        }
318
319        if !updated_branches.is_empty() {
320            info!(
321                "Successfully updated {} PRs using smart force push strategy",
322                updated_branches.len()
323            );
324        }
325
326        Ok(updated_branches)
327    }
328
329    /// Check the enhanced status of all pull requests in a stack
330    pub async fn check_enhanced_stack_status(
331        &self,
332        stack_id: &Uuid,
333    ) -> Result<StackSubmissionStatus> {
334        let stack = self
335            .stack_manager
336            .get_stack(stack_id)
337            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
338
339        let mut status = StackSubmissionStatus {
340            stack_name: stack.name.clone(),
341            total_entries: stack.entries.len(),
342            submitted_entries: 0,
343            open_prs: 0,
344            merged_prs: 0,
345            declined_prs: 0,
346            pull_requests: Vec::new(),
347            enhanced_statuses: Vec::new(),
348        };
349
350        for entry in &stack.entries {
351            if let Some(pr_id_str) = &entry.pull_request_id {
352                status.submitted_entries += 1;
353
354                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
355                    // Get enhanced status instead of basic PR
356                    match self.pr_manager.get_pull_request_status(pr_id).await {
357                        Ok(enhanced_status) => {
358                            match enhanced_status.pr.state {
359                                crate::bitbucket::pull_request::PullRequestState::Open => {
360                                    status.open_prs += 1
361                                }
362                                crate::bitbucket::pull_request::PullRequestState::Merged => {
363                                    status.merged_prs += 1
364                                }
365                                crate::bitbucket::pull_request::PullRequestState::Declined => {
366                                    status.declined_prs += 1
367                                }
368                            }
369                            status.pull_requests.push(enhanced_status.pr.clone());
370                            status.enhanced_statuses.push(enhanced_status);
371                        }
372                        Err(e) => {
373                            warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
374                            // Fallback to basic PR info
375                            match self.pr_manager.get_pull_request(pr_id).await {
376                                Ok(pr) => {
377                                    match pr.state {
378                                        crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
379                                        crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
380                                        crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
381                                    }
382                                    status.pull_requests.push(pr);
383                                }
384                                Err(e2) => {
385                                    warn!("Failed to get basic PR #{}: {}", pr_id, e2);
386                                }
387                            }
388                        }
389                    }
390                }
391            }
392        }
393
394        Ok(status)
395    }
396}
397
398/// Status of stack submission with enhanced mergability information
399#[derive(Debug)]
400pub struct StackSubmissionStatus {
401    pub stack_name: String,
402    pub total_entries: usize,
403    pub submitted_entries: usize,
404    pub open_prs: usize,
405    pub merged_prs: usize,
406    pub declined_prs: usize,
407    pub pull_requests: Vec<PullRequest>,
408    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
409}
410
411impl StackSubmissionStatus {
412    /// Calculate completion percentage
413    pub fn completion_percentage(&self) -> f64 {
414        if self.total_entries == 0 {
415            0.0
416        } else {
417            (self.merged_prs as f64 / self.total_entries as f64) * 100.0
418        }
419    }
420
421    /// Check if all entries are submitted
422    pub fn all_submitted(&self) -> bool {
423        self.submitted_entries == self.total_entries
424    }
425
426    /// Check if all PRs are merged
427    pub fn all_merged(&self) -> bool {
428        self.merged_prs == self.total_entries
429    }
430}