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 let branch_has_remote = git_repo.get_upstream_branch(&entry.branch)?.is_some();
132 let needs_force_push = entry.pull_request_id.is_some() || branch_has_remote;
133
134 if needs_force_push {
135 std::env::set_var("FORCE_PUSH_NO_CONFIRM", "1");
138 let result = git_repo.force_push_single_branch(&entry.branch);
139 std::env::remove_var("FORCE_PUSH_NO_CONFIRM");
140 result.map_err(|e| CascadeError::bitbucket(e.to_string()))?;
141 } else {
142 git_repo
144 .push(&entry.branch)
145 .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
146 }
147
148 if let Some(commit_meta) = self
152 .stack_manager
153 .get_repository_metadata()
154 .commits
155 .get(&entry.commit_hash)
156 {
157 let mut updated_meta = commit_meta.clone();
158 updated_meta.mark_pushed();
159 }
162
163 let target_branch = self.get_target_branch(stack, entry)?;
165
166 if target_branch != stack.base_branch {
168 git_repo.push(&target_branch).map_err(|e| {
172 CascadeError::bitbucket(format!(
173 "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
174 Try manually pushing with: git push origin {target_branch}"
175 ))
176 })?;
177
178 }
180
181 let pr_request =
183 self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
184
185 let pr = match self.pr_manager.create_pull_request(pr_request).await {
186 Ok(pr) => pr,
187 Err(e) => {
188 return Err(CascadeError::bitbucket(format!(
189 "Failed to create pull request for branch '{}' -> '{}': {}. \
190 Ensure both branches exist in the remote repository. \
191 You can manually push with: git push origin {}",
192 entry.branch, target_branch, e, entry.branch
193 )));
194 }
195 };
196
197 self.stack_manager
199 .submit_entry(stack_id, entry_id, pr.id.to_string())?;
200
201 Ok(pr)
203 }
204
205 pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
207 let stack = self
208 .stack_manager
209 .get_stack(stack_id)
210 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
211
212 let mut status = StackSubmissionStatus {
213 stack_name: stack.name.clone(),
214 total_entries: stack.entries.len(),
215 submitted_entries: 0,
216 open_prs: 0,
217 merged_prs: 0,
218 declined_prs: 0,
219 pull_requests: Vec::new(),
220 enhanced_statuses: Vec::new(),
221 };
222
223 for entry in &stack.entries {
224 if let Some(pr_id_str) = &entry.pull_request_id {
225 status.submitted_entries += 1;
226
227 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
228 match self.pr_manager.get_pull_request(pr_id).await {
229 Ok(pr) => {
230 match pr.state {
231 PullRequestState::Open => status.open_prs += 1,
232 PullRequestState::Merged => status.merged_prs += 1,
233 PullRequestState::Declined => status.declined_prs += 1,
234 }
235 status.pull_requests.push(pr);
236 }
237 Err(e) => {
238 warn!("Failed to get pull request #{}: {}", pr_id, e);
239 }
240 }
241 }
242 }
243 }
244
245 Ok(status)
246 }
247
248 pub async fn list_pull_requests(
250 &self,
251 state: Option<PullRequestState>,
252 ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
253 self.pr_manager.list_pull_requests(state).await
254 }
255
256 fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
258 if let Some(first_entry) = stack.entries.first() {
260 if entry.id == first_entry.id {
261 return Ok(stack.base_branch.clone());
262 }
263 }
264
265 let entry_index = stack
267 .entries
268 .iter()
269 .position(|e| e.id == entry.id)
270 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
271
272 if entry_index == 0 {
273 Ok(stack.base_branch.clone())
274 } else {
275 Ok(stack.entries[entry_index - 1].branch.clone())
276 }
277 }
278
279 fn create_pr_request(
281 &self,
282 stack: &Stack,
283 entry: &StackEntry,
284 target_branch: &str,
285 title: Option<String>,
286 description: Option<String>,
287 draft: bool,
288 ) -> Result<CreatePullRequestRequest> {
289 let bitbucket_config = self.config.bitbucket.as_ref()
290 .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
291
292 let repository = Repository {
293 id: 0, name: bitbucket_config.repo.clone(),
295 slug: bitbucket_config.repo.clone(),
296 scm_id: "git".to_string(),
297 state: "AVAILABLE".to_string(),
298 status_message: Some("Available".to_string()),
299 forkable: true,
300 project: Project {
301 id: 0,
302 key: bitbucket_config.project.clone(),
303 name: bitbucket_config.project.clone(),
304 description: None,
305 public: false,
306 project_type: "NORMAL".to_string(),
307 },
308 public: false,
309 };
310
311 let from_ref = PullRequestRef {
312 id: format!("refs/heads/{}", entry.branch),
313 display_id: entry.branch.clone(),
314 latest_commit: entry.commit_hash.clone(),
315 repository: repository.clone(),
316 };
317
318 let to_ref = PullRequestRef {
319 id: format!("refs/heads/{target_branch}"),
320 display_id: target_branch.to_string(),
321 latest_commit: "".to_string(), repository,
323 };
324
325 let mut title =
326 title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
327
328 if draft && !title.starts_with("[DRAFT]") {
330 title = format!("[DRAFT] {title}");
331 }
332
333 let description = {
334 if let Some(template) = &self.config.cascade.pr_description_template {
336 Some(template.clone()) } else if let Some(desc) = description {
338 Some(desc) } else if entry.message.lines().count() > 1 {
340 Some(
342 entry
343 .message
344 .lines()
345 .skip(1)
346 .collect::<Vec<_>>()
347 .join("\n")
348 .trim()
349 .to_string(),
350 )
351 } else {
352 None
353 }
354 };
355
356 let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
358
359 Ok(CreatePullRequestRequest {
360 title,
361 description: description_with_footer,
362 from_ref,
363 to_ref,
364 draft, })
366 }
367
368 fn add_stack_hierarchy_footer(
370 &self,
371 description: Option<String>,
372 stack: &Stack,
373 current_entry: &StackEntry,
374 ) -> Result<Option<String>> {
375 let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
376
377 let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
378
379 match description {
380 Some(desc) => Ok(Some(format!("{desc}{footer}"))),
381 None => Ok(Some(footer.trim_start_matches('\n').to_string())),
382 }
383 }
384
385 fn generate_stack_hierarchy(
387 &self,
388 stack: &Stack,
389 current_entry: &StackEntry,
390 ) -> Result<String> {
391 let mut hierarchy = String::new();
392
393 hierarchy.push_str("### Stack Hierarchy\n\n");
395 hierarchy.push_str("```\n");
396
397 hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
399
400 for (index, entry) in stack.entries.iter().enumerate() {
402 let is_current = entry.id == current_entry.id;
403 let is_last = index == stack.entries.len() - 1;
404
405 let connector = if is_last { "└── " } else { "├── " };
407
408 let indicator = if is_current {
410 "← current"
411 } else if entry.pull_request_id.is_some() {
412 ""
413 } else {
414 "(pending)"
415 };
416
417 let pr_info = if let Some(pr_id) = &entry.pull_request_id {
419 format!(" (PR #{pr_id})")
420 } else {
421 String::new()
422 };
423
424 hierarchy.push_str(&format!(
425 "{}{}{} {}\n",
426 connector, entry.branch, pr_info, indicator
427 ));
428 }
429
430 hierarchy.push_str("```\n\n");
431
432 if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
434 let position = current_index + 1;
435 let total = stack.entries.len();
436 hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
437 }
438
439 Ok(hierarchy)
440 }
441
442 pub async fn update_prs_after_rebase(
445 &mut self,
446 stack_id: &Uuid,
447 branch_mapping: &HashMap<String, String>,
448 ) -> Result<Vec<String>> {
449 info!(
450 "Updating pull requests after rebase for stack {} using smart force push",
451 stack_id
452 );
453
454 let stack = self
455 .stack_manager
456 .get_stack(stack_id)
457 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
458 .clone();
459
460 let mut updated_branches = Vec::new();
461
462 for entry in &stack.entries {
463 if let (Some(pr_id_str), Some(new_branch)) =
465 (&entry.pull_request_id, branch_mapping.get(&entry.branch))
466 {
467 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
468 info!(
469 "Found existing PR #{} for entry {}, updating branch {} -> {}",
470 pr_id, entry.id, entry.branch, new_branch
471 );
472
473 match self.pr_manager.get_pull_request(pr_id).await {
475 Ok(_existing_pr) => {
476 if let Err(validation_error) =
478 self.validate_cumulative_changes(&entry.branch, new_branch)
479 {
480 Output::error(format!(
481 "❌ Validation failed for PR #{pr_id}: {validation_error}"
482 ));
483 Output::warning("Skipping force push to prevent data loss");
484 continue;
485 }
486
487 match self
490 .stack_manager
491 .git_repo()
492 .force_push_branch(&entry.branch, new_branch)
493 {
494 Ok(_) => {
495 info!(
496 "✅ Successfully force-pushed {} to preserve PR #{}",
497 entry.branch, pr_id
498 );
499
500 let rebase_comment = format!(
502 "🔄 **Automatic rebase completed**\n\n\
503 This PR has been automatically rebased to incorporate the latest changes.\n\
504 - Original commits: `{}`\n\
505 - New base: Latest main branch\n\
506 - All review history and comments are preserved\n\n\
507 The changes in this PR remain the same - only the base has been updated.",
508 &entry.commit_hash[..8]
509 );
510
511 if let Err(e) =
512 self.pr_manager.add_comment(pr_id, &rebase_comment).await
513 {
514 warn!(
515 "Failed to add rebase comment to PR #{}: {}",
516 pr_id, e
517 );
518 }
519
520 updated_branches.push(format!(
521 "PR #{}: {} (preserved)",
522 pr_id, entry.branch
523 ));
524 }
525 Err(e) => {
526 error!("Failed to force push {}: {}", entry.branch, e);
527 let error_comment = format!(
529 "⚠️ **Rebase Update Issue**\n\n\
530 The automatic rebase completed, but updating this PR failed.\n\
531 You may need to manually update this branch.\n\
532 Error: {e}"
533 );
534
535 if let Err(e2) =
536 self.pr_manager.add_comment(pr_id, &error_comment).await
537 {
538 warn!(
539 "Failed to add error comment to PR #{}: {}",
540 pr_id, e2
541 );
542 }
543 }
544 }
545 }
546 Err(e) => {
547 warn!("Could not retrieve PR #{}: {}", pr_id, e);
548 }
549 }
550 }
551 } else if branch_mapping.contains_key(&entry.branch) {
552 info!(
554 "Entry {} was remapped but has no PR - no action needed",
555 entry.id
556 );
557 }
558 }
559
560 if !updated_branches.is_empty() {
561 info!(
562 "Successfully updated {} PRs using smart force push strategy",
563 updated_branches.len()
564 );
565 }
566
567 Ok(updated_branches)
568 }
569
570 fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
573 let git_repo = self.stack_manager.git_repo();
574
575 let stack = self
577 .stack_manager
578 .get_all_stacks()
579 .into_iter()
580 .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
581 .ok_or_else(|| {
582 CascadeError::config(format!(
583 "No stack found containing branch '{original_branch}'"
584 ))
585 })?;
586
587 let base_branch = &stack.base_branch;
588
589 match git_repo.get_commits_between(base_branch, new_branch) {
591 Ok(new_commits) => {
592 if new_commits.is_empty() {
593 return Err(CascadeError::validation(format!(
594 "New branch '{new_branch}' contains no commits from base '{base_branch}' - this would result in data loss"
595 )));
596 }
597
598 match git_repo.get_commits_between(base_branch, original_branch) {
600 Ok(original_commits) => {
601 if new_commits.len() < original_commits.len() {
602 return Err(CascadeError::validation(format!(
603 "New branch '{}' has {} commits but original branch '{}' had {} commits - potential data loss",
604 new_branch, new_commits.len(), original_branch, original_commits.len()
605 )));
606 }
607
608 tracing::debug!(
609 "Validation passed: new branch '{}' has {} commits, original had {} commits",
610 new_branch, new_commits.len(), original_commits.len()
611 );
612 Ok(())
613 }
614 Err(e) => {
615 tracing::warn!("Could not validate original branch commits: {}", e);
616 Ok(())
618 }
619 }
620 }
621 Err(e) => Err(CascadeError::validation(format!(
622 "Could not get commits for validation: {e}"
623 ))),
624 }
625 }
626
627 pub async fn check_enhanced_stack_status(
629 &self,
630 stack_id: &Uuid,
631 ) -> Result<StackSubmissionStatus> {
632 let stack = self
633 .stack_manager
634 .get_stack(stack_id)
635 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
636
637 let mut status = StackSubmissionStatus {
638 stack_name: stack.name.clone(),
639 total_entries: stack.entries.len(),
640 submitted_entries: 0,
641 open_prs: 0,
642 merged_prs: 0,
643 declined_prs: 0,
644 pull_requests: Vec::new(),
645 enhanced_statuses: Vec::new(),
646 };
647
648 for entry in &stack.entries {
649 if let Some(pr_id_str) = &entry.pull_request_id {
650 status.submitted_entries += 1;
651
652 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
653 match self.pr_manager.get_pull_request_status(pr_id).await {
655 Ok(enhanced_status) => {
656 match enhanced_status.pr.state {
657 crate::bitbucket::pull_request::PullRequestState::Open => {
658 status.open_prs += 1
659 }
660 crate::bitbucket::pull_request::PullRequestState::Merged => {
661 status.merged_prs += 1
662 }
663 crate::bitbucket::pull_request::PullRequestState::Declined => {
664 status.declined_prs += 1
665 }
666 }
667 status.pull_requests.push(enhanced_status.pr.clone());
668 status.enhanced_statuses.push(enhanced_status);
669 }
670 Err(e) => {
671 warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
672 match self.pr_manager.get_pull_request(pr_id).await {
674 Ok(pr) => {
675 match pr.state {
676 crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
677 crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
678 crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
679 }
680 status.pull_requests.push(pr);
681 }
682 Err(e2) => {
683 warn!("Failed to get basic PR #{}: {}", pr_id, e2);
684 }
685 }
686 }
687 }
688 }
689 }
690 }
691
692 Ok(status)
693 }
694}
695
696#[derive(Debug)]
698pub struct StackSubmissionStatus {
699 pub stack_name: String,
700 pub total_entries: usize,
701 pub submitted_entries: usize,
702 pub open_prs: usize,
703 pub merged_prs: usize,
704 pub declined_prs: usize,
705 pub pull_requests: Vec<PullRequest>,
706 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
707}
708
709impl StackSubmissionStatus {
710 pub fn completion_percentage(&self) -> f64 {
712 if self.total_entries == 0 {
713 0.0
714 } else {
715 (self.merged_prs as f64 / self.total_entries as f64) * 100.0
716 }
717 }
718
719 pub fn all_submitted(&self) -> bool {
721 self.submitted_entries == self.total_entries
722 }
723
724 pub fn all_merged(&self) -> bool {
726 self.merged_prs == self.total_entries
727 }
728}