Skip to main content

jj_ryu/submit/
execute.rs

1//! Phase 3: Submission execution
2//!
3//! Executes the submission plan: push, create PRs, update bases, add comments.
4
5use crate::error::{Error, Result};
6use crate::platform::PlatformService;
7use crate::repo::JjWorkspace;
8use crate::submit::plan::{PrBaseUpdate, PrToCreate};
9use crate::submit::{ExecutionStep, Phase, ProgressCallback, PushStatus, SubmissionPlan};
10use crate::types::{Bookmark, Platform, PullRequest};
11use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt::Write;
15
16/// Result of submission execution
17#[derive(Debug, Clone, Default)]
18pub struct SubmissionResult {
19    /// Whether execution succeeded
20    pub success: bool,
21    /// PRs that were created
22    pub created_prs: Vec<PullRequest>,
23    /// PRs that were updated (base changed)
24    pub updated_prs: Vec<PullRequest>,
25    /// Bookmarks that were pushed
26    pub pushed_bookmarks: Vec<String>,
27    /// Errors encountered (non-fatal)
28    pub errors: Vec<String>,
29}
30
31impl SubmissionResult {
32    /// Create a new successful result
33    pub fn new() -> Self {
34        Self {
35            success: true,
36            ..Default::default()
37        }
38    }
39
40    /// Record a fatal error and mark as failed
41    pub fn fail(&mut self, error: String) {
42        self.errors.push(error);
43        self.success = false;
44    }
45
46    /// Record a non-fatal error (soft fail)
47    pub fn soft_fail(&mut self, error: String) {
48        self.errors.push(error);
49    }
50}
51
52/// Outcome of executing a single step
53#[derive(Debug)]
54pub enum StepOutcome {
55    /// Step succeeded, optionally with a PR to track
56    Success(Option<(String, PullRequest)>),
57    /// Step failed fatally - stop execution
58    FatalError(String),
59    /// Step failed but execution should continue (soft fail)
60    SoftError(String),
61}
62
63/// Stack comment data embedded in PR comments
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct StackCommentData {
66    /// Schema version
67    pub version: u8,
68    /// PRs in the stack, ordered root to leaf
69    pub stack: Vec<StackItem>,
70    /// Base branch name (e.g., "main")
71    pub base_branch: String,
72}
73
74/// A single item in the stack
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct StackItem {
77    /// Bookmark name for this PR
78    pub bookmark_name: String,
79    /// URL to the PR
80    pub pr_url: String,
81    /// PR number
82    pub pr_number: u64,
83    /// PR title
84    pub pr_title: String,
85}
86
87/// Prefix for stack comment data
88pub const COMMENT_DATA_PREFIX: &str = "<!--- JJ-RYU_STACK: ";
89const COMMENT_DATA_PREFIX_OLD: &str = "<!--- JJ-STACK_INFO: ";
90/// Postfix for stack comment data
91pub const COMMENT_DATA_POSTFIX: &str = " --->";
92/// Marker for the current PR in stack comments
93pub const STACK_COMMENT_THIS_PR: &str = "👈";
94
95// =============================================================================
96// Step Execution Functions (testable in isolation)
97// =============================================================================
98
99/// Execute a push step
100pub fn execute_push(workspace: &mut JjWorkspace, bookmark: &Bookmark, remote: &str) -> StepOutcome {
101    match workspace.git_push(&bookmark.name, remote) {
102        Ok(()) => StepOutcome::Success(None),
103        Err(e) => StepOutcome::FatalError(format!("Failed to push {}: {e}", bookmark.name)),
104    }
105}
106
107/// Execute an update base step
108pub async fn execute_update_base(
109    platform: &dyn PlatformService,
110    update: &PrBaseUpdate,
111) -> StepOutcome {
112    match platform
113        .update_pr_base(update.pr.number, &update.expected_base)
114        .await
115    {
116        Ok(updated_pr) => StepOutcome::Success(Some((update.bookmark.name.clone(), updated_pr))),
117        Err(e) => StepOutcome::FatalError(format!(
118            "Failed to update PR base for {}: {e}",
119            update.bookmark.name
120        )),
121    }
122}
123
124/// Execute a create PR step
125pub async fn execute_create_pr(platform: &dyn PlatformService, create: &PrToCreate) -> StepOutcome {
126    match platform
127        .create_pr_with_options(
128            &create.bookmark.name,
129            &create.base_branch,
130            &create.title,
131            create.draft,
132        )
133        .await
134    {
135        Ok(pr) => StepOutcome::Success(Some((create.bookmark.name.clone(), pr))),
136        Err(e) => StepOutcome::FatalError(format!(
137            "Failed to create PR for {}: {e}",
138            create.bookmark.name
139        )),
140    }
141}
142
143/// Execute a publish PR step (soft fail on error)
144pub async fn execute_publish_pr(platform: &dyn PlatformService, pr: &PullRequest) -> StepOutcome {
145    match platform.publish_pr(pr.number).await {
146        Ok(updated_pr) => StepOutcome::Success(Some((pr.head_ref.clone(), updated_pr))),
147        Err(e) => StepOutcome::SoftError(format!("Failed to publish PR #{}: {e}", pr.number)),
148    }
149}
150
151// =============================================================================
152// Main Execution Orchestrator
153// =============================================================================
154
155/// Execute a submission plan
156///
157/// This performs the actual operations:
158/// 1. Push bookmarks to remote
159/// 2. Update PR bases
160/// 3. Create new PRs
161/// 4. Publish draft PRs
162/// 5. Add/update stack comments
163pub async fn execute_submission(
164    plan: &SubmissionPlan,
165    workspace: &mut JjWorkspace,
166    platform: &dyn PlatformService,
167    progress: &dyn ProgressCallback,
168    dry_run: bool,
169) -> Result<SubmissionResult> {
170    let mut result = SubmissionResult::new();
171
172    if dry_run {
173        progress
174            .on_message("Dry run - no changes will be made")
175            .await;
176        report_dry_run(plan, progress).await;
177        return Ok(result);
178    }
179
180    // Track all PRs (existing + created) for comment generation
181    let mut bookmark_to_pr: HashMap<String, PullRequest> = plan.existing_prs.clone();
182
183    // Phase: Executing all steps
184    progress.on_phase(Phase::Executing).await;
185
186    for step in &plan.execution_steps {
187        let outcome = execute_step(step, workspace, platform, &plan.remote, progress).await;
188
189        match outcome {
190            StepOutcome::Success(Some((bookmark, pr))) => {
191                // Track the PR for comment generation
192                match step {
193                    ExecutionStep::CreatePr(_) => result.created_prs.push(pr.clone()),
194                    ExecutionStep::UpdateBase(_) | ExecutionStep::PublishPr(_) => {
195                        result.updated_prs.push(pr.clone());
196                    }
197                    ExecutionStep::Push(_) => {}
198                }
199                bookmark_to_pr.insert(bookmark, pr);
200            }
201            StepOutcome::Success(None) => {
202                // Push succeeded - track it
203                if let ExecutionStep::Push(bm) = step {
204                    result.pushed_bookmarks.push(bm.name.clone());
205                }
206            }
207            StepOutcome::FatalError(msg) => {
208                progress.on_error(&Error::Platform(msg.clone())).await;
209                result.fail(msg);
210                return Ok(result);
211            }
212            StepOutcome::SoftError(msg) => {
213                progress.on_error(&Error::Platform(msg.clone())).await;
214                result.soft_fail(msg);
215            }
216        }
217    }
218
219    // Phase: Adding stack comments
220    progress.on_phase(Phase::AddingComments).await;
221
222    if !bookmark_to_pr.is_empty() {
223        let stack_data = build_stack_comment_data(plan, &bookmark_to_pr);
224
225        for (idx, item) in stack_data.stack.iter().enumerate() {
226            if let Err(e) =
227                create_or_update_stack_comment(platform, &stack_data, idx, item.pr_number).await
228            {
229                let msg = format!(
230                    "Failed to update stack comment for {}: {e}",
231                    item.bookmark_name
232                );
233                progress.on_error(&Error::Platform(msg.clone())).await;
234                result.soft_fail(msg);
235            }
236        }
237    }
238
239    progress.on_phase(Phase::Complete).await;
240
241    Ok(result)
242}
243
244/// Execute a single step with progress reporting
245async fn execute_step(
246    step: &ExecutionStep,
247    workspace: &mut JjWorkspace,
248    platform: &dyn PlatformService,
249    remote: &str,
250    progress: &dyn ProgressCallback,
251) -> StepOutcome {
252    match step {
253        ExecutionStep::Push(bookmark) => {
254            progress
255                .on_bookmark_push(&bookmark.name, PushStatus::Started)
256                .await;
257
258            let outcome = execute_push(workspace, bookmark, remote);
259
260            match &outcome {
261                StepOutcome::Success(_) => {
262                    progress
263                        .on_bookmark_push(&bookmark.name, PushStatus::Success)
264                        .await;
265                }
266                StepOutcome::FatalError(msg) | StepOutcome::SoftError(msg) => {
267                    progress
268                        .on_bookmark_push(&bookmark.name, PushStatus::Failed(msg.clone()))
269                        .await;
270                }
271            }
272
273            outcome
274        }
275
276        ExecutionStep::UpdateBase(update) => {
277            progress
278                .on_message(&format!(
279                    "Updating {} base: {} → {}",
280                    update.bookmark.name, update.current_base, update.expected_base
281                ))
282                .await;
283
284            let outcome = execute_update_base(platform, update).await;
285
286            if let StepOutcome::Success(Some((bookmark, pr))) = &outcome {
287                progress.on_pr_updated(bookmark, pr).await;
288            }
289
290            outcome
291        }
292
293        ExecutionStep::CreatePr(create) => {
294            let draft_str = if create.draft { " [draft]" } else { "" };
295            progress
296                .on_message(&format!(
297                    "Creating PR for {} (base: {}){draft_str}",
298                    create.bookmark.name, create.base_branch
299                ))
300                .await;
301
302            let outcome = execute_create_pr(platform, create).await;
303
304            if let StepOutcome::Success(Some((bookmark, pr))) = &outcome {
305                progress.on_pr_created(bookmark, pr).await;
306            }
307
308            outcome
309        }
310
311        ExecutionStep::PublishPr(pr) => {
312            progress
313                .on_message(&format!("Publishing PR #{} ({})", pr.number, pr.head_ref))
314                .await;
315
316            execute_publish_pr(platform, pr).await
317        }
318    }
319}
320
321// =============================================================================
322// Dry Run Reporting
323// =============================================================================
324
325/// Report what would be done in a dry run
326async fn report_dry_run(plan: &SubmissionPlan, progress: &dyn ProgressCallback) {
327    if plan.execution_steps.is_empty() {
328        progress.on_message("Nothing to do - already in sync").await;
329        return;
330    }
331
332    progress.on_message("Would execute:").await;
333    for step in &plan.execution_steps {
334        let msg = format_step_for_dry_run(step, &plan.remote);
335        progress.on_message(&msg).await;
336    }
337}
338
339/// Format a step for dry run output
340pub fn format_step_for_dry_run(step: &ExecutionStep, remote: &str) -> String {
341    match step {
342        // Push needs special handling to include remote
343        ExecutionStep::Push(bm) => format!("  → push {} to {}", bm.name, remote),
344        // All other steps use Display impl
345        _ => format!("  → {step}"),
346    }
347}
348
349// =============================================================================
350// Stack Comment Functions
351// =============================================================================
352
353/// Build stack comment data from the plan and PRs
354#[allow(clippy::implicit_hasher)]
355pub fn build_stack_comment_data(
356    plan: &SubmissionPlan,
357    bookmark_to_pr: &HashMap<String, PullRequest>,
358) -> StackCommentData {
359    let stack: Vec<StackItem> = plan
360        .segments
361        .iter()
362        .filter_map(|seg| {
363            bookmark_to_pr.get(&seg.bookmark.name).map(|pr| StackItem {
364                bookmark_name: seg.bookmark.name.clone(),
365                pr_url: pr.html_url.clone(),
366                pr_number: pr.number,
367                pr_title: pr.title.clone(),
368            })
369        })
370        .collect();
371
372    StackCommentData {
373        version: 1,
374        stack,
375        base_branch: plan.default_branch.clone(),
376    }
377}
378
379/// Format the stack comment body for a PR (defaults to GitHub format)
380///
381/// For platform-specific formatting, use internal `format_stack_comment_for_platform`.
382pub fn format_stack_comment(data: &StackCommentData, current_idx: usize) -> Result<String> {
383    format_stack_comment_for_platform(data, current_idx, Platform::GitHub)
384}
385
386/// Format the stack comment body for a PR with platform-specific formatting
387///
388/// - GitHub: Uses `#N` which auto-links to PRs
389/// - GitLab: Uses `[title !N](url)` since `#N` links to issues, not MRs
390fn format_stack_comment_for_platform(
391    data: &StackCommentData,
392    current_idx: usize,
393    platform: Platform,
394) -> Result<String> {
395    let encoded_data = BASE64.encode(
396        serde_json::to_string(data)
397            .map_err(|e| Error::Internal(format!("Failed to serialize stack data: {e}")))?,
398    );
399
400    let mut body = format!("{COMMENT_DATA_PREFIX}{encoded_data}{COMMENT_DATA_POSTFIX}\n");
401
402    // Reverse order: newest/leaf at top, oldest at bottom
403    let reversed_idx = data.stack.len() - 1 - current_idx;
404    for (i, item) in data.stack.iter().rev().enumerate() {
405        let is_current = i == reversed_idx;
406        match platform {
407            Platform::GitHub => {
408                // GitHub: "* PR title #N" - #N auto-links to PRs
409                if is_current {
410                    let _ = writeln!(
411                        body,
412                        "* **{} #{} {STACK_COMMENT_THIS_PR}**",
413                        item.pr_title, item.pr_number
414                    );
415                } else {
416                    let _ = writeln!(body, "* {} #{}", item.pr_title, item.pr_number);
417                }
418            }
419            Platform::GitLab => {
420                // GitLab: "* [PR title !N](url)" - !N is MR reference, full link for clickability
421                if is_current {
422                    let _ = writeln!(
423                        body,
424                        "* **[{} !{}]({}) {STACK_COMMENT_THIS_PR}**",
425                        item.pr_title, item.pr_number, item.pr_url
426                    );
427                } else {
428                    let _ = writeln!(
429                        body,
430                        "* [{} !{}]({})",
431                        item.pr_title, item.pr_number, item.pr_url
432                    );
433                }
434            }
435        }
436    }
437
438    // Add base branch at bottom
439    let _ = writeln!(body, "* `{}`", data.base_branch);
440
441    let _ = write!(
442        body,
443        "\n---\nThis stack of pull requests is managed by [jj-ryu](https://github.com/dmmulroy/jj-ryu)."
444    );
445
446    Ok(body)
447}
448
449/// Create or update the stack comment on a PR
450async fn create_or_update_stack_comment(
451    platform: &dyn PlatformService,
452    data: &StackCommentData,
453    current_idx: usize,
454    pr_number: u64,
455) -> Result<()> {
456    let body = format_stack_comment_for_platform(data, current_idx, platform.config().platform)?;
457
458    // Find existing comment by looking for our data prefix (check both old and new)
459    let comments = platform.list_pr_comments(pr_number).await?;
460    let existing = comments
461        .iter()
462        .find(|c| c.body.contains(COMMENT_DATA_PREFIX) || c.body.contains(COMMENT_DATA_PREFIX_OLD));
463
464    if let Some(comment) = existing {
465        platform
466            .update_pr_comment(pr_number, comment.id, &body)
467            .await?;
468    } else {
469        platform.create_pr_comment(pr_number, &body).await?;
470    }
471
472    Ok(())
473}
474
475// =============================================================================
476// Tests
477// =============================================================================
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::types::NarrowedBookmarkSegment;
483
484    fn make_pr(number: u64, bookmark: &str) -> PullRequest {
485        PullRequest {
486            number,
487            html_url: format!("https://github.com/test/test/pull/{number}"),
488            base_ref: "main".to_string(),
489            head_ref: bookmark.to_string(),
490            title: format!("PR for {bookmark}"),
491            node_id: Some(format!("PR_node_{number}")),
492            is_draft: false,
493        }
494    }
495
496    fn make_bookmark(name: &str) -> Bookmark {
497        Bookmark {
498            name: name.to_string(),
499            commit_id: format!("{name}_commit"),
500            change_id: format!("{name}_change"),
501            has_remote: false,
502            is_synced: false,
503        }
504    }
505
506    // === SubmissionResult tests ===
507
508    #[test]
509    fn test_submission_result_new() {
510        let result = SubmissionResult::new();
511        assert!(result.success);
512        assert!(result.errors.is_empty());
513    }
514
515    #[test]
516    fn test_submission_result_fail() {
517        let mut result = SubmissionResult::new();
518        result.fail("something went wrong".to_string());
519
520        assert!(!result.success);
521        assert_eq!(result.errors.len(), 1);
522        assert_eq!(result.errors[0], "something went wrong");
523    }
524
525    #[test]
526    fn test_submission_result_soft_fail() {
527        let mut result = SubmissionResult::new();
528        result.soft_fail("minor issue".to_string());
529
530        // Soft fail records error but doesn't mark as failed
531        assert!(result.success);
532        assert_eq!(result.errors.len(), 1);
533    }
534
535    // === StepOutcome tests ===
536
537    #[test]
538    fn test_step_outcome_success_without_pr() {
539        let outcome = StepOutcome::Success(None);
540        assert!(matches!(outcome, StepOutcome::Success(None)));
541    }
542
543    #[test]
544    fn test_step_outcome_success_with_pr() {
545        let pr = make_pr(1, "feat-a");
546        let outcome = StepOutcome::Success(Some(("feat-a".to_string(), pr)));
547        assert!(matches!(outcome, StepOutcome::Success(Some(_))));
548    }
549
550    #[test]
551    fn test_step_outcome_fatal_error() {
552        let outcome = StepOutcome::FatalError("boom".to_string());
553        assert!(matches!(outcome, StepOutcome::FatalError(_)));
554    }
555
556    #[test]
557    fn test_step_outcome_soft_error() {
558        let outcome = StepOutcome::SoftError("minor".to_string());
559        assert!(matches!(outcome, StepOutcome::SoftError(_)));
560    }
561
562    // === Dry run formatting tests ===
563
564    #[test]
565    fn test_format_step_push() {
566        let bm = make_bookmark("feat-a");
567        let step = ExecutionStep::Push(bm);
568        let output = format_step_for_dry_run(&step, "origin");
569        assert_eq!(output, "  → push feat-a to origin");
570    }
571
572    #[test]
573    fn test_format_step_create_pr() {
574        let bm = make_bookmark("feat-a");
575        let create = PrToCreate {
576            bookmark: bm,
577            base_branch: "main".to_string(),
578            title: "Add feature".to_string(),
579            draft: false,
580        };
581        let step = ExecutionStep::CreatePr(create);
582        let output = format_step_for_dry_run(&step, "origin");
583        assert_eq!(output, "  → create PR feat-a → main (Add feature)");
584    }
585
586    #[test]
587    fn test_format_step_create_pr_draft() {
588        let bm = make_bookmark("feat-a");
589        let create = PrToCreate {
590            bookmark: bm,
591            base_branch: "main".to_string(),
592            title: "Add feature".to_string(),
593            draft: true,
594        };
595        let step = ExecutionStep::CreatePr(create);
596        let output = format_step_for_dry_run(&step, "origin");
597        assert!(output.contains("[draft]"));
598    }
599
600    #[test]
601    fn test_format_step_update_base() {
602        let bm = make_bookmark("feat-b");
603        let update = PrBaseUpdate {
604            bookmark: bm,
605            current_base: "main".to_string(),
606            expected_base: "feat-a".to_string(),
607            pr: make_pr(42, "feat-b"),
608        };
609        let step = ExecutionStep::UpdateBase(update);
610        let output = format_step_for_dry_run(&step, "origin");
611        assert_eq!(output, "  → update feat-b (PR #42) main → feat-a");
612    }
613
614    #[test]
615    fn test_format_step_publish() {
616        let pr = make_pr(99, "feat-a");
617        let step = ExecutionStep::PublishPr(pr);
618        let output = format_step_for_dry_run(&step, "origin");
619        assert_eq!(output, "  → publish PR #99 (feat-a)");
620    }
621
622    // === Stack comment tests ===
623
624    #[test]
625    fn test_build_stack_comment_data() {
626        let plan = SubmissionPlan {
627            segments: vec![
628                NarrowedBookmarkSegment {
629                    bookmark: make_bookmark("feat-a"),
630                    changes: vec![],
631                },
632                NarrowedBookmarkSegment {
633                    bookmark: make_bookmark("feat-b"),
634                    changes: vec![],
635                },
636            ],
637            constraints: vec![],
638            execution_steps: vec![],
639            existing_prs: HashMap::new(),
640            remote: "origin".to_string(),
641            default_branch: "main".to_string(),
642        };
643
644        let mut bookmark_to_pr = HashMap::new();
645        bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
646        bookmark_to_pr.insert("feat-b".to_string(), make_pr(2, "feat-b"));
647
648        let data = build_stack_comment_data(&plan, &bookmark_to_pr);
649
650        assert_eq!(data.version, 1);
651        assert_eq!(data.base_branch, "main");
652        assert_eq!(data.stack.len(), 2);
653        assert_eq!(data.stack[0].bookmark_name, "feat-a");
654        assert_eq!(data.stack[0].pr_number, 1);
655        assert_eq!(data.stack[0].pr_title, "PR for feat-a");
656        assert_eq!(data.stack[1].bookmark_name, "feat-b");
657        assert_eq!(data.stack[1].pr_number, 2);
658    }
659
660    #[test]
661    fn test_build_stack_comment_data_filters_missing_prs() {
662        let plan = SubmissionPlan {
663            segments: vec![
664                NarrowedBookmarkSegment {
665                    bookmark: make_bookmark("feat-a"),
666                    changes: vec![],
667                },
668                NarrowedBookmarkSegment {
669                    bookmark: make_bookmark("feat-b"),
670                    changes: vec![],
671                },
672            ],
673            constraints: vec![],
674            execution_steps: vec![],
675            existing_prs: HashMap::new(),
676            remote: "origin".to_string(),
677            default_branch: "main".to_string(),
678        };
679
680        // Only feat-a has a PR
681        let mut bookmark_to_pr = HashMap::new();
682        bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
683
684        let data = build_stack_comment_data(&plan, &bookmark_to_pr);
685
686        assert_eq!(data.stack.len(), 1);
687        assert_eq!(data.stack[0].bookmark_name, "feat-a");
688    }
689
690    #[test]
691    fn test_format_stack_comment_marks_current() {
692        let data = StackCommentData {
693            version: 1,
694            stack: vec![
695                StackItem {
696                    bookmark_name: "feat-a".to_string(),
697                    pr_url: "https://example.com/1".to_string(),
698                    pr_number: 1,
699                    pr_title: "feat: add auth".to_string(),
700                },
701                StackItem {
702                    bookmark_name: "feat-b".to_string(),
703                    pr_url: "https://example.com/2".to_string(),
704                    pr_number: 2,
705                    pr_title: "feat: add sessions".to_string(),
706                },
707            ],
708            base_branch: "main".to_string(),
709        };
710
711        // Format for PR #2 (index 1)
712        let body = format_stack_comment(&data, 1).unwrap();
713        assert!(body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 2)));
714        assert!(!body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 1)));
715    }
716
717    #[test]
718    fn test_format_stack_comment_contains_prefix() {
719        let data = StackCommentData {
720            version: 1,
721            stack: vec![StackItem {
722                bookmark_name: "feat-a".to_string(),
723                pr_url: "https://example.com/1".to_string(),
724                pr_number: 1,
725                pr_title: "feat: add auth".to_string(),
726            }],
727            base_branch: "main".to_string(),
728        };
729
730        let body = format_stack_comment(&data, 0).unwrap();
731        assert!(body.contains(COMMENT_DATA_PREFIX));
732        assert!(body.contains(COMMENT_DATA_POSTFIX));
733    }
734
735    #[test]
736    fn test_format_stack_comment_gitlab_uses_exclamation_mark() {
737        let data = StackCommentData {
738            version: 1,
739            stack: vec![
740                StackItem {
741                    bookmark_name: "feat-a".to_string(),
742                    pr_url: "https://gitlab.com/test/test/-/merge_requests/1".to_string(),
743                    pr_number: 1,
744                    pr_title: "feat: add auth".to_string(),
745                },
746                StackItem {
747                    bookmark_name: "feat-b".to_string(),
748                    pr_url: "https://gitlab.com/test/test/-/merge_requests/2".to_string(),
749                    pr_number: 2,
750                    pr_title: "feat: add sessions".to_string(),
751                },
752            ],
753            base_branch: "main".to_string(),
754        };
755
756        // GitLab format should use !N and full URLs
757        let body = format_stack_comment_for_platform(&data, 1, Platform::GitLab).unwrap();
758
759        // Should use !N (MR reference) not #N
760        assert!(body.contains("!1"), "GitLab should use !N for MRs: {body}");
761        assert!(body.contains("!2"), "GitLab should use !N for MRs: {body}");
762        assert!(!body.contains("#1"), "GitLab should NOT use #N: {body}");
763        assert!(!body.contains("#2"), "GitLab should NOT use #N: {body}");
764
765        // Should have full URLs
766        assert!(
767            body.contains("https://gitlab.com/test/test/-/merge_requests/1"),
768            "GitLab should include full URLs: {body}"
769        );
770
771        // Current PR should have marker
772        assert!(
773            body.contains(&format!(
774                "!2]({}) {STACK_COMMENT_THIS_PR}",
775                "https://gitlab.com/test/test/-/merge_requests/2"
776            )),
777            "Current PR should have marker: {body}"
778        );
779    }
780
781    #[test]
782    fn test_format_stack_comment_github_uses_hash() {
783        let data = StackCommentData {
784            version: 1,
785            stack: vec![StackItem {
786                bookmark_name: "feat-a".to_string(),
787                pr_url: "https://github.com/test/test/pull/1".to_string(),
788                pr_number: 1,
789                pr_title: "feat: add auth".to_string(),
790            }],
791            base_branch: "main".to_string(),
792        };
793
794        // GitHub format should use #N without URLs in the visible text
795        let body = format_stack_comment_for_platform(&data, 0, Platform::GitHub).unwrap();
796
797        assert!(body.contains("#1"), "GitHub should use #N: {body}");
798        assert!(!body.contains("!1"), "GitHub should NOT use !N: {body}");
799        // GitHub format doesn't include PR URLs in visible text (relies on auto-linking)
800        assert!(
801            !body.contains("](https://github.com/test/test/pull"),
802            "GitHub should NOT have markdown links to PRs: {body}"
803        );
804    }
805
806    // === Plan helper tests ===
807
808    #[test]
809    fn test_plan_is_empty() {
810        let plan = SubmissionPlan {
811            segments: vec![],
812            constraints: vec![],
813            execution_steps: vec![],
814            existing_prs: HashMap::new(),
815            remote: "origin".to_string(),
816            default_branch: "main".to_string(),
817        };
818
819        assert!(plan.is_empty());
820    }
821
822    #[test]
823    fn test_plan_counts() {
824        let bm = make_bookmark("feat-a");
825        let plan = SubmissionPlan {
826            segments: vec![NarrowedBookmarkSegment {
827                bookmark: bm.clone(),
828                changes: vec![],
829            }],
830            constraints: vec![],
831            execution_steps: vec![
832                ExecutionStep::Push(bm.clone()),
833                ExecutionStep::CreatePr(PrToCreate {
834                    bookmark: bm,
835                    base_branch: "main".to_string(),
836                    title: "Add feat-a".to_string(),
837                    draft: false,
838                }),
839            ],
840            existing_prs: HashMap::new(),
841            remote: "origin".to_string(),
842            default_branch: "main".to_string(),
843        };
844
845        assert!(!plan.is_empty());
846        assert_eq!(plan.count_pushes(), 1);
847        assert_eq!(plan.count_creates(), 1);
848        assert_eq!(plan.count_updates(), 0);
849        assert_eq!(plan.count_publishes(), 0);
850    }
851}