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::{debug, error};
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 .cloned()
45 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
46
47 let mut updated_prs = Vec::new();
48
49 for entry in &stack.entries {
51 if let Some(pr_id_str) = &entry.pull_request_id {
52 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
53 match self.pr_manager.get_pull_request(pr_id).await {
55 Ok(pr) => {
56 let updated_description = self.add_stack_hierarchy_footer(
58 pr.description.clone().and_then(|desc| {
59 desc.split("---\n\n## 📚 Stack:")
61 .next()
62 .map(|s| s.trim().to_string())
63 }),
64 &stack,
65 entry,
66 )?;
67
68 match self
70 .pr_manager
71 .update_pull_request(
72 pr_id,
73 None, updated_description,
75 pr.version,
76 )
77 .await
78 {
79 Ok(_) => {
80 updated_prs.push(pr_id);
81 }
82 Err(e) => {
83 let error_msg = e.to_string();
86 if !error_msg.contains("409")
87 && !error_msg.contains("out-of-date")
88 {
89 debug!(
90 "Failed to update PR #{} description: {}",
91 pr_id,
92 error_msg.lines().next().unwrap_or("Unknown error")
93 );
94 }
95 }
96 }
97 }
98 Err(e) => {
99 tracing::debug!("Failed to get PR #{} for update: {}", pr_id, e);
100 }
101 }
102 }
103 }
104 }
105
106 Ok(updated_prs)
107 }
108
109 pub async fn submit_entry(
111 &mut self,
112 stack_id: &Uuid,
113 entry_id: &Uuid,
114 title: Option<String>,
115 description: Option<String>,
116 draft: bool,
117 ) -> Result<PullRequest> {
118 let stack = {
119 let stack_ref = self
120 .stack_manager
121 .get_stack(stack_id)
122 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
123 stack_ref.clone()
124 };
125
126 let entry = stack
127 .get_entry(entry_id)
128 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
129
130 if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
134 return Err(CascadeError::validation(format!(
135 "Cannot submit entry from corrupted stack '{}':\n{}",
136 stack.name, integrity_error
137 )));
138 }
139
140 let git_repo = self.stack_manager.git_repo();
142
143 let branch_has_remote = git_repo.get_upstream_branch(&entry.branch)?.is_some();
147 let needs_force_push = entry.pull_request_id.is_some() || branch_has_remote;
148
149 if needs_force_push {
150 std::env::set_var("FORCE_PUSH_NO_CONFIRM", "1");
153 let result = git_repo.force_push_single_branch(&entry.branch);
154 std::env::remove_var("FORCE_PUSH_NO_CONFIRM");
155 result.map_err(|e| CascadeError::bitbucket(e.to_string()))?;
156 } else {
157 git_repo
159 .push(&entry.branch)
160 .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
161 }
162
163 if let Some(commit_meta) = self
167 .stack_manager
168 .get_repository_metadata()
169 .commits
170 .get(&entry.commit_hash)
171 {
172 let mut updated_meta = commit_meta.clone();
173 updated_meta.mark_pushed();
174 }
177
178 let target_branch = self.get_target_branch(&stack, entry)?;
180
181 if target_branch != stack.base_branch {
183 git_repo.push(&target_branch).map_err(|e| {
187 CascadeError::bitbucket(format!(
188 "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
189 Try manually pushing with: git push origin {target_branch}"
190 ))
191 })?;
192
193 }
195
196 let pr_request =
198 self.create_pr_request(&stack, entry, &target_branch, title, description, draft)?;
199
200 let pr = match self.pr_manager.create_pull_request(pr_request).await {
201 Ok(pr) => pr,
202 Err(e) => {
203 return Err(CascadeError::bitbucket(format!(
204 "Failed to create pull request for branch '{}' -> '{}': {}. \
205 Ensure both branches exist in the remote repository. \
206 You can manually push with: git push origin {}",
207 entry.branch, target_branch, e, entry.branch
208 )));
209 }
210 };
211
212 self.stack_manager
214 .submit_entry(stack_id, entry_id, pr.id.to_string())?;
215
216 Ok(pr)
218 }
219
220 pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
222 let stack = self
223 .stack_manager
224 .get_stack(stack_id)
225 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
226
227 let mut status = StackSubmissionStatus {
228 stack_name: stack.name.clone(),
229 total_entries: stack.entries.len(),
230 submitted_entries: 0,
231 open_prs: 0,
232 merged_prs: 0,
233 declined_prs: 0,
234 pull_requests: Vec::new(),
235 enhanced_statuses: Vec::new(),
236 };
237
238 for entry in &stack.entries {
239 if let Some(pr_id_str) = &entry.pull_request_id {
240 status.submitted_entries += 1;
241
242 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
243 match self.pr_manager.get_pull_request(pr_id).await {
244 Ok(pr) => {
245 match pr.state {
246 PullRequestState::Open => status.open_prs += 1,
247 PullRequestState::Merged => status.merged_prs += 1,
248 PullRequestState::Declined => status.declined_prs += 1,
249 }
250 status.pull_requests.push(pr);
251 }
252 Err(e) => {
253 tracing::debug!("Failed to get pull request #{}: {}", pr_id, e);
254 }
255 }
256 }
257 }
258 }
259
260 Ok(status)
261 }
262
263 pub async fn list_pull_requests(
265 &self,
266 state: Option<PullRequestState>,
267 ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
268 self.pr_manager.list_pull_requests(state).await
269 }
270
271 fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
273 if let Some(first_entry) = stack.entries.first() {
275 if entry.id == first_entry.id {
276 return Ok(stack.base_branch.clone());
277 }
278 }
279
280 let entry_index = stack
282 .entries
283 .iter()
284 .position(|e| e.id == entry.id)
285 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
286
287 if entry_index == 0 {
288 Ok(stack.base_branch.clone())
289 } else {
290 Ok(stack.entries[entry_index - 1].branch.clone())
291 }
292 }
293
294 fn create_pr_request(
296 &self,
297 stack: &Stack,
298 entry: &StackEntry,
299 target_branch: &str,
300 title: Option<String>,
301 description: Option<String>,
302 draft: bool,
303 ) -> Result<CreatePullRequestRequest> {
304 let bitbucket_config = self.config.bitbucket.as_ref()
305 .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
306
307 let repository = Repository {
308 id: 0, name: bitbucket_config.repo.clone(),
310 slug: bitbucket_config.repo.clone(),
311 scm_id: "git".to_string(),
312 state: "AVAILABLE".to_string(),
313 status_message: Some("Available".to_string()),
314 forkable: true,
315 project: Project {
316 id: 0,
317 key: bitbucket_config.project.clone(),
318 name: bitbucket_config.project.clone(),
319 description: None,
320 public: false,
321 project_type: "NORMAL".to_string(),
322 },
323 public: false,
324 };
325
326 let from_ref = PullRequestRef {
327 id: format!("refs/heads/{}", entry.branch),
328 display_id: entry.branch.clone(),
329 latest_commit: entry.commit_hash.clone(),
330 repository: repository.clone(),
331 };
332
333 let to_ref = PullRequestRef {
334 id: format!("refs/heads/{target_branch}"),
335 display_id: target_branch.to_string(),
336 latest_commit: "".to_string(), repository,
338 };
339
340 let mut title =
341 title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
342
343 if draft && !title.starts_with("[DRAFT]") {
345 title = format!("[DRAFT] {title}");
346 }
347
348 let description = {
349 if let Some(template) = &self.config.cascade.pr_description_template {
351 Some(template.clone()) } else if let Some(desc) = description {
353 Some(desc) } else if entry.message.lines().count() > 1 {
355 Some(
357 entry
358 .message
359 .lines()
360 .skip(1)
361 .collect::<Vec<_>>()
362 .join("\n")
363 .trim()
364 .to_string(),
365 )
366 } else {
367 None
368 }
369 };
370
371 let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
373
374 Ok(CreatePullRequestRequest {
375 title,
376 description: description_with_footer,
377 from_ref,
378 to_ref,
379 draft, })
381 }
382
383 fn add_stack_hierarchy_footer(
385 &self,
386 description: Option<String>,
387 stack: &Stack,
388 current_entry: &StackEntry,
389 ) -> Result<Option<String>> {
390 let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
391
392 let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
393
394 match description {
395 Some(desc) => Ok(Some(format!("{desc}{footer}"))),
396 None => Ok(Some(footer.trim_start_matches('\n').to_string())),
397 }
398 }
399
400 fn generate_stack_hierarchy(
402 &self,
403 stack: &Stack,
404 current_entry: &StackEntry,
405 ) -> Result<String> {
406 let mut hierarchy = String::new();
407
408 hierarchy.push_str("### Stack Hierarchy\n\n");
410 hierarchy.push_str("```\n");
411
412 hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
414
415 for (index, entry) in stack.entries.iter().enumerate() {
417 let is_current = entry.id == current_entry.id;
418 let is_last = index == stack.entries.len() - 1;
419
420 let connector = if is_last { "└── " } else { "├── " };
422
423 let indicator = if is_current {
425 "← current"
426 } else if entry.pull_request_id.is_some() {
427 ""
428 } else {
429 "(pending)"
430 };
431
432 let pr_info = if let Some(pr_id) = &entry.pull_request_id {
434 format!(" (PR #{pr_id})")
435 } else {
436 String::new()
437 };
438
439 hierarchy.push_str(&format!(
440 "{}{}{} {}\n",
441 connector, entry.branch, pr_info, indicator
442 ));
443 }
444
445 hierarchy.push_str("```\n\n");
446
447 if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
449 let position = current_index + 1;
450 let total = stack.entries.len();
451 hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
452 }
453
454 Ok(hierarchy)
455 }
456
457 pub async fn update_prs_after_rebase(
460 &mut self,
461 stack_id: &Uuid,
462 branch_mapping: &HashMap<String, String>,
463 ) -> Result<Vec<String>> {
464 debug!(
465 "Updating pull requests after rebase for stack {} using smart force push",
466 stack_id
467 );
468
469 let stack = self
470 .stack_manager
471 .get_stack(stack_id)
472 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
473 .clone();
474
475 let mut updated_branches = Vec::new();
476
477 for entry in &stack.entries {
478 if let (Some(pr_id_str), Some(new_branch)) =
480 (&entry.pull_request_id, branch_mapping.get(&entry.branch))
481 {
482 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
483 debug!(
484 "Found existing PR #{} for entry {}, updating branch {} -> {}",
485 pr_id, entry.id, entry.branch, new_branch
486 );
487
488 match self.pr_manager.get_pull_request_status(pr_id).await {
490 Ok(pr_status) => {
491 match pr_status.pr.state {
492 crate::bitbucket::pull_request::PullRequestState::Merged => {
493 if let Err(e) = self
494 .stack_manager
495 .set_entry_merged(&stack.id, &entry.id, true)
496 {
497 tracing::warn!(
498 "Failed to persist merged state for entry {}: {}",
499 entry.id,
500 e
501 );
502 }
503 debug!(
504 "Skipping PR #{} update because it is already merged",
505 pr_id
506 );
507 continue;
508 }
509 crate::bitbucket::pull_request::PullRequestState::Declined => {
510 if let Err(e) = self
511 .stack_manager
512 .set_entry_merged(&stack.id, &entry.id, false)
513 {
514 tracing::warn!(
515 "Failed to persist merged state for entry {}: {}",
516 entry.id,
517 e
518 );
519 }
520 debug!("Skipping PR #{} update because it is declined", pr_id);
521 continue;
522 }
523 crate::bitbucket::pull_request::PullRequestState::Open => {
524 if let Err(e) = self
525 .stack_manager
526 .set_entry_merged(&stack.id, &entry.id, false)
527 {
528 tracing::warn!(
529 "Failed to persist merged state for entry {}: {}",
530 entry.id,
531 e
532 );
533 }
534 }
535 }
536
537 if let Ok(local_head) =
539 self.stack_manager.git_repo().get_branch_head(&entry.branch)
540 {
541 if local_head != entry.commit_hash {
542 Output::error(format!(
543 "Skipping PR #{}: branch '{}' HEAD ({}) does not match stack metadata ({})",
544 pr_id,
545 entry.branch,
546 &local_head[..8],
547 &entry.commit_hash[..8]
548 ));
549 Output::warning(
550 "Run 'ca stack check --force' to reconcile or investigate manually before retrying."
551 );
552 continue;
553 }
554 }
555
556 if let Err(validation_error) =
558 self.validate_cumulative_changes(&entry.branch, new_branch)
559 {
560 Output::error(format!(
561 "❌ Validation failed for PR #{pr_id}: {validation_error}"
562 ));
563 Output::warning("Skipping force push to prevent data loss");
564 continue;
565 }
566
567 match self
570 .stack_manager
571 .git_repo()
572 .force_push_branch(&entry.branch, new_branch)
573 {
574 Ok(_) => {
575 debug!(
576 "Successfully force-pushed {} to preserve PR #{}",
577 entry.branch, pr_id
578 );
579
580 let rebase_comment = format!(
582 "🔄 **Automatic rebase completed**\n\n\
583 This PR has been automatically rebased onto the latest `{}`.\n\
584 - Updated commit: `{}`\n\
585 - All review history and comments are preserved",
586 stack.base_branch,
587 &entry.commit_hash[..8]
588 );
589
590 if let Err(e) =
591 self.pr_manager.add_comment(pr_id, &rebase_comment).await
592 {
593 tracing::debug!(
594 "Failed to add rebase comment to PR #{}: {}",
595 pr_id,
596 e
597 );
598 }
599
600 updated_branches.push(format!(
601 "PR #{}: {} (preserved)",
602 pr_id, entry.branch
603 ));
604 }
605 Err(e) => {
606 error!("Failed to force push {}: {}", entry.branch, e);
607 let error_comment = format!(
609 "⚠️ **Rebase Update Issue**\n\n\
610 The automatic rebase completed, but updating this PR failed.\n\
611 You may need to manually update this branch.\n\
612 Error: {e}"
613 );
614
615 if let Err(e2) =
616 self.pr_manager.add_comment(pr_id, &error_comment).await
617 {
618 tracing::debug!(
619 "Failed to add error comment to PR #{}: {}",
620 pr_id,
621 e2
622 );
623 }
624 }
625 }
626 }
627 Err(e) => {
628 tracing::debug!("Could not retrieve PR #{}: {}", pr_id, e);
629 }
630 }
631 }
632 } else if branch_mapping.contains_key(&entry.branch) {
633 debug!(
635 "Entry {} was remapped but has no PR - no action needed",
636 entry.id
637 );
638 }
639 }
640
641 if !updated_branches.is_empty() {
642 debug!(
643 "Successfully updated {} PRs using smart force push strategy",
644 updated_branches.len()
645 );
646 }
647
648 Ok(updated_branches)
649 }
650
651 fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
654 let git_repo = self.stack_manager.git_repo();
655
656 let stack = self
658 .stack_manager
659 .get_all_stacks()
660 .into_iter()
661 .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
662 .ok_or_else(|| {
663 CascadeError::config(format!(
664 "No stack found containing branch '{original_branch}'"
665 ))
666 })?;
667
668 let _base_branch = &stack.base_branch;
669
670 let new_head = git_repo.get_branch_head(new_branch).map_err(|e| {
672 CascadeError::validation(format!("Could not get HEAD for branch '{new_branch}': {e}"))
673 })?;
674
675 match git_repo.get_remote_branch_head(original_branch) {
677 Ok(remote_head) => {
678 if remote_head != new_head && !git_repo.is_descendant_of(&new_head, &remote_head)? {
679 return Err(CascadeError::validation(format!(
680 "Local branch '{new_branch}' (commit {}) does not contain remote commit {}. Aborting force push.",
681 &new_head[..8],
682 &remote_head[..8]
683 )));
684 }
685 tracing::debug!(
686 "Validated ancestry for '{}': {} descends from {}",
687 new_branch,
688 &new_head[..8],
689 &remote_head[..8]
690 );
691 }
692 Err(_) => {
693 tracing::debug!(
694 "No remote tracking branch for '{}' - skipping ancestor validation",
695 original_branch
696 );
697 }
698 }
699
700 Ok(())
701 }
702
703 pub async fn check_enhanced_stack_status(
705 &mut self,
706 stack_id: &Uuid,
707 ) -> Result<StackSubmissionStatus> {
708 let stack = self
709 .stack_manager
710 .get_stack(stack_id)
711 .cloned()
712 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
713
714 let stack_uuid = stack.id;
715
716 let mut status = StackSubmissionStatus {
717 stack_name: stack.name.clone(),
718 total_entries: stack.entries.len(),
719 submitted_entries: 0,
720 open_prs: 0,
721 merged_prs: 0,
722 declined_prs: 0,
723 pull_requests: Vec::new(),
724 enhanced_statuses: Vec::new(),
725 };
726
727 let mut merged_updates: Vec<(Uuid, bool)> = Vec::new();
728
729 for entry in &stack.entries {
730 if let Some(pr_id_str) = &entry.pull_request_id {
731 status.submitted_entries += 1;
732
733 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
734 match self.pr_manager.get_pull_request_status(pr_id).await {
736 Ok(enhanced_status) => {
737 match enhanced_status.pr.state {
738 crate::bitbucket::pull_request::PullRequestState::Open => {
739 status.open_prs += 1;
740 merged_updates.push((entry.id, false));
741 }
742 crate::bitbucket::pull_request::PullRequestState::Merged => {
743 status.merged_prs += 1;
744 merged_updates.push((entry.id, true));
745 }
746 crate::bitbucket::pull_request::PullRequestState::Declined => {
747 status.declined_prs += 1;
748 merged_updates.push((entry.id, false));
749 }
750 }
751 status.pull_requests.push(enhanced_status.pr.clone());
752 status.enhanced_statuses.push(enhanced_status);
753 }
754 Err(e) => {
755 tracing::debug!(
756 "Failed to get enhanced status for PR #{}: {}",
757 pr_id,
758 e
759 );
760 match self.pr_manager.get_pull_request(pr_id).await {
762 Ok(pr) => {
763 match pr.state {
764 crate::bitbucket::pull_request::PullRequestState::Open => {
765 status.open_prs += 1;
766 merged_updates.push((entry.id, false));
767 }
768 crate::bitbucket::pull_request::PullRequestState::Merged => {
769 status.merged_prs += 1;
770 merged_updates.push((entry.id, true));
771 }
772 crate::bitbucket::pull_request::PullRequestState::Declined => {
773 status.declined_prs += 1;
774 merged_updates.push((entry.id, false));
775 }
776 }
777 status.pull_requests.push(pr);
778 }
779 Err(e2) => {
780 tracing::debug!("Failed to get basic PR #{}: {}", pr_id, e2);
781 }
782 }
783 }
784 }
785 }
786 }
787 }
788
789 drop(stack);
790
791 if !merged_updates.is_empty() {
792 for (entry_id, merged) in merged_updates {
793 if let Err(e) = self
794 .stack_manager
795 .set_entry_merged(&stack_uuid, &entry_id, merged)
796 {
797 tracing::warn!(
798 "Failed to persist merged state for entry {}: {}",
799 entry_id,
800 e
801 );
802 }
803 }
804 }
805
806 Ok(status)
807 }
808}
809
810#[derive(Debug)]
812pub struct StackSubmissionStatus {
813 pub stack_name: String,
814 pub total_entries: usize,
815 pub submitted_entries: usize,
816 pub open_prs: usize,
817 pub merged_prs: usize,
818 pub declined_prs: usize,
819 pub pull_requests: Vec<PullRequest>,
820 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
821}
822
823impl StackSubmissionStatus {
824 pub fn completion_percentage(&self) -> f64 {
830 if self.submitted_entries == 0 {
831 0.0
832 } else {
833 (self.merged_prs as f64 / self.submitted_entries as f64) * 100.0
834 }
835 }
836
837 pub fn all_submitted(&self) -> bool {
839 self.submitted_entries == self.total_entries
840 }
841
842 pub fn all_merged(&self) -> bool {
844 self.submitted_entries > 0 && self.merged_prs == self.submitted_entries
845 }
846}