From e70b4b74ccfc2678a904bbf89b7ee02f88f006b6 Mon Sep 17 00:00:00 2001 From: Luis Ball Date: Mon, 29 Dec 2025 12:55:42 -0800 Subject: [PATCH] feat: add status module with stack health indicators Add status.rs with: - StatusBit enum (Passed/Failed/Pending/NotApplicable) - PrStatus struct with CI/approval/mergeable/stack_clear bits - build_status_entries() - fetch and compute status for stack - render_status() - format output with status bits (unicode/ASCII) - render_status_json() - JSON output format - Legend detection via ~/.gh-stack-legend-seen (first-run only) - Title truncation to 50 chars Includes 38 tests: - 29 unit tests for status bit logic, formatting, stack computation - 9 snapshot tests for output rendering Also adds dirs dependency for home directory detection and Serialize derive to CommitInfo for JSON output. --- Cargo.lock | 49 + Cargo.toml | 1 + src/lib.rs | 1 + ...s__tests__snapshot_status_all_passing.snap | 11 + ...__status__tests__snapshot_status_json.snap | 30 + ..._status__tests__snapshot_status_mixed.snap | 11 + ...tus__tests__snapshot_status_no_checks.snap | 9 + ...tus__tests__snapshot_status_single_pr.snap | 8 + ...tatus__tests__snapshot_status_unicode.snap | 8 + ...__tests__snapshot_status_with_commits.snap | 12 + ...us__tests__snapshot_status_with_draft.snap | 11 + ...s__tests__snapshot_status_with_legend.snap | 11 + src/status.rs | 1395 +++++++++++++++++ src/tree.rs | 2 +- 14 files changed, 1558 insertions(+), 1 deletion(-) create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_all_passing.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_json.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_mixed.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_no_checks.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_single_pr.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_unicode.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_with_commits.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_with_draft.snap create mode 100644 src/snapshots/gh_stack__status__tests__snapshot_status_with_legend.snap create mode 100644 src/status.rs diff --git a/Cargo.lock b/Cargo.lock index 93b0917..ca99920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -426,6 +447,7 @@ dependencies = [ "clap", "console", "dialoguer", + "dirs", "dotenvy", "futures", "git2", @@ -874,6 +896,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "libz-sys" version = "1.1.19" @@ -973,6 +1005,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1157,6 +1195,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.12.2" diff --git a/Cargo.toml b/Cargo.toml index afc61a7..2ed1fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ petgraph = "0.6" regex = "1" git2 = { version = "0.18", default-features = false } dialoguer = "0.11" +dirs = "5" clap = "2.34" console = "0.15" dotenvy = "0.15" diff --git a/src/lib.rs b/src/lib.rs index c3072a3..7ad85ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod graph; pub mod land; pub mod markdown; pub mod persist; +pub mod status; pub mod tree; pub mod util; diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_all_passing.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_all_passing.snap new file mode 100644 index 0000000..646def4 --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_all_passing.snap @@ -0,0 +1,11 @@ +--- +source: src/status.rs +expression: output +--- +* feature-2 (current) #124 - Add new feature +| [Y Y Y Y] +| +o feature-1 #123 - Setup base +| [Y Y Y Y] +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_json.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_json.snap new file mode 100644 index 0000000..8c977c2 --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_json.snap @@ -0,0 +1,30 @@ +--- +source: src/status.rs +expression: output +--- +{ + "stack": [ + { + "branch": "feature-1", + "pr_number": 123, + "title": "Add feature", + "is_current": true, + "is_draft": false, + "is_trunk": false, + "status": { + "ci": "passed", + "approved": "passed", + "mergeable": "passed", + "stack_clear": "passed" + }, + "updated_at": "2024-01-15T10:30:00Z", + "commits": [ + { + "sha": "abc1234", + "message": "Add widget" + } + ] + } + ], + "trunk": "main" +} diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_mixed.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_mixed.snap new file mode 100644 index 0000000..708a4ce --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_mixed.snap @@ -0,0 +1,11 @@ +--- +source: src/status.rs +expression: output +--- +* feature-2 (current) #124 - Add new feature +| [? N Y N] +| +o feature-1 #123 - Setup base +| [Y Y N Y] +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_no_checks.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_no_checks.snap new file mode 100644 index 0000000..425633b --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_no_checks.snap @@ -0,0 +1,9 @@ +--- +source: src/status.rs +expression: output +--- +* feature-2 (current) #124 - Add new feature +| +o feature-1 #123 - Setup base +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_single_pr.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_single_pr.snap new file mode 100644 index 0000000..e736ec8 --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_single_pr.snap @@ -0,0 +1,8 @@ +--- +source: src/status.rs +expression: output +--- +o feature-1 #123 - Single PR +| [Y Y Y Y] +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_unicode.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_unicode.snap new file mode 100644 index 0000000..33c1d8b --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_unicode.snap @@ -0,0 +1,8 @@ +--- +source: src/status.rs +expression: output +--- +◉ feature-1 (current) #123 - Add feature +│ [✓ ✗ ⏳ ─] +│ +◯ main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_with_commits.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_with_commits.snap new file mode 100644 index 0000000..e015742 --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_with_commits.snap @@ -0,0 +1,12 @@ +--- +source: src/status.rs +expression: output +--- +* feature-1 (current) #123 - Add feature +| [Y Y Y Y] +| +| abc1234 - Add widget component +| def5678 - Update styles +| + 2 more +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_with_draft.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_with_draft.snap new file mode 100644 index 0000000..99907e9 --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_with_draft.snap @@ -0,0 +1,11 @@ +--- +source: src/status.rs +expression: output +--- +* wip-feature (current) #125 - Work in progress (draft) +| [? N Y N] +| +o feature-1 #123 - Setup base +| [Y Y Y Y] +| +o main diff --git a/src/snapshots/gh_stack__status__tests__snapshot_status_with_legend.snap b/src/snapshots/gh_stack__status__tests__snapshot_status_with_legend.snap new file mode 100644 index 0000000..d8ec20b --- /dev/null +++ b/src/snapshots/gh_stack__status__tests__snapshot_status_with_legend.snap @@ -0,0 +1,11 @@ +--- +source: src/status.rs +expression: output +--- +o feature-1 #123 - Add feature +| [Y Y Y Y] +| +o main + +Status: [CI | Approved | Mergeable | Stack] + Y=pass N=fail ?=pending -=n/a diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..853412d --- /dev/null +++ b/src/status.rs @@ -0,0 +1,1395 @@ +//! Status display logic for PR stacks +//! +//! This module provides functionality to display stack status with CI, approval, +//! merge, and stack health indicators. + +use std::path::PathBuf; + +use git2::Repository; +use serde::Serialize; + +use crate::api::checks::{fetch_check_status, fetch_mergeable_status, CheckState, CheckStatus}; +use crate::api::{PullRequest, PullRequestReviewState}; +use crate::graph::FlatDep; +use crate::tree::{ + branch_exists_locally, commits_for_branch, current_branch, format_relative_time, + parse_timestamp, CommitInfo, +}; +use crate::Credentials; + +const MAX_TITLE_LEN: usize = 50; +const LEGEND_FILE_NAME: &str = ".gh-stack-legend-seen"; + +/// Individual status bit result +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatusBit { + Passed, + Failed, + Pending, + #[serde(rename = "n/a")] + NotApplicable, +} + +impl StatusBit { + /// Convert to unicode symbol + pub fn to_unicode(&self) -> &'static str { + match self { + StatusBit::Passed => "✓", + StatusBit::Failed => "✗", + StatusBit::Pending => "⏳", + StatusBit::NotApplicable => "─", + } + } + + /// Convert to ASCII symbol + pub fn to_ascii(&self) -> &'static str { + match self { + StatusBit::Passed => "Y", + StatusBit::Failed => "N", + StatusBit::Pending => "?", + StatusBit::NotApplicable => "-", + } + } +} + +/// Aggregated status for a single PR +#[derive(Debug, Clone, Serialize)] +pub struct PrStatus { + pub ci: StatusBit, + pub approved: StatusBit, + pub mergeable: StatusBit, + pub stack_clear: StatusBit, +} + +impl PrStatus { + /// Create a status with all bits set to NotApplicable + pub fn not_applicable() -> Self { + PrStatus { + ci: StatusBit::NotApplicable, + approved: StatusBit::NotApplicable, + mergeable: StatusBit::NotApplicable, + stack_clear: StatusBit::NotApplicable, + } + } +} + +/// Extended entry with status information +#[derive(Debug, Clone, Serialize)] +pub struct StatusEntry { + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + pub is_current: bool, + pub is_draft: bool, + pub is_trunk: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub commits: Vec, + #[serde(skip_serializing_if = "is_zero")] + pub extra_commits: usize, +} + +fn is_zero(n: &usize) -> bool { + *n == 0 +} + +/// Configuration for status display +#[derive(Debug, Clone)] +pub struct StatusConfig { + pub use_color: bool, + pub use_unicode: bool, + pub show_legend: bool, + pub include_checks: bool, + pub json_output: bool, +} + +impl Default for StatusConfig { + fn default() -> Self { + StatusConfig { + use_color: true, + use_unicode: true, + show_legend: false, + include_checks: true, + json_output: false, + } + } +} + +/// JSON output structure +#[derive(Debug, Serialize)] +pub struct StatusOutput { + pub stack: Vec, + pub trunk: String, +} + +/// Get the path to the legend seen file +fn legend_file_path() -> Option { + dirs::home_dir().map(|h| h.join(LEGEND_FILE_NAME)) +} + +/// Check if we should show the legend (first run detection) +pub fn should_show_legend() -> bool { + match legend_file_path() { + Some(path) if path.exists() => false, + Some(path) => { + // Create marker file + let _ = std::fs::write(&path, "1"); + true + } + None => true, // Show if can't determine + } +} + +/// Mark legend as seen (create the marker file) +pub fn mark_legend_seen() { + if let Some(path) = legend_file_path() { + let _ = std::fs::write(&path, "1"); + } +} + +/// Truncate title to max length with "..." +pub fn truncate_title(title: &str, max_len: usize) -> String { + if title.chars().count() <= max_len { + title.to_string() + } else { + let truncated: String = title.chars().take(max_len.saturating_sub(3)).collect(); + format!("{}...", truncated) + } +} + +/// Convert CheckStatus to StatusBit +fn check_status_to_bit(status: &CheckStatus) -> StatusBit { + match status.state { + CheckState::Success => StatusBit::Passed, + CheckState::Failure => StatusBit::Failed, + CheckState::Pending => StatusBit::Pending, + CheckState::Neutral => StatusBit::NotApplicable, + } +} + +/// Convert approval state to StatusBit +fn approval_to_bit(pr: &PullRequest) -> StatusBit { + match pr.review_state() { + PullRequestReviewState::APPROVED | PullRequestReviewState::MERGED => StatusBit::Passed, + _ => StatusBit::Failed, + } +} + +/// Convert mergeable state to StatusBit +fn mergeable_to_bit(mergeable: Option) -> StatusBit { + match mergeable { + Some(true) => StatusBit::Passed, + Some(false) => StatusBit::Failed, + None => StatusBit::Pending, + } +} + +/// Compute stack clear status for a PR at given index +/// A PR is "stack clear" if all PRs below it are approved and not draft +fn compute_stack_clear(entries: &[StatusEntry], index: usize) -> StatusBit { + // Check all entries below this one (higher indices = lower in stack) + for entry in entries.iter().skip(index + 1) { + if entry.is_trunk { + continue; + } + + // If any PR below is draft, stack is blocked + if entry.is_draft { + return StatusBit::Failed; + } + + // If any PR below is not approved, stack is blocked + if let Some(status) = &entry.status { + if status.approved != StatusBit::Passed { + return StatusBit::Failed; + } + } + } + + // Also check if this PR itself is approved (can't be stack clear if not approved) + if let Some(entry) = entries.get(index) { + if entry.is_draft { + return StatusBit::Failed; + } + if let Some(status) = &entry.status { + if status.approved != StatusBit::Passed { + return StatusBit::Failed; + } + } + } + + StatusBit::Passed +} + +/// Build status entries from a PR stack +pub async fn build_status_entries( + stack: &FlatDep, + repo: Option<&Repository>, + repository: &str, + credentials: &Credentials, + config: &StatusConfig, +) -> Vec { + let current = repo.and_then(current_branch); + let mut entries = Vec::new(); + + // Get trunk branch from first PR's base + let trunk_branch = stack.first().map(|(pr, _)| pr.base().to_string()); + + // Process PRs in reverse order (top of stack first) + for (pr, _parent) in stack.iter().rev() { + // Skip closed/merged PRs + if pr.is_merged() || pr.state() == &crate::api::PullRequestStatus::Closed { + continue; + } + + let is_current = current.as_ref().is_some_and(|c| c == pr.head()); + let timestamp = pr.updated_at().and_then(parse_timestamp); + + // Get commits if we have a repo + let (commits, extra_commits) = if let Some(r) = repo { + if branch_exists_locally(r, pr.head()) { + commits_for_branch(r, pr.head(), pr.base()) + } else { + (vec![], 0) + } + } else { + (vec![], 0) + }; + + // Fetch status if enabled + let status = if config.include_checks { + let ci = match fetch_check_status(pr.head_sha(), repository, credentials).await { + Ok(check) => check_status_to_bit(&check), + Err(_) => StatusBit::NotApplicable, + }; + + let mergeable = match fetch_mergeable_status(pr.number(), repository, credentials).await + { + Ok(m) => mergeable_to_bit(m), + Err(_) => StatusBit::NotApplicable, + }; + + Some(PrStatus { + ci, + approved: approval_to_bit(pr), + mergeable, + stack_clear: StatusBit::Pending, // Will be computed after all entries are built + }) + } else { + None + }; + + entries.push(StatusEntry { + branch: pr.head().to_string(), + pr_number: Some(pr.number()), + title: Some(truncate_title(pr.raw_title(), MAX_TITLE_LEN)), + is_current, + is_draft: pr.is_draft(), + is_trunk: false, + status, + updated_at: timestamp.map(|t| t.to_rfc3339()), + commits, + extra_commits, + }); + } + + // Compute stack_clear for each entry (requires all entries to be built first) + if config.include_checks { + for i in 0..entries.len() { + let stack_clear = compute_stack_clear(&entries, i); + if let Some(status) = &mut entries[i].status { + status.stack_clear = stack_clear; + } + } + } + + // Add trunk branch as final entry + if let Some(trunk) = trunk_branch { + let is_current = current.as_ref() == Some(&trunk); + + entries.push(StatusEntry { + branch: trunk, + pr_number: None, + title: None, + is_current, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }); + } + + entries +} + +/// Format status bits for display +pub fn format_status_bits(status: &PrStatus, use_unicode: bool) -> String { + let bits = [ + status.ci, + status.approved, + status.mergeable, + status.stack_clear, + ]; + + let symbols: Vec<&str> = bits + .iter() + .map(|b| { + if use_unicode { + b.to_unicode() + } else { + b.to_ascii() + } + }) + .collect(); + + format!( + "[{} {} {} {}]", + symbols[0], symbols[1], symbols[2], symbols[3] + ) +} + +/// Format the legend text +pub fn format_legend(use_unicode: bool) -> String { + let mut out = String::new(); + out.push_str("\nStatus: [CI | Approved | Mergeable | Stack]\n"); + + if use_unicode { + out.push_str(" ✓ pass ✗ fail ⏳ pending ─ n/a\n"); + } else { + out.push_str(" Y=pass N=fail ?=pending -=n/a\n"); + } + + out +} + +/// Render status entries to string +pub fn render_status(entries: &[StatusEntry], config: &StatusConfig, has_repo: bool) -> String { + use console::style; + + let mut out = String::new(); + + // Symbols + let (current_node, other_node, pipe) = if config.use_unicode { + ("\u{25C9}", "\u{25EF}", "\u{2502}") + } else { + ("*", "o", "|") + }; + + for (i, entry) in entries.iter().enumerate() { + let is_last = i == entries.len() - 1; + + // Node symbol + let node = if entry.is_current { + if config.use_color { + style(current_node).green().bold().to_string() + } else { + current_node.to_string() + } + } else if config.use_color { + style(other_node).dim().to_string() + } else { + other_node.to_string() + }; + + // Branch name with optional annotations + let mut branch_display = entry.branch.clone(); + if entry.is_current { + branch_display = format!("{} (current)", branch_display); + } + + // Add PR number and title if available + if let Some(pr_num) = entry.pr_number { + if let Some(title) = &entry.title { + branch_display = format!("{} #{} - {}", branch_display, pr_num, title); + } else { + branch_display = format!("{} #{}", branch_display, pr_num); + } + } + + // Add draft indicator + if entry.is_draft { + branch_display = format!("{} (draft)", branch_display); + } + + out.push_str(&format!("{} {}\n", node, branch_display)); + + // Connector for content below + let connector = if is_last { " " } else { pipe }; + + // Status bits (if available) + if let Some(status) = &entry.status { + let bits = format_status_bits(status, config.use_unicode); + let styled_bits = if config.use_color { + colorize_status_bits(status, config.use_unicode) + } else { + bits + }; + + // Add timestamp on same line as status + if let Some(updated_at) = &entry.updated_at { + if let Some(ts) = parse_timestamp(updated_at) { + let time_str = format_relative_time(&ts); + let styled_time = if config.use_color { + style(&time_str).dim().to_string() + } else { + time_str + }; + out.push_str(&format!("{} {} {}\n", connector, styled_bits, styled_time)); + } else { + out.push_str(&format!("{} {}\n", connector, styled_bits)); + } + } else { + out.push_str(&format!("{} {}\n", connector, styled_bits)); + } + } else if let Some(updated_at) = &entry.updated_at { + // No status bits, just timestamp + if let Some(ts) = parse_timestamp(updated_at) { + let time_str = format_relative_time(&ts); + let styled_time = if config.use_color { + style(&time_str).dim().to_string() + } else { + time_str + }; + out.push_str(&format!("{} {}\n", connector, styled_time)); + } + } + + // Commits (only for non-trunk entries with commits) + if !entry.commits.is_empty() { + out.push_str(&format!("{}\n", connector)); + for commit in &entry.commits { + let commit_line = format!("{} - {}", commit.sha, commit.message); + let styled_commit = if config.use_color { + style(&commit_line).dim().to_string() + } else { + commit_line + }; + out.push_str(&format!("{} {}\n", connector, styled_commit)); + } + + // Show "+ N more" if there are extra commits + if entry.extra_commits > 0 { + let more_text = format!("+ {} more", entry.extra_commits); + let styled_more = if config.use_color { + style(&more_text).dim().to_string() + } else { + more_text + }; + out.push_str(&format!("{} {}\n", connector, styled_more)); + } + } + + // Empty line before next entry (except last) + if !is_last { + out.push_str(&format!("{}\n", pipe)); + } + } + + // Show legend if configured + if config.show_legend { + out.push_str(&format_legend(config.use_unicode)); + } + + // Hint if no repo detected + if !has_repo && !entries.is_empty() { + out.push_str( + "\nhint: run from a git repo or use -C to see commits and current branch\n", + ); + } + + out +} + +/// Colorize status bits with appropriate colors +fn colorize_status_bits(status: &PrStatus, use_unicode: bool) -> String { + use console::style; + + let colorize = |bit: StatusBit| -> String { + let symbol = if use_unicode { + bit.to_unicode() + } else { + bit.to_ascii() + }; + + match bit { + StatusBit::Passed => style(symbol).green().to_string(), + StatusBit::Failed => style(symbol).red().to_string(), + StatusBit::Pending => style(symbol).yellow().to_string(), + StatusBit::NotApplicable => style(symbol).dim().to_string(), + } + }; + + format!( + "[{} {} {} {}]", + colorize(status.ci), + colorize(status.approved), + colorize(status.mergeable), + colorize(status.stack_clear) + ) +} + +/// Render status entries as JSON +pub fn render_status_json(entries: &[StatusEntry]) -> Result { + let trunk = entries + .iter() + .find(|e| e.is_trunk) + .map(|e| e.branch.clone()) + .unwrap_or_else(|| "main".to_string()); + + let stack: Vec = entries.iter().filter(|e| !e.is_trunk).cloned().collect(); + + let output = StatusOutput { stack, trunk }; + serde_json::to_string_pretty(&output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{PullRequest, PullRequestStatus}; + use tempfile::TempDir; + + // === StatusBit tests === + + #[test] + fn test_status_bit_to_unicode() { + assert_eq!(StatusBit::Passed.to_unicode(), "✓"); + assert_eq!(StatusBit::Failed.to_unicode(), "✗"); + assert_eq!(StatusBit::Pending.to_unicode(), "⏳"); + assert_eq!(StatusBit::NotApplicable.to_unicode(), "─"); + } + + #[test] + fn test_status_bit_to_ascii() { + assert_eq!(StatusBit::Passed.to_ascii(), "Y"); + assert_eq!(StatusBit::Failed.to_ascii(), "N"); + assert_eq!(StatusBit::Pending.to_ascii(), "?"); + assert_eq!(StatusBit::NotApplicable.to_ascii(), "-"); + } + + // === CheckStatus to StatusBit tests === + + #[test] + fn test_check_status_to_bit_success() { + let status = CheckStatus { + state: CheckState::Success, + total: 1, + passed: 1, + failed: 0, + pending: 0, + }; + assert_eq!(check_status_to_bit(&status), StatusBit::Passed); + } + + #[test] + fn test_check_status_to_bit_failure() { + let status = CheckStatus { + state: CheckState::Failure, + total: 1, + passed: 0, + failed: 1, + pending: 0, + }; + assert_eq!(check_status_to_bit(&status), StatusBit::Failed); + } + + #[test] + fn test_check_status_to_bit_pending() { + let status = CheckStatus { + state: CheckState::Pending, + total: 1, + passed: 0, + failed: 0, + pending: 1, + }; + assert_eq!(check_status_to_bit(&status), StatusBit::Pending); + } + + #[test] + fn test_check_status_to_bit_neutral() { + let status = CheckStatus { + state: CheckState::Neutral, + total: 0, + passed: 0, + failed: 0, + pending: 0, + }; + assert_eq!(check_status_to_bit(&status), StatusBit::NotApplicable); + } + + // === Approval to StatusBit tests === + + #[test] + fn test_approval_to_bit_approved() { + let pr = PullRequest::new_for_test( + 1, + "feature", + "main", + "Test PR", + PullRequestStatus::Open, + false, + None, + vec![crate::api::PullRequestReview::new_for_test( + PullRequestReviewState::APPROVED, + )], + ); + assert_eq!(approval_to_bit(&pr), StatusBit::Passed); + } + + #[test] + fn test_approval_to_bit_pending() { + let pr = PullRequest::new_for_test( + 1, + "feature", + "main", + "Test PR", + PullRequestStatus::Open, + false, + None, + vec![], + ); + assert_eq!(approval_to_bit(&pr), StatusBit::Failed); + } + + // === Mergeable to StatusBit tests === + + #[test] + fn test_mergeable_to_bit_true() { + assert_eq!(mergeable_to_bit(Some(true)), StatusBit::Passed); + } + + #[test] + fn test_mergeable_to_bit_false() { + assert_eq!(mergeable_to_bit(Some(false)), StatusBit::Failed); + } + + #[test] + fn test_mergeable_to_bit_unknown() { + assert_eq!(mergeable_to_bit(None), StatusBit::Pending); + } + + // === Truncate title tests === + + #[test] + fn test_truncate_title_short() { + assert_eq!(truncate_title("Short title", 50), "Short title"); + } + + #[test] + fn test_truncate_title_exact() { + let title = "x".repeat(50); + assert_eq!(truncate_title(&title, 50), title); + } + + #[test] + fn test_truncate_title_long() { + let title = "x".repeat(60); + let result = truncate_title(&title, 50); + assert_eq!(result.len(), 50); + assert!(result.ends_with("...")); + } + + #[test] + fn test_truncate_title_unicode() { + let title = "Add 日本語 support for the feature system here"; + let result = truncate_title(title, 20); + assert!(result.chars().count() <= 20); + assert!(result.ends_with("...")); + } + + // === Format status bits tests === + + #[test] + fn test_format_status_bits_unicode_all_passed() { + let status = PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }; + assert_eq!(format_status_bits(&status, true), "[✓ ✓ ✓ ✓]"); + } + + #[test] + fn test_format_status_bits_unicode_mixed() { + let status = PrStatus { + ci: StatusBit::Pending, + approved: StatusBit::Failed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Failed, + }; + assert_eq!(format_status_bits(&status, true), "[⏳ ✗ ✓ ✗]"); + } + + #[test] + fn test_format_status_bits_ascii_all_passed() { + let status = PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }; + assert_eq!(format_status_bits(&status, false), "[Y Y Y Y]"); + } + + #[test] + fn test_format_status_bits_ascii_mixed() { + let status = PrStatus { + ci: StatusBit::Pending, + approved: StatusBit::Failed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Failed, + }; + assert_eq!(format_status_bits(&status, false), "[? N Y N]"); + } + + #[test] + fn test_format_status_bits_with_na() { + let status = PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::NotApplicable, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }; + assert_eq!(format_status_bits(&status, true), "[✓ ─ ✓ ✓]"); + assert_eq!(format_status_bits(&status, false), "[Y - Y Y]"); + } + + // === Stack clear computation tests === + + fn make_status_entry( + branch: &str, + is_draft: bool, + is_trunk: bool, + approved: StatusBit, + ) -> StatusEntry { + StatusEntry { + branch: branch.to_string(), + pr_number: Some(1), + title: Some("Test".to_string()), + is_current: false, + is_draft, + is_trunk, + status: Some(PrStatus { + ci: StatusBit::Passed, + approved, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Pending, + }), + updated_at: None, + commits: vec![], + extra_commits: 0, + } + } + + #[test] + fn test_compute_stack_clear_all_approved() { + let entries = vec![ + make_status_entry("feature-3", false, false, StatusBit::Passed), + make_status_entry("feature-2", false, false, StatusBit::Passed), + make_status_entry("feature-1", false, false, StatusBit::Passed), + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + assert_eq!(compute_stack_clear(&entries, 0), StatusBit::Passed); + assert_eq!(compute_stack_clear(&entries, 1), StatusBit::Passed); + assert_eq!(compute_stack_clear(&entries, 2), StatusBit::Passed); + } + + #[test] + fn test_compute_stack_clear_blocked_by_draft() { + let entries = vec![ + make_status_entry("feature-2", false, false, StatusBit::Passed), + make_status_entry("feature-1", true, false, StatusBit::Passed), // draft + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + assert_eq!(compute_stack_clear(&entries, 0), StatusBit::Failed); // blocked by draft below + assert_eq!(compute_stack_clear(&entries, 1), StatusBit::Failed); // is draft + } + + #[test] + fn test_compute_stack_clear_blocked_by_unapproved() { + let entries = vec![ + make_status_entry("feature-2", false, false, StatusBit::Passed), + make_status_entry("feature-1", false, false, StatusBit::Failed), // not approved + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + assert_eq!(compute_stack_clear(&entries, 0), StatusBit::Failed); // blocked + assert_eq!(compute_stack_clear(&entries, 1), StatusBit::Failed); // not approved + } + + #[test] + fn test_compute_stack_clear_single_pr() { + let entries = vec![ + make_status_entry("feature-1", false, false, StatusBit::Passed), + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + assert_eq!(compute_stack_clear(&entries, 0), StatusBit::Passed); + } + + // === Legend file tests with temp directory === + + fn with_temp_home(test_fn: F) + where + F: FnOnce(&std::path::Path), + { + let temp_dir = TempDir::new().unwrap(); + let original_home = std::env::var("HOME").ok(); + + // Override HOME for test + std::env::set_var("HOME", temp_dir.path()); + + test_fn(temp_dir.path()); + + // Restore original HOME + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + // TempDir automatically cleaned up on drop + } + + #[test] + fn test_should_show_legend_first_run() { + with_temp_home(|_| { + // First call should return true and create the file + assert!(should_show_legend()); + }); + } + + #[test] + fn test_should_show_legend_subsequent_run() { + with_temp_home(|home| { + // Create the legend file + let legend_path = home.join(LEGEND_FILE_NAME); + std::fs::write(&legend_path, "1").unwrap(); + + // Should return false now + assert!(!should_show_legend()); + }); + } + + // === JSON output tests === + + #[test] + fn test_json_output_structure() { + let entries = vec![ + StatusEntry { + branch: "feature".to_string(), + pr_number: Some(123), + title: Some("Test PR".to_string()), + is_current: true, + is_draft: false, + is_trunk: false, + status: Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + let json = render_status_json(&entries).unwrap(); + assert!(json.contains("\"trunk\": \"main\"")); + assert!(json.contains("\"branch\": \"feature\"")); + assert!(json.contains("\"pr_number\": 123")); + } + + #[test] + fn test_json_output_status_values() { + let entries = vec![StatusEntry { + branch: "feature".to_string(), + pr_number: Some(1), + title: Some("Test".to_string()), + is_current: false, + is_draft: false, + is_trunk: false, + status: Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Failed, + mergeable: StatusBit::Pending, + stack_clear: StatusBit::NotApplicable, + }), + updated_at: None, + commits: vec![], + extra_commits: 0, + }]; + + let json = render_status_json(&entries).unwrap(); + assert!(json.contains("\"ci\": \"passed\"")); + assert!(json.contains("\"approved\": \"failed\"")); + assert!(json.contains("\"mergeable\": \"pending\"")); + assert!(json.contains("\"stack_clear\": \"n/a\"")); + } + + #[test] + fn test_json_output_pretty_formatted() { + let entries = vec![StatusEntry { + branch: "feature".to_string(), + pr_number: Some(1), + title: Some("Test".to_string()), + is_current: false, + is_draft: false, + is_trunk: false, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }]; + + let json = render_status_json(&entries).unwrap(); + // Pretty-printed JSON has newlines + assert!(json.contains('\n')); + } + + // === Snapshot tests === + + fn make_test_entry( + branch: &str, + pr_number: Option, + title: Option<&str>, + is_current: bool, + is_draft: bool, + is_trunk: bool, + status: Option, + ) -> StatusEntry { + StatusEntry { + branch: branch.to_string(), + pr_number, + title: title.map(String::from), + is_current, + is_draft, + is_trunk, + status, + updated_at: None, + commits: vec![], + extra_commits: 0, + } + } + + #[test] + fn test_snapshot_status_all_passing() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-2", + Some(124), + Some("Add new feature"), + true, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry( + "feature-1", + Some(123), + Some("Setup base"), + false, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_mixed() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-2", + Some(124), + Some("Add new feature"), + true, + false, + false, + Some(PrStatus { + ci: StatusBit::Pending, + approved: StatusBit::Failed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Failed, + }), + ), + make_test_entry( + "feature-1", + Some(123), + Some("Setup base"), + false, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Failed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_with_draft() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "wip-feature", + Some(125), + Some("Work in progress"), + true, + true, // draft + false, + Some(PrStatus { + ci: StatusBit::Pending, + approved: StatusBit::Failed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Failed, + }), + ), + make_test_entry( + "feature-1", + Some(123), + Some("Setup base"), + false, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_no_checks() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: false, // no checks + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-2", + Some(124), + Some("Add new feature"), + true, + false, + false, + None, // no status + ), + make_test_entry( + "feature-1", + Some(123), + Some("Setup base"), + false, + false, + false, + None, + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_with_commits() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + StatusEntry { + branch: "feature-1".to_string(), + pr_number: Some(123), + title: Some("Add feature".to_string()), + is_current: true, + is_draft: false, + is_trunk: false, + status: Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + updated_at: None, + commits: vec![ + CommitInfo { + sha: "abc1234".to_string(), + message: "Add widget component".to_string(), + }, + CommitInfo { + sha: "def5678".to_string(), + message: "Update styles".to_string(), + }, + ], + extra_commits: 2, + }, + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_with_legend() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: true, // show legend + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-1", + Some(123), + Some("Add feature"), + false, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_unicode() { + let config = StatusConfig { + use_color: false, + use_unicode: true, // unicode + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-1", + Some(123), + Some("Add feature"), + true, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Failed, + mergeable: StatusBit::Pending, + stack_clear: StatusBit::NotApplicable, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_json() { + let entries = vec![ + StatusEntry { + branch: "feature-1".to_string(), + pr_number: Some(123), + title: Some("Add feature".to_string()), + is_current: true, + is_draft: false, + is_trunk: false, + status: Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + updated_at: Some("2024-01-15T10:30:00Z".to_string()), + commits: vec![CommitInfo { + sha: "abc1234".to_string(), + message: "Add widget".to_string(), + }], + extra_commits: 0, + }, + StatusEntry { + branch: "main".to_string(), + pr_number: None, + title: None, + is_current: false, + is_draft: false, + is_trunk: true, + status: None, + updated_at: None, + commits: vec![], + extra_commits: 0, + }, + ]; + + let output = render_status_json(&entries).unwrap(); + insta::assert_snapshot!(output); + } + + #[test] + fn test_snapshot_status_single_pr() { + let config = StatusConfig { + use_color: false, + use_unicode: false, + show_legend: false, + include_checks: true, + json_output: false, + }; + + let entries = vec![ + make_test_entry( + "feature-1", + Some(123), + Some("Single PR"), + false, + false, + false, + Some(PrStatus { + ci: StatusBit::Passed, + approved: StatusBit::Passed, + mergeable: StatusBit::Passed, + stack_clear: StatusBit::Passed, + }), + ), + make_test_entry("main", None, None, false, false, true, None), + ]; + + let output = render_status(&entries, &config, true); + insta::assert_snapshot!(output); + } +} diff --git a/src/tree.rs b/src/tree.rs index b352e1b..b10fc03 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -55,7 +55,7 @@ pub enum PrState { } /// Information about a single commit -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] pub struct CommitInfo { pub sha: String, pub message: String,