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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
247 lines · 7.7 KB Raw History
//! 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 }))
}