cascade_cli/bitbucket/
integration.rs

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};
12use uuid::Uuid;
13
14/// High-level integration between stacks and Bitbucket
15pub struct BitbucketIntegration {
16    stack_manager: StackManager,
17    pr_manager: PullRequestManager,
18    config: CascadeConfig,
19}
20
21impl BitbucketIntegration {
22    /// Create a new Bitbucket integration
23    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    /// Update all PR descriptions in a stack with current hierarchy
40    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            .cloned()
45            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
46
47        let mut updated_prs = Vec::new();
48
49        // Update each PR with current stack hierarchy
50        for entry in &stack.entries {
51            if let Some(pr_id_str) = &entry.pull_request_id {
52                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
53                    // Get current PR to get its version
54                    match self.pr_manager.get_pull_request(pr_id).await {
55                        Ok(pr) => {
56                            // Generate updated description with current stack state
57                            let updated_description = self.add_stack_hierarchy_footer(
58                                pr.description.clone().and_then(|desc| {
59                                    // Remove old stack hierarchy if present
60                                    desc.split("---\n\n## 📚 Stack:")
61                                        .next()
62                                        .map(|s| s.trim().to_string())
63                                }),
64                                &stack,
65                                entry,
66                            )?;
67
68                            // Update the PR description
69                            match self
70                                .pr_manager
71                                .update_pull_request(
72                                    pr_id,
73                                    None, // Don't change title
74                                    updated_description,
75                                    pr.version,
76                                )
77                                .await
78                            {
79                                Ok(_) => {
80                                    updated_prs.push(pr_id);
81                                }
82                                Err(e) => {
83                                    // Suppress verbose error logging for benign 409 conflicts
84                                    // These happen when PR was just created and version hasn't propagated
85                                    let error_msg = e.to_string();
86                                    if !error_msg.contains("409")
87                                        && !error_msg.contains("out-of-date")
88                                    {
89                                        debug!(
90                                            "Failed to update PR #{} description: {}",
91                                            pr_id,
92                                            error_msg.lines().next().unwrap_or("Unknown error")
93                                        );
94                                    }
95                                }
96                            }
97                        }
98                        Err(e) => {
99                            tracing::debug!("Failed to get PR #{} for update: {}", pr_id, e);
100                        }
101                    }
102                }
103            }
104        }
105
106        Ok(updated_prs)
107    }
108
109    /// Submit a single stack entry as a pull request
110    pub async fn submit_entry(
111        &mut self,
112        stack_id: &Uuid,
113        entry_id: &Uuid,
114        title: Option<String>,
115        description: Option<String>,
116        draft: bool,
117    ) -> Result<PullRequest> {
118        let stack = {
119            let stack_ref = self
120                .stack_manager
121                .get_stack(stack_id)
122                .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
123            stack_ref.clone()
124        };
125
126        let entry = stack
127            .get_entry(entry_id)
128            .ok_or_else(|| CascadeError::config(format!("Entry {entry_id} not found in stack")))?;
129
130        // Submitting stack entry as pull request
131
132        // 🆕 VALIDATE GIT INTEGRITY BEFORE SUBMISSION
133        if let Err(integrity_error) = stack.validate_git_integrity(self.stack_manager.git_repo()) {
134            return Err(CascadeError::validation(format!(
135                "Cannot submit entry from corrupted stack '{}':\n{}",
136                stack.name, integrity_error
137            )));
138        }
139
140        // Push branch to remote
141        let git_repo = self.stack_manager.git_repo();
142
143        // Determine if we need force-push:
144        // 1. Entry has a PR (was already submitted, may have been rebased)
145        // 2. Branch exists on remote but no PR yet (edge case: pushed but PR creation failed)
146        let branch_has_remote = git_repo.get_upstream_branch(&entry.branch)?.is_some();
147        let needs_force_push = entry.pull_request_id.is_some() || branch_has_remote;
148
149        if needs_force_push {
150            // Force push for existing PRs or branches already on remote
151            // Set env var to skip interactive confirmation during submit (user already confirmed submit action)
152            std::env::set_var("FORCE_PUSH_NO_CONFIRM", "1");
153            let result = git_repo.force_push_single_branch(&entry.branch);
154            std::env::remove_var("FORCE_PUSH_NO_CONFIRM");
155            result.map_err(|e| CascadeError::bitbucket(e.to_string()))?;
156        } else {
157            // Regular push for brand new submissions
158            git_repo
159                .push(&entry.branch)
160                .map_err(|e| CascadeError::bitbucket(e.to_string()))?;
161        }
162
163        // Branch pushed successfully
164
165        // Mark as pushed in metadata
166        if let Some(commit_meta) = self
167            .stack_manager
168            .get_repository_metadata()
169            .commits
170            .get(&entry.commit_hash)
171        {
172            let mut updated_meta = commit_meta.clone();
173            updated_meta.mark_pushed();
174            // Note: This would require making mark_pushed public and updating the metadata
175            // For now, we'll track this as a future enhancement
176        }
177
178        // Determine target branch (parent entry's branch or stack base)
179        let target_branch = self.get_target_branch(&stack, entry)?;
180
181        // Ensure target branch is also pushed to remote (if it's not the base branch)
182        if target_branch != stack.base_branch {
183            // Ensure target branch is pushed to remote
184
185            // Push target branch - fail fast if this fails
186            git_repo.push(&target_branch).map_err(|e| {
187                CascadeError::bitbucket(format!(
188                    "Failed to push target branch '{target_branch}': {e}. Cannot create PR without target branch. \
189                    Try manually pushing with: git push origin {target_branch}"
190                ))
191            })?;
192
193            // Target branch pushed successfully
194        }
195
196        // Create pull request
197        let pr_request =
198            self.create_pr_request(&stack, entry, &target_branch, title, description, draft)?;
199
200        let pr = match self.pr_manager.create_pull_request(pr_request).await {
201            Ok(pr) => pr,
202            Err(e) => {
203                return Err(CascadeError::bitbucket(format!(
204                    "Failed to create pull request for branch '{}' -> '{}': {}. \
205                    Ensure both branches exist in the remote repository. \
206                    You can manually push with: git push origin {}",
207                    entry.branch, target_branch, e, entry.branch
208                )));
209            }
210        };
211
212        // Update stack manager with PR information
213        self.stack_manager
214            .submit_entry(stack_id, entry_id, pr.id.to_string())?;
215
216        // Pull request created for entry
217        Ok(pr)
218    }
219
220    /// Check the status of all pull requests in a stack
221    pub async fn check_stack_status(&self, stack_id: &Uuid) -> Result<StackSubmissionStatus> {
222        let stack = self
223            .stack_manager
224            .get_stack(stack_id)
225            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
226
227        let mut status = StackSubmissionStatus {
228            stack_name: stack.name.clone(),
229            total_entries: stack.entries.len(),
230            submitted_entries: 0,
231            open_prs: 0,
232            merged_prs: 0,
233            declined_prs: 0,
234            pull_requests: Vec::new(),
235            enhanced_statuses: Vec::new(),
236        };
237
238        for entry in &stack.entries {
239            if let Some(pr_id_str) = &entry.pull_request_id {
240                status.submitted_entries += 1;
241
242                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
243                    match self.pr_manager.get_pull_request(pr_id).await {
244                        Ok(pr) => {
245                            match pr.state {
246                                PullRequestState::Open => status.open_prs += 1,
247                                PullRequestState::Merged => status.merged_prs += 1,
248                                PullRequestState::Declined => status.declined_prs += 1,
249                            }
250                            status.pull_requests.push(pr);
251                        }
252                        Err(e) => {
253                            tracing::debug!("Failed to get pull request #{}: {}", pr_id, e);
254                        }
255                    }
256                }
257            }
258        }
259
260        Ok(status)
261    }
262
263    /// List all pull requests for the repository
264    pub async fn list_pull_requests(
265        &self,
266        state: Option<PullRequestState>,
267    ) -> Result<crate::bitbucket::pull_request::PullRequestPage> {
268        self.pr_manager.list_pull_requests(state).await
269    }
270
271    /// Get the target branch for a stack entry
272    fn get_target_branch(&self, stack: &Stack, entry: &StackEntry) -> Result<String> {
273        // For the first entry (bottom of stack), target is the base branch
274        if let Some(first_entry) = stack.entries.first() {
275            if entry.id == first_entry.id {
276                return Ok(stack.base_branch.clone());
277            }
278        }
279
280        // For other entries, find the parent entry's branch
281        let entry_index = stack
282            .entries
283            .iter()
284            .position(|e| e.id == entry.id)
285            .ok_or_else(|| CascadeError::config("Entry not found in stack"))?;
286
287        if entry_index == 0 {
288            Ok(stack.base_branch.clone())
289        } else {
290            Ok(stack.entries[entry_index - 1].branch.clone())
291        }
292    }
293
294    /// Create a pull request request object
295    fn create_pr_request(
296        &self,
297        stack: &Stack,
298        entry: &StackEntry,
299        target_branch: &str,
300        title: Option<String>,
301        description: Option<String>,
302        draft: bool,
303    ) -> Result<CreatePullRequestRequest> {
304        let bitbucket_config = self.config.bitbucket.as_ref()
305            .ok_or_else(|| CascadeError::config("Bitbucket configuration is missing. Run 'ca setup' to configure Bitbucket integration."))?;
306
307        let repository = Repository {
308            id: 0, // This will be filled by the API
309            name: bitbucket_config.repo.clone(),
310            slug: bitbucket_config.repo.clone(),
311            scm_id: "git".to_string(),
312            state: "AVAILABLE".to_string(),
313            status_message: Some("Available".to_string()),
314            forkable: true,
315            project: Project {
316                id: 0,
317                key: bitbucket_config.project.clone(),
318                name: bitbucket_config.project.clone(),
319                description: None,
320                public: false,
321                project_type: "NORMAL".to_string(),
322            },
323            public: false,
324        };
325
326        let from_ref = PullRequestRef {
327            id: format!("refs/heads/{}", entry.branch),
328            display_id: entry.branch.clone(),
329            latest_commit: entry.commit_hash.clone(),
330            repository: repository.clone(),
331        };
332
333        let to_ref = PullRequestRef {
334            id: format!("refs/heads/{target_branch}"),
335            display_id: target_branch.to_string(),
336            latest_commit: "".to_string(), // This will be filled by the API
337            repository,
338        };
339
340        let mut title =
341            title.unwrap_or_else(|| entry.message.lines().next().unwrap_or("").to_string());
342
343        // Add [DRAFT] prefix for draft PRs
344        if draft && !title.starts_with("[DRAFT]") {
345            title = format!("[DRAFT] {title}");
346        }
347
348        let description = {
349            // Priority order: 1) Template (if configured), 2) User description, 3) Commit message body, 4) None
350            if let Some(template) = &self.config.cascade.pr_description_template {
351                Some(template.clone()) // Always use template if configured
352            } else if let Some(desc) = description {
353                Some(desc) // Use provided description if no template
354            } else if entry.message.lines().count() > 1 {
355                // Fallback to commit message body if no template and no description
356                Some(
357                    entry
358                        .message
359                        .lines()
360                        .skip(1)
361                        .collect::<Vec<_>>()
362                        .join("\n")
363                        .trim()
364                        .to_string(),
365                )
366            } else {
367                None
368            }
369        };
370
371        // Add stack hierarchy footer to description
372        let description_with_footer = self.add_stack_hierarchy_footer(description, stack, entry)?;
373
374        Ok(CreatePullRequestRequest {
375            title,
376            description: description_with_footer,
377            from_ref,
378            to_ref,
379            draft, // Explicitly set true or false for Bitbucket Server
380        })
381    }
382
383    /// Generate a beautiful stack hierarchy footer for PR descriptions
384    fn add_stack_hierarchy_footer(
385        &self,
386        description: Option<String>,
387        stack: &Stack,
388        current_entry: &StackEntry,
389    ) -> Result<Option<String>> {
390        let hierarchy = self.generate_stack_hierarchy(stack, current_entry)?;
391
392        let footer = format!("\n\n---\n\n## 📚 Stack: {}\n\n{}", stack.name, hierarchy);
393
394        match description {
395            Some(desc) => Ok(Some(format!("{desc}{footer}"))),
396            None => Ok(Some(footer.trim_start_matches('\n').to_string())),
397        }
398    }
399
400    /// Generate a visual hierarchy showing the stack structure
401    fn generate_stack_hierarchy(
402        &self,
403        stack: &Stack,
404        current_entry: &StackEntry,
405    ) -> Result<String> {
406        let mut hierarchy = String::new();
407
408        // Add visual tree directly without redundant info
409        hierarchy.push_str("### Stack Hierarchy\n\n");
410        hierarchy.push_str("```\n");
411
412        // Base branch
413        hierarchy.push_str(&format!("📍 {} (base)\n", stack.base_branch));
414
415        // Stack entries with visual connections
416        for (index, entry) in stack.entries.iter().enumerate() {
417            let is_current = entry.id == current_entry.id;
418            let is_last = index == stack.entries.len() - 1;
419
420            // Visual tree connector
421            let connector = if is_last { "└── " } else { "├── " };
422
423            // Entry indicator
424            let indicator = if is_current {
425                "← current"
426            } else if entry.pull_request_id.is_some() {
427                ""
428            } else {
429                "(pending)"
430            };
431
432            // PR link if available
433            let pr_info = if let Some(pr_id) = &entry.pull_request_id {
434                format!(" (PR #{pr_id})")
435            } else {
436                String::new()
437            };
438
439            hierarchy.push_str(&format!(
440                "{}{}{} {}\n",
441                connector, entry.branch, pr_info, indicator
442            ));
443        }
444
445        hierarchy.push_str("```\n\n");
446
447        // Add position context
448        if let Some(current_index) = stack.entries.iter().position(|e| e.id == current_entry.id) {
449            let position = current_index + 1;
450            let total = stack.entries.len();
451            hierarchy.push_str(&format!("**Position:** {position} of {total} in stack"));
452        }
453
454        Ok(hierarchy)
455    }
456
457    /// Update pull requests after a rebase using smart force push strategy
458    /// This preserves all review history by updating existing branches instead of creating new ones
459    pub async fn update_prs_after_rebase(
460        &mut self,
461        stack_id: &Uuid,
462        branch_mapping: &HashMap<String, String>,
463    ) -> Result<Vec<String>> {
464        debug!(
465            "Updating pull requests after rebase for stack {} using smart force push",
466            stack_id
467        );
468
469        let stack = self
470            .stack_manager
471            .get_stack(stack_id)
472            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?
473            .clone();
474
475        let mut updated_branches = Vec::new();
476
477        for entry in &stack.entries {
478            // Check if this entry has an existing PR and was remapped to a new branch
479            if let (Some(pr_id_str), Some(new_branch)) =
480                (&entry.pull_request_id, branch_mapping.get(&entry.branch))
481            {
482                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
483                    debug!(
484                        "Found existing PR #{} for entry {}, updating branch {} -> {}",
485                        pr_id, entry.id, entry.branch, new_branch
486                    );
487
488                    // Get the existing PR to understand its current state
489                    match self.pr_manager.get_pull_request_status(pr_id).await {
490                        Ok(pr_status) => {
491                            match pr_status.pr.state {
492                                crate::bitbucket::pull_request::PullRequestState::Merged => {
493                                    if let Err(e) = self
494                                        .stack_manager
495                                        .set_entry_merged(&stack.id, &entry.id, true)
496                                    {
497                                        tracing::warn!(
498                                            "Failed to persist merged state for entry {}: {}",
499                                            entry.id,
500                                            e
501                                        );
502                                    }
503                                    debug!(
504                                        "Skipping PR #{} update because it is already merged",
505                                        pr_id
506                                    );
507                                    continue;
508                                }
509                                crate::bitbucket::pull_request::PullRequestState::Declined => {
510                                    if let Err(e) = self
511                                        .stack_manager
512                                        .set_entry_merged(&stack.id, &entry.id, false)
513                                    {
514                                        tracing::warn!(
515                                            "Failed to persist merged state for entry {}: {}",
516                                            entry.id,
517                                            e
518                                        );
519                                    }
520                                    debug!("Skipping PR #{} update because it is declined", pr_id);
521                                    continue;
522                                }
523                                crate::bitbucket::pull_request::PullRequestState::Open => {
524                                    if let Err(e) = self
525                                        .stack_manager
526                                        .set_entry_merged(&stack.id, &entry.id, false)
527                                    {
528                                        tracing::warn!(
529                                            "Failed to persist merged state for entry {}: {}",
530                                            entry.id,
531                                            e
532                                        );
533                                    }
534                                }
535                            }
536
537                            // Ensure local branch head matches recorded commit before updating PR
538                            // If they don't match, auto-reconcile since we just finished a rebase
539                            if let Ok(local_head) =
540                                self.stack_manager.git_repo().get_branch_head(&entry.branch)
541                            {
542                                if local_head != entry.commit_hash {
543                                    tracing::debug!(
544                                        "Branch '{}' HEAD ({}) doesn't match metadata ({}), reconciling...",
545                                        entry.branch,
546                                        &local_head[..8],
547                                        &entry.commit_hash[..8]
548                                    );
549                                    
550                                    // Auto-reconcile: update metadata to match current branch HEAD
551                                    // This is safe during PR updates after a rebase since we know the branch was just updated
552                                    if let Some(stack) = self.stack_manager.get_stack_mut(&stack.id) {
553                                        if let Err(e) = stack.update_entry_commit_hash(&entry.id, local_head.clone()) {
554                                            Output::warning(format!(
555                                                "Could not reconcile metadata for PR #{}: {}",
556                                                pr_id, e
557                                            ));
558                                            continue;
559                                        }
560                                        // Save reconciled metadata
561                                        if let Err(e) = self.stack_manager.save_to_disk() {
562                                            Output::warning(format!(
563                                                "Could not save reconciled metadata: {}",
564                                                e
565                                            ));
566                                        }
567                                    }
568                                }
569                            }
570
571                            // Validate that the new branch contains cumulative changes
572                            if let Err(validation_error) =
573                                self.validate_cumulative_changes(&entry.branch, new_branch)
574                            {
575                                Output::error(format!(
576                                    "❌ Validation failed for PR #{pr_id}: {validation_error}"
577                                ));
578                                Output::warning("Skipping force push to prevent data loss");
579                                continue;
580                            }
581
582                            // Force push the new branch content to the old branch name
583                            // This preserves the PR while updating its contents
584                            match self
585                                .stack_manager
586                                .git_repo()
587                                .force_push_branch(&entry.branch, new_branch)
588                            {
589                                Ok(_) => {
590                                    debug!(
591                                        "Successfully force-pushed {} to preserve PR #{}",
592                                        entry.branch, pr_id
593                                    );
594
595                                    // Add a comment explaining the rebase
596                                    let rebase_comment = format!(
597                                        "🔄 **Automatic rebase completed**\n\n\
598                                        This PR has been automatically rebased onto the latest `{}`.\n\
599                                        - Updated commit: `{}`\n\
600                                        - All review history and comments are preserved",
601                                        stack.base_branch,
602                                        &entry.commit_hash[..8]
603                                    );
604
605                                    if let Err(e) =
606                                        self.pr_manager.add_comment(pr_id, &rebase_comment).await
607                                    {
608                                        tracing::debug!(
609                                            "Failed to add rebase comment to PR #{}: {}",
610                                            pr_id,
611                                            e
612                                        );
613                                    }
614
615                                    updated_branches.push(format!(
616                                        "PR #{}: {} (preserved)",
617                                        pr_id, entry.branch
618                                    ));
619                                }
620                                Err(e) => {
621                                    error!("Failed to force push {}: {}", entry.branch, e);
622                                    // Fall back to creating a comment about the issue
623                                    let error_comment = format!(
624                                        "⚠️ **Rebase Update Issue**\n\n\
625                                        The automatic rebase completed, but updating this PR failed.\n\
626                                        You may need to manually update this branch.\n\
627                                        Error: {e}"
628                                    );
629
630                                    if let Err(e2) =
631                                        self.pr_manager.add_comment(pr_id, &error_comment).await
632                                    {
633                                        tracing::debug!(
634                                            "Failed to add error comment to PR #{}: {}",
635                                            pr_id,
636                                            e2
637                                        );
638                                    }
639                                }
640                            }
641                        }
642                        Err(e) => {
643                            tracing::debug!("Could not retrieve PR #{}: {}", pr_id, e);
644                        }
645                    }
646                }
647            } else if branch_mapping.contains_key(&entry.branch) {
648                // This entry was remapped but doesn't have a PR yet
649                debug!(
650                    "Entry {} was remapped but has no PR - no action needed",
651                    entry.id
652                );
653            }
654        }
655
656        if !updated_branches.is_empty() {
657            debug!(
658                "Successfully updated {} PRs using smart force push strategy",
659                updated_branches.len()
660            );
661        }
662
663        Ok(updated_branches)
664    }
665
666    /// Validate that the new branch contains cumulative changes from the base
667    /// This prevents data loss during force push operations
668    fn validate_cumulative_changes(&self, original_branch: &str, new_branch: &str) -> Result<()> {
669        let git_repo = self.stack_manager.git_repo();
670
671        // Get the stack that contains this branch
672        let stack = self
673            .stack_manager
674            .get_all_stacks()
675            .into_iter()
676            .find(|s| s.entries.iter().any(|e| e.branch == original_branch))
677            .ok_or_else(|| {
678                CascadeError::config(format!(
679                    "No stack found containing branch '{original_branch}'"
680                ))
681            })?;
682
683        let _base_branch = &stack.base_branch;
684
685        // Ensure the updated branch exists locally
686        let new_head = git_repo.get_branch_head(new_branch).map_err(|e| {
687            CascadeError::validation(format!("Could not get HEAD for branch '{new_branch}': {e}"))
688        })?;
689
690        // If a remote counterpart exists, ensure the new head is a descendant before overwriting
691        match git_repo.get_remote_branch_head(original_branch) {
692            Ok(remote_head) => {
693                if remote_head != new_head && !git_repo.is_descendant_of(&new_head, &remote_head)? {
694                    return Err(CascadeError::validation(format!(
695                        "Local branch '{new_branch}' (commit {}) does not contain remote commit {}. Aborting force push.",
696                        &new_head[..8],
697                        &remote_head[..8]
698                    )));
699                }
700                tracing::debug!(
701                    "Validated ancestry for '{}': {} descends from {}",
702                    new_branch,
703                    &new_head[..8],
704                    &remote_head[..8]
705                );
706            }
707            Err(_) => {
708                tracing::debug!(
709                    "No remote tracking branch for '{}' - skipping ancestor validation",
710                    original_branch
711                );
712            }
713        }
714
715        Ok(())
716    }
717
718    /// Check the enhanced status of all pull requests in a stack
719    pub async fn check_enhanced_stack_status(
720        &mut self,
721        stack_id: &Uuid,
722    ) -> Result<StackSubmissionStatus> {
723        let stack = self
724            .stack_manager
725            .get_stack(stack_id)
726            .cloned()
727            .ok_or_else(|| CascadeError::config(format!("Stack {stack_id} not found")))?;
728
729        let stack_uuid = stack.id;
730
731        let mut status = StackSubmissionStatus {
732            stack_name: stack.name.clone(),
733            total_entries: stack.entries.len(),
734            submitted_entries: 0,
735            open_prs: 0,
736            merged_prs: 0,
737            declined_prs: 0,
738            pull_requests: Vec::new(),
739            enhanced_statuses: Vec::new(),
740        };
741
742        let mut merged_updates: Vec<(Uuid, bool)> = Vec::new();
743
744        for entry in &stack.entries {
745            if let Some(pr_id_str) = &entry.pull_request_id {
746                status.submitted_entries += 1;
747
748                if let Ok(pr_id) = pr_id_str.parse::<u64>() {
749                    // Get enhanced status instead of basic PR
750                    match self.pr_manager.get_pull_request_status(pr_id).await {
751                        Ok(enhanced_status) => {
752                            match enhanced_status.pr.state {
753                                crate::bitbucket::pull_request::PullRequestState::Open => {
754                                    status.open_prs += 1;
755                                    merged_updates.push((entry.id, false));
756                                }
757                                crate::bitbucket::pull_request::PullRequestState::Merged => {
758                                    status.merged_prs += 1;
759                                    merged_updates.push((entry.id, true));
760                                }
761                                crate::bitbucket::pull_request::PullRequestState::Declined => {
762                                    status.declined_prs += 1;
763                                    merged_updates.push((entry.id, false));
764                                }
765                            }
766                            status.pull_requests.push(enhanced_status.pr.clone());
767                            status.enhanced_statuses.push(enhanced_status);
768                        }
769                        Err(e) => {
770                            tracing::debug!(
771                                "Failed to get enhanced status for PR #{}: {}",
772                                pr_id,
773                                e
774                            );
775                            // Fallback to basic PR info
776                            match self.pr_manager.get_pull_request(pr_id).await {
777                                Ok(pr) => {
778                                    match pr.state {
779                                        crate::bitbucket::pull_request::PullRequestState::Open => {
780                                            status.open_prs += 1;
781                                            merged_updates.push((entry.id, false));
782                                        }
783                                        crate::bitbucket::pull_request::PullRequestState::Merged => {
784                                            status.merged_prs += 1;
785                                            merged_updates.push((entry.id, true));
786                                        }
787                                        crate::bitbucket::pull_request::PullRequestState::Declined => {
788                                            status.declined_prs += 1;
789                                            merged_updates.push((entry.id, false));
790                                        }
791                                    }
792                                    status.pull_requests.push(pr);
793                                }
794                                Err(e2) => {
795                                    tracing::debug!("Failed to get basic PR #{}: {}", pr_id, e2);
796                                }
797                            }
798                        }
799                    }
800                }
801            }
802        }
803
804        drop(stack);
805
806        if !merged_updates.is_empty() {
807            for (entry_id, merged) in merged_updates {
808                if let Err(e) = self
809                    .stack_manager
810                    .set_entry_merged(&stack_uuid, &entry_id, merged)
811                {
812                    tracing::warn!(
813                        "Failed to persist merged state for entry {}: {}",
814                        entry_id,
815                        e
816                    );
817                }
818            }
819        }
820
821        Ok(status)
822    }
823}
824
825/// Status of stack submission with enhanced mergability information
826#[derive(Debug)]
827pub struct StackSubmissionStatus {
828    pub stack_name: String,
829    pub total_entries: usize,
830    pub submitted_entries: usize,
831    pub open_prs: usize,
832    pub merged_prs: usize,
833    pub declined_prs: usize,
834    pub pull_requests: Vec<PullRequest>,
835    pub enhanced_statuses: Vec<crate::bitbucket::pull_request::PullRequestStatus>,
836}
837
838impl StackSubmissionStatus {
839    /// Calculate completion percentage (merged PRs / submitted PRs)
840    ///
841    /// Measures completion of submitted work, not total stack entries.
842    /// This changed from `total_entries` to `submitted_entries` as divisor
843    /// to provide a more accurate measure of review progress.
844    pub fn completion_percentage(&self) -> f64 {
845        if self.submitted_entries == 0 {
846            0.0
847        } else {
848            (self.merged_prs as f64 / self.submitted_entries as f64) * 100.0
849        }
850    }
851
852    /// Check if all entries are submitted
853    pub fn all_submitted(&self) -> bool {
854        self.submitted_entries == self.total_entries
855    }
856
857    /// Check if all PRs are merged
858    pub fn all_merged(&self) -> bool {
859        self.submitted_entries > 0 && self.merged_prs == self.submitted_entries
860    }
861}