Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/github/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
10 changes: 10 additions & 0 deletions src/github/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ pub struct PullRequest {
pub deletions: u32,
pub review_decision: Option<String>,
pub labels: Vec<String>,
/// 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<String>,
/// 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<String>,
}

impl PullRequest {
Expand Down
4 changes: 4 additions & 0 deletions src/github/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ query($owner: String!, $name: String!, $cursor: String) {
additions
deletions
reviewDecision
mergeable
mergeStateStatus
labels(first: 10) {
nodes { name }
}
Expand Down Expand Up @@ -124,6 +126,8 @@ query($query: String!, $cursor: String) {
additions
deletions
reviewDecision
mergeable
mergeStateStatus
labels(first: 10) {
nodes { name }
}
Expand Down
6 changes: 6 additions & 0 deletions src/ui/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
21 changes: 21 additions & 0 deletions src/ui/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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 { "" },
Expand All @@ -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),
Expand Down
61 changes: 61 additions & 0 deletions tests/graphql_parse_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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()],
};

Expand All @@ -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"]);
}

Expand Down Expand Up @@ -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()));
}
2 changes: 2 additions & 0 deletions tests/state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
}
}
Expand Down
Loading