1use crate::bitbucket::client::BitbucketClient;
2use crate::bitbucket::pull_request::{
3 CreatePullRequestRequest, Project, PullRequest, PullRequestManager, PullRequestRef,
4 PullRequestState, Repository,
5};
6use crate::cli::output::Output;
7use crate::config::CascadeConfig;
8use crate::errors::{CascadeError, Result};
9use crate::stack::{Stack, StackEntry, StackManager};
10use std::collections::HashMap;
11use tracing::{error, info, warn};
12use uuid::Uuid;
13
14pub struct BitbucketIntegration {
16 stack_manager: StackManager,
17 pr_manager: PullRequestManager,
18 config: CascadeConfig,
19}
20
21impl BitbucketIntegration {
22 pub fn new(stack_manager: StackManager, config: CascadeConfig) -> Result<Self> {
24 let bitbucket_config = config
25 .bitbucket
26 .as_ref()
27 .ok_or_else(|| CascadeError::config("Bitbucket configuration not found"))?;
28
29 let client = BitbucketClient::new(bitbucket_config)?;
30 let pr_manager = PullRequestManager::new(client);
31
32 Ok(Self {
33 stack_manager,
34 pr_manager,
35 config,
36 })
37 }
38
39 pub async fn update_all_pr_descriptions(&self, stack_id: &Uuid) -> Result<Vec<u64>> {
41 let stack = self
42 .stack_manager
43 .get_stack(stack_id)
44 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
45
46 let mut updated_prs = Vec::new();
47
48 for entry in &stack.entries {
50 if let Some(pr_id_str) = &entry.pull_request_id {
51 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
52 match self.pr_manager.get_pull_request(pr_id).await {
54 Ok(pr) => {
55 let updated_description = self.add_stack_hierarchy_footer(
57 pr.description.clone().and_then(|desc| {
58 desc.split("---\n\n## 📚 Stack:")
60 .next()
61 .map(|s| s.trim().to_string())
62 }),
63 stack,
64 entry,
65 )?;
66
67 match self
69 .pr_manager
70 .update_pull_request(
71 pr_id,
72 None, updated_description,
74 pr.version,
75 )
76 .await
77 {
78 Ok(_) => {
79 updated_prs.push(pr_id);
80 }
81 Err(e) => {
82 warn!("Failed to update PR #{}: {}", pr_id, e);
83 }
84 }
85 }
86 Err(e) => {
87 warn!("Failed to get PR #{} for update: {}", pr_id, e);
88 }
89 }
90 }
91 }
92 }
93
94 Ok(updated_prs)
95 }
96
97 pub async fn submit_entry(
99 &mut self,
100 stack_id: &Uuid,
101 entry_id: &Uuid,
102 title: Option<String>,
103 description: Option<String>,
104 draft: bool,
105 ) -> Result<PullRequest> {
106 let stack = self
107 .stack_manager
108 .get_stack(stack_id)
109 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
110
111 let entry = stack
112 .get_entry(entry_id)
113 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
114
115 if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
119 return Err(CascadeError::validation(format!(
120 "Cannot submit entry from corrupted stack '{}':\n{}",
121 stack.name, integrity_error
122 )));
123 }
124
125 let git_repo = self.stack_manager.git_repo();
127
128 if entry.pull_request_id.is_some() {
131 git_repo
133 .force_push_single_branch(&entry.branch)
134 .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
135 } else {
136 git_repo
138 .push(&entry.branch)
139 .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
140 }
141
142 if let Some(commit_meta) = self
146 .stack_manager
147 .get_repository_metadata()
148 .commits
149 .get(&entry.commit_hash)
150 {
151 let mut updated_meta = commit_meta.clone();
152 updated_meta.mark_pushed();
153 }
156
157 let target_branch = self.get_target_branch(stack, entry)?;
159
160 if target_branch != stack.base_branch {
162 git_repo.push(&target_branch).map_err(|e| {
166 CascadeError::bitbucket(format!(
167 "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
168 Try manually pushing with: git push origin {target_branch}"
169 ))
170 })?;
171
172 }
174
175 let pr_request =
177 self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
178
179 let pr = match self.pr_manager.create_pull_request(pr_request).await {
180 Ok(pr) => pr,
181 Err(e) => {
182 return Err(CascadeError::bitbucket(format!(
183 "Failed to create pull request for branch '{}' -> '{}': {}. \
184 Ensure both branches exist in the remote repository. \
185 You can manually push with: git push origin {}",
186 entry.branch, target_branch, e, entry.branch
187 )));
188 }
189 };
190
191 self.stack_manager
193 .submit_entry(stack_id, entry_id, pr.id.to_string())?;
194
195 Ok(pr)
197 }
198
199 pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
201 let stack = self
202 .stack_manager
203 .get_stack(stack_id)
204 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
205
206 let mut status = StackSubmissionStatus {
207 stack_name: stack.name.clone(),
208 total_entries: stack.entries.len(),
209 submitted_entries: 0,
210 open_prs: 0,
211 merged_prs: 0,
212 declined_prs: 0,
213 pull_requests: Vec::new(),
214 enhanced_statuses: Vec::new(),
215 };
216
217 for entry in &stack.entries {
218 if let Some(pr_id_str) = &entry.pull_request_id {
219 status.submitted_entries += 1;
220
221 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
222 match self.pr_manager.get_pull_request(pr_id).await {
223 Ok(pr) => {
224 match pr.state {
225 PullRequestState::Open => status.open_prs += 1,
226 PullRequestState::Merged => status.merged_prs += 1,
227 PullRequestState::Declined => status.declined_prs += 1,
228 }
229 status.pull_requests.push(pr);
230 }
231 Err(e) => {
232 warn!("Failed to get pull request #{}: {}", pr_id, e);
233 }
234 }
235 }
236 }
237 }
238
239 Ok(status)
240 }
241
242 pub async fn list_pull_requests(
244 &self,
245 state: Option<PullRequestState>,
246 ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
247 self.pr_manager.list_pull_requests(state).await
248 }
249
250 fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
252 if let Some(first_entry) = stack.entries.first() {
254 if entry.id == first_entry.id {
255 return Ok(stack.base_branch.clone());
256 }
257 }
258
259 let entry_index = stack
261 .entries
262 .iter()
263 .position(|e| e.id == entry.id)
264 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
265
266 if entry_index == 0 {
267 Ok(stack.base_branch.clone())
268 } else {
269 Ok(stack.entries[entry_index - 1].branch.clone())
270 }
271 }
272
273 fn create_pr_request(
275 &self,
276 stack: &Stack,
277 entry: &StackEntry,
278 target_branch: &str,
279 title: Option<String>,
280 description: Option<String>,
281 draft: bool,
282 ) -> Result<CreatePullRequestRequest> {
283 let bitbucket_config = self.config.bitbucket.as_ref()
284 .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
285
286 let repository = Repository {
287 id: 0, name: bitbucket_config.repo.clone(),
289 slug: bitbucket_config.repo.clone(),
290 scm_id: "git".to_string(),
291 state: "AVAILABLE".to_string(),
292 status_message: Some("Available".to_string()),
293 forkable: true,
294 project: Project {
295 id: 0,
296 key: bitbucket_config.project.clone(),
297 name: bitbucket_config.project.clone(),
298 description: None,
299 public: false,
300 project_type: "NORMAL".to_string(),
301 },
302 public: false,
303 };
304
305 let from_ref = PullRequestRef {
306 id: format!("refs/heads/{}", entry.branch),
307 display_id: entry.branch.clone(),
308 latest_commit: entry.commit_hash.clone(),
309 repository: repository.clone(),
310 };
311
312 let to_ref = PullRequestRef {
313 id: format!("refs/heads/{target_branch}"),
314 display_id: target_branch.to_string(),
315 latest_commit: "".to_string(), repository,
317 };
318
319 let mut title =
320 title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
321
322 if draft && !title.starts_with("[DRAFT]") {
324 title = format!("[DRAFT] {title}");
325 }
326
327 let description = {
328 if let Some(template) = &self.config.cascade.pr_description_template {
330 Some(template.clone()) } else if let Some(desc) = description {
332 Some(desc) } else if entry.message.lines().count() > 1 {
334 Some(
336 entry
337 .message
338 .lines()
339 .skip(1)
340 .collect::<Vec<_>>()
341 .join("\n")
342 .trim()
343 .to_string(),
344 )
345 } else {
346 None
347 }
348 };
349
350 let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
352
353 Ok(CreatePullRequestRequest {
354 title,
355 description: description_with_footer,
356 from_ref,
357 to_ref,
358 draft, })
360 }
361
362 fn add_stack_hierarchy_footer(
364 &self,
365 description: Option<String>,
366 stack: &Stack,
367 current_entry: &StackEntry,
368 ) -> Result<Option<String>> {
369 let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
370
371 let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
372
373 match description {
374 Some(desc) => Ok(Some(format!("{desc}{footer}"))),
375 None => Ok(Some(footer.trim_start_matches('\n').to_string())),
376 }
377 }
378
379 fn generate_stack_hierarchy(
381 &self,
382 stack: &Stack,
383 current_entry: &StackEntry,
384 ) -> Result<String> {
385 let mut hierarchy = String::new();
386
387 hierarchy.push_str("### Stack Hierarchy\n\n");
389 hierarchy.push_str("```\n");
390
391 hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
393
394 for (index, entry) in stack.entries.iter().enumerate() {
396 let is_current = entry.id == current_entry.id;
397 let is_last = index == stack.entries.len() - 1;
398
399 let connector = if is_last { "└── " } else { "├── " };
401
402 let indicator = if is_current {
404 "← current"
405 } else if entry.pull_request_id.is_some() {
406 ""
407 } else {
408 "(pending)"
409 };
410
411 let pr_info = if let Some(pr_id) = &entry.pull_request_id {
413 format!(" (PR #{pr_id})")
414 } else {
415 String::new()
416 };
417
418 hierarchy.push_str(&format!(
419 "{}{}{} {}\n",
420 connector, entry.branch, pr_info, indicator
421 ));
422 }
423
424 hierarchy.push_str("```\n\n");
425
426 if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
428 let position = current_index + 1;
429 let total = stack.entries.len();
430 hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
431 }
432
433 Ok(hierarchy)
434 }
435
436 pub async fn update_prs_after_rebase(
439 &mut self,
440 stack_id: &Uuid,
441 branch_mapping: &HashMap<String, String>,
442 ) -> Result<Vec<String>> {
443 info!(
444 "Updating pull requests after rebase for stack {} using smart force push",
445 stack_id
446 );
447
448 let stack = self
449 .stack_manager
450 .get_stack(stack_id)
451 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
452 .clone();
453
454 let mut updated_branches = Vec::new();
455
456 for entry in &stack.entries {
457 if let (Some(pr_id_str), Some(new_branch)) =
459 (&entry.pull_request_id, branch_mapping.get(&entry.branch))
460 {
461 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
462 info!(
463 "Found existing PR #{} for entry {}, updating branch {} -> {}",
464 pr_id, entry.id, entry.branch, new_branch
465 );
466
467 match self.pr_manager.get_pull_request(pr_id).await {
469 Ok(_existing_pr) => {
470 if let Err(validation_error) =
472 self.validate_cumulative_changes(&entry.branch, new_branch)
473 {
474 Output::error(format!(
475 "❌ Validation failed for PR #{pr_id}: {validation_error}"
476 ));
477 Output::warning("Skipping force push to prevent data loss");
478 continue;
479 }
480
481 match self
484 .stack_manager
485 .git_repo()
486 .force_push_branch(&entry.branch, new_branch)
487 {
488 Ok(_) => {
489 info!(
490 "✅ Successfully force-pushed {} to preserve PR #{}",
491 entry.branch, pr_id
492 );
493
494 let rebase_comment = format!(
496 "🔄 **Automatic rebase completed**\n\n\
497 This PR has been automatically rebased to incorporate the latest changes.\n\
498 - Original commits: `{}`\n\
499 - New base: Latest main branch\n\
500 - All review history and comments are preserved\n\n\
501 The changes in this PR remain the same - only the base has been updated.",
502 &entry.commit_hash[..8]
503 );
504
505 if let Err(e) =
506 self.pr_manager.add_comment(pr_id, &rebase_comment).await
507 {
508 warn!(
509 "Failed to add rebase comment to PR #{}: {}",
510 pr_id, e
511 );
512 }
513
514 updated_branches.push(format!(
515 "PR #{}: {} (preserved)",
516 pr_id, entry.branch
517 ));
518 }
519 Err(e) => {
520 error!("Failed to force push {}: {}", entry.branch, e);
521 let error_comment = format!(
523 "⚠️ **Rebase Update Issue**\n\n\
524 The automatic rebase completed, but updating this PR failed.\n\
525 You may need to manually update this branch.\n\
526 Error: {e}"
527 );
528
529 if let Err(e2) =
530 self.pr_manager.add_comment(pr_id, &error_comment).await
531 {
532 warn!(
533 "Failed to add error comment to PR #{}: {}",
534 pr_id, e2
535 );
536 }
537 }
538 }
539 }
540 Err(e) => {
541 warn!("Could not retrieve PR #{}: {}", pr_id, e);
542 }
543 }
544 }
545 } else if branch_mapping.contains_key(&entry.branch) {
546 info!(
548 "Entry {} was remapped but has no PR - no action needed",
549 entry.id
550 );
551 }
552 }
553
554 if !updated_branches.is_empty() {
555 info!(
556 "Successfully updated {} PRs using smart force push strategy",
557 updated_branches.len()
558 );
559 }
560
561 Ok(updated_branches)
562 }
563
564 fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
567 let git_repo = self.stack_manager.git_repo();
568
569 let stack = self
571 .stack_manager
572 .get_all_stacks()
573 .into_iter()
574 .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
575 .ok_or_else(|| {
576 CascadeError::config(format!(
577 "No stack found containing branch '{original_branch}'"
578 ))
579 })?;
580
581 let base_branch = &stack.base_branch;
582
583 match git_repo.get_commits_between(base_branch, new_branch) {
585 Ok(new_commits) => {
586 if new_commits.is_empty() {
587 return Err(CascadeError::validation(format!(
588 "New branch '{new_branch}' contains no commits from base '{base_branch}' - this would result in data loss"
589 )));
590 }
591
592 match git_repo.get_commits_between(base_branch, original_branch) {
594 Ok(original_commits) => {
595 if new_commits.len() < original_commits.len() {
596 return Err(CascadeError::validation(format!(
597 "New branch '{}' has {} commits but original branch '{}' had {} commits - potential data loss",
598 new_branch, new_commits.len(), original_branch, original_commits.len()
599 )));
600 }
601
602 tracing::debug!(
603 "Validation passed: new branch '{}' has {} commits, original had {} commits",
604 new_branch, new_commits.len(), original_commits.len()
605 );
606 Ok(())
607 }
608 Err(e) => {
609 tracing::warn!("Could not validate original branch commits: {}", e);
610 Ok(())
612 }
613 }
614 }
615 Err(e) => Err(CascadeError::validation(format!(
616 "Could not get commits for validation: {e}"
617 ))),
618 }
619 }
620
621 pub async fn check_enhanced_stack_status(
623 &self,
624 stack_id: &Uuid,
625 ) -> Result<StackSubmissionStatus> {
626 let stack = self
627 .stack_manager
628 .get_stack(stack_id)
629 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
630
631 let mut status = StackSubmissionStatus {
632 stack_name: stack.name.clone(),
633 total_entries: stack.entries.len(),
634 submitted_entries: 0,
635 open_prs: 0,
636 merged_prs: 0,
637 declined_prs: 0,
638 pull_requests: Vec::new(),
639 enhanced_statuses: Vec::new(),
640 };
641
642 for entry in &stack.entries {
643 if let Some(pr_id_str) = &entry.pull_request_id {
644 status.submitted_entries += 1;
645
646 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
647 match self.pr_manager.get_pull_request_status(pr_id).await {
649 Ok(enhanced_status) => {
650 match enhanced_status.pr.state {
651 crate::bitbucket::pull_request::PullRequestState::Open => {
652 status.open_prs += 1
653 }
654 crate::bitbucket::pull_request::PullRequestState::Merged => {
655 status.merged_prs += 1
656 }
657 crate::bitbucket::pull_request::PullRequestState::Declined => {
658 status.declined_prs += 1
659 }
660 }
661 status.pull_requests.push(enhanced_status.pr.clone());
662 status.enhanced_statuses.push(enhanced_status);
663 }
664 Err(e) => {
665 warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
666 match self.pr_manager.get_pull_request(pr_id).await {
668 Ok(pr) => {
669 match pr.state {
670 crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
671 crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
672 crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
673 }
674 status.pull_requests.push(pr);
675 }
676 Err(e2) => {
677 warn!("Failed to get basic PR #{}: {}", pr_id, e2);
678 }
679 }
680 }
681 }
682 }
683 }
684 }
685
686 Ok(status)
687 }
688}
689
690#[derive(Debug)]
692pub struct StackSubmissionStatus {
693 pub stack_name: String,
694 pub total_entries: usize,
695 pub submitted_entries: usize,
696 pub open_prs: usize,
697 pub merged_prs: usize,
698 pub declined_prs: usize,
699 pub pull_requests: Vec<PullRequest>,
700 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
701}
702
703impl StackSubmissionStatus {
704 pub fn completion_percentage(&self) -> f64 {
706 if self.total_entries == 0 {
707 0.0
708 } else {
709 (self.merged_prs as f64 / self.total_entries as f64) * 100.0
710 }
711 }
712
713 pub fn all_submitted(&self) -> bool {
715 self.submitted_entries == self.total_entries
716 }
717
718 pub fn all_merged(&self) -> bool {
720 self.merged_prs == self.total_entries
721 }
722}