//! Opening pulls and issues from the pages: `dev.knotless.getTemplate`, //! what a repository asks them to follow; `getCompare`, what a pull from a //! branch would bring in; and `createIssue` and `createPull`, as the //! account logged in, through the same code as `knotless pull template`, //! `issue` and `pull`, with the labels and reviewers chosen beside them. use super::repo::{found, invalid}; use super::threads::{Written, acting, blobs, labelled, refused, reindex}; use super::*; use crate::viewer::Viewer; use axum::Extension; use knotless::env::Ctx; use knotless::records::ImageAttachment; /// The queries and writes the pages open pulls and issues with. pub fn routes() -> Router> { Router::new() .route("/xrpc/dev.knotless.getTemplate", get(get_template)) .route("/xrpc/dev.knotless.getCompare", get(get_compare)) .route("/xrpc/dev.knotless.createIssue", post(create_issue)) .route("/xrpc/dev.knotless.createPull", post(create_pull)) } /// The repository's URL, as the knotless crate opens it. fn url_of(repo: &Repo) -> RepoUrl { RepoUrl { authority: repo.did.clone(), name: repo.name.clone(), } } #[derive(Deserialize)] pub struct TemplateParams { repo: String, kind: String, base: Option, } /// `dev.knotless.getTemplate`'s answer. #[derive(Serialize)] pub struct Template { #[serde(skip_serializing_if = "Option::is_none")] template: Option, } /// `dev.knotless.getTemplate`. pub async fn get_template( State(app): State>, Query(p): Query, ) -> Result { if p.kind != "pull" && p.kind != "issue" { let kind = &p.kind; return Err(invalid(format!( "a template is for a pull or an issue, not {kind}" ))); } let repo = found(&app, &p.repo).await?; // Read from the mirror, as a file is: a branch that is not there, or a // repository with no commits yet, has no template. let rev = p .base .as_ref() .map_or(repo.head.clone(), |b| format!("refs/heads/{b}")); let path = format!(".knotless/{}.md", p.kind); let bytes = repo.objects()?.blob(&rev, &path).ok(); let template = bytes.map(|b| String::from_utf8_lossy(&b).into_owned()); Ok(answer(Template { template })) } #[derive(Deserialize)] pub struct CompareParams { repo: String, base: Option, head: Option, branch: Option, } /// `dev.knotless.getCompare`'s answer. #[derive(Serialize)] pub struct Compared { heads: Vec, #[serde(skip_serializing_if = "Option::is_none")] changes: Option, } /// `dev.knotless.getCompare`. pub async fn get_compare( State(app): State>, Query(p): Query, ) -> Result { let repo = found(&app, &p.repo).await?; let forks = app.index.as_ref().map(|i| i.forks(&repo.did, &repo.key)); let forks = forks .transpose() .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let app = &app; let forks = forks.unwrap_or_default().into_iter().map(|f| async move { RepoViewBasic { handle: handle(app, &f.did).await, did: f.did, name: f.name, } }); let mut heads = vec![repo.basic(app).await]; heads.extend(futures::future::join_all(forks).await); let changes = match p.branch.as_deref() { Some(branch) => { let base = p.base.as_deref().unwrap_or(&repo.head); let from = p.head.as_deref().unwrap_or(&p.repo); Some(compared(app, &repo, base, from, branch).await?) } None => None, }; Ok(answer(Compared { heads, changes })) } /// What `branch` of the repository `from` names would bring into `base` /// of `repo`, as a pull from it shows. async fn compared( app: &App, repo: &Repo, base: &str, from: &str, branch: &str, ) -> Result { let from = found(app, from).await?; let head = PullHead { repo: from.reference(), branch: branch.to_string(), }; pull_changes(app, repo, &Item::default(), base, &head, Rows::All).await } /// A label chosen to go on what is opened, by name. #[derive(Deserialize)] pub struct Named { name: String, } /// An account asked to review a pull, by DID. #[derive(Deserialize)] pub struct Asked { pub did: String, } #[derive(Deserialize)] pub struct IssueInput { repo: String, title: String, body: Option, images: Option>, #[serde(default)] labels: Vec, } /// Refuses, before anything is written, a label of `names` that `repo` /// does not define or that the account `ctx` acts as may not put on. fn may_put(app: &App, repo: &Repo, ctx: &Ctx, names: &[&str]) -> Result<()> { let did = ctx.env.identifier.as_deref().unwrap_or_default(); let record = repo.record(app).unwrap_or_default(); let may = |name: &&str| { let defined = record.label(name); defined.is_some_and(|l| record.may_label(&repo.did, did, l)) }; let why = |name: &&str| format!("{name}: not a label you may put on it"); match names.iter().find(|name| !may(name)) { Some(name) => Err(Shown(StatusCode::BAD_REQUEST, why(name))), None => Ok(()), } } /// Puts `names` on what was just opened at `uri`, if any were chosen. async fn label_opened( app: &App, ctx: &Ctx, repo: &Repo, url: &RepoUrl, uri: &str, names: &[&str], ) -> Result<()> { if !names.is_empty() { labelled(app, ctx, repo, url, uri, names, false).await?; } Ok(()) } /// `dev.knotless.createIssue`. pub async fn create_issue( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, repo, url) = acting(&app, viewer, &headers, "open an issue", &input.repo).await?; let names: Vec<&str> = input.labels.iter().map(|l| l.name.as_str()).collect(); may_put(&app, &repo, &ctx, &names)?; let images = blobs(input.images); let opened = forge::open_issue(&ctx, &url, &input.title, input.body.as_deref(), &images); let uri = opened.await.map_err(refused)?; reindex(&app, &repo, &uri).await?; label_opened(&app, &ctx, &repo, &url, &uri, &names).await?; Ok(answer(Written { uri })) } #[derive(Deserialize)] pub struct PullInput { repo: String, base: String, head: String, branch: String, title: String, body: Option, images: Option>, #[serde(default)] draft: bool, #[serde(default)] labels: Vec, #[serde(default)] reviewers: Vec, } /// `dev.knotless.createPull`. pub async fn create_pull( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (ctx, repo, url) = acting(&app, viewer, &headers, "open a pull", &input.repo).await?; let names: Vec<&str> = input.labels.iter().map(|l| l.name.as_str()).collect(); may_put(&app, &repo, &ctx, &names)?; let from = url_of(&found(&app, &input.head).await?); let (title, body) = (&input.title, input.body.as_deref()); let images = blobs(input.images); let (base, branch) = (&input.base, &input.branch); let asks = forge::PullAsks { draft: Some(input.draft), add: input.reviewers.iter().map(|r| r.did.clone()).collect(), ..Default::default() }; let opened = forge::open_pull(&ctx, &url, base, &from, branch, title, body, &images, &asks); let uri = opened.await.map_err(refused)?; reindex(&app, &repo, &uri).await?; label_opened(&app, &ctx, &repo, &url, &uri, &names).await?; Ok(answer(Written { uri })) }