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