use std::{path::Path, process::Stdio, time::Duration};
use futures_util::FutureExt;
use serde::Deserialize;
use super::{
smoke_injection,
statusline::{CwdExtra, CwdExtraTone},
workspace, App,
};
const GH_PR_FIELDS: &str = "number,reviewDecision,mergeStateStatus,statusCheckRollup";
const GH_PR_VIEW_BUDGET: Duration = Duration::from_secs(8);
#[derive(Clone, Debug, PartialEq, Eq)]
struct GithubPr {
number: u64,
tone: Option<GithubPrTone>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum GithubPrTone {
Ready,
Issues,
}
#[derive(Debug, PartialEq, Eq)]
enum GithubPrProbe {
Unavailable,
Absent,
Found(GithubPr),
}
#[derive(Debug, PartialEq, Eq)]
enum GithubPrPaint {
Keep,
Clear,
Show(GithubPr),
}
#[derive(Debug)]
pub(super) struct GithubPrLookup {
branch: Option<String>,
probe: GithubPrProbe,
}
#[derive(Debug, Deserialize)]
struct GhPrView {
number: u64,
#[serde(default, rename = "reviewDecision")]
review_decision: Option<String>,
#[serde(default, rename = "mergeStateStatus")]
merge_state_status: String,
#[serde(
default,
rename = "statusCheckRollup",
deserialize_with = "deserialize_check_rollup"
)]
status_check_rollup: Vec<GhCheck>,
}
#[derive(Debug, Deserialize, Default)]
struct GhCheck {
#[serde(default)]
conclusion: String,
#[serde(default)]
state: String,
}
fn deserialize_check_rollup<'de, D>(deserializer: D) -> Result<Vec<GhCheck>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Rollup {
List(Vec<GhCheck>),
Other(serde::de::IgnoredAny),
}
Ok(match Option::<Rollup>::deserialize(deserializer)? {
Some(Rollup::List(checks)) => checks,
Some(Rollup::Other(_)) | None => Vec::new(),
})
}
async fn lookup(cwd: &Path) -> GithubPrLookup {
GithubPrLookup {
branch: workspace::git_branch(cwd),
probe: probe(cwd).await,
}
}
async fn probe(cwd: &Path) -> GithubPrProbe {
let Some(gh) = crate::executable::find_on_path("gh") else {
return GithubPrProbe::Unavailable;
};
let mut command = tokio::process::Command::new(gh);
command
.args(["pr", "view", "--json", GH_PR_FIELDS])
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = match command.spawn() {
Ok(child) => child,
Err(_) => return GithubPrProbe::Unavailable,
};
match tokio::time::timeout(GH_PR_VIEW_BUDGET, child.wait_with_output()).await {
Ok(Ok(output)) => {
classify_gh_pr_view(output.status.success(), &output.stdout, &output.stderr)
}
_ => GithubPrProbe::Unavailable,
}
}
fn classify_gh_pr_view(success: bool, stdout: &[u8], stderr: &[u8]) -> GithubPrProbe {
if success {
parse_gh_pr_view(stdout).map_or(GithubPrProbe::Unavailable, GithubPrProbe::Found)
} else if confirmed_no_pr(stderr) {
GithubPrProbe::Absent
} else {
GithubPrProbe::Unavailable
}
}
fn confirmed_no_pr(stderr: &[u8]) -> bool {
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
text.contains("no pull requests found")
|| text.contains("no open pull requests found")
|| text.contains("no closed pull requests found")
}
fn parse_gh_pr_view(bytes: &[u8]) -> Option<GithubPr> {
let view: GhPrView = serde_json::from_slice(bytes).ok()?;
Some(GithubPr {
number: view.number,
tone: tone_from_view(&view),
})
}
fn tone_from_view(view: &GhPrView) -> Option<GithubPrTone> {
let review = view
.review_decision
.as_deref()
.unwrap_or("")
.to_ascii_uppercase();
let merge = view.merge_state_status.to_ascii_uppercase();
let issues = review == "CHANGES_REQUESTED"
|| merge == "DIRTY"
|| view.status_check_rollup.iter().any(check_has_issues);
if issues {
Some(GithubPrTone::Issues)
} else if merge == "CLEAN" {
Some(GithubPrTone::Ready)
} else {
None
}
}
fn check_has_issues(check: &GhCheck) -> bool {
let conclusion = check.conclusion.to_ascii_uppercase();
let state = check.state.to_ascii_uppercase();
matches!(
conclusion.as_str(),
"FAILURE" | "TIMED_OUT" | "ACTION_REQUIRED" | "ERROR"
) || matches!(state.as_str(), "FAILURE" | "ERROR")
}
fn paint_for_current_branch(current: Option<&str>, lookup: GithubPrLookup) -> GithubPrPaint {
if current != lookup.branch.as_deref() {
return GithubPrPaint::Keep;
}
match lookup.probe {
GithubPrProbe::Found(pr) => GithubPrPaint::Show(pr),
GithubPrProbe::Absent => GithubPrPaint::Clear,
GithubPrProbe::Unavailable => GithubPrPaint::Keep,
}
}
fn cwd_extra(pr: &GithubPr) -> CwdExtra {
CwdExtra::new(
format!(" #{}", pr.number),
match pr.tone {
Some(GithubPrTone::Ready) => CwdExtraTone::Success,
Some(GithubPrTone::Issues) => CwdExtraTone::Error,
None => CwdExtraTone::Dim,
},
)
}
impl App {
pub(super) fn start_github_pr_fetch(&mut self) {
if smoke_injection::matrix_enabled()
|| self.pending_github_pr.is_some()
|| self.statusline.branch().is_none()
{
return;
}
let cwd = self.info.runtime.cwd.clone();
self.pending_github_pr = Some(tokio::spawn(async move { lookup(&cwd).await }));
}
fn restart_github_pr_fetch(&mut self) {
if let Some(handle) = self.pending_github_pr.take() {
handle.abort();
}
self.start_github_pr_fetch();
}
pub(super) fn refresh_workspace_on_focus(&mut self) {
if self.statusline.refresh_git_branch() {
self.restart_github_pr_fetch();
} else {
self.start_github_pr_fetch();
}
}
pub(super) fn refresh_git_after_command(&mut self) {
if self.statusline.refresh_git_branch() {
self.restart_github_pr_fetch();
}
}
pub(super) fn poll_github_pr(&mut self) {
let Some(handle) = self.pending_github_pr.as_mut() else {
return;
};
let Some(result) = handle.now_or_never() else {
return;
};
self.pending_github_pr = None;
if let Ok(lookup) = result {
match paint_for_current_branch(self.statusline.branch(), lookup) {
GithubPrPaint::Keep => {}
GithubPrPaint::Clear => self.statusline.update_cwd_extra(None),
GithubPrPaint::Show(pr) => self.statusline.update_cwd_extra(Some(cwd_extra(&pr))),
}
}
}
}
#[cfg(test)]
#[path = "github_pr_tests.rs"]
mod tests;