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