johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<Arc<App>> {
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<String>,
}
/// `dev.knotless.getTemplate`'s answer.
#[derive(Serialize)]
pub struct Template {
#[serde(skip_serializing_if = "Option::is_none")]
template: Option<String>,
}
/// `dev.knotless.getTemplate`.
pub async fn get_template(
State(app): State<Arc<App>>,
Query(p): Query<TemplateParams>,
) -> Result<Response> {
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<String>,
head: Option<String>,
branch: Option<String>,
}
/// `dev.knotless.getCompare`'s answer.
#[derive(Serialize)]
pub struct Compared {
heads: Vec<RepoViewBasic>,
#[serde(skip_serializing_if = "Option::is_none")]
changes: Option<ChangesView>,
}
/// `dev.knotless.getCompare`.
pub async fn get_compare(
State(app): State<Arc<App>>,
Query(p): Query<CompareParams>,
) -> Result<Response> {
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<ChangesView> {
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<String>,
images: Option<Vec<ImageAttachment>>,
#[serde(default)]
labels: Vec<Named>,
}
/// 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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<IssueInput>,
) -> Result<Response> {
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<String>,
images: Option<Vec<ImageAttachment>>,
#[serde(default)]
draft: bool,
#[serde(default)]
labels: Vec<Named>,
#[serde(default)]
reviewers: Vec<Asked>,
}
/// `dev.knotless.createPull`.
pub async fn create_pull(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<PullInput>,
) -> Result<Response> {
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 }))
}