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) =
540 self.stack_manager.git_repo().get_branch_head(&entry.branch)
541 {
542 if local_head != entry.commit_hash {
543 tracing::debug!(
544 "Branch '{}' HEAD ({}) doesn't match metadata ({}), reconciling...",
545 entry.branch,
546 &local_head[..8],
547 &entry.commit_hash[..8]
548 );
549
550 if let Some(stack) = self.stack_manager.get_stack_mut(&stack.id) {
553 if let Err(e) = stack.update_entry_commit_hash(&entry.id, local_head.clone()) {
554 Output::warning(format!(
555 "Could not reconcile metadata for PR #{}: {}",
556 pr_id, e
557 ));
558 continue;
559 }
560 if let Err(e) = self.stack_manager.save_to_disk() {
562 Output::warning(format!(
563 "Could not save reconciled metadata: {}",
564 e
565 ));
566 }
567 }
568 }
569 }
570
571 if let Err(validation_error) =
573 self.validate_cumulative_changes(&entry.branch, new_branch)
574 {
575 Output::error(format!(
576 "❌ Validation failed for PR #{pr_id}: {validation_error}"
577 ));
578 Output::warning("Skipping force push to prevent data loss");
579 continue;
580 }
581
582 match self
585 .stack_manager
586 .git_repo()
587 .force_push_branch(&entry.branch, new_branch)
588 {
589 Ok(_) => {
590 debug!(
591 "Successfully force-pushed {} to preserve PR #{}",
592 entry.branch, pr_id
593 );
594
595 let rebase_comment = format!(
597 "🔄 **Automatic rebase completed**\n\n\
598 This PR has been automatically rebased onto the latest `{}`.\n\
599 - Updated commit: `{}`\n\
600 - All review history and comments are preserved",
601 stack.base_branch,
602 &entry.commit_hash[..8]
603 );
604
605 if let Err(e) =
606 self.pr_manager.add_comment(pr_id, &rebase_comment).await
607 {
608 tracing::debug!(
609 "Failed to add rebase comment to PR #{}: {}",
610 pr_id,
611 e
612 );
613 }
614
615 updated_branches.push(format!(
616 "PR #{}: {} (preserved)",
617 pr_id, entry.branch
618 ));
619 }
620 Err(e) => {
621 error!("Failed to force push {}: {}", entry.branch, e);
622 let error_comment = format!(
624 "⚠️ **Rebase Update Issue**\n\n\
625 The automatic rebase completed, but updating this PR failed.\n\
626 You may need to manually update this branch.\n\
627 Error: {e}"
628 );
629
630 if let Err(e2) =
631 self.pr_manager.add_comment(pr_id, &error_comment).await
632 {
633 tracing::debug!(
634 "Failed to add error comment to PR #{}: {}",
635 pr_id,
636 e2
637 );
638 }
639 }
640 }
641 }
642 Err(e) => {
643 tracing::debug!("Could not retrieve PR #{}: {}", pr_id, e);
644 }
645 }
646 }
647 } else if branch_mapping.contains_key(&entry.branch) {
648 debug!(
650 "Entry {} was remapped but has no PR - no action needed",
651 entry.id
652 );
653 }
654 }
655
656 if !updated_branches.is_empty() {
657 debug!(
658 "Successfully updated {} PRs using smart force push strategy",
659 updated_branches.len()
660 );
661 }
662
663 Ok(updated_branches)
664 }
665
666 fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
669 let git_repo = self.stack_manager.git_repo();
670
671 let stack = self
673 .stack_manager
674 .get_all_stacks()
675 .into_iter()
676 .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
677 .ok_or_else(|| {
678 CascadeError::config(format!(
679 "No stack found containing branch '{original_branch}'"
680 ))
681 })?;
682
683 let _base_branch = &stack.base_branch;
684
685 let new_head = git_repo.get_branch_head(new_branch).map_err(|e| {
687 CascadeError::validation(format!("Could not get HEAD for branch '{new_branch}': {e}"))
688 })?;
689
690 match git_repo.get_remote_branch_head(original_branch) {
692 Ok(remote_head) => {
693 if remote_head != new_head && !git_repo.is_descendant_of(&new_head, &remote_head)? {
694 return Err(CascadeError::validation(format!(
695 "Local branch '{new_branch}' (commit {}) does not contain remote commit {}. Aborting force push.",
696 &new_head[..8],
697 &remote_head[..8]
698 )));
699 }
700 tracing::debug!(
701 "Validated ancestry for '{}': {} descends from {}",
702 new_branch,
703 &new_head[..8],
704 &remote_head[..8]
705 );
706 }
707 Err(_) => {
708 tracing::debug!(
709 "No remote tracking branch for '{}' - skipping ancestor validation",
710 original_branch
711 );
712 }
713 }
714
715 Ok(())
716 }
717
718 pub async fn check_enhanced_stack_status(
720 &mut self,
721 stack_id: &Uuid,
722 ) -> Result<StackSubmissionStatus> {
723 let stack = self
724 .stack_manager
725 .get_stack(stack_id)
726 .cloned()
727 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
728
729 let stack_uuid = stack.id;
730
731 let mut status = StackSubmissionStatus {
732 stack_name: stack.name.clone(),
733 total_entries: stack.entries.len(),
734 submitted_entries: 0,
735 open_prs: 0,
736 merged_prs: 0,
737 declined_prs: 0,
738 pull_requests: Vec::new(),
739 enhanced_statuses: Vec::new(),
740 };
741
742 let mut merged_updates: Vec<(Uuid, bool)> = Vec::new();
743
744 for entry in &stack.entries {
745 if let Some(pr_id_str) = &entry.pull_request_id {
746 status.submitted_entries += 1;
747
748 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
749 match self.pr_manager.get_pull_request_status(pr_id).await {
751 Ok(enhanced_status) => {
752 match enhanced_status.pr.state {
753 crate::bitbucket::pull_request::PullRequestState::Open => {
754 status.open_prs += 1;
755 merged_updates.push((entry.id, false));
756 }
757 crate::bitbucket::pull_request::PullRequestState::Merged => {
758 status.merged_prs += 1;
759 merged_updates.push((entry.id, true));
760 }
761 crate::bitbucket::pull_request::PullRequestState::Declined => {
762 status.declined_prs += 1;
763 merged_updates.push((entry.id, false));
764 }
765 }
766 status.pull_requests.push(enhanced_status.pr.clone());
767 status.enhanced_statuses.push(enhanced_status);
768 }
769 Err(e) => {
770 tracing::debug!(
771 "Failed to get enhanced status for PR #{}: {}",
772 pr_id,
773 e
774 );
775 match self.pr_manager.get_pull_request(pr_id).await {
777 Ok(pr) => {
778 match pr.state {
779 crate::bitbucket::pull_request::PullRequestState::Open => {
780 status.open_prs += 1;
781 merged_updates.push((entry.id, false));
782 }
783 crate::bitbucket::pull_request::PullRequestState::Merged => {
784 status.merged_prs += 1;
785 merged_updates.push((entry.id, true));
786 }
787 crate::bitbucket::pull_request::PullRequestState::Declined => {
788 status.declined_prs += 1;
789 merged_updates.push((entry.id, false));
790 }
791 }
792 status.pull_requests.push(pr);
793 }
794 Err(e2) => {
795 tracing::debug!("Failed to get basic PR #{}: {}", pr_id, e2);
796 }
797 }
798 }
799 }
800 }
801 }
802 }
803
804 drop(stack);
805
806 if !merged_updates.is_empty() {
807 for (entry_id, merged) in merged_updates {
808 if let Err(e) = self
809 .stack_manager
810 .set_entry_merged(&stack_uuid, &entry_id, merged)
811 {
812 tracing::warn!(
813 "Failed to persist merged state for entry {}: {}",
814 entry_id,
815 e
816 );
817 }
818 }
819 }
820
821 Ok(status)
822 }
823}
824
825#[derive(Debug)]
827pub struct StackSubmissionStatus {
828 pub stack_name: String,
829 pub total_entries: usize,
830 pub submitted_entries: usize,
831 pub open_prs: usize,
832 pub merged_prs: usize,
833 pub declined_prs: usize,
834 pub pull_requests: Vec<PullRequest>,
835 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
836}
837
838impl StackSubmissionStatus {
839 pub fn completion_percentage(&self) -> f64 {
845 if self.submitted_entries == 0 {
846 0.0
847 } else {
848 (self.merged_prs as f64 / self.submitted_entries as f64) * 100.0
849 }
850 }
851
852 pub fn all_submitted(&self) -> bool {
854 self.submitted_entries == self.total_entries
855 }
856
857 pub fn all_merged(&self) -> bool {
859 self.submitted_entries > 0 && self.merged_prs == self.submitted_entries
860 }
861}