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