johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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(),
}))
}