//! Accounts and their repositories, as `dev.knotless.defs` views them, //! and `dev.knotless.getActorRepos`. use super::*; /// `dev.knotless.defs#actorView`. #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ActorView { pub did: String, pub handle: String, #[serde(skip_serializing_if = "Option::is_none")] pub avatar: Option, #[serde(skip_serializing_if = "std::ops::Not::not")] pub bot: bool, } /// `did`, with its handle, its picture's address in full, and whether the /// index has it saying it is a bot. pub async fn actor(app: &App, did: &str) -> ActorView { let avatar = avatar(app, did).await; let bot = app.index.as_ref().and_then(|i| i.is_bot(did).ok()); ActorView { did: did.to_string(), handle: handle(app, did).await, avatar: avatar.map(absolute), bot: bot.unwrap_or(false), } } /// `dev.knotless.defs#repoViewBasic`: a repository, under the name it has /// now. #[derive(Serialize, Clone, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RepoViewBasic { pub did: String, pub handle: String, pub name: String, } /// `repo`, under the name it has now if the index knows it. pub async fn repo_basic(app: &App, repo: &RepoRef) -> Option { let (did, name) = now_named(app, repo)?; Some(RepoViewBasic { did: did.to_string(), handle: handle(app, did).await, name, }) } /// `dev.knotless.defs#repoView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RepoView { uri: String, owner: ActorView, name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, #[serde(skip_serializing_if = "Option::is_none")] fork_of: Option, created_at: String, #[serde(skip_serializing_if = "std::ops::Not::not")] private: bool, #[serde(skip_serializing_if = "Option::is_none")] language: Option, #[serde(skip_serializing_if = "Option::is_none")] star_count: Option, #[serde(skip_serializing_if = "Option::is_none")] fork_count: Option, } /// How many accounts starred `did`'s repository with `key`, and how many /// forks of it there are, as `index` has them. fn counts(index: &Index, did: &str, key: &str) -> (Option, Option) { ( index.star_count(did, key).ok(), index.fork_count(did, key).ok(), ) } /// What each private repository was last counted as mostly written in, /// by its owner's DID and its key. static PRIVATE_LANGUAGES: LazyLock> = LazyLock::new(Default::default); /// What each repository was counted as written in, by its owner's DID and /// its key. type Counted = HashMap<(String, String), Option>; /// What `did`'s repository `r` is mostly written in. A public one's is /// the index's, counted as its refs move ([`languages::follow`]): one never /// counted is asked for, for a later read, rather than waited on. A /// private one, which the index never sees, is counted from its mirror: /// once counted, what was counted shows while its mirror syncs and it is /// counted again in the background, as a sync may wait a second on its /// owner's PDS. async fn language( app: &Arc, did: &str, r: &RepoRecord, private: bool, ) -> Option { if private { let key = (did.to_string(), r.key.clone()); let count = count_private(app.clone(), key.clone(), r.name.clone()); let kept = PRIVATE_LANGUAGES.lock().unwrap().get(&key).cloned(); let found = match kept { Some(kept) => { let viewer = crate::private::viewer(); tokio::spawn(crate::private::reading(viewer, None, count)); kept } None => count.await, }; return found.map(LanguageView::from); } let index = app.index.as_ref()?; let kept = index.language(did, &r.key).ok().flatten(); if kept.is_none() { index.recount(did, &r.key); } let found: Option = serde_json::from_str(&kept?).ok()?; found.map(LanguageView::from) } /// Counts what the private repository named `name`, of the owner and key /// `key` names, is mostly written in, from its mirror synced, and keeps it. async fn count_private(app: Arc, key: (String, String), name: String) -> Option { let git = app.mirror(&key.0, &name).await.ok()?; let objects = objects::open(&git.dir).ok()?; let found = languages::main_of(&objects); let mut kept = PRIVATE_LANGUAGES.lock().unwrap(); kept.insert(key, found.clone()); found } #[derive(Serialize)] pub struct Output { repos: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] shared: Vec, } /// `dev.knotless.getActorRepos`: the repositories the account's PDS holds, /// newest first, and to the account itself, asking, its private ones too, /// and apart, those of others shared with it. pub async fn get_actor_repos( State(app): State>, axum::Extension(viewer): axum::Extension, Query(p): Query, ) -> Response { let at = RepoUrl { authority: p.actor, name: "_".into(), }; match Remote::connect(&app.ctx, &at).await { Ok(remote) => repos(&app, remote, viewer.0).await.into_response(), Err(e) => no_profile(e), } } /// `did`'s private repositories, as this server's index of their spaces /// has them, that the viewer, logged in to the pages, may read: all of /// them to `did`, and to anyone else those they were proven a member of. /// What it has yet to find is looked for in the background /// ([`crate::spaces::discover`]), for a look after. fn private_of( app: &Arc, did: &str, viewer: Option, ) -> Option> { let index = app.spaces.as_ref()?; let viewer = viewer?; crate::spaces::discover(app, &viewer); let theirs = index.read_of(&viewer).unwrap_or_default(); let all = index.actor_repos(did).unwrap_or_default().into_iter(); let may = |uri: &String| viewer == did || theirs.contains(uri); let shown: Vec<_> = all.filter(|(_, _, uri)| may(uri)).collect(); if viewer == did || !shown.is_empty() { crate::private::touched(); } Some(shown) } async fn repos( app: &Arc, remote: Remote, viewer: Option, ) -> Result> { let did = remote.did().to_string(); let owner = actor(app, &did).await; // Its repositories as the index has them while a feed keeps it // current, else as its PDS lists them. let watching = app.watching.load(Ordering::Relaxed); let indexed = app.index.as_ref().filter(|_| watching); let mut found = match indexed.map(|index| index.actor_repos(&did)) { Some(Ok(found)) => found, _ => { let listed = remote.list_repos().await.map_err(AppError::upstream)?; let uri = |rkey: &str| forge::repo_uri(&did, rkey); listed .into_iter() .map(|r| (r.rkey.clone(), r.value, uri(&r.rkey))) .collect() } }; let shared = shared_with(app, &did, viewer.as_deref()).await; let mine = private_of(app, &did, viewer).unwrap_or_default(); let private: Vec = mine.iter().map(|(rkey, _, _)| rkey.clone()).collect(); found.extend(mine); found.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); let forks = futures::future::join_all(found.iter().map(|(_, r, _)| async { match &r.fork_of { Some(from) => repo_basic(app, from).await, None => None, } })) .await; let spoken = found .iter() .map(|(rkey, r, _)| language(app, &did, r, private.contains(rkey))); let spoken = futures::future::join_all(spoken).await; let repos = found .into_iter() .zip(forks.into_iter().zip(spoken)) .map(|((rkey, r, uri), (fork_of, language))| RepoView { uri, private: private.contains(&rkey), owner: owner.clone(), name: r.name, description: r.description, fork_of, created_at: r.created_at, language, star_count: None, fork_count: None, }) .collect(); Ok(axum::Json(Output { repos, shared })) } /// The private repositories of others that `did`, asking about itself, was /// proven a member of here, newest first: none to anyone else. async fn shared_with(app: &Arc, did: &str, viewer: Option<&str>) -> Vec { let Some(index) = app.spaces.as_ref().filter(|_| viewer == Some(did)) else { return Vec::new(); }; let record = |uri: &str| -> Option<(String, RepoRecord, String)> { let (owner, _, _) = forge::parse_uri(uri).ok()?; let value = index.repo(uri).ok()??; let record = serde_json::from_value(value).ok()?; Some((owner.to_string(), record, uri.to_string())) }; let read = index.read_of(did).unwrap_or_default(); let mut found: Vec<_> = read.iter().filter_map(|uri| record(uri)).collect(); found.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); let views = found.into_iter().map(|(owner, r, uri)| async move { let fork_of = r.fork_of.as_ref().map(|from| repo_basic(app, from)); let language = language(app, &owner, &r, true).await; RepoView { uri, owner: actor(app, &owner).await, name: r.name, description: r.description, fork_of: futures::future::OptionFuture::from(fork_of).await.flatten(), language, created_at: r.created_at, private: true, star_count: None, fork_count: None, } }); futures::future::join_all(views).await } #[derive(Deserialize)] pub struct ActivityParams { actor: String, since: Option, tz: Option, } /// `dev.knotless.getActorActivity#dayView`. #[derive(Serialize, Default)] pub struct DayView { day: String, repos: u64, pushes: u64, pulls: u64, issues: u64, comments: u64, } /// `dev.knotless.getActorActivity#repoActivity`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RepoActivity { name: String, pushed_at: String, days: Vec, } /// `dev.knotless.getActorActivity#repoDay`. #[derive(Serialize)] pub struct RepoDay { day: String, pushes: u64, } #[derive(Serialize)] pub struct Activity { days: Vec, repos: Vec, } /// `dev.knotless.getActorActivity`: how much the account did on each day, /// and its repositories' pushes, as the index has them; and to a viewer /// who may read them, its private repositories' pushes, as this server's /// index of their spaces has them. pub async fn get_actor_activity( State(app): State>, axum::Extension(viewer): axum::Extension, Query(p): Query, ) -> Result> { let index = app.index.as_ref().ok_or(Shown( StatusCode::NOT_FOUND, "this server keeps no index".into(), ))?; let since = p.since.as_deref(); let tz = p.tz.as_deref().unwrap_or("UTC"); let counted = index.actor_days(&p.actor, since, tz); let pushed = index.actor_pushes(&p.actor, since, tz); let mut days: Vec = Vec::new(); let counted = counted .map_err(anyhow::Error::from) .map_err(AppError::internal)?; for (day, kind, n) in counted { if days.last().is_none_or(|last| last.day != day) { days.push(DayView { day, ..DayView::default() }); } let last = days.last_mut().unwrap(); *match kind.as_str() { "repos" => &mut last.repos, "pushes" => &mut last.pushes, "pulls" => &mut last.pulls, "issues" => &mut last.issues, _ => &mut last.comments, } = n; } let mut pushed = pushed .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let private = private_pushes(&app, &p.actor, viewer.0, since, tz) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; pushed.extend(private); let repos = pushed .into_iter() .map(|r| RepoActivity { name: r.name, pushed_at: r.pushed_at, days: r .days .into_iter() .map(|(day, pushes)| RepoDay { day, pushes }) .collect(), }) .collect(); Ok(axum::Json(Activity { days, repos })) } /// The pushes to `did`'s private repositories that `viewer` may read /// ([`private_of`]), as this server's index of their spaces counts them. fn private_pushes( app: &Arc, did: &str, viewer: Option, since: Option<&str>, tz: &str, ) -> rusqlite::Result> { let shown = private_of(app, did, viewer).unwrap_or_default(); let names: HashSet = shown.into_iter().map(|(name, _, _)| name).collect(); let Some(index) = app.spaces.as_ref().filter(|_| !names.is_empty()) else { return Ok(Vec::new()); }; let mut pushed = index.actor_pushes(did, since, tz)?; pushed.retain(|r| names.contains(&r.name)); Ok(pushed) } /// `dev.knotless.getActorStars`: the repositories the account starred, as /// the index has its stars, the latest starred first. pub async fn get_actor_stars( State(app): State>, Query(p): Query, ) -> Response { let Some(index) = app.index.clone() else { return Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()).into_response(); }; match knotless::identity::resolve(&app.ctx, &p.actor).await { Ok(id) => starred(&app, &index, &id.did).await.into_response(), Err(e) => no_profile(e), } } /// The most items a page of `listActorItems` holds, and how many if not asked. const ACTOR_ITEMS_MAX: usize = 100; const ACTOR_ITEMS_PER_PAGE: usize = 50; #[derive(Deserialize)] pub struct ActorItemsParams { actor: String, kind: String, relation: Option, state: Option, limit: Option, cursor: Option, } /// `dev.knotless.listActorItems`'s answer. #[derive(Serialize)] pub struct ActorItems { items: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } /// A pull or an issue, and the repository it is on. #[derive(Serialize)] pub struct ActorItem { item: ItemView, repo: ActorItemRepo, } #[derive(Serialize)] pub struct ActorItemRepo { did: String, handle: String, name: String, } /// `dev.knotless.listActorItems`: the pulls or issues an account opened, /// is assigned or is asked to review, on any repository, newest first. pub async fn list_actor_items( State(app): State>, Query(p): Query, ) -> Response { let Some(index) = app.index.clone() else { return Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()).into_response(); }; match knotless::identity::resolve(&app.ctx, &p.actor).await { Ok(id) => actor_items(&app, &index, &id.did, &p).await.into_response(), Err(e) => no_profile(e), } } async fn actor_items( app: &Arc, index: &Index, did: &str, p: &ActorItemsParams, ) -> Result { if p.kind != "pull" && p.kind != "issue" { return Err(super::repo::invalid(format!( "{} is no kind of item", p.kind ))); } let skip = super::repo::skipped(p.cursor.as_deref())?; let limit = p .limit .unwrap_or(ACTOR_ITEMS_PER_PAGE) .clamp(1, ACTOR_ITEMS_MAX); let concerns = |i: &Item| match p.relation.as_deref() { Some("assigned") => i.assignees.iter().any(|a| a == did), Some("review") => i.reviewers.iter().any(|r| r == did), _ => i.author == did, }; let open = p.state.as_deref() != Some("closed"); let repos = index.repos_involving(did); let repos = repos .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let mut found: Vec<(&String, &RepoRecord, Item)> = Vec::new(); for (owner, record) in &repos { let made = index.assembled(owner, &record.key, record); let made = made .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let items = made.items.iter().filter(|i| i.kind == p.kind); let items = items.filter(|i| concerns(i) && (i.state == "open") == open); found.extend(items.map(|i| (owner, record, i.clone()))); } found.sort_by(|a, b| b.2.created_at.cmp(&a.2.created_at)); let cursor = (skip + limit < found.len()).then(|| (skip + limit).to_string()); let page = found.iter().skip(skip).take(limit); let views = page.map(|(owner, record, item)| async move { ActorItem { item: item_view(app, record, item).await, repo: ActorItemRepo { did: (*owner).clone(), handle: actor(app, owner).await.handle, name: record.name.clone(), }, } }); let items = futures::future::join_all(views).await; Ok(answer(ActorItems { items, cursor })) } async fn starred(app: &Arc, index: &Index, did: &str) -> Result> { let found = index .actor_stars(did) .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let views = found.into_iter().map(|r| async move { let record = index.repo(&r.uri).ok().flatten(); let record = record.and_then(|v| serde_json::from_value::(v).ok()); let counted = record.as_ref().map(|v| counts(index, &r.did, &v.key)); let (star_count, fork_count) = counted.unwrap_or_default(); let spoken = record.as_ref().map(|v| language(app, &r.did, v, false)); let language = futures::future::OptionFuture::from(spoken).await.flatten(); let fork_of = match record.and_then(|r| r.fork_of) { Some(from) => repo_basic(app, &from).await, None => None, }; RepoView { owner: actor(app, &r.did).await, uri: r.uri, name: r.name, description: r.description, fork_of, created_at: r.created_at, private: false, language, star_count, fork_count, } }); let repos = futures::future::join_all(views).await; let shared = Vec::new(); Ok(axum::Json(Output { repos, shared })) }