hive-forge: add lint verb for triage queries (closes #505)
This commit is contained in:
parent
c11424dd9a
commit
703018106a
4 changed files with 478 additions and 0 deletions
|
|
@ -93,6 +93,34 @@ impl Client {
|
||||||
decode_json(resp, &format!("GET {url}"))
|
decode_json(resp, &format!("GET {url}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET a paginated list endpoint and concatenate all pages.
|
||||||
|
/// `path` should NOT include `page=` (we own it); other query
|
||||||
|
/// params (`?limit=N&state=open&...`) are preserved. Pages drain
|
||||||
|
/// while the response carries a `Link: rel="next"` header, up to
|
||||||
|
/// `max_pages` (the runaway-loop safety cap). Returns the merged
|
||||||
|
/// array. Used by `lint` for repo-wide queries (closes #505).
|
||||||
|
pub fn get_json_all(&self, path: &str, max_pages: u32) -> Result<Vec<Value>> {
|
||||||
|
let sep = if path.contains('?') { '&' } else { '?' };
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
for page in 1..=max_pages {
|
||||||
|
let url = format!("{}{}{sep}page={page}", self.api(), path);
|
||||||
|
let resp = self.http.get(&url).send().context("GET")?;
|
||||||
|
let has_next = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LINK)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|s| s.contains("rel=\"next\""));
|
||||||
|
let v = decode_json(resp, &format!("GET {url}"))?;
|
||||||
|
let arr = v.as_array().cloned().unwrap_or_default();
|
||||||
|
let empty = arr.is_empty();
|
||||||
|
merged.extend(arr);
|
||||||
|
if empty || !has_next {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
|
||||||
/// GET `<api>/<path>` and return the raw response body as text
|
/// GET `<api>/<path>` and return the raw response body as text
|
||||||
/// (used by `diff` which fetches a `text/plain` blob).
|
/// (used by `diff` which fetches a `text/plain` blob).
|
||||||
pub fn get_text(&self, path: &str, accept: &str) -> Result<String> {
|
pub fn get_text(&self, path: &str, accept: &str) -> Result<String> {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ enum Verb {
|
||||||
Close(verbs::close::Args),
|
Close(verbs::close::Args),
|
||||||
/// List, add, or remove labels on an issue or PR.
|
/// List, add, or remove labels on an issue or PR.
|
||||||
Labels(verbs::labels::Args),
|
Labels(verbs::labels::Args),
|
||||||
|
/// Triage lint queries (unassigned / no-reviewer / stale-branches / assignments).
|
||||||
|
Lint(verbs::lint::Args),
|
||||||
/// Manage milestones (list / create / close).
|
/// Manage milestones (list / create / close).
|
||||||
Milestone(verbs::milestone::Args),
|
Milestone(verbs::milestone::Args),
|
||||||
/// List reviews on a PR.
|
/// List reviews on a PR.
|
||||||
|
|
@ -109,6 +111,7 @@ fn main() -> Result<()> {
|
||||||
Verb::Assign(a) => verbs::assign::run(&client, a),
|
Verb::Assign(a) => verbs::assign::run(&client, a),
|
||||||
Verb::Close(a) => verbs::close::run(&client, a),
|
Verb::Close(a) => verbs::close::run(&client, a),
|
||||||
Verb::Labels(a) => verbs::labels::run(&client, a),
|
Verb::Labels(a) => verbs::labels::run(&client, a),
|
||||||
|
Verb::Lint(a) => verbs::lint::run(&client, a),
|
||||||
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
Verb::Milestone(a) => verbs::milestone::run(&client, a),
|
||||||
Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a),
|
Verb::PrReviews(a) => verbs::pr_reviews::run(&client, a),
|
||||||
Verb::Branches(a) => verbs::branches::run(&client, a),
|
Verb::Branches(a) => verbs::branches::run(&client, a),
|
||||||
|
|
|
||||||
446
hive-forge/src/verbs/lint.rs
Normal file
446
hive-forge/src/verbs/lint.rs
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
//! `lint <subcommand>` — issue/PR/branch lint queries for triage
|
||||||
|
//! workflows (closes #505). Replaces ad-hoc curl + jq filtering with
|
||||||
|
//! typed commands that always emit JSON via the global `--json`
|
||||||
|
//! (default is a compact one-line-per-item human shape).
|
||||||
|
//!
|
||||||
|
//! Sub-commands:
|
||||||
|
//! - `unassigned [--type issues|pulls|all] [--state open|closed|all]`
|
||||||
|
//! - `no-reviewer [--state open|closed|all] [--reviewer NAME]`
|
||||||
|
//! - `stale-branches [--days N]`
|
||||||
|
//! - `assignments [--user NAME]`
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::verbs::print_json;
|
||||||
|
|
||||||
|
/// Safety cap on paginated walks: 20 pages × 50 items = 1000.
|
||||||
|
/// Plenty for the hyperhive repo today; bump if a future repo trips it.
|
||||||
|
const MAX_PAGES: u32 = 20;
|
||||||
|
|
||||||
|
/// Page size on list endpoints (Forgejo caps at 50 by default).
|
||||||
|
const PAGE_LIMIT: u32 = 50;
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
pub struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
sub: Sub,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Sub {
|
||||||
|
/// List issues or PRs without an assignee.
|
||||||
|
Unassigned(UnassignedArgs),
|
||||||
|
/// List PRs with no `@reviewer` mention in any comment.
|
||||||
|
NoReviewer(NoReviewerArgs),
|
||||||
|
/// List remote branches with no commits in N days.
|
||||||
|
/// Skips branches that are heads of open PRs.
|
||||||
|
StaleBranches(StaleBranchesArgs),
|
||||||
|
/// Group open issues + PRs by assignee.
|
||||||
|
Assignments(AssignmentsArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, ValueEnum)]
|
||||||
|
enum Kind {
|
||||||
|
Issues,
|
||||||
|
Pulls,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
fn forgejo_type(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Issues => "issues",
|
||||||
|
Kind::Pulls => "pulls",
|
||||||
|
Kind::All => "all",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, ValueEnum)]
|
||||||
|
enum State {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
State::Open => "open",
|
||||||
|
State::Closed => "closed",
|
||||||
|
State::All => "all",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
struct UnassignedArgs {
|
||||||
|
/// Filter by item kind.
|
||||||
|
#[arg(long, value_enum, default_value_t = Kind::All)]
|
||||||
|
r#type: Kind,
|
||||||
|
/// Filter by item state.
|
||||||
|
#[arg(long, value_enum, default_value_t = State::Open)]
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
struct NoReviewerArgs {
|
||||||
|
/// Filter by PR state.
|
||||||
|
#[arg(long, value_enum, default_value_t = State::Open)]
|
||||||
|
state: State,
|
||||||
|
/// Reviewer login to look for (matches `@<reviewer>` in comments).
|
||||||
|
#[arg(long, default_value = "argus")]
|
||||||
|
reviewer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
struct StaleBranchesArgs {
|
||||||
|
/// Threshold in days since the last commit.
|
||||||
|
#[arg(long, default_value_t = 14)]
|
||||||
|
days: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ClapArgs)]
|
||||||
|
struct AssignmentsArgs {
|
||||||
|
/// Restrict to a single user.
|
||||||
|
#[arg(long)]
|
||||||
|
user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
|
match args.sub {
|
||||||
|
Sub::Unassigned(a) => run_unassigned(client, a),
|
||||||
|
Sub::NoReviewer(a) => run_no_reviewer(client, a),
|
||||||
|
Sub::StaleBranches(a) => run_stale_branches(client, a),
|
||||||
|
Sub::Assignments(a) => run_assignments(client, a),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── unassigned ─────────────────────────
|
||||||
|
|
||||||
|
fn run_unassigned(client: &Client, args: UnassignedArgs) -> Result<()> {
|
||||||
|
let repo = client.repo();
|
||||||
|
let items = client.get_json_all(
|
||||||
|
&format!(
|
||||||
|
"/repos/{repo}/issues?type={}&state={}&limit={PAGE_LIMIT}",
|
||||||
|
args.r#type.forgejo_type(),
|
||||||
|
args.state.as_str()
|
||||||
|
),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
|
let filtered: Vec<Value> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|it| {
|
||||||
|
it.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(Vec::is_empty)
|
||||||
|
})
|
||||||
|
.map(trim_item)
|
||||||
|
.collect();
|
||||||
|
emit(client, &filtered, |it| {
|
||||||
|
format!("#{} [{}] {}", num(it), kind_label(it), title(it))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── no-reviewer ────────────────────────
|
||||||
|
|
||||||
|
fn run_no_reviewer(client: &Client, args: NoReviewerArgs) -> Result<()> {
|
||||||
|
let repo = client.repo();
|
||||||
|
// PR-only: `/repos/{repo}/pulls` doesn't return issues.
|
||||||
|
let pulls = client.get_json_all(
|
||||||
|
&format!(
|
||||||
|
"/repos/{repo}/pulls?state={}&limit={PAGE_LIMIT}",
|
||||||
|
args.state.as_str()
|
||||||
|
),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
|
let needle = format!("@{}", args.reviewer);
|
||||||
|
let mut missing: Vec<Value> = Vec::new();
|
||||||
|
for pr in pulls {
|
||||||
|
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
|
||||||
|
if number == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check PR body itself first — saves a comment-fetch on freshly-opened PRs
|
||||||
|
// that already @reviewer in the description.
|
||||||
|
let body = pr.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if body.contains(&needle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let comments =
|
||||||
|
client.get_json(&format!("/repos/{repo}/issues/{number}/comments?limit=50"))?;
|
||||||
|
let mentioned = comments.as_array().is_some_and(|a| {
|
||||||
|
a.iter().any(|c| {
|
||||||
|
c.get("body")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|body| body.contains(&needle))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !mentioned {
|
||||||
|
missing.push(trim_item(pr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(client, &missing, |it| format!("#{} {}", num(it), title(it)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────── stale-branches ───────────────────────
|
||||||
|
|
||||||
|
fn run_stale_branches(client: &Client, args: StaleBranchesArgs) -> Result<()> {
|
||||||
|
if args.days < 0 {
|
||||||
|
bail!("--days must be non-negative");
|
||||||
|
}
|
||||||
|
let repo = client.repo();
|
||||||
|
let branches = client.get_json_all(
|
||||||
|
&format!("/repos/{repo}/branches?limit={PAGE_LIMIT}"),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
|
// Collect active PR head refs to skip — a branch with an open PR
|
||||||
|
// isn't "stale", it's "in review".
|
||||||
|
let open_pulls = client.get_json_all(
|
||||||
|
&format!("/repos/{repo}/pulls?state=open&limit={PAGE_LIMIT}"),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
|
let active_heads: std::collections::HashSet<String> = open_pulls
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| {
|
||||||
|
p.get("head")
|
||||||
|
.and_then(|h| h.get("ref"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cutoff_days = today_days_utc().context("compute today")? - args.days;
|
||||||
|
let mut stale: Vec<Value> = Vec::new();
|
||||||
|
for br in branches {
|
||||||
|
let name = br.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if name.is_empty() || active_heads.contains(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ts = br
|
||||||
|
.get("commit")
|
||||||
|
.and_then(|c| c.get("timestamp"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let Some(date_str) = ts.get(..10) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(days) = parse_yyyy_mm_dd_days(date_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if days <= cutoff_days {
|
||||||
|
stale.push(json!({
|
||||||
|
"name": name,
|
||||||
|
"last_commit": ts,
|
||||||
|
"age_days": (today_days_utc().unwrap_or(days) - days),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(client, &stale, |it| {
|
||||||
|
let name = it.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||||
|
let age = it.get("age_days").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
format!("{name} ({age}d)")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── assignments ────────────────────────
|
||||||
|
|
||||||
|
fn run_assignments(client: &Client, args: AssignmentsArgs) -> Result<()> {
|
||||||
|
let repo = client.repo();
|
||||||
|
let items = client.get_json_all(
|
||||||
|
&format!("/repos/{repo}/issues?type=all&state=open&limit={PAGE_LIMIT}"),
|
||||||
|
MAX_PAGES,
|
||||||
|
)?;
|
||||||
|
let mut by_user: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||||
|
for it in items {
|
||||||
|
let assignees: Vec<String> = it
|
||||||
|
.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.get("login").and_then(Value::as_str))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if assignees.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let slim = trim_item(it);
|
||||||
|
for u in assignees {
|
||||||
|
if args.user.as_deref().is_some_and(|w| w != u) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
by_user.entry(u).or_default().push(slim.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.json_mode() {
|
||||||
|
let out: Value = by_user
|
||||||
|
.into_iter()
|
||||||
|
.map(|(u, items)| {
|
||||||
|
(
|
||||||
|
u,
|
||||||
|
json!({
|
||||||
|
"count": items.len(),
|
||||||
|
"items": items,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<serde_json::Map<_, _>>()
|
||||||
|
.into();
|
||||||
|
print_json(&out)
|
||||||
|
} else {
|
||||||
|
for (u, items) in &by_user {
|
||||||
|
println!("{u}: {} items", items.len());
|
||||||
|
for it in items.iter().take(3) {
|
||||||
|
println!(" #{} [{}] {}", num(it), kind_label(it), title(it));
|
||||||
|
}
|
||||||
|
if items.len() > 3 {
|
||||||
|
println!(" ... (+{} more)", items.len() - 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── shared helpers ─────────────────────
|
||||||
|
|
||||||
|
/// Strip a Forgejo issue/PR JSON down to the fields lint output cares
|
||||||
|
/// about. Mirrors the trim pattern in `verbs/issue.rs`.
|
||||||
|
fn trim_item(it: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"number": it.get("number"),
|
||||||
|
"title": it.get("title"),
|
||||||
|
"state": it.get("state"),
|
||||||
|
"url": it.get("html_url"),
|
||||||
|
// Forgejo's unified /issues endpoint always emits a
|
||||||
|
// `pull_request` key — `null` for plain issues, an object
|
||||||
|
// (with merged/url/etc.) for PRs. Treat any non-null as a PR.
|
||||||
|
"is_pr": it.get("pull_request").is_some_and(|v| !v.is_null()),
|
||||||
|
"assignees": it
|
||||||
|
.get("assignees")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|x| x.get("login").cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(it: &Value) -> i64 {
|
||||||
|
it.get("number").and_then(Value::as_i64).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(it: &Value) -> &str {
|
||||||
|
it.get("title").and_then(Value::as_str).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind_label(it: &Value) -> &'static str {
|
||||||
|
if it.get("is_pr").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
|
"pr"
|
||||||
|
} else {
|
||||||
|
"issue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either pretty-print the JSON array (when --json) or fall back to a
|
||||||
|
/// caller-supplied human one-liner per item.
|
||||||
|
fn emit<F>(client: &Client, items: &[Value], fmt: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: Fn(&Value) -> String,
|
||||||
|
{
|
||||||
|
if client.json_mode() {
|
||||||
|
print_json(&Value::Array(items.to_vec()))
|
||||||
|
} else {
|
||||||
|
if items.is_empty() {
|
||||||
|
println!("(no matches)");
|
||||||
|
}
|
||||||
|
for it in items {
|
||||||
|
println!("{}", fmt(it));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tiny date helpers (avoid pulling in chrono/time for one verb) ───
|
||||||
|
|
||||||
|
/// Days since 1970-01-01 in UTC for "today" (best-effort from system clock).
|
||||||
|
fn today_days_utc() -> Result<i64> {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.context("system clock before epoch")?
|
||||||
|
.as_secs();
|
||||||
|
// `as_secs()` returns u64; clamp into i64 (won't overflow until y2554).
|
||||||
|
Ok(i64::try_from(secs / 86_400).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `YYYY-MM-DD` (e.g. the first 10 chars of an RFC3339 stamp)
|
||||||
|
/// into days-since-1970-01-01 (UTC midnight). Returns `None` on parse
|
||||||
|
/// failure rather than panicking — lint output stays best-effort.
|
||||||
|
fn parse_yyyy_mm_dd_days(stamp: &str) -> Option<i64> {
|
||||||
|
let bytes = stamp.as_bytes();
|
||||||
|
if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year: i32 = std::str::from_utf8(&bytes[0..4]).ok()?.parse().ok()?;
|
||||||
|
let month: u32 = std::str::from_utf8(&bytes[5..7]).ok()?.parse().ok()?;
|
||||||
|
let day: u32 = std::str::from_utf8(&bytes[8..10]).ok()?.parse().ok()?;
|
||||||
|
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(days_from_civil(year, month, day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Howard Hinnant's `days_from_civil`: proleptic Gregorian → days since
|
||||||
|
/// 1970-01-01. Public-domain reference algorithm. Handles negative years.
|
||||||
|
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
|
||||||
|
let y = if month <= 2 { year - 1 } else { year };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = i64::from(y - era * 400); // [0, 399]
|
||||||
|
let m = i64::from(month);
|
||||||
|
let d = i64::from(day);
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
i64::from(era) * 146_097 + doe - 719_468
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn epoch_is_day_zero() {
|
||||||
|
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_dates() {
|
||||||
|
// Hinnant reference values
|
||||||
|
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
|
||||||
|
assert_eq!(days_from_civil(2020, 2, 29), 18_321);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_iso_prefix() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_yyyy_mm_dd_days("2020-02-29T12:00:00+02:00"),
|
||||||
|
Some(18_321)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_input() {
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("not-a-date"), None);
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("2020/02/29"), None);
|
||||||
|
assert_eq!(parse_yyyy_mm_dd_days("2020-13-01"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ pub mod issue;
|
||||||
pub mod issue_create;
|
pub mod issue_create;
|
||||||
pub mod issue_edit;
|
pub mod issue_edit;
|
||||||
pub mod labels;
|
pub mod labels;
|
||||||
|
pub mod lint;
|
||||||
pub mod milestone;
|
pub mod milestone;
|
||||||
pub mod pr;
|
pub mod pr;
|
||||||
pub mod pr_create;
|
pub mod pr_create;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue