//! 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, q: Option, limit: Option, cursor: Option, } /// `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, open_count: usize, closed_count: usize, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, /// The labels the repository defines. #[serde(skip_serializing_if = "Vec::is_empty")] labels: Vec, /// Who opened its pulls or issues, the most recent first. #[serde(skip_serializing_if = "Vec::is_empty")] authors: Vec, } /// `dev.knotless.listItems`. pub async fn list_items( State(app): State>, Extension(viewer): Extension, Query(p): Query, ) -> Result { 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::) { 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 = 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 = 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, #[serde(skip_serializing_if = "Option::is_none")] into: Option, } /// `dev.knotless.defs#stackPullView`. #[derive(Serialize)] struct StackPullView { item: ItemView, #[serde(skip_serializing_if = "Option::is_none")] branch: Option, #[serde(skip_serializing_if = "Option::is_none")] merged: Option, } /// 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 { 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, #[serde(skip_serializing_if = "Option::is_none")] html: Option, /// A pull's target branch, and the branch it brings in. #[serde(skip_serializing_if = "Option::is_none")] base: Option, #[serde(skip_serializing_if = "Option::is_none")] head: Option, /// The merge, for a merged pull. #[serde(skip_serializing_if = "Option::is_none")] merged: Option, comments: Vec, /// For a pull, where each reviewer stands, in the order they last /// reviewed. #[serde(skip_serializing_if = "Vec::is_empty")] reviews: Vec, /// The issues on the repository a pull's title or body names to close /// as it merges into the default branch. closes: Vec, /// The pull whose merge closed an issue. #[serde(skip_serializing_if = "Option::is_none")] closed_by: Option, /// For an issue, the pulls on the repository that name it. #[serde(skip_serializing_if = "Vec::is_empty")] mentioned_by: Vec, /// The revert whose merge undid a merged pull. #[serde(skip_serializing_if = "Option::is_none")] reverted_by: Option, /// The chain of pulls a pull is part of, if any. #[serde(skip_serializing_if = "Option::is_none")] stack: Option, /// 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, /// What the runners of the repository it comes from report of its /// branch, for a pull. checks: Vec, /// For a pull, the minutes the target's runners count for its owner. #[serde(skip_serializing_if = "Vec::is_empty")] minutes: Vec, /// 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, /// 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, /// What the account asking may do with it. #[serde(skip_serializing_if = "Option::is_none")] viewer: Option, } /// 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, } /// `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>, Query(pairs): Query>, ) -> Result { 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>, Extension(viewer): Extension, Query(p): Query, ) -> Result { 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 = 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 = 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| { 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 { 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> { 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::(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 { let attachments = serde_json::from_value::>(value["attachments"].clone()); forge::images(&attachments.unwrap_or_default()) }