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