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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
593 lines · 19.6 KB Raw History
//! Saying things on a pull or an issue from the pages, as the account
//! logged in to them: `dev.knotless.createComment` (with a verdict, a
//! review), `editItem`, `deleteComment`, `setItemState`, `labelItem`,
//! `assignItem`, `hideItem` and `createReport`. Each goes through the same
//! code as the command line's `comment`, `edit`, `delete-comment`, `close`
//! and `reopen`, `label` and `unlabel`, `assign`, `unassign`, `ask-review`
//! and `unask-review`, `hide` and `unhide`, and `report`, and indexes
//! what it wrote at once, so the page after shows it.

use super::opening::Asked;
use super::repo::{found, invalid};
use super::stars::logged_in;
use super::*;
use crate::viewer::Viewer;
use axum::Extension;
use knotless::env::Ctx;
use knotless::moderation;
use knotless::records::{ImageAttachment, LineRef, Role};
use knotless::remote::Stored;
use serde_json::Value;

/// The writes a pull's or an issue's page makes.
pub fn routes() -> Router<Arc<App>> {
    Router::new()
        .route("/xrpc/dev.knotless.createComment", post(create_comment))
        .route("/xrpc/dev.knotless.editItem", post(edit_item))
        .route("/xrpc/dev.knotless.deleteComment", post(delete_comment))
        .route("/xrpc/dev.knotless.setItemState", post(set_item_state))
        .route("/xrpc/dev.knotless.labelItem", post(label_item))
        .route("/xrpc/dev.knotless.assignItem", post(assign_item))
        .route("/xrpc/dev.knotless.dismissReview", post(dismiss_review))
        .route("/xrpc/dev.knotless.hideItem", post(hide_item))
        .route("/xrpc/dev.knotless.createReport", post(create_report))
        .route(
            "/xrpc/dev.knotless.uploadImage",
            post(upload_image).layer(axum::extract::DefaultBodyLimit::max(PICTURE_LIMIT)),
        )
}

/// The most bytes a picture sent to be uploaded may have, before it is
/// made smaller to fit in a blob: a camera's photo, or a screenshot.
pub(super) const PICTURE_LIMIT: usize = 30_000_000;

/// `dev.knotless.getThread#viewerState`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadViewer {
    set_state: bool,
    hide: bool,
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    revert: bool,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    labels: Vec<LabelView>,
    /// Whether it assigns the item, and asks for reviews of a pull, as
    /// the owner or a collaborator.
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    assign: bool,
    /// Whether it dismisses a pull's reviews, as the owner or a
    /// collaborator.
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    dismiss: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    subscription: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    notified_for: Option<&'static str>,
}

/// What `did` hears of `item`, a pull or an issue on `repo`, into
/// `viewer`: what it chose, as the index keeps it, and why it hears of
/// new comments on it without a choice: as its author, as one that
/// `commented` on it, or as watching the repository.
pub(super) fn heard(
    app: &App,
    repo: &Repo,
    item: &forge::Item,
    did: &str,
    commented: bool,
    viewer: &mut ThreadViewer,
) {
    let index = app.index.as_ref();
    let chose = |subject: &str| index.and_then(|i| i.subscription(did, subject).ok().flatten());
    let watching = index.and_then(|i| i.watching(did, &repo.did, &repo.key).ok().flatten());
    viewer.subscription = chose(&item.uri);
    viewer.notified_for = if did == item.author {
        Some("author")
    } else if commented {
        Some("commented")
    } else if watching.as_deref() == Some("all") {
        Some("watching")
    } else {
        None
    };
}

/// What `did` may do with `item`, a pull or an issue on the repository
/// `record` of `owner`, as the knotless crate decides when it writes: who
/// may close it, hide it, revert it once merged, put which labels on, and
/// assign it.
pub(super) fn viewer(
    record: &RepoRecord,
    owner: &str,
    item: &forge::Item,
    did: &str,
) -> ThreadViewer {
    let maintainer = did == owner || record.is_collaborator(did);
    let labels = record.labels.iter();
    let labels = labels.filter(|l| record.may_label(owner, did, l));
    let merged = item.state == "merged" && item.merged_from.is_some();
    ThreadViewer {
        set_state: maintainer || did == item.author,
        hide: did == owner,
        revert: maintainer && merged,
        labels: labels.map(label_view).collect(),
        assign: maintainer,
        dismiss: maintainer && item.kind == "pull",
        subscription: None,
        notified_for: None,
    }
}

/// The account logged in to the pages, to `act` on the repository the
/// record `uri` names: a context acting as it, the repository, and its URL.
pub(super) async fn acting(
    app: &App,
    viewer: Viewer,
    headers: &HeaderMap,
    act: &str,
    uri: &str,
) -> Result<(Ctx, Repo, RepoUrl)> {
    let did = logged_in(app, viewer, headers, act)?;
    let repo = found(app, uri).await?;
    let url = RepoUrl {
        authority: repo.did.clone(),
        name: repo.name.clone(),
    };
    Ok((crate::login::ctx_for(app, &did), repo, url))
}

/// What the knotless crate would not write, in its words, said for the
/// pages.
pub(super) fn refused(e: anyhow::Error) -> Shown {
    Shown(StatusCode::BAD_REQUEST, crate::shown(&e))
}

/// Indexes the record `uri` as its author's repo has it now, or that it
/// is gone: the firehose brings it too, but after the page that follows
/// the write. A private repository's is read from its author's repo in its
/// space, as the viewer, into this server's index of it, which its sync
/// brings it to only a while after too.
pub(super) async fn reindex(app: &App, repo: &Repo, uri: &str) -> Result<()> {
    let wrote = super::indexed(app, repo).map(|index| reindexed(app, index, repo, uri));
    futures::future::OptionFuture::from(wrote)
        .await
        .transpose()?;
    Ok(())
}

/// [`reindex`], into `index`.
async fn reindexed(app: &App, index: &Index, repo: &Repo, uri: &str) -> Result<()> {
    let (did, collection, rkey) = forge::parse_uri(uri).map_err(refused)?;
    let space = repo.in_space.as_deref();
    let read = async {
        let author = author(app, repo, did).await?;
        author.get_record::<Value>(collection, rkey).await
    };
    let found = read.await.map_err(AppError::upstream)?;
    let wrote = match found {
        Some(stored) => index.put_in(space, did, collection, rkey, &stored.value),
        None => index.delete_in(space, did, collection, rkey),
    };
    wrote
        .map_err(anyhow::Error::from)
        .map_err(AppError::internal)?;
    Ok(())
}

/// `did`'s repo, where they write about `repo`: their own, or for a private
/// repository theirs in its space, read as the viewer.
async fn author(app: &App, repo: &Repo, did: &str) -> anyhow::Result<Remote> {
    let url = RepoUrl {
        authority: did.to_string(),
        name: "_".into(),
    };
    match &repo.in_space {
        Some(space) => super::in_space(&repo.reader, space, did).await,
        None => Remote::connect(&app.ctx, &url).await,
    }
}

/// What a write wrote, changed or deleted.
#[derive(Serialize)]
pub struct Written {
    pub(super) uri: String,
}

#[derive(Deserialize)]
pub struct UploadParams {
    repo: String,
}

/// `dev.knotless.uploadImage`: the picture is the whole of the body.
pub async fn upload_image(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    Query(p): Query<UploadParams>,
    body: axum::body::Bytes,
) -> Result<Response> {
    let (ctx, _, url) = acting(&app, viewer, &headers, "upload a picture", &p.repo).await?;
    let uploaded = forge::upload_image(&ctx, &url, body.to_vec());
    Ok(answer(uploaded.await.map_err(refused)?))
}

/// `images`, as the knotless crate attaches them.
pub(super) fn blobs(images: Option<Vec<ImageAttachment>>) -> Vec<ImageAttachment> {
    images.unwrap_or_default()
}

#[derive(Deserialize)]
pub struct CommentInput {
    repo: String,
    subject: String,
    body: String,
    verdict: Option<String>,
    images: Option<Vec<ImageAttachment>>,
    #[serde(default)]
    lines: Vec<LineInput>,
}

/// `dev.knotless.createComment#lineComment`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineInput {
    path: String,
    side: Option<String>,
    line: u32,
    start_line: Option<u32>,
    body: String,
}

/// `dev.knotless.createComment`.
pub async fn create_comment(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<CommentInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "comment", &input.repo).await?;
    // A review may say nothing past its verdict or lines; a comment says
    // something.
    let reviews = input.verdict.is_some() || !input.lines.is_empty();
    if input.body.trim().is_empty() && !reviews {
        return Err(Shown(
            StatusCode::BAD_REQUEST,
            "a comment says something".into(),
        ));
    }
    let verdict = input.verdict.as_deref();
    let images = blobs(input.images);
    let lines: Vec<forge::LineComment> = input.lines.into_iter().map(line_comment).collect();
    let said = forge::comment(
        &ctx,
        &url,
        &input.subject,
        &input.body,
        verdict,
        &images,
        &lines,
    );
    let written = said.await.map_err(refused)?;
    for uri in &written {
        reindex(&app, &repo, uri).await?;
    }
    let uri = written[0].clone();
    Ok(answer(Written { uri }))
}

/// A line comment as the knotless crate takes it.
fn line_comment(l: LineInput) -> forge::LineComment {
    forge::LineComment {
        line: LineRef {
            path: l.path,
            side: l.side.filter(|s| s != "new"),
            line: l.line,
            start_line: l.start_line,
        },
        body: l.body,
    }
}

#[derive(Deserialize)]
pub struct EditInput {
    repo: String,
    subject: String,
    title: Option<String>,
    body: Option<String>,
    images: Option<Vec<ImageAttachment>>,
    draft: Option<bool>,
    #[serde(rename = "addReviewers", default)]
    add_reviewers: Vec<Asked>,
    #[serde(rename = "removeReviewers", default)]
    remove_reviewers: Vec<Asked>,
}

/// `dev.knotless.editItem`.
pub async fn edit_item(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<EditInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "edit", &input.repo).await?;
    let (title, body) = (input.title.as_deref(), input.body.as_deref());
    let images = blobs(input.images);
    let dids = |asked: Vec<Asked>| asked.into_iter().map(|a| a.did).collect();
    let asks = forge::PullAsks {
        draft: input.draft,
        add: dids(input.add_reviewers),
        remove: dids(input.remove_reviewers),
    };
    let edited = forge::edit(&ctx, &url, &input.subject, title, body, &images, &asks);
    edited.await.map_err(refused)?;
    reindex(&app, &repo, &input.subject).await?;
    Ok(answer(Written { uri: input.subject }))
}

#[derive(Deserialize)]
pub struct SubjectInput {
    repo: String,
    subject: String,
}

/// `dev.knotless.deleteComment`.
pub async fn delete_comment(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<SubjectInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "delete", &input.repo).await?;
    let deleted = forge::delete_comment(&ctx, &url, &input.subject);
    deleted.await.map_err(refused)?;
    reindex(&app, &repo, &input.subject).await?;
    Ok(answer(Written { uri: input.subject }))
}

#[derive(Deserialize)]
pub struct StateInput {
    repo: String,
    subject: String,
    state: String,
}

/// `dev.knotless.setItemState`.
pub async fn set_item_state(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<StateInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "close", &input.repo).await?;
    let set = forge::set_state(&ctx, &url, &input.subject, &input.state);
    let uri = set.await.map_err(refused)?;
    reindex(&app, &repo, &uri).await?;
    Ok(answer(Written { uri }))
}

#[derive(Deserialize)]
pub struct LabelInput {
    repo: String,
    subject: String,
    label: String,
    #[serde(default)]
    removed: bool,
}

/// `dev.knotless.labelItem`'s answer.
#[derive(Serialize)]
pub struct Labelled {
    labels: Vec<LabelView>,
}

/// `dev.knotless.labelItem`.
pub async fn label_item(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<LabelInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "label", &input.repo).await?;
    let names = [input.label.as_str()];
    let (subject, removed) = (&input.subject, input.removed);
    let labelling = labelled(&app, &ctx, &repo, &url, subject, &names, removed);
    let now = labelling.await?;
    let record = repo.record(&app).unwrap_or_default();
    let labels = now.iter().filter_map(|name| record.label(name));
    Ok(answer(Labelled {
        labels: labels.map(label_view).collect(),
    }))
}

/// Puts `names` on `subject`, or with `removed` takes them off, as the
/// account `ctx` acts as, and indexes the record that says so: the labels
/// it carries now.
pub(super) async fn labelled(
    app: &App,
    ctx: &Ctx,
    repo: &Repo,
    url: &RepoUrl,
    subject: &str,
    names: &[&str],
    removed: bool,
) -> Result<Vec<String>> {
    let now = forge::label(ctx, url, subject, names, removed);
    let now = now.await.map_err(refused)?;
    // Each labeller keeps one record about it, at a key of its own.
    let did = ctx.env.identifier.clone().unwrap_or_default();
    let rkey = knotless::keys::label_rkey(&repo.key, subject);
    reindex(app, repo, &format!("at://{did}/{}/{rkey}", nsid::LABEL)).await?;
    Ok(now)
}

#[derive(Deserialize)]
pub struct AssignInput {
    repo: String,
    subject: String,
    did: String,
    /// `assignee`, or `reviewer` to ask for a review of a pull.
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    removed: bool,
}

/// `dev.knotless.assignItem`'s answer.
#[derive(Serialize)]
pub struct Assigned {
    assignees: Vec<ActorView>,
    reviewers: Vec<ActorView>,
}

/// `dev.knotless.assignItem`.
pub async fn assign_item(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<AssignInput>,
) -> Result<Response> {
    let role = match input.role.as_deref() {
        None | Some("assignee") => Role::Assignee,
        Some("reviewer") => Role::Reviewer,
        Some(other) => return Err(invalid(format!("{other} is no role to give"))),
    };
    let (ctx, repo, url) = acting(&app, viewer, &headers, "assign", &input.repo).await?;
    let (subject, names) = (&input.subject, [input.did.as_str()]);
    let assigning = forge::assign(&ctx, &url, subject, role, &names, input.removed);
    let item = assigning.await.map_err(refused)?;
    // What it wrote: its own assignment record, or as a pull's author,
    // the pull.
    let did = ctx.env.identifier.clone().unwrap_or_default();
    let rkey = knotless::keys::assignment_rkey(&repo.key, subject);
    let record = format!("at://{did}/{}/{rkey}", nsid::ASSIGNMENT);
    reindex(&app, &repo, &record).await?;
    if forge::parse_uri(subject).is_ok_and(|(author, _, _)| author == did) {
        reindex(&app, &repo, subject).await?;
    }
    let assignees = item.assignees.iter().map(|d| actor(&app, d));
    let reviewers = item.reviewers.iter().map(|d| actor(&app, d));
    Ok(answer(Assigned {
        assignees: futures::future::join_all(assignees).await,
        reviewers: futures::future::join_all(reviewers).await,
    }))
}

#[derive(Deserialize)]
pub struct DismissInput {
    repo: String,
    pull: String,
    reviewer: String,
    reason: String,
}

/// `dev.knotless.dismissReview`.
pub async fn dismiss_review(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<DismissInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "dismiss", &input.repo).await?;
    let (pull, reviewer) = (&input.pull, &input.reviewer);
    let dismissing = forge::dismiss(&ctx, &url, pull, reviewer, &input.reason);
    let uri = dismissing.await.map_err(refused)?;
    reindex(&app, &repo, &uri).await?;
    Ok(answer(Written { uri }))
}

#[derive(Deserialize)]
pub struct HideInput {
    repo: String,
    subject: String,
    #[serde(default = "yes")]
    hidden: bool,
}

fn yes() -> bool {
    true
}

/// `dev.knotless.hideItem`'s answer.
#[derive(Serialize)]
pub struct Hidden {
    hidden: bool,
}

/// `dev.knotless.hideItem`.
pub async fn hide_item(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<HideInput>,
) -> Result<Response> {
    let (ctx, repo, url) = acting(&app, viewer, &headers, "hide", &input.repo).await?;
    let before = hides(&app, &repo, &input.subject).await?;
    let hid = forge::hide(&ctx, &url, &input.subject, input.hidden);
    hid.await.map_err(refused)?;
    let after = hides(&app, &repo, &input.subject).await?;
    // Kept at keys of their own, each the time it was written: those
    // there were go, and those there are now come in, to the index the
    // page reads next, the space's own for a private repository.
    if let Some(index) = super::indexed(&app, &repo) {
        let space = repo.in_space.as_deref();
        let gone = before.iter();
        let gone = gone.map(|h| index.delete_in(space, &repo.did, nsid::HIDE, &h.rkey));
        let now = after.iter();
        let now = now.map(|h| index.put_in(space, &repo.did, nsid::HIDE, &h.rkey, &h.value));
        let wrote: rusqlite::Result<()> = gone.chain(now).collect();
        wrote
            .map_err(anyhow::Error::from)
            .map_err(AppError::internal)?;
    }
    Ok(answer(Hidden {
        hidden: input.hidden,
    }))
}

/// The owner's hides of `subject`, as their repo has them now, where an
/// index keeps them: for a private repository, their repo in its space,
/// read as the viewer.
async fn hides(app: &App, repo: &Repo, subject: &str) -> Result<Vec<Stored<Value>>> {
    let mut found = Vec::new();
    if super::indexed(app, repo).is_some() {
        let read = async {
            let owner = author(app, repo, &repo.did).await?;
            owner.list_all::<Value>(nsid::HIDE).await
        };
        found = read.await.map_err(AppError::upstream)?;
        let subject = repo.named(subject);
        found.retain(|h| h.value["subject"] == subject.as_str());
    }
    Ok(found)
}

#[derive(Deserialize)]
pub struct ReportInput {
    subject: String,
    reason: String,
    text: Option<String>,
}

/// `dev.knotless.createReport`'s answer.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Reported {
    report_id: i64,
}

/// `dev.knotless.createReport`.
pub async fn create_report(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<ReportInput>,
) -> Result<Response> {
    let did = logged_in(&app, viewer, &headers, "report")?;
    let ctx = crate::login::ctx_for(&app, &did);
    let text = input.text.as_deref().filter(|t| !t.trim().is_empty());
    let made = moderation::report(&ctx, &input.subject, &input.reason, text);
    let made = made.await.map_err(refused)?;
    Ok(answer(Reported {
        report_id: made["id"].as_i64().unwrap_or_default(),
    }))
}