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: "* #N" - GitHub auto-expands to show title with rich previews
409                if is_current {
410                    let _ = writeln!(body, "* **#{} {STACK_COMMENT_THIS_PR}**", item.pr_number);
411                } else {
412                    let _ = writeln!(body, "* #{}", item.pr_number);
413                }
414            }
415            Platform::GitLab => {
416                // GitLab: "* [PR title !N](url)" - !N is MR reference, full link for clickability
417                if is_current {
418                    let _ = writeln!(
419                        body,
420                        "* **[{} !{}]({}) {STACK_COMMENT_THIS_PR}**",
421                        item.pr_title, item.pr_number, item.pr_url
422                    );
423                } else {
424                    let _ = writeln!(
425                        body,
426                        "* [{} !{}]({})",
427                        item.pr_title, item.pr_number, item.pr_url
428                    );
429                }
430            }
431        }
432    }
433
434    // Add base branch at bottom
435    let _ = writeln!(body, "* `{}`", data.base_branch);
436
437    let _ = write!(
438        body,
439        "\n---\nThis stack of pull requests is managed by [jj-ryu](https://github.com/dmmulroy/jj-ryu)."
440    );
441
442    Ok(body)
443}
444
445/// Create or update the stack comment on a PR
446async fn create_or_update_stack_comment(
447    platform: &dyn PlatformService,
448    data: &StackCommentData,
449    current_idx: usize,
450    pr_number: u64,
451) -> Result<()> {
452    let body = format_stack_comment_for_platform(data, current_idx, platform.config().platform)?;
453
454    // Find existing comment by looking for our data prefix (check both old and new)
455    let comments = platform.list_pr_comments(pr_number).await?;
456    let existing = comments
457        .iter()
458        .find(|c| c.body.contains(COMMENT_DATA_PREFIX) || c.body.contains(COMMENT_DATA_PREFIX_OLD));
459
460    if let Some(comment) = existing {
461        platform
462            .update_pr_comment(pr_number, comment.id, &body)
463            .await?;
464    } else {
465        platform.create_pr_comment(pr_number, &body).await?;
466    }
467
468    Ok(())
469}
470
471// =============================================================================
472// Tests
473// =============================================================================
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::types::NarrowedBookmarkSegment;
479
480    fn make_pr(number: u64, bookmark: &str) -> PullRequest {
481        PullRequest {
482            number,
483            html_url: format!("https://github.com/test/test/pull/{number}"),
484            base_ref: "main".to_string(),
485            head_ref: bookmark.to_string(),
486            title: format!("PR for {bookmark}"),
487            node_id: Some(format!("PR_node_{number}")),
488            is_draft: false,
489        }
490    }
491
492    fn make_bookmark(name: &str) -> Bookmark {
493        Bookmark {
494            name: name.to_string(),
495            commit_id: format!("{name}_commit"),
496            change_id: format!("{name}_change"),
497            has_remote: false,
498            is_synced: false,
499        }
500    }
501
502    // === SubmissionResult tests ===
503
504    #[test]
505    fn test_submission_result_new() {
506        let result = SubmissionResult::new();
507        assert!(result.success);
508        assert!(result.errors.is_empty());
509    }
510
511    #[test]
512    fn test_submission_result_fail() {
513        let mut result = SubmissionResult::new();
514        result.fail("something went wrong".to_string());
515
516        assert!(!result.success);
517        assert_eq!(result.errors.len(), 1);
518        assert_eq!(result.errors[0], "something went wrong");
519    }
520
521    #[test]
522    fn test_submission_result_soft_fail() {
523        let mut result = SubmissionResult::new();
524        result.soft_fail("minor issue".to_string());
525
526        // Soft fail records error but doesn't mark as failed
527        assert!(result.success);
528        assert_eq!(result.errors.len(), 1);
529    }
530
531    // === StepOutcome tests ===
532
533    #[test]
534    fn test_step_outcome_success_without_pr() {
535        let outcome = StepOutcome::Success(None);
536        assert!(matches!(outcome, StepOutcome::Success(None)));
537    }
538
539    #[test]
540    fn test_step_outcome_success_with_pr() {
541        let pr = make_pr(1, "feat-a");
542        let outcome = StepOutcome::Success(Some(("feat-a".to_string(), pr)));
543        assert!(matches!(outcome, StepOutcome::Success(Some(_))));
544    }
545
546    #[test]
547    fn test_step_outcome_fatal_error() {
548        let outcome = StepOutcome::FatalError("boom".to_string());
549        assert!(matches!(outcome, StepOutcome::FatalError(_)));
550    }
551
552    #[test]
553    fn test_step_outcome_soft_error() {
554        let outcome = StepOutcome::SoftError("minor".to_string());
555        assert!(matches!(outcome, StepOutcome::SoftError(_)));
556    }
557
558    // === Dry run formatting tests ===
559
560    #[test]
561    fn test_format_step_push() {
562        let bm = make_bookmark("feat-a");
563        let step = ExecutionStep::Push(bm);
564        let output = format_step_for_dry_run(&step, "origin");
565        assert_eq!(output, "  → push feat-a to origin");
566    }
567
568    #[test]
569    fn test_format_step_create_pr() {
570        let bm = make_bookmark("feat-a");
571        let create = PrToCreate {
572            bookmark: bm,
573            base_branch: "main".to_string(),
574            title: "Add feature".to_string(),
575            draft: false,
576        };
577        let step = ExecutionStep::CreatePr(create);
578        let output = format_step_for_dry_run(&step, "origin");
579        assert_eq!(output, "  → create PR feat-a → main (Add feature)");
580    }
581
582    #[test]
583    fn test_format_step_create_pr_draft() {
584        let bm = make_bookmark("feat-a");
585        let create = PrToCreate {
586            bookmark: bm,
587            base_branch: "main".to_string(),
588            title: "Add feature".to_string(),
589            draft: true,
590        };
591        let step = ExecutionStep::CreatePr(create);
592        let output = format_step_for_dry_run(&step, "origin");
593        assert!(output.contains("[draft]"));
594    }
595
596    #[test]
597    fn test_format_step_update_base() {
598        let bm = make_bookmark("feat-b");
599        let update = PrBaseUpdate {
600            bookmark: bm,
601            current_base: "main".to_string(),
602            expected_base: "feat-a".to_string(),
603            pr: make_pr(42, "feat-b"),
604        };
605        let step = ExecutionStep::UpdateBase(update);
606        let output = format_step_for_dry_run(&step, "origin");
607        assert_eq!(output, "  → update feat-b (PR #42) main → feat-a");
608    }
609
610    #[test]
611    fn test_format_step_publish() {
612        let pr = make_pr(99, "feat-a");
613        let step = ExecutionStep::PublishPr(pr);
614        let output = format_step_for_dry_run(&step, "origin");
615        assert_eq!(output, "  → publish PR #99 (feat-a)");
616    }
617
618    // === Stack comment tests ===
619
620    #[test]
621    fn test_build_stack_comment_data() {
622        let plan = SubmissionPlan {
623            segments: vec![
624                NarrowedBookmarkSegment {
625                    bookmark: make_bookmark("feat-a"),
626                    changes: vec![],
627                },
628                NarrowedBookmarkSegment {
629                    bookmark: make_bookmark("feat-b"),
630                    changes: vec![],
631                },
632            ],
633            constraints: vec![],
634            execution_steps: vec![],
635            existing_prs: HashMap::new(),
636            remote: "origin".to_string(),
637            default_branch: "main".to_string(),
638        };
639
640        let mut bookmark_to_pr = HashMap::new();
641        bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
642        bookmark_to_pr.insert("feat-b".to_string(), make_pr(2, "feat-b"));
643
644        let data = build_stack_comment_data(&plan, &bookmark_to_pr);
645
646        assert_eq!(data.version, 1);
647        assert_eq!(data.base_branch, "main");
648        assert_eq!(data.stack.len(), 2);
649        assert_eq!(data.stack[0].bookmark_name, "feat-a");
650        assert_eq!(data.stack[0].pr_number, 1);
651        assert_eq!(data.stack[0].pr_title, "PR for feat-a");
652        assert_eq!(data.stack[1].bookmark_name, "feat-b");
653        assert_eq!(data.stack[1].pr_number, 2);
654    }
655
656    #[test]
657    fn test_build_stack_comment_data_filters_missing_prs() {
658        let plan = SubmissionPlan {
659            segments: vec![
660                NarrowedBookmarkSegment {
661                    bookmark: make_bookmark("feat-a"),
662                    changes: vec![],
663                },
664                NarrowedBookmarkSegment {
665                    bookmark: make_bookmark("feat-b"),
666                    changes: vec![],
667                },
668            ],
669            constraints: vec![],
670            execution_steps: vec![],
671            existing_prs: HashMap::new(),
672            remote: "origin".to_string(),
673            default_branch: "main".to_string(),
674        };
675
676        // Only feat-a has a PR
677        let mut bookmark_to_pr = HashMap::new();
678        bookmark_to_pr.insert("feat-a".to_string(), make_pr(1, "feat-a"));
679
680        let data = build_stack_comment_data(&plan, &bookmark_to_pr);
681
682        assert_eq!(data.stack.len(), 1);
683        assert_eq!(data.stack[0].bookmark_name, "feat-a");
684    }
685
686    #[test]
687    fn test_format_stack_comment_marks_current() {
688        let data = StackCommentData {
689            version: 1,
690            stack: vec![
691                StackItem {
692                    bookmark_name: "feat-a".to_string(),
693                    pr_url: "https://example.com/1".to_string(),
694                    pr_number: 1,
695                    pr_title: "feat: add auth".to_string(),
696                },
697                StackItem {
698                    bookmark_name: "feat-b".to_string(),
699                    pr_url: "https://example.com/2".to_string(),
700                    pr_number: 2,
701                    pr_title: "feat: add sessions".to_string(),
702                },
703            ],
704            base_branch: "main".to_string(),
705        };
706
707        // Format for PR #2 (index 1)
708        let body = format_stack_comment(&data, 1).unwrap();
709        assert!(body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 2)));
710        assert!(!body.contains(&format!("#{} {STACK_COMMENT_THIS_PR}", 1)));
711    }
712
713    #[test]
714    fn test_format_stack_comment_contains_prefix() {
715        let data = StackCommentData {
716            version: 1,
717            stack: vec![StackItem {
718                bookmark_name: "feat-a".to_string(),
719                pr_url: "https://example.com/1".to_string(),
720                pr_number: 1,
721                pr_title: "feat: add auth".to_string(),
722            }],
723            base_branch: "main".to_string(),
724        };
725
726        let body = format_stack_comment(&data, 0).unwrap();
727        assert!(body.contains(COMMENT_DATA_PREFIX));
728        assert!(body.contains(COMMENT_DATA_POSTFIX));
729    }
730
731    #[test]
732    fn test_format_stack_comment_gitlab_uses_exclamation_mark() {
733        let data = StackCommentData {
734            version: 1,
735            stack: vec![
736                StackItem {
737                    bookmark_name: "feat-a".to_string(),
738                    pr_url: "https://gitlab.com/test/test/-/merge_requests/1".to_string(),
739                    pr_number: 1,
740                    pr_title: "feat: add auth".to_string(),
741                },
742                StackItem {
743                    bookmark_name: "feat-b".to_string(),
744                    pr_url: "https://gitlab.com/test/test/-/merge_requests/2".to_string(),
745                    pr_number: 2,
746                    pr_title: "feat: add sessions".to_string(),
747                },
748            ],
749            base_branch: "main".to_string(),
750        };
751
752        // GitLab format should use !N and full URLs
753        let body = format_stack_comment_for_platform(&data, 1, Platform::GitLab).unwrap();
754
755        // Should use !N (MR reference) not #N
756        assert!(body.contains("!1"), "GitLab should use !N for MRs: {body}");
757        assert!(body.contains("!2"), "GitLab should use !N for MRs: {body}");
758        assert!(!body.contains("#1"), "GitLab should NOT use #N: {body}");
759        assert!(!body.contains("#2"), "GitLab should NOT use #N: {body}");
760
761        // Should have full URLs
762        assert!(
763            body.contains("https://gitlab.com/test/test/-/merge_requests/1"),
764            "GitLab should include full URLs: {body}"
765        );
766
767        // Current PR should have marker
768        assert!(
769            body.contains(&format!(
770                "!2]({}) {STACK_COMMENT_THIS_PR}",
771                "https://gitlab.com/test/test/-/merge_requests/2"
772            )),
773            "Current PR should have marker: {body}"
774        );
775    }
776
777    #[test]
778    fn test_format_stack_comment_github_uses_hash() {
779        let data = StackCommentData {
780            version: 1,
781            stack: vec![StackItem {
782                bookmark_name: "feat-a".to_string(),
783                pr_url: "https://github.com/test/test/pull/1".to_string(),
784                pr_number: 1,
785                pr_title: "feat: add auth".to_string(),
786            }],
787            base_branch: "main".to_string(),
788        };
789
790        // GitHub format should use #N without URLs in the visible text
791        let body = format_stack_comment_for_platform(&data, 0, Platform::GitHub).unwrap();
792
793        assert!(body.contains("#1"), "GitHub should use #N: {body}");
794        assert!(!body.contains("!1"), "GitHub should NOT use !N: {body}");
795        // GitHub format doesn't include PR URLs in visible text (relies on auto-linking)
796        assert!(
797            !body.contains("](https://github.com/test/test/pull"),
798            "GitHub should NOT have markdown links to PRs: {body}"
799        );
800        // Title should NOT be in the output - GitHub will auto-expand it
801        assert!(
802            !body.contains("feat: add auth"),
803            "GitHub auto-expands, so title should not be in source: {body}"
804        );
805    }
806
807    // === Plan helper tests ===
808
809    #[test]
810    fn test_plan_is_empty() {
811        let plan = SubmissionPlan {
812            segments: vec![],
813            constraints: vec![],
814            execution_steps: vec![],
815            existing_prs: HashMap::new(),
816            remote: "origin".to_string(),
817            default_branch: "main".to_string(),
818        };
819
820        assert!(plan.is_empty());
821    }
822
823    #[test]
824    fn test_plan_counts() {
825        let bm = make_bookmark("feat-a");
826        let plan = SubmissionPlan {
827            segments: vec![NarrowedBookmarkSegment {
828                bookmark: bm.clone(),
829                changes: vec![],
830            }],
831            constraints: vec![],
832            execution_steps: vec![
833                ExecutionStep::Push(bm.clone()),
834                ExecutionStep::CreatePr(PrToCreate {
835                    bookmark: bm,
836                    base_branch: "main".to_string(),
837                    title: "Add feat-a".to_string(),
838                    draft: false,
839                }),
840            ],
841            existing_prs: HashMap::new(),
842            remote: "origin".to_string(),
843            default_branch: "main".to_string(),
844        };
845
846        assert!(!plan.is_empty());
847        assert_eq!(plan.count_pushes(), 1);
848        assert_eq!(plan.count_creates(), 1);
849        assert_eq!(plan.count_updates(), 0);
850        assert_eq!(plan.count_publishes(), 0);
851    }
852}