use std::fmt::Display;
use std::fmt::Formatter;
use serde::Deserialize;
use serde::Serialize;
use super::constants::CI_CANCELLED;
use super::constants::CI_FAILED;
use super::constants::CI_PASSED;
use super::constants::CI_SKIPPED;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub(crate) struct OwnerRepo {
owner: String,
repo: String,
}
impl OwnerRepo {
pub(crate) fn new(owner: impl Into<String>, repo: impl Into<String>) -> Self {
Self {
owner: owner.into(),
repo: repo.into(),
}
}
pub(crate) fn owner(&self) -> &str { &self.owner }
pub(crate) fn repo(&self) -> &str { &self.repo }
}
impl Display for OwnerRepo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.owner, self.repo)
}
}
#[derive(Deserialize)]
pub(crate) struct GhRun {
pub id: u64,
pub node_id: String,
pub created_at: String,
pub updated_at: String,
pub head_branch: String,
pub display_title: Option<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GqlCheckRun {
pub(super) name: String,
pub(super) conclusion: Option<String>,
pub(super) started_at: Option<String>,
pub(super) completed_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum FetchStatus {
#[default]
Fetched,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum CiStatus {
Passed,
Failed,
Cancelled,
Skipped,
}
impl CiStatus {
pub(crate) const fn icon(self) -> &'static str {
match self {
Self::Passed => CI_PASSED,
Self::Failed => CI_FAILED,
Self::Cancelled => CI_CANCELLED,
Self::Skipped => CI_SKIPPED,
}
}
pub(crate) const fn is_success(self) -> bool { matches!(self, Self::Passed) }
pub(crate) const fn is_failure(self) -> bool { matches!(self, Self::Failed) }
}
impl Display for CiStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(self.icon()) }
}
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct CiRun {
pub run_id: u64,
pub created_at: String,
pub branch: String,
pub url: String,
pub ci_status: CiStatus,
pub jobs: Vec<CiJob>,
pub wall_clock_secs: Option<u64>,
#[serde(default)]
pub commit_title: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub fetched: FetchStatus,
}
#[derive(Serialize, Deserialize, Clone)]
pub(crate) struct CiJob {
pub name: String,
pub ci_status: CiStatus,
pub duration: String,
pub duration_secs: Option<u64>,
}
pub(crate) fn build_ci_run(gh_run: &GhRun, check_runs: Vec<GqlCheckRun>, repo_url: &str) -> CiRun {
let mut earliest_start: Option<u64> = None;
let mut latest_completion: Option<u64> = None;
let ci_jobs: Vec<CiJob> = check_runs
.into_iter()
.map(|job| {
if let Some(start) = job.started_at.as_ref().and_then(|s| parse_iso8601(s).ok()) {
earliest_start =
Some(earliest_start.map_or(start, |current: u64| current.min(start)));
}
if let Some(end) = job
.completed_at
.as_ref()
.and_then(|s| parse_iso8601(s).ok())
{
latest_completion =
Some(latest_completion.map_or(end, |current: u64| current.max(end)));
}
let conclusion = parse_gql_conclusion(job.conclusion.as_deref());
let duration_secs =
compute_duration_secs(job.started_at.as_ref(), job.completed_at.as_ref());
let duration = duration_secs.map_or_else(|| "—".to_string(), format_secs);
CiJob {
name: job.name,
ci_status: conclusion,
duration,
duration_secs,
}
})
.collect();
let wall_clock_secs = earliest_start
.zip(latest_completion)
.map(|(start, end)| end.saturating_sub(start));
let conclusion = run_conclusion(&ci_jobs);
CiRun {
run_id: gh_run.id,
created_at: gh_run.created_at.clone(),
branch: gh_run.head_branch.clone(),
url: format!("{repo_url}/actions/runs/{}", gh_run.id),
ci_status: conclusion,
wall_clock_secs,
jobs: ci_jobs,
commit_title: gh_run.display_title.clone(),
updated_at: Some(gh_run.updated_at.clone()),
fetched: FetchStatus::Fetched,
}
}
pub(crate) fn parse_owner_repo(url: &str) -> Option<OwnerRepo> {
let stripped = url.strip_prefix("https://github.com/")?;
let mut parts = stripped.split('/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(OwnerRepo::new(owner, repo))
}
fn run_conclusion(jobs: &[CiJob]) -> CiStatus {
if jobs.iter().any(|j| j.ci_status.is_failure()) {
return CiStatus::Failed;
}
if jobs.iter().any(|j| j.ci_status == CiStatus::Cancelled) {
return CiStatus::Cancelled;
}
if jobs.iter().any(|j| j.ci_status.is_success()) {
return CiStatus::Passed;
}
CiStatus::Skipped
}
fn parse_gql_conclusion(conclusion: Option<&str>) -> CiStatus {
match conclusion {
Some("SUCCESS") => CiStatus::Passed,
Some("FAILURE") => CiStatus::Failed,
Some("SKIPPED") => CiStatus::Skipped,
_ => CiStatus::Cancelled,
}
}
fn compute_duration_secs(
started_at: Option<&String>,
completed_at: Option<&String>,
) -> Option<u64> {
let start = started_at?;
let end = completed_at?;
let start_ts = parse_iso8601(start).ok()?;
let end_ts = parse_iso8601(end).ok()?;
Some(end_ts.saturating_sub(start_ts))
}
pub(crate) fn format_secs(secs: u64) -> String {
if secs >= 3600 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
format!("{h}h {m:>2}m")
} else if secs >= 60 {
format!("{:>2}m {:>2}s", secs / 60, secs % 60)
} else {
format!("{secs:>2}s")
}
}
fn parse_iso8601(s: &str) -> Result<u64, ()> {
let s = s.trim_end_matches('Z');
let (date_part, time_part) = s.split_once('T').ok_or(())?;
let date_parts: Vec<&str> = date_part.split('-').collect();
let time_parts: Vec<&str> = time_part.split(':').collect();
if date_parts.len() != 3 || time_parts.len() != 3 {
return Err(());
}
let year: u64 = date_parts[0].parse().map_err(|_| ())?;
let month: u64 = date_parts[1].parse().map_err(|_| ())?;
let day: u64 = date_parts[2].parse().map_err(|_| ())?;
let hour: u64 = time_parts[0].parse().map_err(|_| ())?;
let min: u64 = time_parts[1].parse().map_err(|_| ())?;
let sec: u64 = time_parts[2].parse().map_err(|_| ())?;
let days = days_from_civil(year, month, day);
Ok(days * 86400 + hour * 3600 + min * 60 + sec)
}
const fn days_from_civil(year: u64, month: u64, day: u64) -> u64 {
let y = if month <= 2 { year - 1 } else { year };
let era = y / 400;
let yoe = y - era * 400;
let m = if month > 2 { month - 3 } else { month + 9 };
let doy = (153 * m + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days_since_epoch_0 = era * 146_097 + doe;
days_since_epoch_0 - 719_468
}