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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weekly_commits: Option<Vec<u32>>,
}
pub const ACTIVITY_WEEKS: usize = 12;
#[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?;
let weekly_commits = recent_weekly_commits(client, owner, name)
.await
.ok()
.flatten();
Ok(GhSnapshot {
open_pr_count: Some(open_pr_count),
ci_status,
weekly_commits,
})
}
#[derive(Debug, Clone, serde::Deserialize)]
struct WeekActivity {
total: u32,
}
async fn recent_weekly_commits(
client: &Octocrab,
owner: &str,
name: &str,
) -> Result<Option<Vec<u32>>> {
let route = format!("/repos/{owner}/{name}/stats/commit_activity");
let weeks: Vec<WeekActivity> = client.get(route, None::<&()>).await?;
if weeks.is_empty() {
return Ok(None);
}
let start = weeks.len().saturating_sub(ACTIVITY_WEEKS);
let tail = weeks[start..].iter().map(|w| w.total).collect();
Ok(Some(tail))
}
async fn open_pr_count(client: &Octocrab, owner: &str, name: &str) -> Result<usize> {
let route = format!("/repos/{owner}/{name}/pulls");
let prs: Vec<serde::de::IgnoredAny> = client
.get(route, Some(&[("state", "open"), ("per_page", "100")]))
.await?;
Ok(prs.len())
}
#[derive(Debug, Clone, serde::Deserialize)]
struct RunMinimal {
status: String,
#[serde(default)]
conclusion: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct CiRunsResponse {
#[serde(default)]
workflow_runs: Option<Vec<RunMinimal>>,
}
async fn latest_ci_status(client: &Octocrab, owner: &str, name: &str) -> Result<Option<CiStatus>> {
let route = format!("/repos/{owner}/{name}/actions/runs");
let response: CiRunsResponse = client.get(route, Some(&[("per_page", "1")])).await?;
let Some(run) = response
.workflow_runs
.unwrap_or_default()
.into_iter()
.next()
else {
return Ok(None);
};
Ok(Some(classify_ci(&run)))
}
fn classify_ci(run: &RunMinimal) -> 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(",")
)
}
}
}
#[derive(Debug, Clone, serde::Deserialize)]
struct PickerItemRaw {
number: u64,
title: String,
html_url: String,
#[serde(default)]
labels: Vec<LabelMinimal>,
#[serde(default)]
pull_request: Option<serde::de::IgnoredAny>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct LabelMinimal {
name: String,
}
impl PickerItemRaw {
fn into_picker_item(self) -> PickerItem {
PickerItem {
number: self.number,
title: self.title,
html_url: self.html_url,
labels: self.labels.into_iter().map(|l| l.name).collect(),
}
}
}
pub async fn list_open_issues(
client: &Octocrab,
owner: &str,
name: &str,
) -> Result<Vec<PickerItem>> {
let route = format!("/repos/{owner}/{name}/issues");
let raw: Vec<PickerItemRaw> = client
.get(route, Some(&[("state", "open"), ("per_page", "100")]))
.await?;
Ok(raw
.into_iter()
.filter(|i| i.pull_request.is_none())
.map(PickerItemRaw::into_picker_item)
.collect())
}
pub async fn list_open_prs(client: &Octocrab, owner: &str, name: &str) -> Result<Vec<PickerItem>> {
let route = format!("/repos/{owner}/{name}/pulls");
let raw: Vec<PickerItemRaw> = client
.get(route, Some(&[("state", "open"), ("per_page", "100")]))
.await?;
Ok(raw
.into_iter()
.map(PickerItemRaw::into_picker_item)
.collect())
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RepoListItem {
#[serde(rename = "owner", deserialize_with = "deserialize_owner_login")]
pub owner: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub archived: bool,
}
fn deserialize_owner_login<'de, D>(de: D) -> std::result::Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct OwnerLogin {
login: String,
}
OwnerLogin::deserialize(de).map(|o| o.login)
}
impl std::fmt::Display for RepoListItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.description {
Some(d) if !d.is_empty() => write!(f, "{}/{} — {d}", self.owner, self.name),
_ => write!(f, "{}/{}", self.owner, self.name),
}
}
}
pub async fn whoami(client: &Octocrab) -> Result<String> {
#[derive(serde::Deserialize)]
struct User {
login: String,
}
let user: User = client.get("/user", None::<&()>).await?;
Ok(user.login)
}
pub fn is_not_found(err: &anyhow::Error) -> bool {
err.chain()
.filter_map(|e| e.downcast_ref::<octocrab::GitHubError>())
.any(|gh| gh.status_code.as_u16() == 404)
}
pub async fn list_repos(
client: &Octocrab,
owner: Option<&str>,
is_org: bool,
) -> Result<Vec<RepoListItem>> {
let (route, params): (String, &[(&str, &str)]) = match owner {
None => (
"/user/repos".to_string(),
&[
("affiliation", "owner"),
("sort", "updated"),
("per_page", "100"),
],
),
Some(o) if is_org => (
format!("/orgs/{o}/repos"),
&[("sort", "updated"), ("per_page", "100")],
),
Some(o) => (
format!("/users/{o}/repos"),
&[("sort", "updated"), ("per_page", "100")],
),
};
let raw: Vec<RepoListItem> = client.get(route, Some(params)).await?;
Ok(raw.into_iter().filter(|r| !r.archived).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),
weekly_commits: Some(vec![0, 1, 4, 2, 7]),
};
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,
weekly_commits: 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}"
);
assert!(
!s.contains("weekly_commits"),
"None weekly_commits shouldn't serialise, got: {s}"
);
}
#[test]
fn repo_list_item_deserialises_owner_login_flat() {
let json = r#"{
"owner": { "login": "yukimemi", "id": 1 },
"name": "shoka",
"description": "jj-aware ghq successor",
"archived": false,
"extra_field_we_dont_care_about": 42
}"#;
let item: RepoListItem = serde_json::from_str(json).unwrap();
assert_eq!(item.owner, "yukimemi");
assert_eq!(item.name, "shoka");
assert_eq!(item.description.as_deref(), Some("jj-aware ghq successor"));
assert!(!item.archived);
}
#[test]
fn repo_list_item_display_includes_description() {
let item = RepoListItem {
owner: "yukimemi".into(),
name: "shoka".into(),
description: Some("书架".into()),
archived: false,
};
assert_eq!(item.to_string(), "yukimemi/shoka — 书架");
}
#[test]
fn repo_list_item_display_falls_back_to_slug_when_no_description() {
let item = RepoListItem {
owner: "yukimemi".into(),
name: "shoka".into(),
description: None,
archived: false,
};
assert_eq!(item.to_string(), "yukimemi/shoka");
let empty_desc = RepoListItem {
description: Some(String::new()),
..item
};
assert_eq!(empty_desc.to_string(), "yukimemi/shoka");
}
#[test]
fn ci_status_serialises_snake_case() {
let s = toml::to_string(&GhSnapshot {
open_pr_count: None,
ci_status: Some(CiStatus::Success),
weekly_commits: None,
})
.unwrap();
assert!(s.contains("\"success\""), "wire format = lowercase: {s}");
}
}