1use crate::bitbucket::client::BitbucketClient;
2use crate::bitbucket::pull_request::{
3 CreatePullRequestRequest, Project, PullRequest, PullRequestManager, PullRequestRef,
4 PullRequestState, Repository,
5};
6use crate::config::CascadeConfig;
7use crate::errors::{CascadeError, Result};
8use crate::stack::{Stack, StackEntry, StackManager};
9use std::collections::HashMap;
10use tracing::{error, info, warn};
11use uuid::Uuid;
12
13pub struct BitbucketIntegration {
15 stack_manager: StackManager,
16 pr_manager: PullRequestManager,
17 config: CascadeConfig,
18}
19
20impl BitbucketIntegration {
21 pub fn new(stack_manager: StackManager, config: CascadeConfig) -> Result<Self> {
23 let bitbucket_config = config
24 .bitbucket
25 .as_ref()
26 .ok_or_else(|| CascadeError::config("Bitbucket configuration not found"))?;
27
28 let client = BitbucketClient::new(bitbucket_config)?;
29 let pr_manager = PullRequestManager::new(client);
30
31 Ok(Self {
32 stack_manager,
33 pr_manager,
34 config,
35 })
36 }
37
38 pub async fn submit_entry(
40 &mut self,
41 stack_id: &Uuid,
42 entry_id: &Uuid,
43 title: Option<String>,
44 description: Option<String>,
45 draft: bool,
46 ) -> Result<PullRequest> {
47 let stack = self
48 .stack_manager
49 .get_stack(stack_id)
50 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
51
52 let entry = stack
53 .get_entry(entry_id)
54 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
55
56 info!("Submitting stack entry {} as pull request", entry_id);
57
58 if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
60 return Err(CascadeError::validation(format!(
61 "Cannot submit entry from corrupted stack '{}':\n{}",
62 stack.name, integrity_error
63 )));
64 }
65
66 let git_repo = self.stack_manager.git_repo();
68 info!("Ensuring branch '{}' is pushed to remote", entry.branch);
69
70 git_repo.push(&entry.branch).map_err(|e| {
72 CascadeError::bitbucket(format!(
73 "Failed to push branch '{}': {}. Cannot create PR without remote branch. \
74 Try manually pushing with: git push origin {}",
75 entry.branch, e, entry.branch
76 ))
77 })?;
78
79 info!("✅ Successfully pushed branch '{}' to remote", entry.branch);
80
81 if let Some(commit_meta) = self
83 .stack_manager
84 .get_repository_metadata()
85 .commits
86 .get(&entry.commit_hash)
87 {
88 let mut updated_meta = commit_meta.clone();
89 updated_meta.mark_pushed();
90 }
93
94 let target_branch = self.get_target_branch(stack, entry)?;
96
97 if target_branch != stack.base_branch {
99 info!(
100 "Ensuring target branch '{}' is pushed to remote",
101 target_branch
102 );
103
104 git_repo.push(&target_branch).map_err(|e| {
106 CascadeError::bitbucket(format!(
107 "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
108 Try manually pushing with: git push origin {target_branch}"
109 ))
110 })?;
111
112 info!(
113 "✅ Successfully pushed target branch '{}' to remote",
114 target_branch
115 );
116 }
117
118 let pr_request =
120 self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
121
122 let pr = match self.pr_manager.create_pull_request(pr_request).await {
123 Ok(pr) => pr,
124 Err(e) => {
125 return Err(CascadeError::bitbucket(format!(
126 "Failed to create pull request for branch '{}' -> '{}': {}. \
127 Ensure both branches exist in the remote repository. \
128 You can manually push with: git push origin {}",
129 entry.branch, target_branch, e, entry.branch
130 )));
131 }
132 };
133
134 self.stack_manager
136 .submit_entry(stack_id, entry_id, pr.id.to_string())?;
137
138 info!("Created pull request #{} for entry {}", pr.id, entry_id);
139 Ok(pr)
140 }
141
142 pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
144 let stack = self
145 .stack_manager
146 .get_stack(stack_id)
147 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
148
149 let mut status = StackSubmissionStatus {
150 stack_name: stack.name.clone(),
151 total_entries: stack.entries.len(),
152 submitted_entries: 0,
153 open_prs: 0,
154 merged_prs: 0,
155 declined_prs: 0,
156 pull_requests: Vec::new(),
157 enhanced_statuses: Vec::new(),
158 };
159
160 for entry in &stack.entries {
161 if let Some(pr_id_str) = &entry.pull_request_id {
162 status.submitted_entries += 1;
163
164 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
165 match self.pr_manager.get_pull_request(pr_id).await {
166 Ok(pr) => {
167 match pr.state {
168 PullRequestState::Open => status.open_prs += 1,
169 PullRequestState::Merged => status.merged_prs += 1,
170 PullRequestState::Declined => status.declined_prs += 1,
171 }
172 status.pull_requests.push(pr);
173 }
174 Err(e) => {
175 warn!("Failed to get pull request #{}: {}", pr_id, e);
176 }
177 }
178 }
179 }
180 }
181
182 Ok(status)
183 }
184
185 pub async fn list_pull_requests(
187 &self,
188 state: Option<PullRequestState>,
189 ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
190 self.pr_manager.list_pull_requests(state).await
191 }
192
193 fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
195 if let Some(first_entry) = stack.entries.first() {
197 if entry.id == first_entry.id {
198 return Ok(stack.base_branch.clone());
199 }
200 }
201
202 let entry_index = stack
204 .entries
205 .iter()
206 .position(|e| e.id == entry.id)
207 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
208
209 if entry_index == 0 {
210 Ok(stack.base_branch.clone())
211 } else {
212 Ok(stack.entries[entry_index - 1].branch.clone())
213 }
214 }
215
216 fn create_pr_request(
218 &self,
219 stack: &Stack,
220 entry: &StackEntry,
221 target_branch: &str,
222 title: Option<String>,
223 description: Option<String>,
224 draft: bool,
225 ) -> Result<CreatePullRequestRequest> {
226 let bitbucket_config = self.config.bitbucket.as_ref()
227 .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
228
229 let repository = Repository {
230 id: 0, name: bitbucket_config.repo.clone(),
232 slug: bitbucket_config.repo.clone(),
233 scm_id: "git".to_string(),
234 state: "AVAILABLE".to_string(),
235 status_message: "Available".to_string(),
236 forkable: true,
237 project: Project {
238 id: 0,
239 key: bitbucket_config.project.clone(),
240 name: bitbucket_config.project.clone(),
241 description: None,
242 public: false,
243 project_type: "NORMAL".to_string(),
244 },
245 public: false,
246 };
247
248 let from_ref = PullRequestRef {
249 id: format!("refs/heads/{}", entry.branch),
250 display_id: entry.branch.clone(),
251 latest_commit: entry.commit_hash.clone(),
252 repository: repository.clone(),
253 };
254
255 let to_ref = PullRequestRef {
256 id: format!("refs/heads/{target_branch}"),
257 display_id: target_branch.to_string(),
258 latest_commit: "".to_string(), repository,
260 };
261
262 let mut title =
263 title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
264
265 if draft && !title.starts_with("[DRAFT]") {
267 title = format!("[DRAFT] {title}");
268 }
269
270 let description = {
271 if let Some(template) = &self.config.cascade.pr_description_template {
273 Some(template.clone()) } else if let Some(desc) = description {
275 Some(desc) } else if entry.message.lines().count() > 1 {
277 Some(
279 entry
280 .message
281 .lines()
282 .skip(1)
283 .collect::<Vec<_>>()
284 .join("\n")
285 .trim()
286 .to_string(),
287 )
288 } else {
289 None
290 }
291 };
292
293 let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
295
296 Ok(CreatePullRequestRequest {
297 title,
298 description: description_with_footer,
299 from_ref,
300 to_ref,
301 draft, })
303 }
304
305 fn add_stack_hierarchy_footer(
307 &self,
308 description: Option<String>,
309 stack: &Stack,
310 current_entry: &StackEntry,
311 ) -> Result<Option<String>> {
312 let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
313
314 let footer = format!(
315 "\n\n---\n\n## 📚 Stack Information\n\n{}\n\n<sub>Generated by [Cascade CLI](https://github.com/your-org/cascade-cli) • Stack: **{}**</sub>",
316 hierarchy,
317 stack.name
318 );
319
320 match description {
321 Some(desc) => Ok(Some(format!("{desc}{footer}"))),
322 None => Ok(Some(footer.trim_start_matches('\n').to_string())),
323 }
324 }
325
326 fn generate_stack_hierarchy(
328 &self,
329 stack: &Stack,
330 current_entry: &StackEntry,
331 ) -> Result<String> {
332 let mut hierarchy = String::new();
333
334 hierarchy.push_str(&format!("**Stack:** `{}`\n", stack.name));
336 hierarchy.push_str(&format!("**Base Branch:** `{}`\n", stack.base_branch));
337 hierarchy.push_str(&format!("**Total Entries:** {}\n\n", stack.entries.len()));
338
339 hierarchy.push_str("### 🌳 Stack Hierarchy\n\n");
341 hierarchy.push_str("```\n");
342
343 hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
345
346 for (index, entry) in stack.entries.iter().enumerate() {
348 let is_current = entry.id == current_entry.id;
349 let is_last = index == stack.entries.len() - 1;
350
351 let connector = if is_last { "└── " } else { "├── " };
353
354 let indicator = if is_current {
356 "👈 YOU ARE HERE"
357 } else if entry.pull_request_id.is_some() {
358 "✅ PR Created"
359 } else {
360 "⏳ Not Submitted"
361 };
362
363 let pr_info = if let Some(pr_id) = &entry.pull_request_id {
365 format!(" (PR #{pr_id})")
366 } else {
367 String::new()
368 };
369
370 hierarchy.push_str(&format!(
371 "{}🔗 {}{} {} {}\n",
372 connector,
373 entry.branch,
374 pr_info,
375 if is_current { "◀" } else { " " },
376 indicator
377 ));
378 }
379
380 hierarchy.push_str("```\n\n");
381
382 hierarchy.push_str("### 📋 Current Entry Details\n\n");
384 hierarchy.push_str(&format!("- **Branch:** `{}`\n", current_entry.branch));
385 hierarchy.push_str(&format!(
386 "- **Commit:** `{}`\n",
387 ¤t_entry.commit_hash[0..8]
388 ));
389
390 if let Some(pr_id) = ¤t_entry.pull_request_id {
391 hierarchy.push_str(&format!("- **Pull Request:** #{pr_id}\n"));
392 }
393
394 hierarchy.push_str("\n### 🧭 Navigation\n\n");
396 hierarchy.push_str("- **Previous Entry:** ");
397
398 if let Some(prev_index) = stack
399 .entries
400 .iter()
401 .position(|e| e.id == current_entry.id)
402 .and_then(|i| i.checked_sub(1))
403 {
404 let prev_entry = &stack.entries[prev_index];
405 if let Some(pr_id) = &prev_entry.pull_request_id {
406 hierarchy.push_str(&format!("`{}` (PR #{})", prev_entry.branch, pr_id));
408 } else {
409 hierarchy.push_str(&format!("`{}` (not submitted)", prev_entry.branch));
410 }
411 } else {
412 hierarchy.push_str(&format!("`{}` (base branch)", stack.base_branch));
413 }
414
415 hierarchy.push('\n');
416 hierarchy.push_str("- **Next Entry:** ");
417
418 if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
419 if current_index + 1 < stack.entries.len() {
420 let next_entry = &stack.entries[current_index + 1];
421 if let Some(pr_id) = &next_entry.pull_request_id {
422 hierarchy.push_str(&format!("`{}` (PR #{})", next_entry.branch, pr_id));
423 } else {
424 hierarchy.push_str(&format!("`{}` (not submitted)", next_entry.branch));
425 }
426 } else {
427 hierarchy.push_str("*This is the top of the stack*");
428 }
429 } else {
430 hierarchy.push_str("*Unknown*");
431 }
432
433 Ok(hierarchy)
434 }
435
436 pub async fn update_prs_after_rebase(
439 &mut self,
440 stack_id: &Uuid,
441 branch_mapping: &HashMap<String, String>,
442 ) -> Result<Vec<String>> {
443 info!(
444 "Updating pull requests after rebase for stack {} using smart force push",
445 stack_id
446 );
447
448 let stack = self
449 .stack_manager
450 .get_stack(stack_id)
451 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
452 .clone();
453
454 let mut updated_branches = Vec::new();
455
456 for entry in &stack.entries {
457 if let (Some(pr_id_str), Some(new_branch)) =
459 (&entry.pull_request_id, branch_mapping.get(&entry.branch))
460 {
461 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
462 info!(
463 "Found existing PR #{} for entry {}, updating branch {} -> {}",
464 pr_id, entry.id, entry.branch, new_branch
465 );
466
467 match self.pr_manager.get_pull_request(pr_id).await {
469 Ok(_existing_pr) => {
470 match self
473 .stack_manager
474 .git_repo()
475 .force_push_branch(&entry.branch, new_branch)
476 {
477 Ok(_) => {
478 info!(
479 "✅ Successfully force-pushed {} to preserve PR #{}",
480 entry.branch, pr_id
481 );
482
483 let rebase_comment = format!(
485 "🔄 **Automatic rebase completed**\n\n\
486 This PR has been automatically rebased to incorporate the latest changes.\n\
487 - Original commits: `{}`\n\
488 - New base: Latest main branch\n\
489 - All review history and comments are preserved\n\n\
490 The changes in this PR remain the same - only the base has been updated.",
491 &entry.commit_hash[..8]
492 );
493
494 if let Err(e) =
495 self.pr_manager.add_comment(pr_id, &rebase_comment).await
496 {
497 warn!(
498 "Failed to add rebase comment to PR #{}: {}",
499 pr_id, e
500 );
501 }
502
503 updated_branches.push(format!(
504 "PR #{}: {} (preserved)",
505 pr_id, entry.branch
506 ));
507 }
508 Err(e) => {
509 error!("Failed to force push {}: {}", entry.branch, e);
510 let error_comment = format!(
512 "⚠️ **Rebase Update Issue**\n\n\
513 The automatic rebase completed, but updating this PR failed.\n\
514 You may need to manually update this branch.\n\
515 Error: {e}"
516 );
517
518 if let Err(e2) =
519 self.pr_manager.add_comment(pr_id, &error_comment).await
520 {
521 warn!(
522 "Failed to add error comment to PR #{}: {}",
523 pr_id, e2
524 );
525 }
526 }
527 }
528 }
529 Err(e) => {
530 warn!("Could not retrieve PR #{}: {}", pr_id, e);
531 }
532 }
533 }
534 } else if branch_mapping.contains_key(&entry.branch) {
535 info!(
537 "Entry {} was remapped but has no PR - no action needed",
538 entry.id
539 );
540 }
541 }
542
543 if !updated_branches.is_empty() {
544 info!(
545 "Successfully updated {} PRs using smart force push strategy",
546 updated_branches.len()
547 );
548 }
549
550 Ok(updated_branches)
551 }
552
553 pub async fn check_enhanced_stack_status(
555 &self,
556 stack_id: &Uuid,
557 ) -> Result<StackSubmissionStatus> {
558 let stack = self
559 .stack_manager
560 .get_stack(stack_id)
561 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
562
563 let mut status = StackSubmissionStatus {
564 stack_name: stack.name.clone(),
565 total_entries: stack.entries.len(),
566 submitted_entries: 0,
567 open_prs: 0,
568 merged_prs: 0,
569 declined_prs: 0,
570 pull_requests: Vec::new(),
571 enhanced_statuses: Vec::new(),
572 };
573
574 for entry in &stack.entries {
575 if let Some(pr_id_str) = &entry.pull_request_id {
576 status.submitted_entries += 1;
577
578 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
579 match self.pr_manager.get_pull_request_status(pr_id).await {
581 Ok(enhanced_status) => {
582 match enhanced_status.pr.state {
583 crate::bitbucket::pull_request::PullRequestState::Open => {
584 status.open_prs += 1
585 }
586 crate::bitbucket::pull_request::PullRequestState::Merged => {
587 status.merged_prs += 1
588 }
589 crate::bitbucket::pull_request::PullRequestState::Declined => {
590 status.declined_prs += 1
591 }
592 }
593 status.pull_requests.push(enhanced_status.pr.clone());
594 status.enhanced_statuses.push(enhanced_status);
595 }
596 Err(e) => {
597 warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
598 match self.pr_manager.get_pull_request(pr_id).await {
600 Ok(pr) => {
601 match pr.state {
602 crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
603 crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
604 crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
605 }
606 status.pull_requests.push(pr);
607 }
608 Err(e2) => {
609 warn!("Failed to get basic PR #{}: {}", pr_id, e2);
610 }
611 }
612 }
613 }
614 }
615 }
616 }
617
618 Ok(status)
619 }
620}
621
622#[derive(Debug)]
624pub struct StackSubmissionStatus {
625 pub stack_name: String,
626 pub total_entries: usize,
627 pub submitted_entries: usize,
628 pub open_prs: usize,
629 pub merged_prs: usize,
630 pub declined_prs: usize,
631 pub pull_requests: Vec<PullRequest>,
632 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
633}
634
635impl StackSubmissionStatus {
636 pub fn completion_percentage(&self) -> f64 {
638 if self.total_entries == 0 {
639 0.0
640 } else {
641 (self.merged_prs as f64 / self.total_entries as f64) * 100.0
642 }
643 }
644
645 pub fn all_submitted(&self) -> bool {
647 self.submitted_entries == self.total_entries
648 }
649
650 pub fn all_merged(&self) -> bool {
652 self.merged_prs == self.total_entries
653 }
654}