use anyhow::Result;
use octocrab::Octocrab;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GhSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_pr_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ci_status: Option<CiStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CiStatus {
Success,
Failure,
Pending,
Skipped,
Other,
}
pub async fn resolve_token() -> Option<String> {
if let Ok(t) = std::env::var("GITHUB_TOKEN") {
if !t.is_empty() {
return Some(t);
}
}
gh_cli_token().await
}
async fn gh_cli_token() -> Option<String> {
let gh = which::which("gh").ok()?;
let mut cmd = tokio::process::Command::new(gh);
cmd.args(["auth", "token"]);
#[cfg(windows)]
cmd.creation_flags(crate::silent_creation_flags());
let output = cmd.output().await.ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8(output.stdout).ok()?;
let token = token.trim().to_string();
if token.is_empty() { None } else { Some(token) }
}
pub fn build_client(token: &str) -> Result<Octocrab> {
Ok(Octocrab::builder()
.personal_token(token.to_string())
.build()?)
}
pub async fn capture_snapshot(client: &Octocrab, owner: &str, name: &str) -> Result<GhSnapshot> {
let open_pr_count = open_pr_count(client, owner, name).await?;
let ci_status = latest_ci_status(client, owner, name).await?;
Ok(GhSnapshot {
open_pr_count: Some(open_pr_count),
ci_status,
})
}
async fn open_pr_count(client: &Octocrab, owner: &str, name: &str) -> Result<usize> {
let prs = client
.pulls(owner, name)
.list()
.state(octocrab::params::State::Open)
.per_page(100)
.send()
.await?;
Ok(prs.items.len())
}
async fn latest_ci_status(client: &Octocrab, owner: &str, name: &str) -> Result<Option<CiStatus>> {
let runs = client
.workflows(owner, name)
.list_all_runs()
.per_page(1)
.send()
.await?;
let Some(run) = runs.items.into_iter().next() else {
return Ok(None);
};
Ok(Some(classify_ci(&run)))
}
fn classify_ci(run: &octocrab::models::workflows::Run) -> CiStatus {
match run.status.as_str() {
"queued" | "in_progress" | "waiting" | "requested" | "pending" => CiStatus::Pending,
_ => match run.conclusion.as_deref().unwrap_or("") {
"success" => CiStatus::Success,
"failure" => CiStatus::Failure,
"skipped" | "neutral" => CiStatus::Skipped,
_ => CiStatus::Other,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerItem {
pub number: u64,
pub title: String,
pub html_url: String,
pub labels: Vec<String>,
}
impl PickerItem {
pub fn search_key(&self) -> String {
if self.labels.is_empty() {
format!("#{} {}", self.number, self.title)
} else {
format!(
"#{} {} [{}]",
self.number,
self.title,
self.labels.join(",")
)
}
}
}
pub async fn list_open_issues(
client: &Octocrab,
owner: &str,
name: &str,
) -> Result<Vec<PickerItem>> {
let page = client
.issues(owner, name)
.list()
.state(octocrab::params::State::Open)
.per_page(100)
.send()
.await?;
Ok(page
.items
.into_iter()
.filter(|i| i.pull_request.is_none())
.map(|i| PickerItem {
number: i.number,
title: i.title,
html_url: i.html_url.to_string(),
labels: i.labels.into_iter().map(|l| l.name).collect(),
})
.collect())
}
pub async fn list_open_prs(client: &Octocrab, owner: &str, name: &str) -> Result<Vec<PickerItem>> {
let page = client
.pulls(owner, name)
.list()
.state(octocrab::params::State::Open)
.per_page(100)
.send()
.await?;
Ok(page
.items
.into_iter()
.map(|pr| PickerItem {
number: pr.number,
title: pr.title,
html_url: pr.html_url.to_string(),
labels: pr.labels.into_iter().map(|l| l.name).collect(),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn resolve_token_uses_env_when_set() {
let prev = std::env::var("GITHUB_TOKEN").ok();
unsafe {
std::env::set_var("GITHUB_TOKEN", "test-token-xyz");
}
assert_eq!(resolve_token().await.as_deref(), Some("test-token-xyz"));
match prev {
Some(p) => unsafe { std::env::set_var("GITHUB_TOKEN", p) },
None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn snapshot_round_trips_through_toml() {
let snap = GhSnapshot {
open_pr_count: Some(3),
ci_status: Some(CiStatus::Failure),
};
let s = toml::to_string(&snap).unwrap();
let parsed: GhSnapshot = toml::from_str(&s).unwrap();
assert_eq!(parsed, snap);
}
#[test]
fn snapshot_skips_none_fields_on_serialize() {
let empty = GhSnapshot {
open_pr_count: None,
ci_status: None,
};
let s = toml::to_string(&empty).unwrap();
let parsed: GhSnapshot = toml::from_str(&s).unwrap();
assert_eq!(parsed, empty);
assert!(
!s.contains("open_pr_count"),
"None open_pr_count shouldn't serialise, got: {s}"
);
assert!(
!s.contains("ci_status"),
"None ci_status shouldn't serialise, got: {s}"
);
}
#[test]
fn ci_status_serialises_snake_case() {
let s = toml::to_string(&GhSnapshot {
open_pr_count: None,
ci_status: Some(CiStatus::Success),
})
.unwrap();
assert!(s.contains("\"success\""), "wire format = lowercase: {s}");
}
}