johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String>,
#[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<RepoViewBasic> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fork_of: Option<RepoViewBasic>,
created_at: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
language: Option<LanguageView>,
#[serde(skip_serializing_if = "Option::is_none")]
star_count: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
fork_count: Option<u64>,
}
/// 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<u64>, Option<u64>) {
(
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<Mutex<Counted>> = 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<Language>>;
/// 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<App>,
did: &str,
r: &RepoRecord,
private: bool,
) -> Option<LanguageView> {
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<Language> = 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<App>, key: (String, String), name: String) -> Option<Language> {
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<RepoView>,
#[serde(skip_serializing_if = "Vec::is_empty")]
shared: Vec<RepoView>,
}
/// `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<Arc<App>>,
axum::Extension(viewer): axum::Extension<crate::viewer::Viewer>,
Query(p): Query<ProfileParams>,
) -> 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<App>,
did: &str,
viewer: Option<String>,
) -> Option<Vec<(String, RepoRecord, String)>> {
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<App>,
remote: Remote,
viewer: Option<String>,
) -> Result<axum::Json<Output>> {
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<String> = 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<App>, did: &str, viewer: Option<&str>) -> Vec<RepoView> {
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<String>,
tz: Option<String>,
}
/// `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<RepoDay>,
}
/// `dev.knotless.getActorActivity#repoDay`.
#[derive(Serialize)]
pub struct RepoDay {
day: String,
pushes: u64,
}
#[derive(Serialize)]
pub struct Activity {
days: Vec<DayView>,
repos: Vec<RepoActivity>,
}
/// `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<Arc<App>>,
axum::Extension(viewer): axum::Extension<crate::viewer::Viewer>,
Query(p): Query<ActivityParams>,
) -> Result<axum::Json<Activity>> {
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<DayView> = 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<App>,
did: &str,
viewer: Option<String>,
since: Option<&str>,
tz: &str,
) -> rusqlite::Result<Vec<crate::index::RepoPushes>> {
let shown = private_of(app, did, viewer).unwrap_or_default();
let names: HashSet<String> = 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<Arc<App>>,
Query(p): Query<ProfileParams>,
) -> 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<String>,
state: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
}
/// `dev.knotless.listActorItems`'s answer.
#[derive(Serialize)]
pub struct ActorItems {
items: Vec<ActorItem>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// 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<Arc<App>>,
Query(p): Query<ActorItemsParams>,
) -> 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<App>,
index: &Index,
did: &str,
p: &ActorItemsParams,
) -> Result<Response> {
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<App>, index: &Index, did: &str) -> Result<axum::Json<Output>> {
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::<RepoRecord>(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 }))
}