mirror of
https://github.com/storytold/craft-repo-status-app.git
synced 2026-10-13 23:17:45 +00:00
7 files changed
+152
-57
No files matched your search
@@ -32,12 +32,15 @@ with no data always sort last.
|
||||
| Builds | most recent release tag · its publish date · **Unreleased** (commits on `main` since it) · its downloads · downloads across all releases |
|
||||
|
||||
- **Activity** covers the window picked in that group's header: the last
|
||||
10 min, 30 min, hour, 4 h (the default), 12 h, day or week, or all time.
|
||||
Every poll fetches all eight, so switching is instant; the choice is
|
||||
remembered. People are distinct commit authors in the window; all time, it
|
||||
is GitHub's contributor count.
|
||||
The numbers are as of the last poll, so short windows lag by up to
|
||||
`poll_minutes`.
|
||||
10 min, 30 min, hour, 4 h (the default), 12 h, day or week, since the last
|
||||
release, or all time. Every poll fetches all nine, so switching is instant;
|
||||
the choice is remembered. People are distinct commit authors in the window;
|
||||
all time, it is GitHub's contributor count. The numbers are as of the last
|
||||
poll, so short windows lag by up to `poll_minutes`.
|
||||
- **Since last release** starts at each repo's own latest build, so the span
|
||||
differs per row (hover a number for the release and its date). Its commits
|
||||
are the **Unreleased** count; PRs, merges, issues and people count from the
|
||||
release's publish date. Repos with no release show `—`.
|
||||
- **Builds** are GitHub releases. Drafts are ignored. Prereleases count, and
|
||||
they carry a `PRE` tag.
|
||||
- **Unreleased** counts the commits that have landed on `main` since the latest
|
||||
|
||||
@@ -10,7 +10,9 @@ pub mod urgency;
|
||||
|
||||
pub use cache::Cache;
|
||||
pub use config::{Config, WindowState};
|
||||
pub use model::{Activity, RepoEntry, RepoStats, Snapshot, UrgentIssue, ALL_TIME, WINDOWS};
|
||||
pub use model::{
|
||||
Activity, RepoEntry, RepoStats, Snapshot, UrgentIssue, ALL_TIME, SINCE_RELEASE, WINDOWS,
|
||||
};
|
||||
pub use releases::{summarize_releases, Asset, BuildLag, LagBasis, Release, ReleaseSummary};
|
||||
pub use urgency::{rank_issues, score_issue, IssueInput, Scored};
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ pub const WINDOWS: &[(&str, i64)] = &[
|
||||
/// in [`WINDOWS`] (it has no length); its counts come from totals, not searches.
|
||||
pub const ALL_TIME: &str = "all";
|
||||
|
||||
/// The id of the window that starts when the repository's latest build was
|
||||
/// published, so its length differs per repository. Like [`ALL_TIME`] it isn't
|
||||
/// in [`WINDOWS`]; repos without a build don't have it.
|
||||
pub const SINCE_RELEASE: &str = "release";
|
||||
|
||||
/// What happened in one recent window.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
@@ -56,7 +61,8 @@ pub struct RepoStats {
|
||||
pub oldest_open_pr_at: Option<Time>,
|
||||
/// Newest PR, any state.
|
||||
pub newest_pr_at: Option<Time>,
|
||||
/// Activity per window, keyed by the ids in [`WINDOWS`] plus [`ALL_TIME`].
|
||||
/// Activity per window, keyed by the ids in [`WINDOWS`] plus [`ALL_TIME`]
|
||||
/// and, when there is a build, [`SINCE_RELEASE`].
|
||||
pub recent: BTreeMap<String, Activity>,
|
||||
/// Newest published (non-draft) release.
|
||||
pub latest_build: Option<String>,
|
||||
@@ -163,7 +169,8 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
/// False when some repo's stats predate a field the UI needs: today, a
|
||||
/// recent-activity window (all time included). Such a cache (from an older build) shouldn't
|
||||
/// recent-activity window (all time included, and since the release
|
||||
/// for repos with one). Such a cache (from an older build) shouldn't
|
||||
/// delay the launch poll.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.repos.iter().filter_map(|r| r.stats.as_ref()).all(|s| {
|
||||
@@ -171,6 +178,7 @@ impl Snapshot {
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain([ALL_TIME])
|
||||
.chain(s.latest_build_at.map(|_| SINCE_RELEASE))
|
||||
.all(|id| s.recent.contains_key(id))
|
||||
})
|
||||
}
|
||||
@@ -242,6 +250,17 @@ mod tests {
|
||||
s.repos[0].stats = Some(stats.clone());
|
||||
assert!(!s.is_complete(), "cache from before all time was a window");
|
||||
stats.recent.insert(ALL_TIME.into(), Activity::default());
|
||||
s.repos[0].stats = Some(stats.clone());
|
||||
assert!(s.is_complete(), "no build, so no since-release window");
|
||||
stats.latest_build_at = Some(Utc::now());
|
||||
s.repos[0].stats = Some(stats.clone());
|
||||
assert!(
|
||||
!s.is_complete(),
|
||||
"cache from before since-release was a window"
|
||||
);
|
||||
stats
|
||||
.recent
|
||||
.insert(SINCE_RELEASE.into(), Activity::default());
|
||||
s.repos[0].stats = Some(stats);
|
||||
assert!(s.is_complete());
|
||||
}
|
||||
|
||||
+104
-42
@@ -1,10 +1,12 @@
|
||||
//! Fetches one repository's [`RepoStats`] from GitHub.
|
||||
//!
|
||||
//! Per repo: one GraphQL query for counts, dates, every recent-activity window
|
||||
//! ([`WINDOWS`]), the app icon's blob id and the open issues to rank; one
|
||||
//! (usually) GraphQL query for releases + asset download counts; one GraphQL
|
||||
//! query comparing the latest build's tag with `main`; one (usually) GraphQL
|
||||
//! query for the last week's commit authors, which give each window's people;
|
||||
//! Per repo: one (usually) GraphQL query for releases + asset download
|
||||
//! counts; one GraphQL query for counts, dates, every recent-activity window
|
||||
//! ([`WINDOWS`], plus [`SINCE_RELEASE`], which needs the releases), the app
|
||||
//! icon's blob id and the open issues to rank; one GraphQL query comparing the
|
||||
//! latest build's tag with `main`; one or more GraphQL queries for the commit
|
||||
//! authors since a week ago or the latest build, whichever is earlier, which
|
||||
//! give each window's people;
|
||||
//! one (usually) REST call for the contributor list, which GraphQL lacks.
|
||||
//! The icon itself is a REST blob fetch, made only when its blob id changes
|
||||
//! (see [`GitHub::icon`]). Blocking I/O: callers run this on worker threads.
|
||||
@@ -15,7 +17,7 @@ use std::time::Duration;
|
||||
use chrono::{DateTime, Utc};
|
||||
use craft_core::{
|
||||
rank_issues, summarize_releases, Activity, Asset, BuildLag, IssueInput, LagBasis, Release,
|
||||
RepoStats, ALL_TIME, WINDOWS,
|
||||
RepoStats, ALL_TIME, SINCE_RELEASE, WINDOWS,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
@@ -167,13 +169,14 @@ impl GitHub {
|
||||
let (owner, name) = repo
|
||||
.split_once('/')
|
||||
.ok_or_else(|| Error::GraphQl(format!("bad repo {repo:?}")))?;
|
||||
let releases = self.releases(owner, name)?;
|
||||
let release_at = summarize_releases(&releases).latest_at;
|
||||
let main = self.graphql(
|
||||
&main_query(repo, now),
|
||||
&main_query(repo, now, release_at),
|
||||
json!({ "owner": owner, "name": name }),
|
||||
)?;
|
||||
let releases = self.releases(owner, name)?;
|
||||
let since_build = self.since_build(owner, name, &main, &releases)?;
|
||||
let authors = self.authors(owner, name, now)?;
|
||||
let authors = self.authors(owner, name, now, release_at)?;
|
||||
let contributors = self.contributors(repo)?;
|
||||
let budget = Budget {
|
||||
graphql_remaining: main["rateLimit"]["remaining"].as_u64(),
|
||||
@@ -198,14 +201,22 @@ impl GitHub {
|
||||
Ok(format!("data:image/png;base64,{b64}"))
|
||||
}
|
||||
|
||||
/// `(committed, author)` for each commit on `main` in the longest window.
|
||||
fn authors(&self, owner: &str, name: &str, now: DateTime<Utc>) -> Result<Vec<CommitAuthor>> {
|
||||
/// `(committed, author)` for each commit on `main` in the longest window,
|
||||
/// or since the latest build if that's longer.
|
||||
fn authors(
|
||||
&self,
|
||||
owner: &str,
|
||||
name: &str,
|
||||
now: DateTime<Utc>,
|
||||
release_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Vec<CommitAuthor>> {
|
||||
let longest = WINDOWS.iter().map(|(_, m)| *m).max().unwrap_or(0);
|
||||
let since = (now - chrono::Duration::minutes(longest))
|
||||
.min(release_at.unwrap_or(now))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let mut out = Vec::new();
|
||||
let mut after: Option<String> = None;
|
||||
// 10 pages × 100 commits; a busier week undercounts its oldest people.
|
||||
// 10 pages × 100 commits; a busier stretch undercounts its oldest people.
|
||||
for _ in 0..10 {
|
||||
let v = self.graphql(
|
||||
AUTHORS_QUERY,
|
||||
@@ -456,11 +467,15 @@ fn search_count(main: &Value, alias: &str) -> u64 {
|
||||
/// default branch still gets every window (with zero commits).
|
||||
/// The all-time totals come along as the [`ALL_TIME`] window; its people are
|
||||
/// GitHub's contributors plus any newer `authors`, the others' the distinct
|
||||
/// `authors` inside them.
|
||||
/// `authors` inside them. With a build published at `release_at`, the
|
||||
/// [`SINCE_RELEASE`] window starts then; its commits are `unreleased` (the
|
||||
/// Unreleased column's count) when that is known.
|
||||
fn recent_activity(
|
||||
main: &Value,
|
||||
contributors: &[String],
|
||||
authors: &[CommitAuthor],
|
||||
release_at: Option<DateTime<Utc>>,
|
||||
unreleased: Option<u64>,
|
||||
now: DateTime<Utc>,
|
||||
) -> std::collections::BTreeMap<String, Activity> {
|
||||
let r = &main["repository"];
|
||||
@@ -479,39 +494,58 @@ fn recent_activity(
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len() as u64,
|
||||
};
|
||||
let window = |key: &str, since: DateTime<Utc>| Activity {
|
||||
commits: count(&head[format!("c{key}")]),
|
||||
prs_opened: search_count(main, &format!("p{key}")),
|
||||
prs_merged: search_count(main, &format!("m{key}")),
|
||||
issues_opened: search_count(main, &format!("i{key}")),
|
||||
people: authors
|
||||
.iter()
|
||||
.filter(|(at, _)| *at >= since)
|
||||
.map(|(_, who)| who)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len() as u64,
|
||||
};
|
||||
let mut out: std::collections::BTreeMap<_, _> = windows(now, release_at)
|
||||
.map(|(id, key, since)| (id.to_string(), window(&key, since)))
|
||||
.collect();
|
||||
if let (Some(a), Some(n)) = (out.get_mut(SINCE_RELEASE), unreleased) {
|
||||
a.commits = n;
|
||||
}
|
||||
out.insert(ALL_TIME.to_string(), all);
|
||||
out
|
||||
}
|
||||
|
||||
/// `(id, alias suffix, start)` for each window in [`WINDOWS`] (suffixed by
|
||||
/// index), then [`SINCE_RELEASE`] (suffixed `r`) when there is a build
|
||||
/// published at `release_at`.
|
||||
fn windows(
|
||||
now: DateTime<Utc>,
|
||||
release_at: Option<DateTime<Utc>>,
|
||||
) -> impl Iterator<Item = (&'static str, String, DateTime<Utc>)> {
|
||||
WINDOWS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, minutes))| {
|
||||
let since = now - chrono::Duration::minutes(*minutes);
|
||||
let people = authors
|
||||
.iter()
|
||||
.filter(|(at, _)| *at >= since)
|
||||
.map(|(_, who)| who)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len() as u64;
|
||||
let a = Activity {
|
||||
commits: count(&head[format!("c{i}")]),
|
||||
prs_opened: search_count(main, &format!("p{i}")),
|
||||
prs_merged: search_count(main, &format!("m{i}")),
|
||||
issues_opened: search_count(main, &format!("i{i}")),
|
||||
people,
|
||||
};
|
||||
(id.to_string(), a)
|
||||
.map(move |(i, (id, minutes))| {
|
||||
(
|
||||
*id,
|
||||
i.to_string(),
|
||||
now - chrono::Duration::minutes(*minutes),
|
||||
)
|
||||
})
|
||||
.chain([(ALL_TIME.to_string(), all)])
|
||||
.collect()
|
||||
.chain(release_at.map(|at| (SINCE_RELEASE, "r".to_string(), at)))
|
||||
}
|
||||
|
||||
/// [`MAIN_QUERY`] with one commit-history count and three searches per
|
||||
/// window, aliased by window index (`c0`, `p0`, `m0`, `i0`…). GitHub charges
|
||||
/// the whole query one rate-limit point however many windows it holds.
|
||||
pub fn main_query(repo: &str, now: DateTime<Utc>) -> String {
|
||||
/// window, aliased by window index (`c0`, `p0`, `m0`, `i0`…), plus the
|
||||
/// [`SINCE_RELEASE`] window (`cr`, `pr`…) when there is a build published at
|
||||
/// `release_at`. GitHub charges the whole query one rate-limit point however
|
||||
/// many windows it holds.
|
||||
pub fn main_query(repo: &str, now: DateTime<Utc>, release_at: Option<DateTime<Utc>>) -> String {
|
||||
let mut history = String::new();
|
||||
let mut searches = String::new();
|
||||
for (i, (_, minutes)) in WINDOWS.iter().enumerate() {
|
||||
let since = (now - chrono::Duration::minutes(*minutes))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
for (_, i, since) in windows(now, release_at) {
|
||||
let since = since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
history += &format!("c{i}: history(since: \"{since}\") {{ totalCount }}\n");
|
||||
for (alias, filter) in [
|
||||
("p", format!("is:pr created:>={since}")),
|
||||
@@ -551,7 +585,14 @@ pub fn build_stats(
|
||||
last_issue_at: first_time(&r["newestIssue"], "createdAt"),
|
||||
oldest_open_pr_at: first_time(&r["oldestOpenPr"], "createdAt"),
|
||||
newest_pr_at: first_time(&r["newestPr"], "createdAt"),
|
||||
recent: recent_activity(main, contributors, authors, now),
|
||||
recent: recent_activity(
|
||||
main,
|
||||
contributors,
|
||||
authors,
|
||||
rel.latest_at,
|
||||
since_build.as_ref().map(|l| l.commits),
|
||||
now,
|
||||
),
|
||||
latest_build: rel.latest_tag,
|
||||
latest_build_at: rel.latest_at,
|
||||
latest_build_prerelease: rel.latest_prerelease,
|
||||
@@ -676,9 +717,10 @@ mod tests {
|
||||
"hotOpen": {"nodes": []},
|
||||
"defaultBranchRef": {"target": {
|
||||
"all": {"totalCount": 1234, "nodes": [{"committedDate": "2026-10-08T03:00:00Z"}]},
|
||||
"c0": {"totalCount": 1}, "c3": {"totalCount": 9}}}
|
||||
"c0": {"totalCount": 1}, "c3": {"totalCount": 9}, "cr": {"totalCount": 99}}}
|
||||
},
|
||||
"p3": {"issueCount": 2}, "m3": {"issueCount": 3}, "i3": {"issueCount": 4}, "m6": {"issueCount": 30}
|
||||
"p3": {"issueCount": 2}, "m3": {"issueCount": 3}, "i3": {"issueCount": 4}, "m6": {"issueCount": 30},
|
||||
"pr": {"issueCount": 11}, "mr": {"issueCount": 12}, "ir": {"issueCount": 13}
|
||||
});
|
||||
let rel = parse_releases(&json!({"nodes": [
|
||||
{"tagName": "v1.1.0", "isDraft": true, "isPrerelease": false, "publishedAt": null, "createdAt": "2026-10-08T00:00:00Z",
|
||||
@@ -705,7 +747,7 @@ mod tests {
|
||||
assert_eq!(people[1], "old@z");
|
||||
let s = build_stats(&main, &rel, Some(lag.clone()), &people, &authors, now);
|
||||
assert_eq!((s.open_prs, s.open_issues), (3, 7));
|
||||
assert_eq!(s.recent.len(), WINDOWS.len() + 1);
|
||||
assert_eq!(s.recent.len(), WINDOWS.len() + 2);
|
||||
let a = |id: &str| s.recent[id];
|
||||
assert_eq!(
|
||||
a("4h"),
|
||||
@@ -730,6 +772,20 @@ mod tests {
|
||||
people: 14,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
a(SINCE_RELEASE),
|
||||
Activity {
|
||||
// The Unreleased count, not commits dated after publishing.
|
||||
commits: 5,
|
||||
prs_opened: 11,
|
||||
prs_merged: 12,
|
||||
issues_opened: 13,
|
||||
// Cy committed on the 3rd, after v1.0.0 but before the last week.
|
||||
people: 3,
|
||||
}
|
||||
);
|
||||
let no_lag = build_stats(&main, &rel, None, &people, &authors, now);
|
||||
assert_eq!(no_lag.recent[SINCE_RELEASE].commits, 99);
|
||||
assert_eq!(s.icon_oid.as_deref(), Some("i1"));
|
||||
assert_eq!(s.latest_build.as_deref(), Some("v1.0.0"));
|
||||
assert_eq!((s.latest_build_downloads, s.downloads_total), (10, 10));
|
||||
@@ -780,10 +836,16 @@ mod tests {
|
||||
#[test]
|
||||
fn query_asks_for_every_window() {
|
||||
let now: DateTime<Utc> = "2026-10-08T04:00:00Z".parse().unwrap();
|
||||
let q = main_query("o/r", now);
|
||||
let q = main_query("o/r", now, None);
|
||||
assert!(q.contains(r#"c3: history(since: "2026-10-08T00:00:00Z")"#));
|
||||
assert!(q.contains(r#"m6: search(query: "repo:o/r is:pr merged:>=2026-10-01T04:00:00Z""#));
|
||||
assert!(!q.contains("{HISTORY}") && !q.contains("{SEARCHES}"));
|
||||
assert!(!q.contains("cr:"), "no build, no since-release window");
|
||||
let q = main_query("o/r", now, Some("2026-09-20T12:00:00Z".parse().unwrap()));
|
||||
assert!(q.contains(r#"cr: history(since: "2026-09-20T12:00:00Z")"#));
|
||||
assert!(
|
||||
q.contains(r#"ir: search(query: "repo:o/r is:issue created:>=2026-09-20T12:00:00Z""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -98,8 +98,14 @@ function cell(entry, col) {
|
||||
case 'open_prs': return link(num(v), gh(entry.repo, '/pulls'));
|
||||
case 'open_issues': return link(num(v), gh(entry.repo, '/issues'));
|
||||
case 'recent_commits': case 'recent_prs': case 'recent_merged': case 'recent_issues': case 'recent_people':
|
||||
if (v === null) return el('span', { class: 'muted', title: 'Not fetched yet for this window' }, '—');
|
||||
return el('span', { class: v ? 'active' : 'zero' }, num(v));
|
||||
if (v === null) {
|
||||
const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';
|
||||
return el('span', { class: 'muted', title: why }, '—');
|
||||
}
|
||||
return el('span', {
|
||||
class: v ? 'active' : 'zero',
|
||||
title: state.window === 'release' ? `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at)} ago)` : null,
|
||||
}, num(v));
|
||||
case 'commits_since_build': {
|
||||
const lag = s.since_build;
|
||||
if (!lag) return el('span', { class: 'muted' }, '—');
|
||||
|
||||
+5
-3
@@ -4,9 +4,10 @@
|
||||
/** @typedef {'text'|'num'|'date'|'version'} Kind */
|
||||
|
||||
/**
|
||||
* The activity windows the dropdown offers, shortest first, then all time. The
|
||||
* ids match `WINDOWS` and `ALL_TIME` in crates/craft-core/src/model.rs; every
|
||||
* poll fetches all of them.
|
||||
* The activity windows the dropdown offers, shortest first, then since each
|
||||
* repo's latest release (so a different span per repo; repos without a release
|
||||
* have no data), then all time. The ids match `WINDOWS`, `SINCE_RELEASE` and
|
||||
* `ALL_TIME` in crates/craft-core/src/model.rs; every poll fetches all of them.
|
||||
*/
|
||||
export const WINDOWS = [
|
||||
{ id: '10m', label: 'Last 10 min', span: 'in the last 10 minutes' },
|
||||
@@ -16,6 +17,7 @@ export const WINDOWS = [
|
||||
{ id: '12h', label: 'Last 12 h', span: 'in the last 12 hours' },
|
||||
{ id: '1d', label: 'Last day', span: 'in the last day' },
|
||||
{ id: '7d', label: 'Last week', span: 'in the last week' },
|
||||
{ id: 'release', label: 'Since last release', span: 'since each repo’s latest release' },
|
||||
{ id: 'all', label: 'All time', span: 'over all time' },
|
||||
];
|
||||
export const DEFAULT_WINDOW = '4h';
|
||||
|
||||
+2
-1
@@ -93,5 +93,6 @@ test('recent-activity columns follow the selected window', () => {
|
||||
assert.equal(value(e[0], 'recent_people', 'all'), 12, 'all time is just another window');
|
||||
assert.equal(value(e[1], 'recent_commits', 'all'), null, 'cache from before all time');
|
||||
assert.equal(names(sortEntries(e, 'recent_merged', 'desc', 'all')), 'abold');
|
||||
assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'all']);
|
||||
assert.equal(value(e[0], 'recent_commits', 'release'), null, 'no release');
|
||||
assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']);
|
||||
});
|
||||
Reference in new issue
Block a user