Merge pull request #1 from storytold/bt-since-release

since release
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-09 19:23:31 -04:00
commit 47aabd6635
7 files changed
+152 -57

No files matched your search

+9 -6
View File
@@ -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
+3 -1
View File
@@ -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};
+21 -2
View File
@@ -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
View File
@@ -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]
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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']);
});