use std::collections::BTreeMap;
use std::fmt::{self, Write as _};
use serde::{Deserialize, Serialize};
use super::JobState;
use super::units::{civil_from_days, days_from_civil, short_date};
pub const SELF_HOSTED_RATE: u64 = 0;
const THIRTY_DAYS: u64 = 30 * 86_400;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Usd(pub u64);
impl fmt::Display for Usd {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let cents = self.0 / 10_000;
write!(f, "${}.{:02}", thousands(cents / 100), cents % 100)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Sku {
#[serde(rename = "actions_linux_slim")]
ActionsLinuxSlim,
#[serde(rename = "actions_linux")]
ActionsLinux,
#[serde(rename = "actions_linux_arm")]
ActionsLinuxArm,
#[serde(rename = "linux_2_core_advanced")]
Linux2CoreAdvanced,
#[serde(rename = "linux_4_core")]
Linux4Core,
#[serde(rename = "linux_8_core")]
Linux8Core,
#[serde(rename = "linux_16_core")]
Linux16Core,
#[serde(rename = "linux_32_core")]
Linux32Core,
#[serde(rename = "linux_64_core")]
Linux64Core,
#[serde(rename = "linux_96_core")]
Linux96Core,
#[serde(rename = "linux_2_core_arm")]
Linux2CoreArm,
#[serde(rename = "linux_4_core_arm")]
Linux4CoreArm,
#[serde(rename = "linux_8_core_arm")]
Linux8CoreArm,
#[serde(rename = "linux_16_core_arm")]
Linux16CoreArm,
#[serde(rename = "linux_32_core_arm")]
Linux32CoreArm,
#[serde(rename = "linux_64_core_arm")]
Linux64CoreArm,
}
impl Sku {
pub fn standard_for(arch: &str) -> Sku {
if arch == "aarch64" {
Sku::ActionsLinuxArm
} else {
Sku::ActionsLinux
}
}
pub fn rate(self) -> Usd {
Usd(match self {
Sku::ActionsLinuxSlim => 2_000,
Sku::ActionsLinux | Sku::Linux2CoreAdvanced => 6_000,
Sku::ActionsLinuxArm | Sku::Linux2CoreArm => 5_000,
Sku::Linux4Core => 12_000,
Sku::Linux8Core => 22_000,
Sku::Linux16Core => 42_000,
Sku::Linux32Core => 82_000,
Sku::Linux64Core => 162_000,
Sku::Linux96Core => 252_000,
Sku::Linux4CoreArm => 8_000,
Sku::Linux8CoreArm => 14_000,
Sku::Linux16CoreArm => 26_000,
Sku::Linux32CoreArm => 50_000,
Sku::Linux64CoreArm => 98_000,
})
}
pub fn standard(self) -> bool {
matches!(
self,
Sku::ActionsLinuxSlim | Sku::ActionsLinux | Sku::ActionsLinuxArm
)
}
pub fn key(self) -> &'static str {
match self {
Sku::ActionsLinuxSlim => "actions_linux_slim",
Sku::ActionsLinux => "actions_linux",
Sku::ActionsLinuxArm => "actions_linux_arm",
Sku::Linux2CoreAdvanced => "linux_2_core_advanced",
Sku::Linux4Core => "linux_4_core",
Sku::Linux8Core => "linux_8_core",
Sku::Linux16Core => "linux_16_core",
Sku::Linux32Core => "linux_32_core",
Sku::Linux64Core => "linux_64_core",
Sku::Linux96Core => "linux_96_core",
Sku::Linux2CoreArm => "linux_2_core_arm",
Sku::Linux4CoreArm => "linux_4_core_arm",
Sku::Linux8CoreArm => "linux_8_core_arm",
Sku::Linux16CoreArm => "linux_16_core_arm",
Sku::Linux32CoreArm => "linux_32_core_arm",
Sku::Linux64CoreArm => "linux_64_core_arm",
}
}
pub fn runner(self) -> &'static str {
match self {
Sku::ActionsLinuxSlim => "1-core Linux",
Sku::ActionsLinux => "2-core Linux",
Sku::ActionsLinuxArm => "2-core Linux arm64",
Sku::Linux2CoreAdvanced => "Advanced 2-core Linux",
Sku::Linux4Core => "4-core Linux",
Sku::Linux8Core => "8-core Linux",
Sku::Linux16Core => "16-core Linux",
Sku::Linux32Core => "32-core Linux",
Sku::Linux64Core => "64-core Linux",
Sku::Linux96Core => "96-core Linux",
Sku::Linux2CoreArm => "2-core Linux arm64 (larger runner)",
Sku::Linux4CoreArm => "4-core Linux arm64",
Sku::Linux8CoreArm => "8-core Linux arm64",
Sku::Linux16CoreArm => "16-core Linux arm64",
Sku::Linux32CoreArm => "32-core Linux arm64",
Sku::Linux64CoreArm => "64-core Linux arm64",
}
}
}
impl fmt::Display for Sku {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.key())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Visibility {
Public,
Private,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pricing {
pub profiles: BTreeMap<String, Sku>,
pub default_sku: Sku,
}
impl Pricing {
pub fn of(&self, profile: Option<&str>) -> Sku {
profile
.and_then(|profile| self.profiles.get(profile))
.copied()
.unwrap_or(self.default_sku)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PricedRun {
pub repository: String,
pub workflow: Option<String>,
pub labels: Vec<String>,
pub state: JobState,
pub profile: Option<String>,
pub queued_at: u64,
pub started_at: Option<u64>,
pub finished_at: Option<u64>,
}
impl PricedRun {
pub fn at(&self) -> u64 {
self.finished_at
.or(self.started_at)
.unwrap_or(self.queued_at)
}
fn named_like_service(&self) -> bool {
self.workflow.as_deref().is_some_and(|workflow| {
let workflow = workflow.to_ascii_lowercase();
workflow == "dependabot updates"
|| workflow == "pages build and deployment"
|| workflow.starts_with("dynamic/dependabot/")
|| workflow.starts_with("dynamic/pages/")
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PricingHistory {
pub runs: Vec<PricedRun>,
pub visibility: BTreeMap<String, Visibility>,
pub kept_since: u64,
}
pub fn reads_since(now: u64) -> u64 {
month_start(now / 1000)
.min((now / 1000).saturating_sub(THIRTY_DAYS))
.saturating_mul(1000)
}
fn month_start(at: u64) -> u64 {
let (year, month, _) = civil_from_days(at / 86_400);
days_from_civil(year, month, 1) * 86_400
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Savings {
pub until: u64,
pub month: SavedWindow,
pub last_30_days: SavedWindow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Period {
Month,
Last30Days,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedWindow {
pub period: Period,
pub since: u64,
pub cut: bool,
pub jobs: u64,
pub minutes: u64,
pub cost: Usd,
pub runners: Vec<PricedRunner>,
pub free: u64,
pub services: u64,
pub unknown: u64,
pub lost: u64,
pub untimed: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PricedRunner {
pub sku: Sku,
pub jobs: u64,
pub minutes: u64,
pub cost: Usd,
}
enum Priced {
Billed {
sku: Sku,
minutes: u64,
},
Free,
Service,
Unknown,
Lost,
Untimed,
}
pub fn billed_minutes(started: u64, finished: u64) -> u64 {
finished.saturating_sub(started).div_ceil(60_000).max(1)
}
fn price(run: &PricedRun, history: &PricingHistory, pricing: &Pricing) -> Option<Priced> {
match run.state {
JobState::Completed => {}
JobState::Lost => return Some(Priced::Lost),
JobState::Queued | JobState::Running | JobState::Elsewhere => return None,
}
let (Some(started), Some(finished)) = (run.started_at, run.finished_at) else {
return Some(Priced::Untimed);
};
let sku = pricing.of(run.profile.as_deref());
if sku.standard() && run.named_like_service() {
return Some(Priced::Service);
}
Some(match history.visibility.get(&run.repository) {
None => Priced::Unknown,
Some(Visibility::Public) if sku.standard() => Priced::Free,
Some(_) => Priced::Billed {
sku,
minutes: billed_minutes(started, finished),
},
})
}
impl Savings {
pub fn of(history: &PricingHistory, pricing: &Pricing, now: u64) -> Savings {
let until = now / 1000;
Savings {
until,
month: SavedWindow::of(Period::Month, month_start(until), history, pricing, now),
last_30_days: SavedWindow::of(
Period::Last30Days,
until.saturating_sub(THIRTY_DAYS),
history,
pricing,
now,
),
}
}
pub fn windows(&self) -> Vec<&SavedWindow> {
if self.last_30_days.since == self.month.since {
vec![&self.month]
} else {
vec![&self.month, &self.last_30_days]
}
}
}
impl SavedWindow {
fn of(
period: Period,
start: u64,
history: &PricingHistory,
pricing: &Pricing,
now: u64,
) -> SavedWindow {
let kept_since = history.kept_since / 1000;
let cut = kept_since > start;
let mut window = SavedWindow {
period,
since: start.max(kept_since),
cut,
jobs: 0,
minutes: 0,
cost: Usd(0),
runners: Vec::new(),
free: 0,
services: 0,
unknown: 0,
lost: 0,
untimed: 0,
};
let mut runners: BTreeMap<Sku, PricedRunner> = BTreeMap::new();
let from = start.saturating_mul(1000).max(history.kept_since);
for run in history
.runs
.iter()
.filter(|run| (from..=now).contains(&run.at()))
{
match price(run, history, pricing) {
None => {}
Some(Priced::Billed { sku, minutes }) => {
let cost =
minutes.saturating_mul(sku.rate().0.saturating_sub(SELF_HOSTED_RATE));
let runner = runners.entry(sku).or_insert(PricedRunner {
sku,
jobs: 0,
minutes: 0,
cost: Usd(0),
});
runner.jobs += 1;
runner.minutes += minutes;
runner.cost.0 += cost;
window.jobs += 1;
window.minutes += minutes;
window.cost.0 += cost;
}
Some(Priced::Free) => window.free += 1,
Some(Priced::Service) => window.services += 1,
Some(Priced::Unknown) => window.unknown += 1,
Some(Priced::Lost) => window.lost += 1,
Some(Priced::Untimed) => window.untimed += 1,
}
}
window.runners = runners.into_values().collect();
window
}
pub fn not_priced(&self) -> u64 {
self.free + self.services + self.unknown + self.lost + self.untimed
}
pub fn headline(&self) -> String {
let when = if self.cut {
format!("Since {}", short_date(self.since))
} else {
match self.period {
Period::Month => "This month".to_owned(),
Period::Last30Days => "Last 30 days".to_owned(),
}
};
if self.jobs == 0 && self.not_priced() == 0 {
return format!("{when}: no job-minutes here yet");
}
let rate = match self.runners.as_slice() {
[one] => format!("at GitHub's {} rate", one.sku.runner()),
[] => "at GitHub's rates".to_owned(),
_ => "at GitHub's rates for each profile's runner".to_owned(),
};
let mut line = format!(
"{when}: {} here, about {} {rate} before your plan's free minutes",
count(self.minutes, "job-minute", "job-minutes"),
self.cost,
);
if self.not_priced() > 0 {
let _ = write!(
line,
" ({} not priced)",
count(self.not_priced(), "job", "jobs")
);
}
line
}
pub fn details(&self) -> Vec<String> {
let mut lines: Vec<String> = self
.runners
.iter()
.map(|runner| {
format!(
"{} of {} at {} ({}, {} a minute): {}",
count(runner.minutes, "minute", "minutes"),
count(runner.jobs, "job", "jobs"),
runner.sku.key(),
runner.sku.runner(),
rate(runner.sku.rate()),
runner.cost,
)
})
.collect();
for (jobs, why) in [
(
self.free,
"free on GitHub: on a standard runner, of a public repository",
),
(
self.services,
"not priced: their workflow is named as GitHub's Dependabot or Pages workflow, free on a standard \
runner",
),
(
self.unknown,
"not priced: GitHub has not said whether their repository is public",
),
(
self.lost,
"not priced: lost, so how long they ran is not known",
),
(self.untimed, "not priced: no start or end recorded"),
] {
if jobs > 0 {
lines.push(format!("{} {why}", count(jobs, "job", "jobs")));
}
}
if self.cut {
lines.push(format!(
"The history keeps jobs since {}",
short_date(self.since)
));
}
lines.push(
"An estimate from this machine's run times, at each profile's priced_as and each repository's \
visibility as they are now; on GitHub's runners the jobs would run longer or shorter"
.to_owned(),
);
lines
}
}
fn rate(rate: Usd) -> String {
let thousandths = rate.0 / 1000;
format!(
"${}.{:03}",
thousands(thousandths / 1000),
thousandths % 1000
)
}
fn count(number: u64, one: &str, many: &str) -> String {
format!(
"{} {}",
thousands(number),
if number == 1 { one } else { many }
)
}
fn thousands(number: u64) -> String {
let digits = number.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
#[cfg(test)]
mod tests;