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 update_all_pr_descriptions(&self, stack_id: &Uuid) -> Result<Vec<u64>> {
40 let stack = self
41 .stack_manager
42 .get_stack(stack_id)
43 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
44
45 let mut updated_prs = Vec::new();
46
47 for entry in &stack.entries {
49 if let Some(pr_id_str) = &entry.pull_request_id {
50 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
51 match self.pr_manager.get_pull_request(pr_id).await {
53 Ok(pr) => {
54 let updated_description = self.add_stack_hierarchy_footer(
56 pr.description.clone().and_then(|desc| {
57 desc.split("---\n\n## 📚 Stack:")
59 .next()
60 .map(|s| s.trim().to_string())
61 }),
62 stack,
63 entry,
64 )?;
65
66 match self
68 .pr_manager
69 .update_pull_request(
70 pr_id,
71 None, updated_description,
73 pr.version,
74 )
75 .await
76 {
77 Ok(_) => {
78 updated_prs.push(pr_id);
79 }
80 Err(e) => {
81 warn!("Failed to update PR #{}: {}", pr_id, e);
82 }
83 }
84 }
85 Err(e) => {
86 warn!("Failed to get PR #{} for update: {}", pr_id, e);
87 }
88 }
89 }
90 }
91 }
92
93 Ok(updated_prs)
94 }
95
96 pub async fn submit_entry(
98 &mut self,
99 stack_id: &Uuid,
100 entry_id: &Uuid,
101 title: Option<String>,
102 description: Option<String>,
103 draft: bool,
104 ) -> Result<PullRequest> {
105 let stack = self
106 .stack_manager
107 .get_stack(stack_id)
108 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
109
110 let entry = stack
111 .get_entry(entry_id)
112 .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
113
114 if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
118 return Err(CascadeError::validation(format!(
119 "Cannot submit entry from corrupted stack '{}':\n{}",
120 stack.name, integrity_error
121 )));
122 }
123
124 let git_repo = self.stack_manager.git_repo();
126 git_repo
128 .push(&entry.branch)
129 .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
130
131 if let Some(commit_meta) = self
135 .stack_manager
136 .get_repository_metadata()
137 .commits
138 .get(&entry.commit_hash)
139 {
140 let mut updated_meta = commit_meta.clone();
141 updated_meta.mark_pushed();
142 }
145
146 let target_branch = self.get_target_branch(stack, entry)?;
148
149 if target_branch != stack.base_branch {
151 git_repo.push(&target_branch).map_err(|e| {
155 CascadeError::bitbucket(format!(
156 "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
157 Try manually pushing with: git push origin {target_branch}"
158 ))
159 })?;
160
161 }
163
164 let pr_request =
166 self.create_pr_request(stack, entry, &target_branch, title, description, draft)?;
167
168 let pr = match self.pr_manager.create_pull_request(pr_request).await {
169 Ok(pr) => pr,
170 Err(e) => {
171 return Err(CascadeError::bitbucket(format!(
172 "Failed to create pull request for branch '{}' -> '{}': {}. \
173 Ensure both branches exist in the remote repository. \
174 You can manually push with: git push origin {}",
175 entry.branch, target_branch, e, entry.branch
176 )));
177 }
178 };
179
180 self.stack_manager
182 .submit_entry(stack_id, entry_id, pr.id.to_string())?;
183
184 Ok(pr)
186 }
187
188 pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
190 let stack = self
191 .stack_manager
192 .get_stack(stack_id)
193 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
194
195 let mut status = StackSubmissionStatus {
196 stack_name: stack.name.clone(),
197 total_entries: stack.entries.len(),
198 submitted_entries: 0,
199 open_prs: 0,
200 merged_prs: 0,
201 declined_prs: 0,
202 pull_requests: Vec::new(),
203 enhanced_statuses: Vec::new(),
204 };
205
206 for entry in &stack.entries {
207 if let Some(pr_id_str) = &entry.pull_request_id {
208 status.submitted_entries += 1;
209
210 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
211 match self.pr_manager.get_pull_request(pr_id).await {
212 Ok(pr) => {
213 match pr.state {
214 PullRequestState::Open => status.open_prs += 1,
215 PullRequestState::Merged => status.merged_prs += 1,
216 PullRequestState::Declined => status.declined_prs += 1,
217 }
218 status.pull_requests.push(pr);
219 }
220 Err(e) => {
221 warn!("Failed to get pull request #{}: {}", pr_id, e);
222 }
223 }
224 }
225 }
226 }
227
228 Ok(status)
229 }
230
231 pub async fn list_pull_requests(
233 &self,
234 state: Option<PullRequestState>,
235 ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
236 self.pr_manager.list_pull_requests(state).await
237 }
238
239 fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
241 if let Some(first_entry) = stack.entries.first() {
243 if entry.id == first_entry.id {
244 return Ok(stack.base_branch.clone());
245 }
246 }
247
248 let entry_index = stack
250 .entries
251 .iter()
252 .position(|e| e.id == entry.id)
253 .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
254
255 if entry_index == 0 {
256 Ok(stack.base_branch.clone())
257 } else {
258 Ok(stack.entries[entry_index - 1].branch.clone())
259 }
260 }
261
262 fn create_pr_request(
264 &self,
265 stack: &Stack,
266 entry: &StackEntry,
267 target_branch: &str,
268 title: Option<String>,
269 description: Option<String>,
270 draft: bool,
271 ) -> Result<CreatePullRequestRequest> {
272 let bitbucket_config = self.config.bitbucket.as_ref()
273 .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
274
275 let repository = Repository {
276 id: 0, name: bitbucket_config.repo.clone(),
278 slug: bitbucket_config.repo.clone(),
279 scm_id: "git".to_string(),
280 state: "AVAILABLE".to_string(),
281 status_message: "Available".to_string(),
282 forkable: true,
283 project: Project {
284 id: 0,
285 key: bitbucket_config.project.clone(),
286 name: bitbucket_config.project.clone(),
287 description: None,
288 public: false,
289 project_type: "NORMAL".to_string(),
290 },
291 public: false,
292 };
293
294 let from_ref = PullRequestRef {
295 id: format!("refs/heads/{}", entry.branch),
296 display_id: entry.branch.clone(),
297 latest_commit: entry.commit_hash.clone(),
298 repository: repository.clone(),
299 };
300
301 let to_ref = PullRequestRef {
302 id: format!("refs/heads/{target_branch}"),
303 display_id: target_branch.to_string(),
304 latest_commit: "".to_string(), repository,
306 };
307
308 let mut title =
309 title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
310
311 if draft && !title.starts_with("[DRAFT]") {
313 title = format!("[DRAFT] {title}");
314 }
315
316 let description = {
317 if let Some(template) = &self.config.cascade.pr_description_template {
319 Some(template.clone()) } else if let Some(desc) = description {
321 Some(desc) } else if entry.message.lines().count() > 1 {
323 Some(
325 entry
326 .message
327 .lines()
328 .skip(1)
329 .collect::<Vec<_>>()
330 .join("\n")
331 .trim()
332 .to_string(),
333 )
334 } else {
335 None
336 }
337 };
338
339 let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
341
342 Ok(CreatePullRequestRequest {
343 title,
344 description: description_with_footer,
345 from_ref,
346 to_ref,
347 draft, })
349 }
350
351 fn add_stack_hierarchy_footer(
353 &self,
354 description: Option<String>,
355 stack: &Stack,
356 current_entry: &StackEntry,
357 ) -> Result<Option<String>> {
358 let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
359
360 let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
361
362 match description {
363 Some(desc) => Ok(Some(format!("{desc}{footer}"))),
364 None => Ok(Some(footer.trim_start_matches('\n').to_string())),
365 }
366 }
367
368 fn generate_stack_hierarchy(
370 &self,
371 stack: &Stack,
372 current_entry: &StackEntry,
373 ) -> Result<String> {
374 let mut hierarchy = String::new();
375
376 hierarchy.push_str("### Stack Hierarchy\n\n");
378 hierarchy.push_str("```\n");
379
380 hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
382
383 for (index, entry) in stack.entries.iter().enumerate() {
385 let is_current = entry.id == current_entry.id;
386 let is_last = index == stack.entries.len() - 1;
387
388 let connector = if is_last { "└── " } else { "├── " };
390
391 let indicator = if is_current {
393 "← current"
394 } else if entry.pull_request_id.is_some() {
395 ""
396 } else {
397 "(pending)"
398 };
399
400 let pr_info = if let Some(pr_id) = &entry.pull_request_id {
402 format!(" (PR #{pr_id})")
403 } else {
404 String::new()
405 };
406
407 hierarchy.push_str(&format!(
408 "{}{}{} {}\n",
409 connector, entry.branch, pr_info, indicator
410 ));
411 }
412
413 hierarchy.push_str("```\n\n");
414
415 if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
417 let position = current_index + 1;
418 let total = stack.entries.len();
419 hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
420 }
421
422 Ok(hierarchy)
423 }
424
425 pub async fn update_prs_after_rebase(
428 &mut self,
429 stack_id: &Uuid,
430 branch_mapping: &HashMap<String, String>,
431 ) -> Result<Vec<String>> {
432 info!(
433 "Updating pull requests after rebase for stack {} using smart force push",
434 stack_id
435 );
436
437 let stack = self
438 .stack_manager
439 .get_stack(stack_id)
440 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
441 .clone();
442
443 let mut updated_branches = Vec::new();
444
445 for entry in &stack.entries {
446 if let (Some(pr_id_str), Some(new_branch)) =
448 (&entry.pull_request_id, branch_mapping.get(&entry.branch))
449 {
450 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
451 info!(
452 "Found existing PR #{} for entry {}, updating branch {} -> {}",
453 pr_id, entry.id, entry.branch, new_branch
454 );
455
456 match self.pr_manager.get_pull_request(pr_id).await {
458 Ok(_existing_pr) => {
459 match self
462 .stack_manager
463 .git_repo()
464 .force_push_branch(&entry.branch, new_branch)
465 {
466 Ok(_) => {
467 info!(
468 "✅ Successfully force-pushed {} to preserve PR #{}",
469 entry.branch, pr_id
470 );
471
472 let rebase_comment = format!(
474 "🔄 **Automatic rebase completed**\n\n\
475 This PR has been automatically rebased to incorporate the latest changes.\n\
476 - Original commits: `{}`\n\
477 - New base: Latest main branch\n\
478 - All review history and comments are preserved\n\n\
479 The changes in this PR remain the same - only the base has been updated.",
480 &entry.commit_hash[..8]
481 );
482
483 if let Err(e) =
484 self.pr_manager.add_comment(pr_id, &rebase_comment).await
485 {
486 warn!(
487 "Failed to add rebase comment to PR #{}: {}",
488 pr_id, e
489 );
490 }
491
492 updated_branches.push(format!(
493 "PR #{}: {} (preserved)",
494 pr_id, entry.branch
495 ));
496 }
497 Err(e) => {
498 error!("Failed to force push {}: {}", entry.branch, e);
499 let error_comment = format!(
501 "⚠️ **Rebase Update Issue**\n\n\
502 The automatic rebase completed, but updating this PR failed.\n\
503 You may need to manually update this branch.\n\
504 Error: {e}"
505 );
506
507 if let Err(e2) =
508 self.pr_manager.add_comment(pr_id, &error_comment).await
509 {
510 warn!(
511 "Failed to add error comment to PR #{}: {}",
512 pr_id, e2
513 );
514 }
515 }
516 }
517 }
518 Err(e) => {
519 warn!("Could not retrieve PR #{}: {}", pr_id, e);
520 }
521 }
522 }
523 } else if branch_mapping.contains_key(&entry.branch) {
524 info!(
526 "Entry {} was remapped but has no PR - no action needed",
527 entry.id
528 );
529 }
530 }
531
532 if !updated_branches.is_empty() {
533 info!(
534 "Successfully updated {} PRs using smart force push strategy",
535 updated_branches.len()
536 );
537 }
538
539 Ok(updated_branches)
540 }
541
542 pub async fn check_enhanced_stack_status(
544 &self,
545 stack_id: &Uuid,
546 ) -> Result<StackSubmissionStatus> {
547 let stack = self
548 .stack_manager
549 .get_stack(stack_id)
550 .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
551
552 let mut status = StackSubmissionStatus {
553 stack_name: stack.name.clone(),
554 total_entries: stack.entries.len(),
555 submitted_entries: 0,
556 open_prs: 0,
557 merged_prs: 0,
558 declined_prs: 0,
559 pull_requests: Vec::new(),
560 enhanced_statuses: Vec::new(),
561 };
562
563 for entry in &stack.entries {
564 if let Some(pr_id_str) = &entry.pull_request_id {
565 status.submitted_entries += 1;
566
567 if let Ok(pr_id) = pr_id_str.parse::<u64>() {
568 match self.pr_manager.get_pull_request_status(pr_id).await {
570 Ok(enhanced_status) => {
571 match enhanced_status.pr.state {
572 crate::bitbucket::pull_request::PullRequestState::Open => {
573 status.open_prs += 1
574 }
575 crate::bitbucket::pull_request::PullRequestState::Merged => {
576 status.merged_prs += 1
577 }
578 crate::bitbucket::pull_request::PullRequestState::Declined => {
579 status.declined_prs += 1
580 }
581 }
582 status.pull_requests.push(enhanced_status.pr.clone());
583 status.enhanced_statuses.push(enhanced_status);
584 }
585 Err(e) => {
586 warn!("Failed to get enhanced status for PR #{}: {}", pr_id, e);
587 match self.pr_manager.get_pull_request(pr_id).await {
589 Ok(pr) => {
590 match pr.state {
591 crate::bitbucket::pull_request::PullRequestState::Open => status.open_prs += 1,
592 crate::bitbucket::pull_request::PullRequestState::Merged => status.merged_prs += 1,
593 crate::bitbucket::pull_request::PullRequestState::Declined => status.declined_prs += 1,
594 }
595 status.pull_requests.push(pr);
596 }
597 Err(e2) => {
598 warn!("Failed to get basic PR #{}: {}", pr_id, e2);
599 }
600 }
601 }
602 }
603 }
604 }
605 }
606
607 Ok(status)
608 }
609}
610
611#[derive(Debug)]
613pub struct StackSubmissionStatus {
614 pub stack_name: String,
615 pub total_entries: usize,
616 pub submitted_entries: usize,
617 pub open_prs: usize,
618 pub merged_prs: usize,
619 pub declined_prs: usize,
620 pub pull_requests: Vec<PullRequest>,
621 pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
622}
623
624impl StackSubmissionStatus {
625 pub fn completion_percentage(&self) -> f64 {
627 if self.total_entries == 0 {
628 0.0
629 } else {
630 (self.merged_prs as f64 / self.total_entries as f64) * 100.0
631 }
632 }
633
634 pub fn all_submitted(&self) -> bool {
636 self.submitted_entries == self.total_entries
637 }
638
639 pub fn all_merged(&self) -> bool {
641 self.merged_prs == self.total_entries
642 }
643}