~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
614 lines · 23.5 KB Raw History
//! 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())
}