diff --git a/src/github/graphql.rs b/src/github/graphql.rs index 810c778..68ce2bf 100644 --- a/src/github/graphql.rs +++ b/src/github/graphql.rs @@ -307,6 +307,8 @@ fn parse_search_pr(node: &Value) -> PullRequest { additions: node["additions"].as_u64().unwrap_or(0) as u32, deletions: node["deletions"].as_u64().unwrap_or(0) as u32, review_decision: node["reviewDecision"].as_str().map(|s| s.to_string()), + mergeable: node["mergeable"].as_str().map(|s| s.to_string()), + merge_state_status: node["mergeStateStatus"].as_str().map(|s| s.to_string()), labels, } } diff --git a/src/github/models.rs b/src/github/models.rs index f64561a..db08497 100644 --- a/src/github/models.rs +++ b/src/github/models.rs @@ -38,6 +38,16 @@ pub struct PullRequest { pub deletions: u32, pub review_decision: Option, pub labels: Vec, + /// GitHub `mergeable` enum: `MERGEABLE` / `CONFLICTING` / `UNKNOWN`. + /// `None` when absent (e.g. older cache entries). Note: GitHub computes this + /// lazily, so the search API frequently returns `UNKNOWN`. + #[serde(default)] + pub mergeable: Option, + /// GitHub `mergeStateStatus` enum: `CLEAN` / `DIRTY` / `BLOCKED` / `BEHIND` / + /// `UNSTABLE` / `HAS_HOOKS` / `DRAFT` / `UNKNOWN`. Richer than `mergeable`; + /// same lazy-compute caveat. + #[serde(default)] + pub merge_state_status: Option, } impl PullRequest { diff --git a/src/github/queries.rs b/src/github/queries.rs index cc4ca89..6cd7321 100644 --- a/src/github/queries.rs +++ b/src/github/queries.rs @@ -87,6 +87,8 @@ query($owner: String!, $name: String!, $cursor: String) { additions deletions reviewDecision + mergeable + mergeStateStatus labels(first: 10) { nodes { name } } @@ -124,6 +126,8 @@ query($query: String!, $cursor: String) { additions deletions reviewDecision + mergeable + mergeStateStatus labels(first: 10) { nodes { name } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 4be722b..7682f2c 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -34,3 +34,9 @@ pub const NAV_VIRTUAL: Style = Style::new().fg(Color::Magenta).add_modifier(Modi pub const PR_NUMBER: Style = Style::new().fg(Color::Cyan); pub const PR_AUTHOR: Style = Style::new().fg(Color::Yellow); + +// Merge-state column. Color is paired with a distinct glyph in the widget so the +// signal survives colorblindness and monochrome terminals. +pub const MERGE_CLEAN: Style = Style::new().fg(Color::Green); + +pub const MERGE_CONFLICT: Style = Style::new().fg(Color::Red).add_modifier(Modifier::BOLD); diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 313be2f..8ebe05f 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -6,6 +6,7 @@ use ratatui::{ }; use crate::app::state::{AppState, ContentView, FocusedPane, NavNode}; +use crate::github::models::PullRequest; use crate::ui::theme; use crate::util::time::relative_time; @@ -114,6 +115,17 @@ pub fn render_content_pane(f: &mut Frame, area: Rect, state: &AppState) { } } +/// Compact, colorblind-safe label + color for a PR's merge state. +/// Driven by GitHub's `mergeable` enum; `UNKNOWN`/absent renders as a dim `?` +/// because the search API computes `mergeable` lazily (often `UNKNOWN` at first). +fn merge_state_display(pr: &PullRequest) -> (&'static str, ratatui::style::Style) { + match pr.mergeable.as_deref() { + Some("MERGEABLE") => ("✓ ok", theme::MERGE_CLEAN), + Some("CONFLICTING") => ("✗ cf", theme::MERGE_CONFLICT), + _ => ("?", theme::DIM), + } +} + fn render_pr_table( f: &mut Frame, area: Rect, @@ -151,6 +163,7 @@ fn render_pr_table( let header = Row::new(vec![ Cell::from("#").style(theme::HEADER), + Cell::from("State").style(theme::HEADER), Cell::from("Title").style(theme::HEADER), Cell::from("Author").style(theme::HEADER), Cell::from("Repo").style(theme::HEADER), @@ -176,12 +189,19 @@ fn render_pr_table( _ => "", }; + let (merge_label, merge_style) = merge_state_display(pr); + Row::new(vec![ Cell::from(format!("#{}", pr.number)).style(if style == theme::HIGHLIGHT { style } else { theme::PR_NUMBER }), + Cell::from(merge_label).style(if style == theme::HIGHLIGHT { + style + } else { + merge_style + }), Cell::from(format!( "{}{}{}", if pr.is_draft { "[Draft] " } else { "" }, @@ -207,6 +227,7 @@ fn render_pr_table( let widths = [ Constraint::Length(7), + Constraint::Length(5), Constraint::Min(20), Constraint::Length(16), Constraint::Length(24), diff --git a/tests/graphql_parse_tests.rs b/tests/graphql_parse_tests.rs index 6852186..b2b7646 100644 --- a/tests/graphql_parse_tests.rs +++ b/tests/graphql_parse_tests.rs @@ -28,6 +28,8 @@ fn test_pr_repo_full_name() { additions: 0, deletions: 0, review_decision: None, + mergeable: None, + merge_state_status: None, labels: vec![], }; assert_eq!(pr.repo_full_name(), "org/repo"); @@ -69,6 +71,8 @@ fn test_pr_serialization_roundtrip() { additions: 100, deletions: 50, review_decision: Some("APPROVED".into()), + mergeable: Some("MERGEABLE".into()), + merge_state_status: Some("CLEAN".into()), labels: vec!["bug".into(), "urgent".into()], }; @@ -80,6 +84,8 @@ fn test_pr_serialization_roundtrip() { assert_eq!(deserialized.author, "alice"); assert!(deserialized.is_draft); assert_eq!(deserialized.review_decision, Some("APPROVED".into())); + assert_eq!(deserialized.mergeable, Some("MERGEABLE".into())); + assert_eq!(deserialized.merge_state_status, Some("CLEAN".into())); assert_eq!(deserialized.labels, vec!["bug", "urgent"]); } @@ -121,9 +127,64 @@ fn test_pr_with_no_review_decision() { additions: 0, deletions: 0, review_decision: None, + mergeable: None, + merge_state_status: None, labels: vec![], }; assert!(pr.review_decision.is_none()); assert!(pr.labels.is_empty()); } + +#[test] +fn test_pr_deserializes_without_merge_fields() { + // Older cache entries won't have mergeable / mergeStateStatus. #[serde(default)] + // must let them deserialize to None rather than failing (which would drop the + // whole cache entry). Guards the CI-3 cache-schema-evolution concern. + let legacy = r#"{ + "number": 7, + "title": "Legacy cached PR", + "author": "bob", + "repo_owner": "org", + "repo_name": "repo", + "url": "https://github.com/org/repo/pull/7", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "is_draft": false, + "additions": 3, + "deletions": 1, + "review_decision": null, + "labels": [] + }"#; + + let pr: PullRequest = serde_json::from_str(legacy).expect("legacy cache must deserialize"); + assert_eq!(pr.number, 7); + assert!(pr.mergeable.is_none()); + assert!(pr.merge_state_status.is_none()); +} + +#[test] +fn test_pr_conflicting_merge_state_roundtrip() { + let pr = PullRequest { + number: 9, + title: "Conflicting PR".into(), + author: "carol".into(), + repo_owner: "org".into(), + repo_name: "repo".into(), + url: "https://github.com/org/repo/pull/9".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + is_draft: false, + additions: 1, + deletions: 1, + review_decision: None, + mergeable: Some("CONFLICTING".into()), + merge_state_status: Some("DIRTY".into()), + labels: vec![], + }; + + let json = serde_json::to_string(&pr).unwrap(); + let deserialized: PullRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.mergeable, Some("CONFLICTING".into())); + assert_eq!(deserialized.merge_state_status, Some("DIRTY".into())); +} diff --git a/tests/state_tests.rs b/tests/state_tests.rs index db74768..8718eed 100644 --- a/tests/state_tests.rs +++ b/tests/state_tests.rs @@ -35,6 +35,8 @@ fn make_pr(repo_owner: &str, repo_name: &str, number: u32, title: &str) -> PullR additions: 10, deletions: 5, review_decision: None, + mergeable: None, + merge_state_status: None, labels: vec![], } }