//! 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> { 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, /// 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, #[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::(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 { 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>, Extension(viewer): Extension, headers: HeaderMap, Query(p): Query, body: axum::body::Bytes, ) -> Result { 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 { images.unwrap_or_default() } #[derive(Deserialize)] pub struct CommentInput { repo: String, subject: String, body: String, verdict: Option, images: Option>, #[serde(default)] lines: Vec, } /// `dev.knotless.createComment#lineComment`. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct LineInput { path: String, side: Option, line: u32, start_line: Option, body: String, } /// `dev.knotless.createComment`. pub async fn create_comment( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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 = 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, body: Option, images: Option>, draft: Option, #[serde(rename = "addReviewers", default)] add_reviewers: Vec, #[serde(rename = "removeReviewers", default)] remove_reviewers: Vec, } /// `dev.knotless.editItem`. pub async fn edit_item( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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.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>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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, } /// `dev.knotless.labelItem`. pub async fn label_item( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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> { 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, #[serde(default)] removed: bool, } /// `dev.knotless.assignItem`'s answer. #[derive(Serialize)] pub struct Assigned { assignees: Vec, reviewers: Vec, } /// `dev.knotless.assignItem`. pub async fn assign_item( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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>> { 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::(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, } /// `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>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { 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(), })) }