From 4db2d932b9638eaf767541bf09b6ab9f33cffb8b Mon Sep 17 00:00:00 2001 From: erishforG Date: Tue, 1 Sep 2026 09:36:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(smartlog):=20Phase=205=20=E2=80=94=20PR=20?= =?UTF-8?q?merge=20readiness=20overlay=20=C2=B7=20Refs=20#309?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `SmartlogPrOverlay::merge_ready: Option` that surfaces GitHub's merge-ability verdict in the smartlog one-line badge. Changes: - `SmartlogPrOverlay`: new `merge_ready: Option` field (skip_serializing_if=None for backward-compat) - `fetch_overlay()`: populate from `PrStatus::mergeable` (open PRs only) - `format_pr_badge()`: append '⬆ ready' (green) / '⚡ conflicts' (red) for open PRs when readiness is known; no segment when None or non-open - 7 Phase 5 unit tests: ready/conflict badge, None omit, merged/draft skip, serde round-trip (absent + present) - `mk_overlay_with_readiness()` test helper for targeted badge tests Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands/smartlog.rs | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/cli/commands/smartlog.rs b/src/cli/commands/smartlog.rs index e184892..246b040 100644 --- a/src/cli/commands/smartlog.rs +++ b/src/cli/commands/smartlog.rs @@ -34,6 +34,17 @@ //! grandparent → parent → child ordering is preserved. //! - Cycle-safe: a `placed` set ensures no group is emitted twice even if //! worktree branches form unusual reference loops. +//! +//! Phase 5 (Issue #309 — PR merge readiness overlay): +//! - `SmartlogPrOverlay::merge_ready: Option` surface the GitHub +//! `mergeable` field that `get_pr_status()` already fetches but previously +//! discarded before reaching the smartlog layer. +//! - `format_pr_badge()` appends `⬆ ready` (green) or `⚡ conflicts` (red) +//! when the field is populated; open PRs with unknown mergeable state or +//! non-open PRs omit the segment so output stays compact. +//! - Backward-compatible: `merge_ready` is `#[serde(skip_serializing_if = +//! "Option::is_none")]` so existing JSON consumers and golden fixtures keep +//! working. use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::IsTerminal as _; @@ -87,6 +98,14 @@ pub struct SmartlogPrOverlay { /// `approved` / `changes_requested` / `pending` / `no reviews`. pub review_status: String, pub url: String, + /// GitHub merge readiness — `Some(true)` when the PR can be merged + /// (no conflicts, all checks green per GitHub's internal verdict), + /// `Some(false)` when there are conflicts or blocking checks. + /// `None` when GitHub has not yet computed the state (e.g., immediately + /// after a push) or the PR is already merged/closed. + /// Skip-serialised when absent so existing JSON consumers see no change. + #[serde(skip_serializing_if = "Option::is_none")] + pub merge_ready: Option, } /// Single commit in a worktree's diff against its base. @@ -198,12 +217,20 @@ async fn fetch_overlay(client: &GitHubClient, branch: &str) -> Result return Ok(None), }; let status = client.get_pr_status(pr_num).await?; + // Populate merge_ready only for open PRs; merged/closed PRs don't have a + // meaningful "can be merged" state from GitHub's perspective. + let merge_ready = if status.state == "open" { + status.mergeable + } else { + None + }; Ok(Some(SmartlogPrOverlay { number: status.number, state: status.state, ci_status: status.ci_status, review_status: status.review_status, url: status.url, + merge_ready, })) } @@ -277,6 +304,18 @@ fn format_pr_badge(pr: &SmartlogPrOverlay, color: bool) -> String { out.pop(); out.push_str(&format!(" {}]", r)); } + // Phase 5: merge readiness segment — only for open PRs. + if pr.state == "open" { + if let Some(ready) = pr.merge_ready { + let segment = if ready { + ansi_wrap(32, "⬆ ready", color) // green + } else { + ansi_wrap(31, "⚡ conflicts", color) // red + }; + out.pop(); // remove ']' + out.push_str(&format!(" {}]", segment)); + } + } out } @@ -613,6 +652,23 @@ mod tests { ci_status: ci.to_string(), review_status: review.to_string(), url: "https://github.com/erishforG/git-parsec/pull/42".to_string(), + merge_ready: None, + } + } + + fn mk_overlay_with_readiness( + state: &str, + ci: &str, + review: &str, + merge_ready: Option, + ) -> SmartlogPrOverlay { + SmartlogPrOverlay { + number: 99, + state: state.to_string(), + ci_status: ci.to_string(), + review_status: review.to_string(), + url: "https://github.com/erishforG/git-parsec/pull/99".to_string(), + merge_ready, } } @@ -860,4 +916,102 @@ mod tests { assert!(s.contains("CL-A") && s.contains("CL-B")); assert!(s.contains("0 stacked"), "no stacks expected"); } + + // ----------------------------------------------------------------------- + // Phase 5 tests: merge readiness overlay (#309) + // ----------------------------------------------------------------------- + + #[test] + fn merge_ready_true_appends_ready_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(true)); + let badge = format_pr_badge(&overlay, false); + assert!( + badge.contains("⬆ ready"), + "expected '⬆ ready' in badge but got: {}", + badge + ); + assert!( + badge.ends_with("⬆ ready]"), + "ready segment should be last: {}", + badge + ); + } + + #[test] + fn merge_ready_false_appends_conflicts_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(false)); + let badge = format_pr_badge(&overlay, false); + assert!( + badge.contains("⚡ conflicts"), + "expected '⚡ conflicts' in badge but got: {}", + badge + ); + } + + #[test] + fn merge_ready_none_omits_readiness_segment() { + let overlay = mk_overlay_with_readiness("open", "success", "no reviews", None); + let badge = format_pr_badge(&overlay, false); + assert!( + !badge.contains("ready") && !badge.contains("conflicts"), + "unknown readiness should omit segment: {}", + badge + ); + } + + #[test] + fn merge_ready_skipped_for_merged_pr() { + // A merged PR with merge_ready=Some(true) should NOT show the segment + // because the PR is no longer open. + let overlay = mk_overlay_with_readiness("merged", "success", "approved", Some(true)); + let badge = format_pr_badge(&overlay, false); + assert!( + !badge.contains("⬆ ready"), + "merged PR should not show ready segment: {}", + badge + ); + } + + #[test] + fn merge_ready_skipped_for_draft_pr() { + // Draft PRs are open but merge_ready is typically None from GitHub; + // even if Some(true) somehow arrives, verify the format is correct. + let overlay = mk_overlay_with_readiness("draft", "pending", "no reviews", Some(false)); + let badge = format_pr_badge(&overlay, false); + // draft is rendered as state="draft"; open check uses pr.state == "open" + // so draft should NOT emit the readiness segment. + assert!( + !badge.contains("⚡ conflicts"), + "draft PR should not show conflicts segment: {}", + badge + ); + } + + #[test] + fn merge_ready_serde_roundtrip_omits_none() { + // When merge_ready is None, the serialised JSON must not contain + // the field (backward-compat for existing JSON consumers). + let overlay = mk_overlay("open", "pending", "no reviews"); + assert!(overlay.merge_ready.is_none()); + let json = serde_json::to_string(&overlay).expect("serialize"); + assert!( + !json.contains("merge_ready"), + "merge_ready=None should be omitted from JSON, got: {}", + json + ); + } + + #[test] + fn merge_ready_serde_roundtrip_present() { + // When merge_ready is Some, it must survive a JSON round-trip. + let overlay = mk_overlay_with_readiness("open", "success", "approved", Some(true)); + let json = serde_json::to_string(&overlay).expect("serialize"); + assert!( + json.contains("\"merge_ready\":true"), + "missing field: {}", + json + ); + let back: SmartlogPrOverlay = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.merge_ready, Some(true)); + } }