diff --git a/api/org_tangled/actorgetProfileActivity.go b/api/org_tangled/actorgetProfileActivity.go index aab1822a1..163aadc92 100644 --- a/api/org_tangled/actorgetProfileActivity.go +++ b/api/org_tangled/actorgetProfileActivity.go @@ -16,14 +16,31 @@ const ( // ActorGetProfileActivity_ActivityMonth is a "activityMonth" in the org.tangled.actor.getProfileActivity schema. type ActorGetProfileActivity_ActivityMonth struct { - Commits int64 `json:"commits" cborgen:"commits"` - Issues []*ActorGetProfileActivity_IssueActivity `json:"issues" cborgen:"issues"` - Pulls []*ActorGetProfileActivity_PullActivity `json:"pulls" cborgen:"pulls"` - Repos []*ActorGetProfileActivity_RepoActivity `json:"repos" cborgen:"repos"` + // commitRepos: Repos the actor committed to this month, busiest first. + CommitRepos []*ActorGetProfileActivity_CommitRepoActivity `json:"commitRepos,omitempty" cborgen:"commitRepos,omitempty"` + Commits int64 `json:"commits" cborgen:"commits"` + Issues []*ActorGetProfileActivity_IssueActivity `json:"issues" cborgen:"issues"` + Pulls []*ActorGetProfileActivity_PullActivity `json:"pulls" cborgen:"pulls"` + Repos []*ActorGetProfileActivity_RepoActivity `json:"repos" cborgen:"repos"` // start: Start of this UTC calendar month. Start string `json:"start" cborgen:"start"` } +// ActorGetProfileActivity_CommitActivity is a "commitActivity" in the org.tangled.actor.getProfileActivity schema. +type ActorGetProfileActivity_CommitActivity struct { + CommittedAt string `json:"committedAt" cborgen:"committedAt"` + Sha string `json:"sha" cborgen:"sha"` + Subject string `json:"subject" cborgen:"subject"` +} + +// ActorGetProfileActivity_CommitRepoActivity is a "commitRepoActivity" in the org.tangled.actor.getProfileActivity schema. +type ActorGetProfileActivity_CommitRepoActivity struct { + Commits int64 `json:"commits" cborgen:"commits"` + // recent: Most recent commits in this repo during the month. + Recent []*ActorGetProfileActivity_CommitActivity `json:"recent" cborgen:"recent"` + Repo *ActorGetProfileActivity_RepoRef `json:"repo" cborgen:"repo"` +} + // ActorGetProfileActivity_IssueActivity is a "issueActivity" in the org.tangled.actor.getProfileActivity schema. type ActorGetProfileActivity_IssueActivity struct { Repo *ActorGetProfileActivity_RepoRef `json:"repo" cborgen:"repo"` @@ -49,8 +66,9 @@ type ActorGetProfileActivity_PullActivity struct { // ActorGetProfileActivity_RepoActivity is a "repoActivity" in the org.tangled.actor.getProfileActivity schema. type ActorGetProfileActivity_RepoActivity struct { - Kind string `json:"kind" cborgen:"kind"` - Repo *ActorGetProfileActivity_RepoRef `json:"repo" cborgen:"repo"` + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + Kind string `json:"kind" cborgen:"kind"` + Repo *ActorGetProfileActivity_RepoRef `json:"repo" cborgen:"repo"` } // ActorGetProfileActivity_RepoRef is a "repoRef" in the org.tangled.actor.getProfileActivity schema. diff --git a/api/tangled/gittemp2getCommitStats.go b/api/tangled/gittemp2getCommitStats.go index 057f4ff93..82ca93bd3 100644 --- a/api/tangled/gittemp2getCommitStats.go +++ b/api/tangled/gittemp2getCommitStats.go @@ -14,10 +14,19 @@ const ( GitTemp2GetCommitStatsNSID = "sh.tangled.git.temp2.getCommitStats" ) +// GitTemp2GetCommitStats_Commit is a "commit" in the sh.tangled.git.temp2.getCommitStats schema. +type GitTemp2GetCommitStats_Commit struct { + CommittedAt string `json:"committedAt" cborgen:"committedAt"` + Sha string `json:"sha" cborgen:"sha"` + Subject string `json:"subject" cborgen:"subject"` +} + // GitTemp2GetCommitStats_Month is a "month" in the sh.tangled.git.temp2.getCommitStats schema. type GitTemp2GetCommitStats_Month struct { // commits: Commits attributed to the actor during this month. Commits int64 `json:"commits" cborgen:"commits"` + // repos: Repos the actor committed to this month, busiest first. + Repos []*GitTemp2GetCommitStats_RepoCommits `json:"repos,omitempty" cborgen:"repos,omitempty"` // start: Start of this UTC calendar month. Start string `json:"start" cborgen:"start"` } @@ -27,6 +36,15 @@ type GitTemp2GetCommitStats_Output struct { Months []*GitTemp2GetCommitStats_Month `json:"months" cborgen:"months"` } +// GitTemp2GetCommitStats_RepoCommits is a "repoCommits" in the sh.tangled.git.temp2.getCommitStats schema. +type GitTemp2GetCommitStats_RepoCommits struct { + Commits int64 `json:"commits" cborgen:"commits"` + // recent: Most recent commits in this repo during the month. + Recent []*GitTemp2GetCommitStats_Commit `json:"recent" cborgen:"recent"` + // repo: DID of the repo the commits were first seen in. + Repo string `json:"repo" cborgen:"repo"` +} + // GitTemp2GetCommitStats calls the XRPC method "sh.tangled.git.temp2.getCommitStats". // // actor: Actor whose commits to count. diff --git a/bobbin/crates/xrpc/src/commit_stats.rs b/bobbin/crates/xrpc/src/commit_stats.rs index d57167492..db8f495d3 100644 --- a/bobbin/crates/xrpc/src/commit_stats.rs +++ b/bobbin/crates/xrpc/src/commit_stats.rs @@ -14,7 +14,7 @@ use tokio::sync::{Notify, Semaphore}; const COMMIT_STATS_NSID: &str = "sh.tangled.git.temp2.getCommitStats"; const FETCH_MONTHS: u32 = 24; -const MAX_RESPONSE_BYTES: usize = 64 * 1024; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const DEFAULT_CAPACITY: usize = 4_096; const DEFAULT_FRESHNESS: Duration = Duration::from_secs(30); const FAILURE_RETRY: Duration = Duration::from_secs(5); @@ -24,16 +24,42 @@ const MAX_CONCURRENT_REFRESHES: usize = 16; struct CommitMonth { start: String, commits: i64, + #[serde(default)] + repos: Vec, +} + +pub(crate) type CommitStats = BTreeMap; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct MonthCommits { + pub(crate) commits: i64, + pub(crate) repos: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub(crate) struct RepoCommits { + pub(crate) repo: String, + pub(crate) commits: i64, + #[serde(default)] + pub(crate) recent: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Commit { + pub(crate) sha: String, + pub(crate) subject: String, + pub(crate) committed_at: String, } #[derive(Debug, Deserialize)] -struct CommitStats { +struct CommitStatsOutput { months: Vec, } #[derive(Clone)] struct Entry { - counts: BTreeMap, + counts: CommitStats, refresh_after: Option, refreshing: bool, loaded: bool, @@ -78,7 +104,7 @@ impl CommitStatsCache { self: &Arc, mirror: Option>, actor: &Did, - ) -> BTreeMap { + ) -> CommitStats { let Some(mirror) = mirror else { return BTreeMap::new(); }; @@ -193,7 +219,7 @@ impl CommitStatsCache { } } - fn finish(&self, key: String, counts: Option>) { + fn finish(&self, key: String, counts: Option) { let mut state = self .state .lock() @@ -235,7 +261,7 @@ impl CommitStatsCache { } #[cfg(test)] - fn seed(&self, actor: &str, counts: BTreeMap, refresh_after: Option) { + fn seed(&self, actor: &str, counts: CommitStats, refresh_after: Option) { self.state .lock() .expect("commit stats cache mutex poisoned") @@ -298,10 +324,7 @@ fn evict_one(entries: &mut HashMap, capacity: usize) -> bool { true } -async fn fetch_commit_stats( - mirror: &MirrorProxy, - actor: &str, -) -> Result, String> { +async fn fetch_commit_stats(mirror: &MirrorProxy, actor: &str) -> Result { let nsid = Nsid::new_static(COMMIT_STATS_NSID).expect("commit stats NSID is valid"); let months = FETCH_MONTHS.to_string(); let response = mirror @@ -327,7 +350,8 @@ async fn fetch_commit_stats( } bytes.extend_from_slice(&chunk); } - let output: CommitStats = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let output: CommitStatsOutput = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; if output.months.len() > FETCH_MONTHS as usize { return Err("mirror returned too many commit stat months".to_owned()); } @@ -341,7 +365,13 @@ async fn fetch_commit_stats( if month.commits < 0 { return Err("mirror returned a negative commit count".to_owned()); } - counts.insert(month_number(start), month.commits); + counts.insert( + month_number(start), + MonthCommits { + commits: month.commits, + repos: month.repos, + }, + ); Ok(counts) }) } @@ -389,17 +419,53 @@ mod tests { let cache = Arc::new(CommitStatsCache::new(4, Duration::from_secs(60))); let counts = cache.get(Some(mirror(&server)), &actor()).await; - assert_eq!(counts.get(&(2026 * 12 + 8)), Some(&7)); + assert_eq!( + counts.get(&(2026 * 12 + 8)).map(|month| month.commits), + Some(7) + ); assert_eq!( cache .get(Some(mirror(&server)), &actor()) .await - .get(&(2026 * 12 + 8)), - Some(&7), + .get(&(2026 * 12 + 8)) + .map(|month| month.commits), + Some(7), ); server.verify().await; } + #[tokio::test] + async fn keeps_each_months_repos() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/xrpc/sh.tangled.git.temp2.getCommitStats")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "months": [{ + "start": "2026-09-01T00:00:00Z", + "commits": 2, + "repos": [{ + "repo": "did:plc:repo", + "commits": 2, + "recent": [{ + "sha": "a".repeat(40), + "subject": "Fix the build", + "committedAt": "2026-09-02T00:00:00Z", + }], + }], + }] + }))) + .mount(&server) + .await; + + let stats = fetch_commit_stats(&mirror(&server), actor().as_ref()) + .await + .unwrap(); + let repos = &stats[&(2026 * 12 + 8)].repos; + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].commits, 2); + assert_eq!(repos[0].recent[0].subject, "Fix the build"); + } + #[tokio::test] async fn rejects_more_months_than_requested() { let server = MockServer::start().await; @@ -443,8 +509,14 @@ mod tests { let second = cache.get(Some(mirror(&server)), &actor); let (first, second) = tokio::join!(first, second); - assert_eq!(first.get(&(2026 * 12 + 8)), Some(&7)); - assert_eq!(second.get(&(2026 * 12 + 8)), Some(&7)); + assert_eq!( + first.get(&(2026 * 12 + 8)).map(|month| month.commits), + Some(7) + ); + assert_eq!( + second.get(&(2026 * 12 + 8)).map(|month| month.commits), + Some(7) + ); server.verify().await; } @@ -638,11 +710,20 @@ mod tests { let cache = Arc::new(CommitStatsCache::new(4, Duration::ZERO)); cache.seed( actor().as_ref(), - BTreeMap::from([(2026 * 12 + 8, 5)]), + BTreeMap::from([( + 2026 * 12 + 8, + MonthCommits { + commits: 5, + repos: Vec::new(), + }, + )]), Some(Instant::now()), ); let counts = cache.get(Some(mirror(&server)), &actor()).await; - assert_eq!(counts.get(&(2026 * 12 + 8)), Some(&5)); + assert_eq!( + counts.get(&(2026 * 12 + 8)).map(|month| month.commits), + Some(5) + ); for _ in 0..50 { let refreshing = cache .state @@ -660,8 +741,9 @@ mod tests { cache .get(Some(mirror(&server)), &actor()) .await - .get(&(2026 * 12 + 8)), - Some(&5), + .get(&(2026 * 12 + 8)) + .map(|month| month.commits), + Some(5), ); server.verify().await; } diff --git a/bobbin/crates/xrpc/src/profile_activity.rs b/bobbin/crates/xrpc/src/profile_activity.rs index 76bca36a5..60ff8f540 100644 --- a/bobbin/crates/xrpc/src/profile_activity.rs +++ b/bobbin/crates/xrpc/src/profile_activity.rs @@ -7,8 +7,8 @@ use bobbin_types::edges::{ }; use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static, owner_did_from_aturi}; use bobbin_types::org_tangled::actor::get_profile_activity::{ - ActivityMonth, GetProfileActivityOutput, IssueActivity, PullActivity, RepoActivity, - RepoActivityKind, RepoRef, + ActivityMonth, CommitActivity, CommitRepoActivity, GetProfileActivityOutput, IssueActivity, + PullActivity, RepoActivity, RepoActivityKind, RepoRef, }; use bobbin_types::org_tangled::repo::manifest::{Manifest, ManifestRecord}; use bobbin_types::org_tangled::track::ticket::{Ticket, TicketRecord}; @@ -21,7 +21,7 @@ use serde::Deserialize; use crate::{ AppState, XrpcError, XrpcQuery, - commit_stats::month_number, + commit_stats::{CommitStats, RepoCommits, month_number}, fetch, source_authority_did, view::{build_profile_basic, manifest_uri}, }; @@ -49,6 +49,7 @@ enum RawActivity { months_ago: usize, repo: RepoRef, kind: RepoActivityKind, + description: Option, }, Issue { months_ago: usize, @@ -78,7 +79,7 @@ pub(crate) async fn get_profile_activity( } let _permit = state.heavy_permit()?; let now = Utc::now(); - let commit_counts = state + let commit_stats = state .commit_stats_cache .get(state.mirror.clone(), &query.actor); let (skeleton, truncated) = collect_skeleton(&state.edges, &query.actor, now, months); @@ -89,7 +90,7 @@ pub(crate) async fn get_profile_activity( ) .buffered(HYDRATE_CONCURRENCY) .collect::>(); - let (commit_counts, hydrated) = tokio::join!(commit_counts, hydration); + let (commit_stats, hydrated) = tokio::join!(commit_stats, hydration); let mut activity = Vec::with_capacity(hydrated.len()); for item in hydrated { @@ -101,7 +102,7 @@ pub(crate) async fn get_profile_activity( } } - let months = assemble_months(&state, activity, &commit_counts, now, months).await; + let months = assemble_months(&state, activity, &commit_stats, now, months).await; Ok(Json(GetProfileActivityOutput { months, truncated, @@ -198,6 +199,7 @@ async fn hydrate_activity( months_ago: item.months_ago, repo, kind, + description: record.description.clone(), }) } Some(TicketRecord::NSID) => { @@ -239,10 +241,12 @@ async fn hydrate_activity( async fn assemble_months( state: &AppState, activity: Vec, - commit_counts: &BTreeMap, + commit_stats: &CommitStats, now: DateTime, count: u32, ) -> Vec> { + let current_month = month_number(now); + let window = (current_month - (count as i32 - 1))..=current_month; let mut refs = HashMap::>::new(); let mut targets = BTreeMap::>::new(); for item in &activity { @@ -259,6 +263,15 @@ async fn assemble_months( targets.insert(repo_did.to_string(), repo_did.clone()); } } + for (_, month) in commit_stats.range(window) { + for repo in &month.repos { + if !refs.contains_key(&repo.repo) + && let Ok(did) = Did::new_owned(&repo.repo) + { + targets.insert(repo.repo.clone(), did); + } + } + } let hydrated = stream::iter(targets.into_values().map(|did| async move { let result = repo_ref_by_did(state, &did).await; @@ -278,13 +291,14 @@ async fn assemble_months( } } - let current_month = month_number(now); let mut months = (0..count) .map(|months_ago| { let month = current_month - months_ago as i32; + let stats = commit_stats.get(&month); ActivityMonth { start: Datetime::from(month_start(month).fixed_offset()), - commits: commit_counts.get(&month).copied().unwrap_or(0), + commits: stats.map_or(0, |stats| stats.commits), + commit_repos: stats.map(|stats| commit_repos(&stats.repos, &refs)), repos: Vec::new(), issues: Vec::new(), pulls: Vec::new(), @@ -299,9 +313,11 @@ async fn assemble_months( months_ago, repo, kind, + description, } => months[months_ago].repos.push(RepoActivity { repo, kind, + description, extra_data: None, }), RawActivity::Issue { @@ -352,6 +368,36 @@ async fn assemble_months( months } +fn commit_repos( + repos: &[RepoCommits], + refs: &HashMap>, +) -> Vec> { + repos + .iter() + .filter_map(|repo| { + Some(CommitRepoActivity { + repo: refs.get(&repo.repo)?.clone(), + commits: repo.commits, + recent: repo + .recent + .iter() + .filter_map(|commit| { + let committed_at = + DateTime::parse_from_rfc3339(&commit.committed_at).ok()?; + Some(CommitActivity { + sha: DefaultStr::from(commit.sha.as_str()), + subject: DefaultStr::from(commit.subject.as_str()), + committed_at: Datetime::from(committed_at), + extra_data: None, + }) + }) + .collect(), + extra_data: None, + }) + }) + .collect() +} + async fn repo_ref_by_did( state: &AppState, did: &Did, diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index 516ba54a9..7baec8b67 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -2164,6 +2164,18 @@ async fn profile_activity_returns_one_hydrated_aggregate() { "months": [{ "start": Utc::now().format("%Y-%m-01T00:00:00Z").to_string(), "commits": 3, + "repos": [ + { + "repo": created_repo_did.as_ref(), + "commits": 3, + "recent": [{ + "sha": "a".repeat(40), + "subject": "Add the activity panel", + "committedAt": Utc::now().to_rfc3339(), + }], + }, + { "repo": "did:plc:unresolvable", "commits": 1, "recent": [] }, + ], }] }))) .expect(1) @@ -2191,6 +2203,7 @@ async fn profile_activity_returns_one_hydrated_aggregate() { json!({ "$type": "org.tangled.repo.manifest", "declaration": { "owner": actor.as_ref(), "name": "fresh" }, + "description": "A fresh repository", "createdAt": Utc::now().to_rfc3339(), }), ) @@ -2218,6 +2231,15 @@ async fn profile_activity_returns_one_hydrated_aggregate() { assert_eq!(months[0]["commits"], 3); assert_eq!(months[0]["repos"][0]["kind"], "created"); assert_eq!(months[0]["repos"][0]["repo"]["name"], "fresh"); + assert_eq!(months[0]["repos"][0]["description"], "A fresh repository"); + let commit_repos = months[0]["commitRepos"].as_array().expect("commit repos"); + assert_eq!(commit_repos.len(), 1, "unresolved repos are skipped"); + assert_eq!(commit_repos[0]["repo"]["name"], "fresh"); + assert_eq!(commit_repos[0]["commits"], 3); + assert_eq!( + commit_repos[0]["recent"][0]["subject"], + "Add the activity panel" + ); assert_eq!(months[0]["issues"], json!([]), "tickets are repo-owned"); assert_eq!(months[0]["pulls"], json!([]), "tickets are repo-owned"); } diff --git a/knotmirror/crates/knotmirror-db/.sqlx/query-6e0e316c8a8844dc0a121dbfec51a65773677ab09329929c68d80c33412f2192.json b/knotmirror/crates/knotmirror-db/.sqlx/query-6e0e316c8a8844dc0a121dbfec51a65773677ab09329929c68d80c33412f2192.json new file mode 100644 index 000000000..aa73215b8 --- /dev/null +++ b/knotmirror/crates/knotmirror-db/.sqlx/query-6e0e316c8a8844dc0a121dbfec51a65773677ab09329929c68d80c33412f2192.json @@ -0,0 +1,79 @@ +{ + "db_name": "PostgreSQL", + "query": "select month as \"month!\", repo_did as \"repo_did!\", oid as \"oid!\",\n subject as \"subject!\", committed_at as \"committed_at!\"\n from (\n select month, repo_did, oid, subject, committed_at,\n row_number() over (partition by month order by committed_at desc, oid) as rank\n from seen_commits\n where committer = any($1) and month >= $2 and month < $3\n and repo_did is not null\n ) ranked\n where rank <= $4\n order by month desc, committed_at desc, oid", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "month!", + "type_info": "Date", + "origin": { + "Table": { + "table": "seen_commits", + "name": "month" + } + } + }, + { + "ordinal": 1, + "name": "repo_did!", + "type_info": "Text", + "origin": { + "Table": { + "table": "seen_commits", + "name": "repo_did" + } + } + }, + { + "ordinal": 2, + "name": "oid!", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "seen_commits", + "name": "oid" + } + } + }, + { + "ordinal": 3, + "name": "subject!", + "type_info": "Text", + "origin": { + "Table": { + "table": "seen_commits", + "name": "subject" + } + } + }, + { + "ordinal": 4, + "name": "committed_at!", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "seen_commits", + "name": "committed_at" + } + } + } + ], + "parameters": { + "Left": [ + "TextArray", + "Date", + "Date", + "Int8" + ] + }, + "nullable": [ + false, + true, + false, + true, + true + ] + }, + "hash": "6e0e316c8a8844dc0a121dbfec51a65773677ab09329929c68d80c33412f2192" +} diff --git a/knotmirror/crates/knotmirror-db/.sqlx/query-74734b28a703645bcb0b74f195660c13b437191a403c0155a6b22f503773b4ad.json b/knotmirror/crates/knotmirror-db/.sqlx/query-74734b28a703645bcb0b74f195660c13b437191a403c0155a6b22f503773b4ad.json new file mode 100644 index 000000000..abaafe9b8 --- /dev/null +++ b/knotmirror/crates/knotmirror-db/.sqlx/query-74734b28a703645bcb0b74f195660c13b437191a403c0155a6b22f503773b4ad.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "with input as (\n select distinct on (oid) oid, repo_did, committer, committed_at, month, subject\n from unnest($1::bytea[], $2::text[], $3::text[], $4::timestamptz[], $5::date[], $6::text[])\n as t(oid, repo_did, committer, committed_at, month, subject)\n ), written as (\n insert into seen_commits (oid, month, repo_did, committer, committed_at, subject)\n select oid, month, repo_did, committer, committed_at, subject from input\n on conflict (oid) do update\n set repo_did = excluded.repo_did,\n committer = excluded.committer,\n committed_at = excluded.committed_at,\n subject = excluded.subject\n where seen_commits.repo_did is null\n returning oid, xmax = 0 as fresh\n ), inserted as (\n select oid from written where fresh\n ), counted as (\n insert into commit_counts (committer, month, commits)\n select committer, month, count(*)\n from input join inserted using (oid)\n group by committer, month\n on conflict (committer, month) do update\n set commits = commit_counts.commits + excluded.commits\n )\n select count(*) as \"count!\" from inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "ByteaArray", + "TextArray", + "TextArray", + "TimestamptzArray", + "DateArray", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "74734b28a703645bcb0b74f195660c13b437191a403c0155a6b22f503773b4ad" +} diff --git a/knotmirror/crates/knotmirror-db/.sqlx/query-b057faa0086464df1fdab478400d930738527c58363ab63efe72519dccb62329.json b/knotmirror/crates/knotmirror-db/.sqlx/query-b057faa0086464df1fdab478400d930738527c58363ab63efe72519dccb62329.json new file mode 100644 index 000000000..3c9108c83 --- /dev/null +++ b/knotmirror/crates/knotmirror-db/.sqlx/query-b057faa0086464df1fdab478400d930738527c58363ab63efe72519dccb62329.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "select month, repo_did as \"repo_did!\", count(*) as \"commits!\"\n from seen_commits\n where committer = any($1) and month >= $2 and month < $3\n and repo_did is not null\n group by month, repo_did\n order by month desc, count(*) desc, repo_did", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "month", + "type_info": "Date", + "origin": { + "Table": { + "table": "seen_commits", + "name": "month" + } + } + }, + { + "ordinal": 1, + "name": "repo_did!", + "type_info": "Text", + "origin": { + "Table": { + "table": "seen_commits", + "name": "repo_did" + } + } + }, + { + "ordinal": 2, + "name": "commits!", + "type_info": "Int8", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Date", + "Date" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "b057faa0086464df1fdab478400d930738527c58363ab63efe72519dccb62329" +} diff --git a/knotmirror/crates/knotmirror-db/.sqlx/query-c15b02adc2ecf9a6f65e30af9ac43c3d663f73924e887886e15aa971c79f5053.json b/knotmirror/crates/knotmirror-db/.sqlx/query-c15b02adc2ecf9a6f65e30af9ac43c3d663f73924e887886e15aa971c79f5053.json deleted file mode 100644 index 3c7f124ff..000000000 --- a/knotmirror/crates/knotmirror-db/.sqlx/query-c15b02adc2ecf9a6f65e30af9ac43c3d663f73924e887886e15aa971c79f5053.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "with input as (\n select distinct on (oid) oid, committer, month\n from unnest($1::bytea[], $2::text[], $3::date[]) as t(oid, committer, month)\n ), inserted as (\n insert into seen_commits (oid, month)\n select oid, month from input\n on conflict (oid) do nothing\n returning oid\n ), counted as (\n insert into commit_counts (committer, month, commits)\n select committer, month, count(*)\n from input join inserted using (oid)\n group by committer, month\n on conflict (committer, month) do update\n set commits = commit_counts.commits + excluded.commits\n )\n select count(*) as \"count!\" from inserted", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count!", - "type_info": "Int8", - "origin": "Expression" - } - ], - "parameters": { - "Left": [ - "ByteaArray", - "TextArray", - "DateArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "c15b02adc2ecf9a6f65e30af9ac43c3d663f73924e887886e15aa971c79f5053" -} diff --git a/knotmirror/crates/knotmirror-db/src/lib.rs b/knotmirror/crates/knotmirror-db/src/lib.rs index 88e18681c..989551d0e 100644 --- a/knotmirror/crates/knotmirror-db/src/lib.rs +++ b/knotmirror/crates/knotmirror-db/src/lib.rs @@ -137,8 +137,27 @@ pub struct ActivityRow { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitActivity { pub oid: Vec, + pub repo: String, pub committer: String, + pub committed_at: DateTime, pub month: NaiveDate, + pub subject: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoCommitCount { + pub month: NaiveDate, + pub repo: String, + pub commits: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecentCommit { + pub month: NaiveDate, + pub repo: String, + pub oid: Vec, + pub subject: String, + pub committed_at: DateTime, } #[derive(Debug)] @@ -621,19 +640,37 @@ impl Db { /// returns how many commits no repo had shown before pub async fn save_commit_activity(&self, commits: &[CommitActivity]) -> Result { - let (oids, committers, months): (Vec<&[u8]>, Vec<&str>, Vec) = commits - .iter() - .map(|c| (c.oid.as_slice(), c.committer.as_str(), c.month)) - .collect(); + let mut oids = Vec::with_capacity(commits.len()); + let mut repos = Vec::with_capacity(commits.len()); + let mut committers = Vec::with_capacity(commits.len()); + let mut committed = Vec::with_capacity(commits.len()); + let mut months = Vec::with_capacity(commits.len()); + let mut subjects = Vec::with_capacity(commits.len()); + for c in commits { + oids.push(c.oid.as_slice()); + repos.push(c.repo.as_str()); + committers.push(c.committer.as_str()); + committed.push(c.committed_at); + months.push(c.month); + subjects.push(c.subject.as_str()); + } let inserted = sqlx::query_scalar!( r#"with input as ( - select distinct on (oid) oid, committer, month - from unnest($1::bytea[], $2::text[], $3::date[]) as t(oid, committer, month) + select distinct on (oid) oid, repo_did, committer, committed_at, month, subject + from unnest($1::bytea[], $2::text[], $3::text[], $4::timestamptz[], $5::date[], $6::text[]) + as t(oid, repo_did, committer, committed_at, month, subject) + ), written as ( + insert into seen_commits (oid, month, repo_did, committer, committed_at, subject) + select oid, month, repo_did, committer, committed_at, subject from input + on conflict (oid) do update + set repo_did = excluded.repo_did, + committer = excluded.committer, + committed_at = excluded.committed_at, + subject = excluded.subject + where seen_commits.repo_did is null + returning oid, xmax = 0 as fresh ), inserted as ( - insert into seen_commits (oid, month) - select oid, month from input - on conflict (oid) do nothing - returning oid + select oid from written where fresh ), counted as ( insert into commit_counts (committer, month, commits) select committer, month, count(*) @@ -644,8 +681,11 @@ impl Db { ) select count(*) as "count!" from inserted"#, &oids as &[&[u8]], + &repos as &[&str], &committers as &[&str], + &committed, &months, + &subjects as &[&str], ) .fetch_one(&self.0) .await?; @@ -733,4 +773,71 @@ impl Db { .await?; Ok(rows.into_iter().map(|r| (r.month, r.commits)).collect()) } + + pub async fn repo_commit_counts( + &self, + committers: &[String], + since: NaiveDate, + before: NaiveDate, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#"select month, repo_did as "repo_did!", count(*) as "commits!" + from seen_commits + where committer = any($1) and month >= $2 and month < $3 + and repo_did is not null + group by month, repo_did + order by month desc, count(*) desc, repo_did"#, + committers, + since, + before, + ) + .fetch_all(&self.0) + .await?; + Ok(rows + .into_iter() + .map(|r| RepoCommitCount { + month: r.month, + repo: r.repo_did, + commits: r.commits, + }) + .collect()) + } + + pub async fn recent_commits( + &self, + committers: &[String], + since: NaiveDate, + before: NaiveDate, + per_month: i64, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#"select month as "month!", repo_did as "repo_did!", oid as "oid!", + subject as "subject!", committed_at as "committed_at!" + from ( + select month, repo_did, oid, subject, committed_at, + row_number() over (partition by month order by committed_at desc, oid) as rank + from seen_commits + where committer = any($1) and month >= $2 and month < $3 + and repo_did is not null + ) ranked + where rank <= $4 + order by month desc, committed_at desc, oid"#, + committers, + since, + before, + per_month, + ) + .fetch_all(&self.0) + .await?; + Ok(rows + .into_iter() + .map(|r| RecentCommit { + month: r.month, + repo: r.repo_did, + oid: r.oid, + subject: r.subject, + committed_at: r.committed_at, + }) + .collect()) + } } diff --git a/knotmirror/crates/knotmirror-db/tests/commit_activity.rs b/knotmirror/crates/knotmirror-db/tests/commit_activity.rs index ce4518af5..f6531b398 100644 --- a/knotmirror/crates/knotmirror-db/tests/commit_activity.rs +++ b/knotmirror/crates/knotmirror-db/tests/commit_activity.rs @@ -36,8 +36,12 @@ async fn test_db() -> Option { fn commit(oid: u8, committer: &str, month: NaiveDate) -> CommitActivity { CommitActivity { oid: vec![oid; 20], + repo: "did:plc:repo".to_owned(), committer: committer.to_owned(), + committed_at: month.and_hms_opt(0, 0, 0).unwrap().and_utc() + + chrono::TimeDelta::seconds(oid.into()), month, + subject: format!("commit {oid:02x}"), } } @@ -91,6 +95,81 @@ async fn a_commit_counts_once_however_often_it_is_seen() { assert_eq!(counts, [(month, 2)]); } +#[tokio::test] +async fn details_fill_in_without_counting_again() { + let Some(db) = test_db().await else { return }; + let month = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); + sqlx::query("insert into seen_commits (oid, month) values ($1, $2)") + .bind(vec![0xaa_u8; 20]) + .bind(month) + .execute(db.pool()) + .await + .unwrap(); + let commits = [commit(0xaa, ACTOR, month), commit(0xbb, ACTOR, month)]; + assert_eq!(db.save_commit_activity(&commits).await.unwrap(), 1); + + let committers = [ACTOR.to_owned()]; + let before = month + Months::new(1); + assert_eq!( + db.commit_counts(&committers, month, before, 24) + .await + .unwrap(), + [(month, 1)] + ); + let recent = db + .recent_commits(&committers, month, before, 50) + .await + .unwrap(); + assert_eq!( + recent.iter().map(|c| c.oid[0]).collect::>(), + [0xbb, 0xaa] + ); + assert_eq!(recent[0].subject, "commit bb"); +} + +#[tokio::test] +async fn commits_group_by_repo_and_keep_the_newest_per_month() { + let Some(db) = test_db().await else { return }; + let month = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); + let in_repo = |oid, repo: &str| CommitActivity { + repo: repo.to_owned(), + ..commit(oid, ACTOR, month) + }; + db.save_commit_activity(&[ + in_repo(1, "did:plc:core"), + in_repo(2, "did:plc:core"), + in_repo(3, "did:plc:hop"), + commit(4, "someone@else.example", month), + ]) + .await + .unwrap(); + + let committers = [ACTOR.to_owned()]; + let before = month + Months::new(1); + let counts = db + .repo_commit_counts(&committers, month, before) + .await + .unwrap(); + assert_eq!( + counts + .iter() + .map(|c| (c.repo.as_str(), c.commits)) + .collect::>(), + [("did:plc:core", 2), ("did:plc:hop", 1)] + ); + let recent = db + .recent_commits(&committers, month, before, 2) + .await + .unwrap(); + assert_eq!( + recent + .iter() + .map(|c| (c.repo.as_str(), c.oid[0])) + .collect::>(), + [("did:plc:hop", 3), ("did:plc:core", 2)] + ); +} + #[tokio::test] async fn a_synced_repo_is_counted_until_its_next_sync() { let Some(db) = test_db().await else { return }; diff --git a/knotmirror/crates/knotmirror-index/Cargo.toml b/knotmirror/crates/knotmirror-index/Cargo.toml index f6efed523..bb6a58857 100644 --- a/knotmirror/crates/knotmirror-index/Cargo.toml +++ b/knotmirror/crates/knotmirror-index/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true [dependencies] bobbin-runtime = { workspace = true } +chrono = { workspace = true } knotmirror-db = { workspace = true } knotmirror-git = { workspace = true } knotmirror-types = { workspace = true } @@ -25,7 +26,6 @@ tokio-util = { workspace = true } tracing = { workspace = true } [dev-dependencies] -chrono = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/knotmirror/crates/knotmirror-index/src/activity.rs b/knotmirror/crates/knotmirror-index/src/activity.rs index bf6756ea5..291caeea0 100644 --- a/knotmirror/crates/knotmirror-index/src/activity.rs +++ b/knotmirror/crates/knotmirror-index/src/activity.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::time::Duration; use bobbin_runtime::Clock; +use chrono::DateTime; use gix::ObjectId; use jacquard_common::types::did::Did; use knotmirror_db::{ACTIVITY_MONTHS, ActivityRow, CommitActivity, Db, DbError, MonthWindow}; @@ -15,6 +16,7 @@ use crate::IndexError; const BATCH_SIZE: i64 = 64; const SAVE_CHUNK: usize = 1_000; const MAX_COMMITTER_LEN: usize = 2_048; +const MAX_SUBJECT_CHARS: usize = 200; const PRUNE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); pub struct ActivityRuntime { @@ -61,9 +63,10 @@ async fn count_repo(rt: &ActivityRuntime, row: ActivityRow) { let path = rt.layout.repo_path(&row.did); let window = MonthWindow::ending_at(now_us(&*rt.clock), ACTIVITY_MONTHS); let counted = row.tips.clone(); + let did = row.did.clone(); let scanned = tokio::task::spawn_blocking(move || { let repo = gix::open(path).map_err(|e| e.to_string())?; - scan(&repo, &counted, window) + scan(&repo, &did, &counted, window) }) .await .unwrap_or_else(|_| Err("commit scan panicked".to_owned())); @@ -105,6 +108,7 @@ pub struct Scan { /// a counted tip that no longer exists is ignored, which walks its history again pub fn scan( repo: &gix::Repository, + did: &Did, counted: &[String], window: MonthWindow, ) -> Result { @@ -141,20 +145,30 @@ pub fn scan( let info = info.map_err(|e| e.to_string())?; let commit = info.object().map_err(|e| e.to_string())?; let committer = commit.committer().map_err(|e| e.to_string())?; - let Some(month) = committer + let Some((seconds, month)) = committer .time() .ok() - .and_then(|time| window.month_of(time.seconds)) + .and_then(|time| Some((time.seconds, window.month_of(time.seconds)?))) else { continue; }; + let Some(committed_at) = DateTime::from_timestamp(seconds, 0) else { + continue; + }; let Some(committer) = normalize_committer(&committer.email.to_string()) else { continue; }; + let subject = commit + .message() + .map(|message| subject_of(&message.summary().to_string())) + .unwrap_or_default(); commits.push(CommitActivity { oid: info.id.as_bytes().to_vec(), + repo: did.as_str().to_owned(), committer, + committed_at, month, + subject, }); } } @@ -165,6 +179,15 @@ pub fn scan( }) } +fn subject_of(summary: &str) -> String { + summary + .trim() + .replace('\0', "") + .chars() + .take(MAX_SUBJECT_CHARS) + .collect() +} + /// dids keep their case and emails get lowercased fn normalize_committer(email: &str) -> Option { let email = email.trim(); @@ -245,7 +268,13 @@ mod tests { } fn scan(&self, counted: &[String]) -> Scan { - scan(&gix::open(self.dir.path()).unwrap(), counted, self.window).unwrap() + scan( + &gix::open(self.dir.path()).unwrap(), + &Did::new_static("did:plc:repo").unwrap(), + counted, + self.window, + ) + .unwrap() } fn commit(&self, file: &str, committer: &str) { @@ -296,6 +325,22 @@ mod tests { ); } + #[test] + fn commits_carry_their_repo_subject_and_time() { + let fixture = Fixture::new(); + let scan = fixture.scan(&[]); + let main = scan + .commits + .iter() + .find(|c| c.subject == "main") + .expect("main commit scanned"); + assert_eq!(main.repo, "did:plc:repo"); + assert_eq!( + main.committed_at.date_naive(), + fixture.current + Days::new(1) + ); + } + #[test] fn a_vanished_counted_tip_walks_everything_again() { let fixture = Fixture::new(); diff --git a/knotmirror/crates/knotmirror-xrpc/src/routes/activity.rs b/knotmirror/crates/knotmirror-xrpc/src/routes/activity.rs index ed2eeea84..d3bf7aa85 100644 --- a/knotmirror/crates/knotmirror-xrpc/src/routes/activity.rs +++ b/knotmirror/crates/knotmirror-xrpc/src/routes/activity.rs @@ -2,15 +2,18 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse as _, Response}; +use chrono::NaiveDate; use jacquard_axum::{ExtractXrpc, XrpcResponse}; +use jacquard_common::types::did::Did; use jacquard_common::types::string::Datetime; -use knotmirror_db::{ACTIVITY_MONTHS, MonthWindow}; +use knotmirror_db::{ACTIVITY_MONTHS, MonthWindow, RecentCommit, RepoCommitCount}; use knotmirror_types::now_us; use lexicons::sh_tangled::git::temp2::get_commit_stats; use crate::{AppState, error::XrpcError}; const DEFAULT_MONTHS: i64 = 7; +const RECENT_PER_MONTH: i64 = 50; pub(crate) async fn get_commit_stats( State(state): State, @@ -30,17 +33,29 @@ pub(crate) async fn get_commit_stats( .committers .resolve(&state.http, &*state.clock, &args.actor) .await; + let internal = |e: knotmirror_db::DbError| XrpcError::Internal(e.to_string()); let counts = state .db .commit_counts(&committers, window.since, window.before, i64::from(months)) .await - .map_err(|e| XrpcError::Internal(e.to_string()))?; + .map_err(internal)?; + let repo_counts = state + .db + .repo_commit_counts(&committers, window.since, window.before) + .await + .map_err(internal)?; + let recent = state + .db + .recent_commits(&committers, window.since, window.before, RECENT_PER_MONTH) + .await + .map_err(internal)?; Ok(XrpcResponse(get_commit_stats::GetCommitStatsOutput { months: counts .into_iter() .map(|(month, commits)| get_commit_stats::Month { commits, start: Datetime::new(month.and_time(Default::default()).and_utc().fixed_offset()), + repos: Some(month_repos(month, &repo_counts, &recent)), extra_data: Default::default(), }) .collect(), @@ -48,6 +63,40 @@ pub(crate) async fn get_commit_stats( })) } +fn month_repos( + month: NaiveDate, + counts: &[RepoCommitCount], + recent: &[RecentCommit], +) -> Vec { + counts + .iter() + .filter(|count| count.month == month) + .filter_map(|count| { + let repo = Did::new_owned(&count.repo).ok()?; + Some(get_commit_stats::RepoCommits { + repo, + commits: count.commits, + recent: recent + .iter() + .filter(|commit| commit.month == month && commit.repo == count.repo) + .map(|commit| get_commit_stats::Commit { + sha: commit + .oid + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + .into(), + subject: commit.subject.as_str().into(), + committed_at: Datetime::new(commit.committed_at.fixed_offset()), + extra_data: Default::default(), + }) + .collect(), + extra_data: Default::default(), + }) + }) + .collect() +} + pub(crate) async fn did_document(State(state): State) -> Response { match &state.signer { Some(signer) => Json(signer.did_document()).into_response(), @@ -240,8 +289,14 @@ mod tests { let current = window.before - Months::new(1); let commit = |oid: u8, committer: &str, months_back| CommitActivity { oid: vec![oid; 20], + repo: "did:plc:repo".to_owned(), committer: committer.to_owned(), + committed_at: (current - Months::new(months_back)) + .and_hms_opt(0, 0, oid.into()) + .unwrap() + .and_utc(), month: current - Months::new(months_back), + subject: format!("commit {oid}"), }; db.save_commit_activity(&[ commit(1, ACTOR, 0), @@ -257,6 +312,22 @@ mod tests { [start(0), start(1)] } + fn repo(oids: &[u8], month: &str) -> Value { + let day = month.split('T').next().unwrap(); + json!({ + "repo": "did:plc:repo", + "commits": oids.len(), + "recent": oids + .iter() + .map(|oid| json!({ + "sha": format!("{oid:02x}").repeat(20), + "subject": format!("commit {oid}"), + "committedAt": format!("{day}T00:00:{oid:02}.000000Z"), + })) + .collect::>(), + }) + } + #[tokio::test] async fn stats_count_the_did_and_verified_emails() { let Some(db) = test_db().await else { return }; @@ -268,8 +339,8 @@ mod tests { assert_eq!( body, json!({"months": [ - {"start": this_month, "commits": 2}, - {"start": last_month, "commits": 1}, + {"start": this_month, "commits": 2, "repos": [repo(&[2, 1], &this_month)]}, + {"start": last_month, "commits": 1, "repos": [repo(&[3], &last_month)]}, ]}) ); @@ -288,7 +359,9 @@ mod tests { assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!( body, - json!({"months": [{"start": this_month, "commits": 1}]}) + json!({"months": [ + {"start": this_month, "commits": 1, "repos": [repo(&[1], &this_month)]}, + ]}) ); } diff --git a/lexicons/git/temp2/getCommitStats.json b/lexicons/git/temp2/getCommitStats.json index 132e072b3..fee298498 100644 --- a/lexicons/git/temp2/getCommitStats.json +++ b/lexicons/git/temp2/getCommitStats.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Get an actor's monthly commit counts.", + "description": "Get an actor's monthly commit counts, with the repos they committed to.", "parameters": { "type": "params", "required": [ @@ -66,6 +66,64 @@ "type": "integer", "minimum": 0, "description": "Commits attributed to the actor during this month." + }, + "repos": { + "type": "array", + "description": "Repos the actor committed to this month, busiest first.", + "items": { + "type": "ref", + "ref": "#repoCommits" + } + } + } + }, + "repoCommits": { + "type": "object", + "required": [ + "repo", + "commits", + "recent" + ], + "properties": { + "repo": { + "type": "string", + "format": "did", + "description": "DID of the repo the commits were first seen in." + }, + "commits": { + "type": "integer", + "minimum": 0 + }, + "recent": { + "type": "array", + "description": "Most recent commits in this repo during the month.", + "items": { + "type": "ref", + "ref": "#commit" + } + } + } + }, + "commit": { + "type": "object", + "required": [ + "sha", + "subject", + "committedAt" + ], + "properties": { + "sha": { + "type": "string", + "minLength": 40, + "maxLength": 40 + }, + "subject": { + "type": "string", + "maxLength": 1000 + }, + "committedAt": { + "type": "string", + "format": "datetime" } } } diff --git a/lexicons/org/tangled/actor/getProfileActivity.json b/lexicons/org/tangled/actor/getProfileActivity.json index 7b46af138..88673c8f2 100644 --- a/lexicons/org/tangled/actor/getProfileActivity.json +++ b/lexicons/org/tangled/actor/getProfileActivity.json @@ -88,6 +88,14 @@ "type": "ref", "ref": "#pullActivity" } + }, + "commitRepos": { + "type": "array", + "description": "Repos the actor committed to this month, busiest first.", + "items": { + "type": "ref", + "ref": "#commitRepoActivity" + } } } }, @@ -138,6 +146,56 @@ "created", "forked" ] + }, + "description": { + "type": "string", + "maxGraphemes": 140 + } + } + }, + "commitRepoActivity": { + "type": "object", + "required": [ + "repo", + "commits", + "recent" + ], + "properties": { + "repo": { + "type": "ref", + "ref": "#repoRef" + }, + "commits": { + "type": "integer", + "minimum": 0 + }, + "recent": { + "type": "array", + "description": "Most recent commits in this repo during the month.", + "items": { + "type": "ref", + "ref": "#commitActivity" + } + } + } + }, + "commitActivity": { + "type": "object", + "required": [ + "sha", + "subject", + "committedAt" + ], + "properties": { + "sha": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "committedAt": { + "type": "string", + "format": "datetime" } } }, diff --git a/web/src/lib/api/lexicons/types/org/tangled/actor/getProfileActivity.ts b/web/src/lib/api/lexicons/types/org/tangled/actor/getProfileActivity.ts index 82e72a727..f3fa48b07 100644 --- a/web/src/lib/api/lexicons/types/org/tangled/actor/getProfileActivity.ts +++ b/web/src/lib/api/lexicons/types/org/tangled/actor/getProfileActivity.ts @@ -8,6 +8,14 @@ const _activityMonthSchema = /*#__PURE__*/ v.object({ "org.tangled.actor.getProfileActivity#activityMonth", ), ), + /** + * Repos the actor committed to this month, busiest first. + */ + get commitRepos() { + return /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array(commitRepoActivitySchema), + ); + }, /** * @minimum 0 */ @@ -26,6 +34,36 @@ const _activityMonthSchema = /*#__PURE__*/ v.object({ */ start: /*#__PURE__*/ v.datetimeString(), }); +const _commitActivitySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "org.tangled.actor.getProfileActivity#commitActivity", + ), + ), + committedAt: /*#__PURE__*/ v.datetimeString(), + sha: /*#__PURE__*/ v.string(), + subject: /*#__PURE__*/ v.string(), +}); +const _commitRepoActivitySchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal( + "org.tangled.actor.getProfileActivity#commitRepoActivity", + ), + ), + /** + * @minimum 0 + */ + commits: /*#__PURE__*/ v.integer(), + /** + * Most recent commits in this repo during the month. + */ + get recent() { + return /*#__PURE__*/ v.array(commitActivitySchema); + }, + get repo() { + return repoRefSchema; + }, +}); const _issueActivitySchema = /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.optional( /*#__PURE__*/ v.literal( @@ -93,6 +131,14 @@ const _repoActivitySchema = /*#__PURE__*/ v.object({ "org.tangled.actor.getProfileActivity#repoActivity", ), ), + /** + * @maxGraphemes 140 + */ + description: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringGraphemes(0, 140), + ]), + ), kind: /*#__PURE__*/ v.string<"created" | "forked" | (string & {})>(), get repo() { return repoRefSchema; @@ -110,6 +156,8 @@ const _repoRefSchema = /*#__PURE__*/ v.object({ }); type activityMonth$schematype = typeof _activityMonthSchema; +type commitActivity$schematype = typeof _commitActivitySchema; +type commitRepoActivity$schematype = typeof _commitRepoActivitySchema; type issueActivity$schematype = typeof _issueActivitySchema; type main$schematype = typeof _mainSchema; type pullActivity$schematype = typeof _pullActivitySchema; @@ -117,6 +165,8 @@ type repoActivity$schematype = typeof _repoActivitySchema; type repoRef$schematype = typeof _repoRefSchema; export interface activityMonthSchema extends activityMonth$schematype {} +export interface commitActivitySchema extends commitActivity$schematype {} +export interface commitRepoActivitySchema extends commitRepoActivity$schematype {} export interface issueActivitySchema extends issueActivity$schematype {} export interface mainSchema extends main$schematype {} export interface pullActivitySchema extends pullActivity$schematype {} @@ -124,6 +174,10 @@ export interface repoActivitySchema extends repoActivity$schematype {} export interface repoRefSchema extends repoRef$schematype {} export const activityMonthSchema = _activityMonthSchema as activityMonthSchema; +export const commitActivitySchema = + _commitActivitySchema as commitActivitySchema; +export const commitRepoActivitySchema = + _commitRepoActivitySchema as commitRepoActivitySchema; export const issueActivitySchema = _issueActivitySchema as issueActivitySchema; export const mainSchema = _mainSchema as mainSchema; export const pullActivitySchema = _pullActivitySchema as pullActivitySchema; @@ -133,6 +187,12 @@ export const repoRefSchema = _repoRefSchema as repoRefSchema; export interface ActivityMonth extends v.InferInput< typeof activityMonthSchema > {} +export interface CommitActivity extends v.InferInput< + typeof commitActivitySchema +> {} +export interface CommitRepoActivity extends v.InferInput< + typeof commitRepoActivitySchema +> {} export interface IssueActivity extends v.InferInput< typeof issueActivitySchema > {} diff --git a/web/src/lib/api/lexicons/types/sh/tangled/git/temp2/getCommitStats.ts b/web/src/lib/api/lexicons/types/sh/tangled/git/temp2/getCommitStats.ts index 2ab9172dc..e40d19d10 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/git/temp2/getCommitStats.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/git/temp2/getCommitStats.ts @@ -2,6 +2,25 @@ import type {} from "@atcute/lexicons"; import * as v from "@atcute/lexicons/validations"; import type {} from "@atcute/lexicons/ambient"; +const _commitSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.git.temp2.getCommitStats#commit"), + ), + committedAt: /*#__PURE__*/ v.datetimeString(), + /** + * @minLength 40 + * @maxLength 40 + */ + sha: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(40, 40), + ]), + /** + * @maxLength 1000 + */ + subject: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 1000), + ]), +}); const _mainSchema = /*#__PURE__*/ v.query( "sh.tangled.git.temp2.getCommitStats", { @@ -46,22 +65,55 @@ const _monthSchema = /*#__PURE__*/ v.object({ * @minimum 0 */ commits: /*#__PURE__*/ v.integer(), + /** + * Repos the actor committed to this month, busiest first. + */ + get repos() { + return /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(repoCommitsSchema)); + }, /** * Start of this UTC calendar month. */ start: /*#__PURE__*/ v.datetimeString(), }); +const _repoCommitsSchema = /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.literal("sh.tangled.git.temp2.getCommitStats#repoCommits"), + ), + /** + * @minimum 0 + */ + commits: /*#__PURE__*/ v.integer(), + /** + * Most recent commits in this repo during the month. + */ + get recent() { + return /*#__PURE__*/ v.array(commitSchema); + }, + /** + * DID of the repo the commits were first seen in. + */ + repo: /*#__PURE__*/ v.didString(), +}); +type commit$schematype = typeof _commitSchema; type main$schematype = typeof _mainSchema; type month$schematype = typeof _monthSchema; +type repoCommits$schematype = typeof _repoCommitsSchema; +export interface commitSchema extends commit$schematype {} export interface mainSchema extends main$schematype {} export interface monthSchema extends month$schematype {} +export interface repoCommitsSchema extends repoCommits$schematype {} +export const commitSchema = _commitSchema as commitSchema; export const mainSchema = _mainSchema as mainSchema; export const monthSchema = _monthSchema as monthSchema; +export const repoCommitsSchema = _repoCommitsSchema as repoCommitsSchema; +export interface Commit extends v.InferInput {} export interface Month extends v.InferInput {} +export interface RepoCommits extends v.InferInput {} export interface $params extends v.InferInput {} export interface $output extends v.InferXRPCBodyInput {} diff --git a/web/src/lib/components/profile/ActivityMonthPanel.svelte b/web/src/lib/components/profile/ActivityMonthPanel.svelte new file mode 100644 index 000000000..13aacc69a --- /dev/null +++ b/web/src/lib/components/profile/ActivityMonthPanel.svelte @@ -0,0 +1,338 @@ + + +{#snippet repoLabel(repo: ActivityRepoRef)} + + {repo.ownerHandle}/{repo.name} +{/snippet} + +{#snippet repoTags(entries: { repo: ActivityRepoRef; count: number }[])} + {#each entries.slice(0, HEADER_REPOS) as entry (repoPath(entry.repo))} + + {@render repoLabel(entry.repo)} + {entry.count} + + {/each} + {#if entries.length > HEADER_REPOS} + +{entries.length - HEADER_REPOS} + {/if} +{/snippet} + +{#snippet repoTerm(repo: ActivityRepoRef)} +
+ + {@render repoLabel(repo)} + +
+{/snippet} + +{#snippet row( + key: ActivityMetric, + icon: Icon, + label: string, + enabled: boolean, + header: Snippet, + body: Snippet +)} + {@const RowIcon = icon} + {@const rowOpen = Boolean(expanded[key]) && enabled} +
+ +
+
+
+ {@render body()} +
+
+
+
+{/snippet} + +
+
+ {#if month} + {@const current = month} +
+ {#snippet commitsHeader()} + {@render repoTags( + current.commitRepos.map((entry) => ({ + repo: entry.repo, + count: entry.commits + })) + )} + {/snippet} + {#snippet commitsBody()} +
+ {#each current.commitRepos as entry (repoPath(entry.repo))} + {@render repoTerm(entry.repo)} +
+ {#each entry.recent as commit (commit.sha)} + + {commit.sha.slice(0, SHORT_SHA)} + {commit.subject} + + {/each} + {#if entry.commits > entry.recent.length} + + +{entry.commits - entry.recent.length} more + + {/if} +
+ {/each} +
+ {/snippet} + {@render row( + "commits", + GitCommit, + pluralize(current.commits, "commit", "commits"), + current.commitRepos.length > 0, + commitsHeader, + commitsBody + )} + + {#each tickets as ticketRow (ticketRow.key)} + {#snippet ticketsHeader()} + {@render repoTags( + ticketRow.groups.map((group) => ({ + repo: group.repo, + count: group.items.length + })) + )} + {/snippet} + {#snippet ticketsBody()} +
+ {#each ticketRow.groups as group (repoPath(group.repo))} + {@render repoTerm(group.repo)} +
+ {#each group.items as ticket (ticket.uri)} + + {/each} +
+ {/each} +
+ {/snippet} + {@render row( + ticketRow.key, + ticketRow.icon, + ticketRow.count, + ticketRow.groups.length > 0, + ticketsHeader, + ticketsBody + )} + {/each} + + {#snippet reposHeader()} + {#each current.repos as repo (repoPath(repo))} + + {repo.ownerHandle}/{repo.name} + + {/each} + {/snippet} + {#snippet reposBody()} +
    + {#each current.repos as repo (repoPath(repo))} +
  • + + + {@render repoLabel(repo)} + + + {repo.forked ? "Forked" : "Created"} + + + {#if repo.description} +

    {repo.description}

    + {/if} +
  • + {/each} +
+ {/snippet} + {@render row( + "repos", + BookPlus, + pluralize(current.repos.length, "new repository", "new repositories"), + current.repos.length > 0, + reposHeader, + reposBody + )} +
+ {/if} +
+
diff --git a/web/src/lib/components/profile/ActivityTicket.svelte b/web/src/lib/components/profile/ActivityTicket.svelte new file mode 100644 index 000000000..4dc467219 --- /dev/null +++ b/web/src/lib/components/profile/ActivityTicket.svelte @@ -0,0 +1,91 @@ + + + + {#snippet content()} +
+ + +

+ {ticket.repo.ownerHandle}/{ticket.repo.name} + {#if view} + + + {/if} +

+ {#if status === "loaded" && view?.body?.text} +

+ {view.body.text} +

+ {:else if status === "loading"} +
+
+
+
+ {/if} +
+ {/snippet} + + +
diff --git a/web/src/lib/components/profile/ProfileActivityChart.stories.svelte b/web/src/lib/components/profile/ProfileActivityChart.stories.svelte new file mode 100644 index 000000000..698f8d1ae --- /dev/null +++ b/web/src/lib/components/profile/ProfileActivityChart.stories.svelte @@ -0,0 +1,193 @@ + + + { + await expect(canvasElement.querySelectorAll("[data-line]")).toHaveLength(4); + }} +/> + + { + const canvas = within(canvasElement); + const [first] = canvas.getAllByRole("button", { pressed: false }); + await userEvent.click(first); + await expect(first).toHaveAttribute("aria-pressed", "true"); + const region = canvas.getByRole("region"); + await expect(region).not.toHaveAttribute("inert"); + await userEvent.click(within(region).getByRole("button", { name: /pull requests/ })); + await expect(region.querySelectorAll(`[id$="-pulls"] dd a[href*="/pulls/"]`)).toHaveLength( + activity.months[0].pulls.length + ); + }} +/> + + { + const canvas = within(canvasElement); + const point = canvasElement.querySelector('[data-metric="commits"][data-month="0"]'); + await userEvent.click(point!.querySelector("span.pointer-events-auto")!); + const region = canvas.getByRole("region"); + await expect(region).not.toHaveAttribute("inert"); + await expect(within(region).getByRole("button", { name: /commits/ })).toHaveAttribute( + "aria-expanded", + "true" + ); + await expect( + region.querySelectorAll(`[id$="-commits"] dd a[href*="/commit/"]`) + ).toHaveLength( + activity.months[0].commitRepos.reduce((sum, entry) => sum + entry.recent.length, 0) + ); + await expect(within(region).getByRole("button", { name: /pull requests/ })).toHaveAttribute( + "aria-expanded", + "false" + ); + }} +/> diff --git a/web/src/lib/components/profile/ProfileActivityChart.svelte b/web/src/lib/components/profile/ProfileActivityChart.svelte index e5fcdc733..88b0fcbca 100644 --- a/web/src/lib/components/profile/ProfileActivityChart.svelte +++ b/web/src/lib/components/profile/ProfileActivityChart.svelte @@ -3,14 +3,14 @@ import type { Component } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import { SvelteDate } from "svelte/reactivity"; - import ArrowDownRight from "$icon/arrow-down-right"; - import ArrowUpRight from "$icon/arrow-up-right"; - import Minus from "$icon/minus"; + import { Tween } from "svelte/motion"; + import { linear } from "svelte/easing"; import BookPlus from "$icon/book-plus"; import CircleDot from "$icon/circle-dot"; import GitCommit from "$icon/git-commit-horizontal"; import GitPullRequest from "$icon/git-pull-request"; import EmptyState from "#lib/components/ui/EmptyState.svelte"; + import ActivityMonthPanel from "#lib/components/profile/ActivityMonthPanel.svelte"; import { formatCount, plural } from "#lib/format.ts"; import { ACTIVITY_METRICS, @@ -26,9 +26,15 @@ let { activity }: { activity: ProfileActivityData } = $props(); + const uid = $props.id(); + const shimmerId = `${uid}-shimmer`; + const glowId = `${uid}-glow`; + const panelId = `${uid}-month`; + const emptyMonth = (monthsAgo: number): ProfileActivityMonth => ({ monthsAgo, commits: 0, + commitRepos: [], repos: [], issues: [], pulls: [] @@ -118,9 +124,70 @@ }; }; + let selected = $state(null); + let shown = $state(null); + const select = (monthIndex: number) => { + selected = selected === monthIndex ? null : monthIndex; + if (selected !== null) shown = selected; + }; + let focus = $state<{ metric: ActivityMetric } | null>(null); + const openPoint = (monthIndex: number, metric: ActivityMetric) => { + selected = monthIndex; + shown = monthIndex; + focus = { metric }; + }; + let pointerInside = $state(false); let shiftHeld = $state(false); let hoveredMetric = $state(null); + let activeMonth = $state(null); + const SWEEP_BAND = 56; + const SWEEP_SPEED = 0.5; + const SWEEP_OPACITY = 0.25; + const sweep = new Tween(0, { easing: linear }); + let sweepOrigin = $state(0); + const startSweep = (x: number) => { + const reach = Math.max(x, layout.width - x) + SWEEP_BAND; + sweepOrigin = x; + if (matchMedia("(prefers-reduced-motion: reduce)").matches) { + sweep.set(reach, { duration: 0 }); + return; + } + sweep.set(0, { duration: 0 }); + sweep.set(reach, { duration: reach / SWEEP_SPEED }); + }; + const sweepStops = $derived.by(() => { + const span = layout.width + SWEEP_BAND * 4; + const offset = (x: number) => (x + SWEEP_BAND * 2) / span; + const left = sweepOrigin - sweep.current; + const right = sweepOrigin + sweep.current; + return [ + [left - SWEEP_BAND, false], + [left, true], + [Math.min(left + SWEEP_BAND, sweepOrigin), false], + [Math.max(right - SWEEP_BAND, sweepOrigin), false], + [right, true], + [right + SWEEP_BAND, false] + ].map(([x, bright]) => ({ offset: offset(x as number), bright: bright as boolean })); + }); + const hover = ( + metric: ActivityMetric | null, + monthIndex: number | null = null, + origin?: number + ) => { + const entersPoint = + monthIndex !== null && monthIndex !== activeMonth && sweep.current === sweep.target; + if (metric && origin !== undefined && (metric !== hoveredMetric || entersPoint)) + startSweep(origin); + hoveredMetric = metric; + activeMonth = monthIndex; + }; + const leave = (event: PointerEvent) => { + const next = event.relatedTarget instanceof Element ? event.relatedTarget : null; + if (next?.closest(`[data-metric="${hoveredMetric}"], [data-line="${hoveredMetric}"]`)) + return; + hover(null); + }; const trackPointer = (event: PointerEvent) => { pointerInside = true; shiftHeld = event.shiftKey; @@ -132,131 +199,248 @@ (shiftHeld = false)} /> -
+

Activity

{#if activity.months.length === 0} {:else} -
+
+
{#if activity.truncated}

diff --git a/web/src/lib/components/profile/ProfileChrome.stories.svelte b/web/src/lib/components/profile/ProfileChrome.stories.svelte index 298b06459..762cfd88e 100644 --- a/web/src/lib/components/profile/ProfileChrome.stories.svelte +++ b/web/src/lib/components/profile/ProfileChrome.stories.svelte @@ -74,8 +74,10 @@ return { monthsAgo, commits: month.commits, + commitRepos: [], repos: Array.from({ length: month.repos }, (_, repo) => ({ repoDid: `did:plc:repo-${monthsAgo}-${repo}`, + ownerDid: identity.did, ownerHandle: identity.handle, name: `${["knot", "spindle", "bobbin"][repo % 3]}-${monthsAgo}${repo}`, forked: repo % 3 === 2 @@ -85,14 +87,24 @@ rkey: `3mi${monthsAgo}${issue}`, title: "Knot mirror drops refs after a force push", state: issue % 3 === 0 ? ("closed" as const) : ("open" as const), - repo: { repoDid: "did:plc:core", ownerHandle: "tangled.org", name: "core" } + repo: { + repoDid: "did:plc:core", + ownerDid: "did:plc:tangled", + ownerHandle: "tangled.org", + name: "core" + } })), pulls: Array.from({ length: month.pulls }, (_, pull) => ({ uri: `at://${identity.did}/sh.tangled.repo.pull/3mp${monthsAgo}${pull}`, rkey: `3mp${monthsAgo}${pull}`, title: "Derive activity geometry from the measured width", state: pull % 4 === 3 ? ("open" as const) : ("merged" as const), - repo: { repoDid: "did:plc:core", ownerHandle: "tangled.org", name: "core" } + repo: { + repoDid: "did:plc:core", + ownerDid: "did:plc:tangled", + ownerHandle: "tangled.org", + name: "core" + } })) }; }), diff --git a/web/src/lib/components/profile/pages.test.ts b/web/src/lib/components/profile/pages.test.ts index 054e288cb..7fe88141f 100644 --- a/web/src/lib/components/profile/pages.test.ts +++ b/web/src/lib/components/profile/pages.test.ts @@ -553,9 +553,29 @@ describe("fetchProfileActivity", () => { { start: "2026-09-01T00:00:00Z", commits: 3, + commitRepos: [ + { + repo: { + uri: "at://did:plc:bob/sh.tangled.repo/target", + did: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "bob.test", + name: "target" + }, + commits: 3, + recent: [ + { + sha: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", + subject: "Fix ref sync", + committedAt: "2026-09-02T10:00:00Z" + } + ] + } + ], repos: [ { kind: "created", + description: "Current work", repo: { uri: "at://did:plc:alice/sh.tangled.repo/current", did: "did:plc:current", @@ -654,14 +674,43 @@ describe("fetchProfileActivity", () => { expect(activity.truncated).toBe(true); expect(activity.months[0].commits).toBe(3); expect(activity.months[0].repos).toMatchObject([ - { name: "current", ownerHandle: "alice.test", forked: false } + { + name: "current", + ownerHandle: "alice.test", + forked: false, + description: "Current work" + } + ]); + expect(activity.months[0].commitRepos).toEqual([ + { + repo: { + repoDid: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "bob.test", + name: "target" + }, + commits: 3, + recent: [ + { + sha: "9a925efef6a0e6dfcd2d4317b4a1eee8752928b8", + subject: "Fix ref sync", + committedAt: "2026-09-02T10:00:00Z" + } + ] + } ]); + expect(activity.months[1].commitRepos).toEqual([]); expect(activity.months[1].repos).toMatchObject([{ name: "fork", forked: true }]); expect(activity.months[0].issues).toMatchObject([ { rkey: "open", state: "open", - repo: { repoDid: "did:plc:target", ownerHandle: "did:plc:bob", name: "target" } + repo: { + repoDid: "did:plc:target", + ownerDid: "did:plc:bob", + ownerHandle: "did:plc:bob", + name: "target" + } } ]); expect(activity.months[0].pulls).toMatchObject([{ rkey: "merged", state: "merged" }]); diff --git a/web/src/lib/components/profile/pages.ts b/web/src/lib/components/profile/pages.ts index f331ec1a1..db77a0d14 100644 --- a/web/src/lib/components/profile/pages.ts +++ b/web/src/lib/components/profile/pages.ts @@ -617,6 +617,7 @@ const activityRepoRef = ( profileHandle: string ): ActivityRepoRef => ({ repoDid: repo.did ?? "", + ownerDid: repo.ownerDid, ownerHandle: repo.ownerDid === profileDid ? profileHandle @@ -646,9 +647,19 @@ export const fetchProfileActivity = async ( { monthsAgo, commits: Math.max(0, month.commits), + commitRepos: (month.commitRepos ?? []).map((item) => ({ + repo: activityRepoRef(item.repo, did, profileHandle), + commits: Math.max(0, item.commits), + recent: item.recent.map((commit) => ({ + sha: commit.sha, + subject: commit.subject, + committedAt: commit.committedAt + })) + })), repos: month.repos.map((item) => ({ ...activityRepoRef(item.repo, did, profileHandle), - forked: item.kind === "forked" + forked: item.kind === "forked", + ...(item.description ? { description: item.description } : {}) })), issues: month.issues.map((item) => ({ uri: item.uri, diff --git a/web/src/lib/components/profile/profileActivityChart.test.ts b/web/src/lib/components/profile/profileActivityChart.test.ts index 592e223cf..dd0608aef 100644 --- a/web/src/lib/components/profile/profileActivityChart.test.ts +++ b/web/src/lib/components/profile/profileActivityChart.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { ACTIVITY_METRICS, + groupByRepo, layoutActivityChart, type ActivityMetric } from "#lib/components/profile/profileActivityChart.ts"; @@ -27,7 +28,7 @@ describe("layoutActivityChart", () => { for (const [month, column] of layout.columns.entries()) { for (const metric of ACTIVITY_METRICS) { - expect(layout.points[metric][month].x).toBe(column.left + 12); + expect(layout.points[metric][month].x).toBe(column.left + 26); } } }); @@ -83,12 +84,10 @@ describe("layoutActivityChart", () => { const layout = layoutActivityChart(values, 988); for (const metric of ACTIVITY_METRICS) { - const connectors = layout.segments[metric].slice(1, -1); - for (const [index, segment] of connectors.entries()) { - const point = layout.points[metric][index]; + const curves = layout.paths[metric].split(" C ").slice(1); + for (const [index, curve] of curves.entries()) { const next = layout.points[metric][index + 1]; - expect(segment.d).toMatch(new RegExp(`^M ${point.x} ${point.y} C`)); - expect(segment.d).toMatch(new RegExp(`${next.x} ${next.y}$`)); + expect(curve).toMatch(new RegExp(`, ${next.x} ${next.y}( L|$)`)); } } }); @@ -114,8 +113,37 @@ describe("layoutActivityChart", () => { for (const metric of ACTIVITY_METRICS) { const first = layout.points[metric][0]; const last = layout.points[metric].at(-1)!; - expect(layout.segments[metric].at(0)!.d).toBe(`M 0 ${first.y} L ${first.x} ${first.y}`); - expect(layout.segments[metric].at(-1)!.d).toBe(`M ${last.x} ${last.y} L 988 ${last.y}`); + expect(layout.paths[metric]).toMatch( + new RegExp(`^M 0 ${first.y} L ${first.x} ${first.y} C`) + ); + expect(layout.paths[metric]).toMatch(new RegExp(`L 988 ${last.y}$`)); } }); }); + +describe("groupByRepo", () => { + it("groups items per repo, busiest repo first, keeping item order", () => { + const core = { + repoDid: "did:plc:core", + ownerDid: "did:plc:tangled", + ownerHandle: "tangled.org", + name: "core" + }; + const hop = { + repoDid: "did:plc:hop", + ownerDid: "did:plc:eti", + ownerHandle: "eti.tf", + name: "hop" + }; + const items = [ + { id: 1, repo: hop }, + { id: 2, repo: core }, + { id: 3, repo: core } + ]; + + expect(groupByRepo(items)).toEqual([ + { repo: core, items: [items[1], items[2]] }, + { repo: hop, items: [items[0]] } + ]); + }); +}); diff --git a/web/src/lib/components/profile/profileActivityChart.ts b/web/src/lib/components/profile/profileActivityChart.ts index cf185b8bc..38fc9c578 100644 --- a/web/src/lib/components/profile/profileActivityChart.ts +++ b/web/src/lib/components/profile/profileActivityChart.ts @@ -1,3 +1,5 @@ +import type { ActivityRepoRef } from "#lib/components/profile/types.ts"; + export const ACTIVITY_METRICS = ["pulls", "commits", "repos", "issues"] as const; export type ActivityMetric = (typeof ACTIVITY_METRICS)[number]; @@ -14,17 +16,12 @@ export interface ActivityChartPoint { value: number; } -export interface ActivityChartSegment { - d: string; - rising: boolean; -} - export interface ActivityChartLayout { width: number; height: number; columns: ActivityChartColumn[]; points: Record; - segments: Record; + paths: Record; } const DESIGN_WIDTH = 988; @@ -33,7 +30,7 @@ const HEIGHT = 256; const PLOT_TOP = 20; const PLOT_BOTTOM = 196; const POINT_GAP = 30; -const POINT_INSET = 12; +const POINT_INSET = 26; const COLUMN_WEIGHTS = [146, 146, 146, 146, 146, 258]; const NARROW_PAST_GROWTH = 1.5; @@ -123,35 +120,42 @@ export const layoutActivityChart = ( } } - const segments = Object.fromEntries( + const paths = Object.fromEntries( ACTIVITY_METRICS.map((metric) => { const metricPoints = points[metric]; - const connectors = metricPoints.slice(0, -1).map((point, index) => { - const next = metricPoints[index + 1]; - const control = (next.x - point.x) / 2; - return { - d: `M ${point.x} ${point.y} C ${point.x + control} ${point.y}, ${next.x - control} ${next.y}, ${next.x} ${next.y}`, - rising: next.value >= point.value - }; - }); const first = metricPoints[0]; const last = metricPoints.at(-1)!; + const curves = metricPoints.slice(1).map((next, index) => { + const point = metricPoints[index]; + const control = (next.x - point.x) / 2; + return `C ${point.x + control} ${point.y}, ${next.x - control} ${next.y}, ${next.x} ${next.y}`; + }); return [ metric, - [ - { - d: `M 0 ${first.y} L ${first.x} ${first.y}`, - rising: connectors[0]?.rising ?? true - }, - ...connectors, - { - d: `M ${last.x} ${last.y} L ${width} ${last.y}`, - rising: connectors.at(-1)?.rising ?? true - } - ] + [`M 0 ${first.y} L ${first.x} ${first.y}`, ...curves, `L ${width} ${last.y}`].join( + " " + ) ]; }) - ) as Record; + ) as Record; - return { width, height: HEIGHT, columns, points, segments }; + return { width, height: HEIGHT, columns, points, paths }; +}; + +export interface RepoGroup { + repo: ActivityRepoRef; + items: T[]; +} + +export const repoPath = (repo: ActivityRepoRef) => `/${repo.ownerHandle}/${repo.name}`; + +export const groupByRepo = (items: T[]): RepoGroup[] => { + const groups = new Map>(); + for (const item of items) { + const key = repoPath(item.repo); + const group = groups.get(key) ?? { repo: item.repo, items: [] }; + group.items.push(item); + groups.set(key, group); + } + return [...groups.values()].sort((a, b) => b.items.length - a.items.length); }; diff --git a/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte b/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte index 0364a7749..b5d8da9c2 100644 --- a/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte +++ b/web/src/lib/components/profile/tabs/OverviewTab.stories.svelte @@ -17,6 +17,7 @@ const repoRefs = (monthsAgo: number, count: number) => Array.from({ length: count }, (_, index) => ({ repoDid: `did:plc:repo-${monthsAgo}-${index}`, + ownerDid: "did:plc:alice", ownerHandle: "alice.tngl.sh", name: `${["knot", "spindle", "bobbin", "loom"][index % 4]}-${monthsAgo}${index}`, forked: index % 3 === 2 @@ -33,7 +34,12 @@ "OAuth session refresh races on slow networks" ][index % 5], state: index % 3 === 0 ? ("closed" as const) : ("open" as const), - repo: { repoDid: "did:plc:core", ownerHandle: "tangled.org", name: "core" } + repo: { + repoDid: "did:plc:core", + ownerDid: "did:plc:tangled", + ownerHandle: "tangled.org", + name: "core" + } })); const pullRefs = (monthsAgo: number, count: number) => Array.from({ length: count }, (_, index) => ({ @@ -47,7 +53,12 @@ "Reuse pinned repository grid across profiles" ][index % 5], state: index % 4 === 3 ? ("open" as const) : ("merged" as const), - repo: { repoDid: "did:plc:core", ownerHandle: "tangled.org", name: "core" } + repo: { + repoDid: "did:plc:core", + ownerDid: "did:plc:tangled", + ownerHandle: "tangled.org", + name: "core" + } })); const activityShape = [ { commits: 341, repos: 2, issues: 4, pulls: 9 }, @@ -63,6 +74,7 @@ return { monthsAgo, commits: month.commits, + commitRepos: [], repos: repoRefs(monthsAgo, month.repos), issues: issueRefs(monthsAgo, month.issues), pulls: pullRefs(monthsAgo, month.pulls) diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts index e0dfa075c..ec2ae98f2 100644 --- a/web/src/lib/components/profile/types.ts +++ b/web/src/lib/components/profile/types.ts @@ -109,12 +109,26 @@ export type StarData = export interface ActivityRepoRef { repoDid: string; + ownerDid: string; ownerHandle: string; name: string; } export interface ActivityRepo extends ActivityRepoRef { forked: boolean; + description?: string; +} + +export interface ActivityCommit { + sha: string; + subject: string; + committedAt: string; +} + +export interface ActivityCommitRepo { + repo: ActivityRepoRef; + commits: number; + recent: ActivityCommit[]; } export interface ActivityIssue { @@ -136,6 +150,7 @@ export interface ActivityPull { export interface ProfileActivityMonth { monthsAgo: number; commits: number; + commitRepos: ActivityCommitRepo[]; repos: ActivityRepo[]; issues: ActivityIssue[]; pulls: ActivityPull[];