johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! A repository's pulls and issues, as XRPC queries:
//! `dev.knotless.listItems`, `dev.knotless.getThread` and
//! `dev.knotless.getPullDiff`.
use super::repo::{found, invalid};
use super::threads;
use super::*;
use crate::viewer::Viewer;
use axum::Extension;
use knotless::forge::Entry;
use knotless::mentions;
use knotless::query::Query as Filter;
use knotless::records::{ImageAttachment, LineRef};
/// The most items a page of `listItems` holds, and how many if not asked.
const ITEMS_MAX: usize = 100;
const ITEMS_PER_PAGE: usize = 50;
#[derive(Deserialize)]
pub struct ItemsParams {
repo: String,
kind: String,
state: Option<String>,
q: Option<String>,
limit: Option<usize>,
cursor: Option<String>,
}
/// `dev.knotless.listItems`'s answer: a page of a repository's pulls or
/// issues, open or closed, that a query matches, how many there are of
/// each, and what a filter of them can name.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Items {
items: Vec<ItemView>,
open_count: usize,
closed_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
/// The labels the repository defines.
#[serde(skip_serializing_if = "Vec::is_empty")]
labels: Vec<LabelView>,
/// Who opened its pulls or issues, the most recent first.
#[serde(skip_serializing_if = "Vec::is_empty")]
authors: Vec<ActorView>,
}
/// `dev.knotless.listItems`.
pub async fn list_items(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
Query(p): Query<ItemsParams>,
) -> Result<Response> {
if p.kind != "pull" && p.kind != "issue" {
return Err(invalid(format!("{} is no kind of item", p.kind)));
}
let limit = p.limit.unwrap_or(ITEMS_PER_PAGE).clamp(1, ITEMS_MAX);
let skip = match p.cursor.as_deref().map(str::parse::<usize>) {
None => 0,
Some(Ok(skip)) => skip,
Some(Err(_)) => return Err(invalid("cursor is no count of items".into())),
};
let repo = found(&app, &p.repo).await?;
let made = assembled(&app, &repo).await?;
let record = repo.record(&app).unwrap_or_default();
let all = made.items.iter().filter(|i| i.kind == p.kind).cloned();
let all: Vec<Item> = all.collect();
let mut seen = HashSet::new();
let authors = all.iter().filter(|i| seen.insert(&i.author));
let authors = authors.map(|i| actor(&app, &i.author));
let authors = futures::future::join_all(authors).await;
let mut filter = Filter::parse(p.q.as_deref().unwrap_or_default());
filter.authors = futures::future::join_all(filter.authors.iter().map(|a| did(&app, a))).await;
let mut filter = filter.resolve(&app.ctx, viewer.0.as_deref()).await;
// The counts are of all it matches, the list of those in its state.
let state = match filter.state.take().or(p.state).as_deref() {
Some(s @ ("closed" | "merged")) => s.to_string(),
_ => "open".to_string(),
};
let all: Vec<Item> = all.into_iter().filter(|i| filter.matches(i)).collect();
let open_count = all.iter().filter(|i| i.state == "open").count();
let closed_count = all.len() - open_count;
let shown = Filter {
state: Some(state),
..Filter::default()
};
let listed: Vec<&Item> = all.iter().filter(|i| shown.in_state(i)).collect();
let page = listed.iter().copied().skip(skip).take(limit);
let cursor = (skip + limit < listed.len()).then(|| (skip + limit).to_string());
Ok(answer(Items {
items: item_views(&app, &record, page).await,
open_count,
closed_count,
cursor,
labels: record.labels.iter().map(label_view).collect(),
authors,
}))
}
/// The DID a filter's `author:` names, by handle or DID; as written if it
/// resolves to none, so it matches no one. A handle the index found for an
/// account is that account's; any other is looked up, and what was found
/// kept a while, as a crawler asks for the same filter again and again and
/// a handle's host can take seconds to answer.
async fn did(app: &App, author: &str) -> String {
let indexed = app.index.as_ref().map(|i| i.did_of_handle(author));
if let Some(Ok(Some(did))) = indexed {
return did;
}
let (ctx, handle) = (app.ctx.clone(), author.to_string());
let look = async move {
let found = knotless::identity::resolve_handle(&ctx, &handle).await;
Some(found.ok())
};
let found = AUTHORS.through(&app.ctx.env.cache_dir, author, look).await;
found.flatten().unwrap_or(author.to_string())
}
#[derive(Deserialize)]
pub struct ThreadParams {
repo: String,
uri: String,
#[serde(rename = "filesOnly", default)]
files_only: bool,
/// With `files_only`, the first files' rows too.
#[serde(rename = "firstRows", default)]
first_rows: bool,
}
/// `dev.knotless.getThread#mention`: a pull that names an issue.
#[derive(Serialize)]
struct MentionView {
item: ItemView,
#[serde(skip_serializing_if = "std::ops::Not::not")]
closes: bool,
}
/// `dev.knotless.defs#stackView`.
#[derive(Serialize)]
struct StackView {
pulls: Vec<StackPullView>,
#[serde(skip_serializing_if = "Option::is_none")]
into: Option<String>,
}
/// `dev.knotless.defs#stackPullView`.
#[derive(Serialize)]
struct StackPullView {
item: ItemView,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
merged: Option<String>,
}
/// The stack `uri` is part of among `items`, from what is already read.
async fn stack_view(
app: &App,
repo: &Repo,
record: &RepoRecord,
items: &[Item],
uri: &str,
) -> Option<StackView> {
let chain = forge::stack_of(&repo.did, &repo.key, items, uri);
let lowest = chain.last()?;
let row = |i: &'_ Item| {
let (branch, merged) = (i.head.as_ref().map(|h| h.branch.clone()), i.merged.clone());
let i = i.clone();
async move {
let item = item_view(app, record, &i).await;
StackPullView {
item,
branch,
merged,
}
}
};
let into = lowest.base.clone();
let pulls = futures::future::join_all(chain.iter().map(|i| row(i))).await;
Some(StackView { pulls, into })
}
/// `dev.knotless.getThread`'s answer: a pull or an issue, and everything
/// said on it.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadView {
item: ItemView,
/// What it says, as its author wrote it, and made into HTML.
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
html: Option<String>,
/// A pull's target branch, and the branch it brings in.
#[serde(skip_serializing_if = "Option::is_none")]
base: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
head: Option<PullHeadView>,
/// The merge, for a merged pull.
#[serde(skip_serializing_if = "Option::is_none")]
merged: Option<String>,
comments: Vec<CommentView>,
/// For a pull, where each reviewer stands, in the order they last
/// reviewed.
#[serde(skip_serializing_if = "Vec::is_empty")]
reviews: Vec<ReviewView>,
/// The issues on the repository a pull's title or body names to close
/// as it merges into the default branch.
closes: Vec<ItemView>,
/// The pull whose merge closed an issue.
#[serde(skip_serializing_if = "Option::is_none")]
closed_by: Option<ItemViewBasic>,
/// For an issue, the pulls on the repository that name it.
#[serde(skip_serializing_if = "Vec::is_empty")]
mentioned_by: Vec<MentionView>,
/// The revert whose merge undid a merged pull.
#[serde(skip_serializing_if = "Option::is_none")]
reverted_by: Option<ItemViewBasic>,
/// The chain of pulls a pull is part of, if any.
#[serde(skip_serializing_if = "Option::is_none")]
stack: Option<StackView>,
/// Where an open pull stands in its branch's merge queue, if it was
/// queued or ejected from it.
#[serde(skip_serializing_if = "Option::is_none")]
queue: Option<InQueue>,
/// What the runners of the repository it comes from report of its
/// branch, for a pull.
checks: Vec<CheckView>,
/// For a pull, the minutes the target's runners count for its owner.
#[serde(skip_serializing_if = "Vec::is_empty")]
minutes: Vec<MinutesView>,
/// What a pull brings in; none for an issue, or a pull whose branch is
/// gone.
#[serde(skip_serializing_if = "Option::is_none")]
changes: Option<ChangesView>,
/// The path of the picture its page unfurls into.
card: String,
/// How accounts reacted to it.
#[serde(skip_serializing_if = "Vec::is_empty")]
reactions: Vec<super::reactions::ReactionCountView>,
/// What the account asking may do with it.
#[serde(skip_serializing_if = "Option::is_none")]
viewer: Option<threads::ThreadViewer>,
}
/// The most files one `getPullDiff` or `getCommit` asks for.
pub(super) const DIFF_PATHS_MAX: usize = 50;
/// `dev.knotless.getPullDiff`'s answer: some files of a pull's diff.
#[derive(Serialize)]
pub struct PullDiff {
files: Vec<FileDiffView>,
}
/// `dev.knotless.getPullDiff`: the files of a pull's diff that `paths`
/// names, as `getThread` would show them, for a page that listed them
/// with `filesOnly`. Its parameters are pairs, as `paths` repeats.
pub async fn get_pull_diff(
State(app): State<Arc<App>>,
Query(pairs): Query<Vec<(String, String)>>,
) -> Result<Response> {
let (repo, uri) = (named(&pairs, "repo"), named(&pairs, "uri"));
let (Some(repo), Some(uri)) = (repo.first(), uri.first()) else {
return Err(invalid("repo and uri are required".into()));
};
let paths = named(&pairs, "paths");
if paths.is_empty() || paths.len() > DIFF_PATHS_MAX {
let message = format!("paths names 1 to {DIFF_PATHS_MAX} files");
return Err(invalid(message));
}
let Ok((did, nsid::PULL, rkey)) = forge::parse_uri(uri) else {
return Err(invalid(format!("{uri} is no pull")));
};
let repo = found(&app, repo).await?;
let (thread, ..) = self::thread(&app, &repo, "pull", did, rkey).await?;
let item = thread.item;
let gone = Shown(StatusCode::NOT_FOUND, "no head to the pull".into());
let (base, head) = item.base.as_ref().zip(item.head.as_ref()).ok_or(gone)?;
let (git, start, tip, _) = pull_range(&app, &repo, &item, base, head).await?;
let range = format!("{start}...{tip}");
let files = files(&git, &["diff"], &range, &paths, Rows::All).await?;
Ok(answer(PullDiff { files }))
}
/// The values of the parameters `pairs` names `name`, in order.
pub(super) fn named<'a>(pairs: &'a [(String, String)], name: &str) -> Vec<&'a str> {
let named = pairs.iter().filter(|(k, _)| k == name);
named.map(|(_, v)| v.as_str()).collect()
}
/// `dev.knotless.getThread`.
pub async fn get_thread(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
Query(p): Query<ThreadParams>,
) -> Result<Response> {
let (did, kind, rkey) = match forge::parse_uri(&p.uri) {
Ok((did, nsid::PULL, rkey)) => (did, "pull", rkey),
Ok((did, nsid::ISSUE, rkey)) => (did, "issue", rkey),
_ => return Err(invalid(format!("{} is no pull or issue", p.uri))),
};
let repo = found(&app, &p.repo).await?;
let (thread, author, made) = self::thread(&app, &repo, kind, did, rkey).await?;
let all = &made.items;
let page = repo.href(&format!("/{kind}/{did}/{rkey}"));
let card = item_card(&app, &repo, kind, &thread, &author).await;
let card = card_path(&page, &card);
let Thread {
item,
comments,
resolved,
mentioned_by,
..
} = thread;
let lined = anchored(&app, &repo, &item, &comments).await;
let said = comments.iter().map(|c| c.uri.clone());
let subjects: Vec<String> = std::iter::once(item.uri.clone()).chain(said).collect();
let asking = viewer.0.as_deref();
let mut reactions = super::reactions::counted(&app, &repo, &subjects, asking);
let changes = match (&item.base, &item.head) {
(Some(base), Some(head)) => {
let rows = match (p.files_only, p.first_rows) {
(false, _) => Rows::All,
(true, false) => Rows::None,
(true, true) => Rows::First,
};
pull_changes(&app, &repo, &item, base, head, rows)
.await
.ok()
}
_ => None,
};
let record = repo.record(&app).unwrap_or_default();
let viewer = asking.map(|did| {
let mut viewer = threads::viewer(&record, &repo.did, &item, did);
let commented = comments.iter().any(|c| c.author == did);
threads::heard(&app, &repo, &item, did, commented, &mut viewer);
viewer
});
let (head, checks, minutes) = match &item.head {
Some(head) => {
// Its own runs, by the target's runners, then those of its
// branch, by the runners of the repository it comes from.
let pulled = CheckFilter {
pull: Some(&item.uri),
..CheckFilter::default()
};
let branch = format!("refs/heads/{}", head.branch);
let pushed = CheckFilter {
refname: Some(&branch),
..CheckFilter::default()
};
let shown = repo.basic(&app).await;
let mut all = self::checks(&app, &repo.reference(), &shown, pulled, 100);
let from = repo_basic(&app, &head.repo).await;
if let Some(from) = &from {
all.extend(self::checks(&app, &head.repo, from, pushed, 100));
}
// An open pull's checks of an older head are not where it is
// now: shown as its own, they read as passing what never ran.
let at = changes.as_ref().and_then(|c| c.head.as_deref());
all.retain(|c| at.is_none_or(|at| c.commit == at));
// A pull the merge queue landed ran only its pre-flights at
// its head: the rest ran on its batch, and those are the
// checks it landed on, kept on its base at the batch's tip.
let landed = item.merged_batch.as_deref().zip(item.base.as_deref());
let on_base = landed.map(|(tip, base)| {
let refname = format!("refs/heads/{base}");
let at = CheckFilter {
refname: Some(&refname),
commit: Some(tip),
..CheckFilter::default()
};
self::checks(&app, &repo.reference(), &shown, at, 100)
});
if let Some(batch) = on_base.filter(|b| !b.is_empty()) {
all = batch;
}
let head = PullHeadView {
repo: from,
branch: head.branch.clone(),
};
let runners: Vec<String> = record.runners.iter().map(|r| r.did.clone()).collect();
let minutes = self::minutes(&app, &repo.uri(), &repo.did, &runners).await;
(Some(head), newest(all), minutes)
}
None => (None, Vec::new(), Vec::new()),
};
let stack = stack_view(&app, &repo, &record, all, &item.uri).await;
let base = repo.href("");
let shown_repo = repo.uri();
let named = named_items(&repo, all);
let said = futures::future::join_all(comments.iter().map(|c| async {
CommentView {
uri: c.uri.clone(),
author: actor(&app, &c.author).await,
created_at: c.value["createdAt"].as_str().unwrap_or_default().into(),
updated_at: c.value["updatedAt"].as_str().map(str::to_string),
body: c.value["body"].as_str().map(str::to_string),
html: {
let body = c.value["body"].as_str().unwrap_or_default();
let facets = mentions::facets_of(&c.value, "facets");
let attached = Attached {
repo: &shown_repo,
uri: &c.uri,
images: &comment_pictures(&c.value),
};
written_naming(
body,
&base,
Some(attached),
&mentions::mentioned(body, &facets),
&named,
)
},
verdict: c.value["verdict"].as_str().map(str::to_string),
commit: c.value["commit"].as_str().map(str::to_string),
line: lined.get(&c.uri).cloned(),
reply: c.value["reply"].as_str().map(str::to_string),
review: c.value["review"].as_str().map(str::to_string),
dismisses: (c.value["dismisses"].as_str())
.filter(|_| c.author == repo.did || record.is_collaborator(&c.author))
.map(str::to_string),
resolved: resolved.contains(&c.uri),
reactions: reactions.get(&c.uri).cloned().unwrap_or_default(),
}
}))
.await;
// Against the head as it is now: a merged pull's reviews stand as
// they were.
let at = changes.as_ref().and_then(|c| c.head.as_deref());
let rule = item.base.as_deref().and_then(|b| record.rule(b));
let gates = (rule.and_then(|r| r.reviews.as_ref())).map_or(&[][..], |r| &r.gates[..]);
let reviews = futures::future::join_all(item.reviews.iter().map(|r| async {
ReviewView {
reviewer: actor(&app, &r.by).await,
verdict: r.verdict.clone(),
uri: r.uri.clone(),
commit: r.commit.clone(),
outdated: at.is_some_and(|at| !r.at_head(at)),
maintainer: r.by == repo.did || record.is_collaborator(&r.by),
dismissed: r.dismissed.is_some(),
gate: gates.iter().any(|g| g.did == r.by),
}
}))
.await;
let queue = queue::in_queue(&app, &repo, &item).await?;
let closes = item_views(&app, &record, closing::closes(&item, all)).await;
let mentions = mentioned_by.iter().filter_map(|m| {
let pull = all.iter().find(|i| i.uri == m.pull)?;
Some((pull, m.closes))
});
let mentions: Vec<(&Item, bool)> = mentions.collect();
let pulls = item_views(&app, &record, mentions.iter().map(|(p, _)| *p)).await;
let mentioned_by = pulls.into_iter().zip(&mentions);
let mentioned_by = mentioned_by.map(|(item, (_, closes))| MentionView {
item,
closes: *closes,
});
let mentioned_by = mentioned_by.collect();
// The pull that closed an issue, or the revert that undid a pull.
let basic = |uri: &Option<String>| {
let found = all.iter().find(|i| uri.as_ref() == Some(&i.uri));
found.map(|p| ItemViewBasic {
uri: p.uri.clone(),
title: p.title.clone(),
})
};
let (closed_by, reverted_by) = (basic(&item.closed_by), basic(&item.reverted_by));
Ok(answer(ThreadView {
html: item.body.as_deref().map(|b| {
let (uri, images) = (&item.uri, &item.images);
let attached = Attached {
repo: &shown_repo,
uri,
images,
};
written_naming(
b,
&base,
Some(attached),
&mentions::mentioned(b, &item.facets),
&named,
)
}),
item: item_view(&app, &record, &item).await,
body: item.body,
base: item.base,
head,
merged: item.merged,
comments: said,
reviews,
closes,
closed_by,
mentioned_by,
reverted_by,
stack,
queue,
checks,
minutes,
changes,
card,
reactions: reactions.remove(&subjects[0]).unwrap_or_default(),
viewer,
}))
}
/// `repo`'s pulls and issues among `items`, for what is said on one to
/// link to by their `at://` URIs.
fn named_items(repo: &Repo, items: &[Item]) -> Named {
let page = |i: &Item| {
let (did, _, rkey) = forge::parse_uri(&i.uri).ok()?;
let path = repo.href(&format!("/{}/{did}/{rkey}", i.kind));
Some((i.uri.clone(), (path, i.title.clone())))
};
items.iter().filter_map(page).collect()
}
/// The most lines a comment on a pull's lines shows of them.
const LINES_SHOWN: u32 = 20;
/// The lines a pull's line comments are on, as they stand now, by
/// comment. One whose pull's head and base are gone has none.
async fn anchored(
app: &App,
repo: &Repo,
item: &Item,
comments: &[Entry],
) -> HashMap<String, LineView> {
lines_shown(app, repo, item, comments)
.await
.unwrap_or_default()
}
/// [`anchored`]'s, or none when nothing is said on a pull's lines or its
/// commits are gone.
async fn lines_shown(
app: &App,
repo: &Repo,
item: &Item,
comments: &[Entry],
) -> Option<HashMap<String, LineView>> {
let lined: Vec<(&str, LineRef, &str)> = comments.iter().filter_map(on_lines).collect();
lined.first()?;
let (base, head) = (item.base.as_ref()?, item.head.as_ref()?);
let range = pull_range(app, repo, item, base, head).await;
let (mut git, start, tip, _) = range.ok()?;
// A merged pull's commits may be only where it came from.
if let Some((did, name)) = now_named(app, &head.repo)
&& let Ok(from) = app.located(did, &name).await
{
let objects = from.git.dir.join("objects").to_string_lossy().into_owned();
git.envs
.push(("GIT_ALTERNATE_OBJECT_DIRECTORIES".into(), objects));
}
let mut out = HashMap::new();
for (uri, at, commit) in lined {
out.insert(
uri.to_string(),
line_view(&git, &start, &tip, at, commit).await,
);
}
Some(out)
}
/// A comment's URI, the lines it is on and the commit they are of, if it
/// is on a pull's lines.
fn on_lines(c: &Entry) -> Option<(&str, LineRef, &str)> {
let at = serde_json::from_value::<LineRef>(c.value["line"].clone()).ok()?;
Some((c.uri.as_str(), at, c.value["commit"].as_str()?))
}
/// `at`, lines of the file at `commit` on its side of the pull from
/// `start` to `tip`: outdated once that side of the file has changed.
async fn line_view(git: &Git, start: &str, tip: &str, at: LineRef, commit: &str) -> LineView {
let base = |of: String| async move {
let found = git.text(&["merge-base", &of, start], None).await;
found.unwrap_or(of)
};
let (then, now) = match at.old() {
true => (base(commit.to_string()).await, base(tip.to_string()).await),
false => (commit.to_string(), tip.to_string()),
};
let path = &at.path;
let blob = |rev: String| async move { git.rev_parse(&format!("{rev}:{path}")).await.ok() };
let (was, is) = (blob(then).await, blob(now).await);
let text = match &was {
Some(oid) => git.run(&["cat-file", "blob", oid], None).await.ok(),
None => None,
};
let first = at.start_line.unwrap_or(at.line.saturating_sub(3));
let first = first.max(at.line.saturating_sub(LINES_SHOWN - 1)).max(1);
let text = String::from_utf8_lossy(&text.unwrap_or_default()).into_owned();
let numbered = (1..).zip(text.lines());
let shown = numbered.filter(|(n, _)| (first..=at.line).contains(n));
let lines = shown.map(|(number, text)| LineTextView {
number,
text: text.to_string(),
});
LineView {
outdated: was.is_none() || was != is,
lines: lines.collect(),
path: at.path,
side: at.side,
line: at.line,
start_line: at.start_line,
}
}
/// The pictures a comment's record attaches.
fn comment_pictures(value: &serde_json::Value) -> Vec<ImageAttachment> {
let attachments = serde_json::from_value::<Vec<Attachment>>(value["attachments"].clone());
forge::images(&attachments.unwrap_or_default())
}