Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ day from public GitHub activity.
contributor, top reviewer, most used AI model), totals for the selected time range
and repository, activity over time, ranked contributor lists, repository and company
shares, code review coverage, and the languages in use.
- **Repositories**: every tracked repository and branch, with its activity, top
contributors and reviewers, companies, and recent pull requests.
- **Repositories**: every tracked repository and branch, with its activity, open and
stale pull request counts, top contributors and reviewers, companies, and its recent
and open pull requests, most recently active first.
- **Contributors**: new and active contributors. Each person's page shows their
activity, company affiliations, reviews, AI usage, pull requests, and commits.
- **Journey**: the second tab of a contributor's page: a timeline from their first
Expand Down Expand Up @@ -50,6 +51,9 @@ shared as a link.
Review coverage is the share of merged pull requests that someone other than the
author reviewed before the merge; time to first review runs from opening,
including time as a draft.
- An open pull request is stale after 60 days without activity: no push, comment,
review, label, or other change GitHub records as an update. Drafts count too. Pull
request tables mark it stale, and their State filter has a "stale" choice.
- A new contributor made their first recorded contribution in the selected range.
- Each contribution is credited to the company the person was affiliated with on
that day, as recorded in `config/affiliations.yaml`. Without an affiliation it
Expand Down
4 changes: 3 additions & 1 deletion crates/pulse-core/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};

use crate::state::{Language, TargetBranch};

pub const MAGIC: &[u8; 8] = b"PULSE\x00\x00\x05";
pub const MAGIC: &[u8; 8] = b"PULSE\x00\x00\x06";
pub const NONE: u32 = u32::MAX;

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
Expand Down Expand Up @@ -268,6 +268,8 @@ pub struct Pr {
pub author_ai_identity: u32,
/// AI identity if the author is AI by kind or by name, else NONE.
pub author_provider_identity: u32,
/// GitHub's last update: pushes, comments, reviews, labels, and state changes.
pub updated_at: Option<i64>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
Expand Down
52 changes: 43 additions & 9 deletions crates/pulse-core/src/views/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ use super::{Dataset, Ordered, Totals, View, day_of, days_value, fold, in_range,
use crate::snapshot::{MetricKind, NONE, Pr};
use crate::time::format_date;

/// Days without activity after which an open pull request counts as stale.
pub(crate) const STALE_AFTER_DAYS: i32 = 60;

/// Which metric rows make up the "snapshot" a query sees. Repository pages use
/// their repository's (and branch's) rows only, which also bound "all" dates.
#[derive(Clone, Copy)]
Expand Down Expand Up @@ -98,7 +101,17 @@ impl Dataset {
(merged, hours.len() as i64, median.map(|h| (h * 10.0).round() / 10.0))
}

pub(crate) fn pull_request_view(&self, index: u32) -> Value {
/// The day of a pull request's latest activity: its last update, else its creation.
pub(crate) fn last_activity_day(pr: &Pr) -> Option<i32> {
day_of(pr.updated_at.or(pr.created_at))
}

/// An open pull request without activity for `STALE_AFTER_DAYS` days.
pub(crate) fn is_stale(pr: &Pr, today: i32) -> bool {
pr.state == "open" && Self::last_activity_day(pr).is_some_and(|day| today - day >= STALE_AFTER_DAYS)
}

pub(crate) fn pull_request_view(&self, index: u32, today: i32) -> Value {
let pr = &self.snapshot.pull_requests[index as usize];
let author_id = self.key_path(pr.author);
json!({
Expand All @@ -110,6 +123,8 @@ impl Dataset {
"state": if pr.state.is_empty() { "unknown" } else { pr.state.as_str() },
"created_at": iso(pr.created_at),
"merged_at": iso(pr.merged_at),
"updated_at": iso(pr.updated_at.or(pr.created_at)),
"stale": Self::is_stale(pr, today),
"target_branch": self.branch(pr.branch),
"author_id": author_id,
"author_name": self.name_or(pr.author, author_id),
Expand Down Expand Up @@ -413,12 +428,23 @@ impl View<'_> {
for (index, repo) in data.snapshot.repositories.iter().enumerate() {
for branch in repo.branch_views() {
let activity = self.dashboard(Scope::All, Some(365), Some(&repo.id), branch.map(|b| b.name.as_str()));
let branch_index = branch.map(|b| data.branch_filter(&b.name));
let open_counts = data
.snapshot
.pull_requests
.iter()
.filter(|pr| {
pr.repo == index as u32 && pr.state == "open" && branch_index.is_none_or(|b| pr.branch == b)
})
.fold((0, 0), |(open, stale), pr| (open + 1, stale + i64::from(Dataset::is_stale(pr, self.today))));
output.push(json!({
"id": data.repo_path(index as u32),
"name": data.repository_label(index as u32, branch.map(|b| (b.name.as_str(), b.label.as_str()))),
"target_branch": branch.map_or("", |b| b.name.as_str()),
"branch_label": branch.map_or("", |b| b.label.as_str()),
"branches": data.branch_names(index as u32, branch.map(|b| b.name.as_str())),
"open_pull_requests": open_counts.0,
"stale_pull_requests": open_counts.1,
"status": repo.status_label(),
"summary": activity["summary"],
}));
Expand All @@ -439,20 +465,27 @@ impl View<'_> {
};
let activity = self.dashboard(scope, days, Some(id), target_branch);
let start = days.map(|d| self.today - (d - 1));
let recent: Vec<Value> = data
let pr_at = |i: u32| &data.snapshot.pull_requests[i as usize];
let in_scope = |i: u32| pr_at(i).repo == repo_index && branch_index.is_none_or(|b| pr_at(i).branch == b);
// The 50 newest pull requests opened or merged in the range, and every open one, so
// stale pull requests are listed whatever the range; most recently active first.
let mut shown: Vec<u32> = data
.prs_newest_first
.iter()
.copied()
.filter(|&i| {
let pr = &data.snapshot.pull_requests[i as usize];
pr.repo == repo_index
&& branch_index.is_none_or(|b| pr.branch == b)
&& (in_range(day_of(pr.created_at), start, Some(self.today))
|| in_range(day_of(pr.merged_at), start, Some(self.today)))
in_scope(i)
&& (in_range(day_of(pr_at(i).created_at), start, Some(self.today))
|| in_range(day_of(pr_at(i).merged_at), start, Some(self.today)))
})
.take(50)
.map(|i| data.pull_request_view(i))
.collect();
shown
.extend((0..data.snapshot.pull_requests.len() as u32).filter(|&i| in_scope(i) && pr_at(i).state == "open"));
shown.sort_unstable();
shown.dedup();
shown.sort_by_key(|&i| std::cmp::Reverse((pr_at(i).updated_at.or(pr_at(i).created_at), pr_at(i).number)));
let pull_requests: Vec<Value> = shown.into_iter().map(|i| data.pull_request_view(i, self.today)).collect();
let selected_label = repo
.target_branches
.iter()
Expand All @@ -476,7 +509,8 @@ impl View<'_> {
"top_contributors": activity["top_contributors"],
"top_reviewers": activity["top_reviewers"].as_array().map(|rows| rows.iter().take(12).cloned().collect::<Vec<_>>()),
"languages": activity["languages"],
"recent_pull_requests": recent,
"pull_requests": pull_requests,
"stale_after_days": STALE_AFTER_DAYS,
}))
}
}
2 changes: 1 addition & 1 deletion crates/pulse-core/src/views/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ impl View<'_> {
value
};
let pr_value = |row: &PrRow, categories: Value| -> Value {
let mut value = data.pull_request_view(row.index);
let mut value = data.pull_request_view(row.index, self.today);
value["categories"] = categories;
value
};
Expand Down
2 changes: 1 addition & 1 deletion crates/pulse-core/src/views/people.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ impl View<'_> {
pr.author == key && range(day_of(pr.created_at)) && selected_ok(pr.repo)
})
.take(100)
.map(|i| data.pull_request_view(i))
.map(|i| data.pull_request_view(i, self.today))
.collect();
let mut matched: Vec<(Option<i64>, u32)> = Vec::new();
for (index, commit) in data.snapshot.commits.iter().enumerate() {
Expand Down
1 change: 1 addition & 0 deletions crates/pulse/src/snapshot_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ pub fn build_snapshot(state: &State, companies: &CompanyCatalog, generated_at: i
commit_count: pr.commit_count,
author_ai_identity,
author_provider_identity,
updated_at: pr.updated_at.map(|t| t.0),
});
}

Expand Down
68 changes: 66 additions & 2 deletions crates/pulse/tests/snapshot_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ fn state() -> State {
}

fn dataset() -> Dataset {
let state = state();
dataset_of(state())
}

fn dataset_of(state: State) -> Dataset {
let config = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let companies = load_company_catalog(&config, &state.contributors).unwrap();
let snapshot = build_snapshot(&state, &companies, parse_timestamp("2026-10-10T12:00:00Z").unwrap());
Expand Down Expand Up @@ -263,7 +266,7 @@ fn detail_pages_and_errors() {
let data = dataset();
let repo = get(&data, "/api/repositories/github/flyteorg/flyte?days=365&target_branch=main");
assert_eq!(repo["summary"]["pull_requests_merged"], 1);
assert_eq!(repo["recent_pull_requests"][0]["url"], "https://github.com/flyteorg/flyte/pull/1");
assert_eq!(repo["pull_requests"][0]["url"], "https://github.com/flyteorg/flyte/pull/1");
let repositories = get(&data, "/api/repositories")["repositories"].clone();
assert_eq!(repositories.as_array().unwrap().len(), 3);
// A branch row covers its branch; a repository-wide row covers every tracked branch.
Expand Down Expand Up @@ -295,6 +298,67 @@ fn detail_pages_and_errors() {
assert_eq!(data.request("/api/nope", today()).unwrap_err().status, 404);
}

#[test]
fn stale_pull_requests() {
let mut state = state();
let open = |number: u64, created: &str, updated: Option<&str>, pr_state: &str| PullRequest {
repository_id: "github.com:R1".into(),
provider_id: format!("90{number}"),
number,
author_contributor_id: "github:2".into(),
title: format!("PR {number}"),
state: pr_state.into(),
created_at: ts(created),
updated_at: updated.and_then(ts),
target_branch: "master".into(),
..PullRequest::default()
};
state.pull_requests.extend([
// Quiet for 87 days: stale.
open(4, "2026-07-01T10:00:00Z", Some("2026-07-15T10:00:00Z"), "open"),
// Opened long ago but updated recently: not stale.
open(5, "2026-01-05T10:00:00Z", Some("2026-10-01T10:00:00Z"), "open"),
// Closed pull requests are never stale.
open(6, "2026-01-01T10:00:00Z", None, "closed"),
]);
let data = dataset_of(state);

let listed = |url: &str| {
let repo = get(&data, url);
let rows = repo["pull_requests"].as_array().unwrap().clone();
let numbers: Vec<_> = rows.iter().map(|pr| pr["number"].as_u64().unwrap()).collect();
let stale: Vec<_> = rows.iter().map(|pr| pr["stale"].as_bool().unwrap()).collect();
(repo, rows, numbers, stale)
};
// Most recently active first; PR 2 has no update time, so its creation counts.
let (repo, rows, numbers, stale) = listed("/api/repositories/github/flyteorg/flyte?days=365&target_branch=master");
assert_eq!(repo["stale_after_days"], 60);
assert_eq!(numbers, [2, 5, 4, 6]);
assert_eq!(stale, [false, false, true, false]);
assert_eq!(rows[0]["updated_at"], "2026-10-03T10:00:00+00:00");
assert_eq!(rows[2]["updated_at"], "2026-07-15T10:00:00+00:00");
// Open pull requests are listed whatever the range; closed ones only in it.
let (_, _, numbers, _) = listed("/api/repositories/github/flyteorg/flyte?days=30&target_branch=master");
assert_eq!(numbers, [2, 5, 4]);
let (_, _, numbers, _) = listed("/api/repositories/github/flyteorg/flyte?days=365&target_branch=main");
assert_eq!(numbers, [1]);

let repositories = get(&data, "/api/repositories")["repositories"].clone();
let counts = |name: &str| {
let row = repositories.as_array().unwrap().iter().find(|r| r["name"] == name).unwrap();
(row["open_pull_requests"].as_u64().unwrap(), row["stale_pull_requests"].as_u64().unwrap())
};
assert_eq!(counts("flyteorg/flyte · v1"), (3, 1));
assert_eq!(counts("flyteorg/flyte · v2"), (0, 0));
assert_eq!(counts("flyteorg/flytekit"), (0, 0));

// Every pull request table marks stale ones, such as a person's list.
let bob = get(&data, "/api/users/github/bob/history?days=all");
let bob_prs = bob["pull_requests"].as_array().unwrap();
assert!(bob_prs.iter().any(|pr| pr["number"] == 4 && pr["stale"] == true));
assert!(bob_prs.iter().all(|pr| pr["number"] == 4 || pr["stale"] == false));
}

#[test]
fn page_paths_and_stable_ids() {
let data = dataset();
Expand Down
14 changes: 14 additions & 0 deletions web/assets/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,14 @@ a {
color: var(--muted);
}

/* An open pull request without activity for 60 days. */
.stale-label {
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--highlight) 30%, transparent);
font-weight: 600;
}

.link-button {
margin-top: 10px;
padding: 0;
Expand Down Expand Up @@ -850,6 +858,12 @@ tbody tr:hover td {
min-width: 180px;
}

/* Tables keep every cell on one line, so long titles wrap only within this width. */
.table-link > .external-link {
display: inline-block;
max-width: 34em;
}

.table-link small {
display: block;
color: var(--muted);
Expand Down
25 changes: 21 additions & 4 deletions web/js/browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ function repositoryList(data) {
column.count("commits", "Commits"),
column.count("active_contributors", "Contributors"),
column.percent("review_coverage", "Review coverage"),
column.count("open_pull_requests", "Open PRs"),
column.count("stale_pull_requests", "Stale PRs", {
title: "Open pull requests without activity for 60 days",
}),
],
filters: [targetBranchFilter()],
rows,
Expand Down Expand Up @@ -264,6 +268,22 @@ function repositoryFilters(data) {
]);
}

// Recent and open pull requests, most recently active first, so stale ones come last.
function repositoryPullRequestsPanel(data) {
const rows = data.pull_requests || [];
const open = rows.filter((pr) => pr.state === "open").length;
const stale = rows.filter((pr) => pr.stale).length;
return panel({
title: "Pull requests",
subtitle: `Up to 50 opened or merged in the selected range and branch, and all ${number.format(open)} open (${number.format(stale)} stale: no activity for ${data.stale_after_days} days).`,
body: pullRequestTable("pull-requests", rows, {
repository: false,
activity: true,
sort: { key: "updated_at", direction: "desc" },
}),
});
}

function repositoryDetailPage(data) {
const repo = data.repository;
return (
Expand Down Expand Up @@ -304,10 +324,7 @@ function repositoryDetailPage(data) {
sort: { key: "reviews", direction: "desc" },
}),
})}</section>` +
panel({
title: "Recent pull requests",
body: pullRequestTable("pull-requests", data.recent_pull_requests),
})
repositoryPullRequestsPanel(data)
);
}

Expand Down
Loading
Loading