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