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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
412 lines · 12.4 KB Raw History
//! What a repository's settings change besides its name and place: its
//! branches (`dev.knotless.createBranch`, `deleteBranch`, `renameBranch`),
//! which branches are protected (`protectBranch`, `unprotectBranch`), its
//! labels (`defineLabel`, `removeLabel`), who may push to it or label it
//! (`shareRepo`, `unshareRepo`, `setTriager`, `removeTriager`), and whose
//! pulls its runners check as code no one vouches for (`addAgent`,
//! `removeAgent`), each through the same code as the command it is named
//! for.
//!
//! Branches are made and deleted as `knotless branch` does them, by the
//! owner directly and by a collaborator through the gateway, which refuses
//! anyone else; the rest is the owner's alone, checked here.

use super::repos::{refused, rewrote, wrote};
use super::settings::owned;
use super::stars::logged_in;
use super::*;
use crate::viewer::Viewer;
use axum::Extension;
use knotless::manage;
use knotless::records::{Label, Reviews, Rule};

/// The repository `repo` names, as a URL by its owner's DID.
fn url_of(repo: &Repo) -> RepoUrl {
    RepoUrl {
        authority: repo.did.clone(),
        name: repo.name.clone(),
    }
}

#[derive(Deserialize)]
pub struct BranchInput {
    repo: String,
    name: String,
    from: Option<String>,
    to: Option<String>,
}

/// `dev.knotless.createBranch`'s, `deleteBranch`'s and `renameBranch`'s
/// answer.
#[derive(Serialize)]
pub struct Branched {
    name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    sha: Option<String>,
}

/// The repository `uri` names, for a branch to be changed in by the
/// account logged in, with a context acting as them.
async fn pushing(
    app: &App,
    viewer: Viewer,
    headers: &HeaderMap,
    uri: &str,
) -> Result<(Repo, knotless::env::Ctx)> {
    let did = logged_in(app, viewer, headers, "change its branches")?;
    let repo = repo::found(app, uri).await?;
    Ok((repo, crate::login::ctx_for(app, &did)))
}

/// `dev.knotless.createBranch`.
pub async fn create_branch(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<BranchInput>,
) -> Result<Response> {
    let (repo, ctx) = pushing(&app, viewer, &headers, &input.repo).await?;
    let from = input.from.as_deref().filter(|f| !f.is_empty());
    let made = forge::branch(&ctx, &url_of(&repo), &input.name, from, false).await;
    let sha = made.map_err(refused)?;
    wrote(&app, &repo.did, &[]).await?;
    Ok(answer(Branched {
        name: input.name,
        sha,
    }))
}

/// `dev.knotless.deleteBranch`.
pub async fn delete_branch(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<BranchInput>,
) -> Result<Response> {
    let (repo, ctx) = pushing(&app, viewer, &headers, &input.repo).await?;
    let deleted = forge::branch(&ctx, &url_of(&repo), &input.name, None, true).await;
    deleted.map_err(refused)?;
    wrote(&app, &repo.did, &[]).await?;
    Ok(answer(Branched {
        name: input.name,
        sha: None,
    }))
}

/// `dev.knotless.renameBranch`.
pub async fn rename_branch(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<BranchInput>,
) -> Result<Response> {
    let (repo, ctx) = pushing(&app, viewer, &headers, &input.repo).await?;
    let to = input.to.unwrap_or_default();
    let renamed = forge::rename_branch(&ctx, &url_of(&repo), &input.name, &to).await;
    renamed.map_err(refused)?;
    wrote(&app, &repo.did, &[]).await?;
    Ok(answer(Branched {
        name: to,
        sha: None,
    }))
}

#[derive(Deserialize)]
pub struct ProtectInput {
    repo: String,
    branch: String,
    #[serde(default)]
    merges: bool,
    #[serde(default)]
    queue: bool,
    #[serde(default)]
    reviews: Reviews,
}

/// `dev.knotless.protectBranch`'s and `unprotectBranch`'s answer.
#[derive(Serialize)]
pub struct Rules {
    rules: Vec<Rule>,
}

/// `dev.knotless.protectBranch`.
pub async fn protect_branch(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<ProtectInput>,
) -> Result<Response> {
    protecting(&app, viewer, &headers, input, true).await
}

/// `dev.knotless.unprotectBranch`.
pub async fn unprotect_branch(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<ProtectInput>,
) -> Result<Response> {
    protecting(&app, viewer, &headers, input, false).await
}

/// Protects `input.branch`, or with `on` false no longer, as `knotless
/// protect` and `unprotect` do.
async fn protecting(
    app: &Arc<App>,
    viewer: Viewer,
    headers: &HeaderMap,
    input: ProtectInput,
    on: bool,
) -> Result<Response> {
    let act = "protect its branches";
    let (repo, ctx) = owned(app, viewer, headers, &input.repo, act).await?;
    let (merges, queue) = (input.merges, input.queue);
    let rule = on.then(|| Rule::new(&input.branch, merges, queue).reviewed(input.reviews));
    let protected = manage::protect(&ctx, &url_of(&repo), &input.branch, rule).await;
    let rules = protected.map_err(refused)?;
    rewrote(app, &repo).await?;
    Ok(answer(Rules { rules }))
}

#[derive(Deserialize)]
pub struct LabelInput {
    repo: String,
    name: String,
    color: Option<String>,
    description: Option<String>,
    #[serde(default)]
    exclusive: bool,
}

/// `dev.knotless.defineLabel`'s and `removeLabel`'s answer.
#[derive(Serialize)]
pub struct Labels {
    labels: Vec<Label>,
}

/// `dev.knotless.defineLabel`.
pub async fn define_label(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<LabelInput>,
) -> Result<Response> {
    let given = |s: Option<String>| s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
    let name = input.name.trim();
    let label = Label {
        color: given(input.color),
        description: given(input.description),
        exclusive: input.exclusive,
        ..Label::new(name)
    };
    labelling(&app, viewer, &headers, &input.repo, name, Some(label)).await
}

#[derive(Deserialize)]
pub struct NamedInput {
    repo: String,
    name: String,
}

/// `dev.knotless.removeLabel`.
pub async fn remove_label(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<NamedInput>,
) -> Result<Response> {
    labelling(&app, viewer, &headers, &input.repo, &input.name, None).await
}

/// Defines `label` as `name`, or with none takes the label `name` away, as
/// `knotless label define` and `remove` do.
async fn labelling(
    app: &Arc<App>,
    viewer: Viewer,
    headers: &HeaderMap,
    uri: &str,
    name: &str,
    label: Option<Label>,
) -> Result<Response> {
    let (repo, ctx) = owned(app, viewer, headers, uri, "define its labels").await?;
    let defined = manage::define_label(&ctx, &url_of(&repo), name, label).await;
    let labels = defined.map_err(refused)?;
    rewrote(app, &repo).await?;
    Ok(answer(Labels { labels }))
}

#[derive(Deserialize)]
pub struct ShareInput {
    repo: String,
    with: String,
    /// Whether to let them push, or, for a private repository, only read
    /// it: pushing unless said.
    write: Option<bool>,
}

/// `dev.knotless.shareRepo`'s and `unshareRepo`'s answer.
#[derive(Serialize)]
pub struct Collaborators {
    collaborators: Vec<ActorView>,
}

/// `dev.knotless.shareRepo`.
pub async fn share_repo(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<ShareInput>,
) -> Result<Response> {
    sharing(&app, viewer, &headers, input, true).await
}

/// `dev.knotless.unshareRepo`.
pub async fn unshare_repo(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<ShareInput>,
) -> Result<Response> {
    sharing(&app, viewer, &headers, input, false).await
}

/// Lets `input.with` push to the repository (or with `input.write` false
/// read a private one), or with `on` false no longer, as `knotless repo share`
/// and `unshare` do.
async fn sharing(
    app: &Arc<App>,
    viewer: Viewer,
    headers: &HeaderMap,
    input: ShareInput,
    on: bool,
) -> Result<Response> {
    let (repo, ctx) = owned(app, viewer, headers, &input.repo, "share it").await?;
    let url = url_of(&repo);
    let who = input.with.trim();
    let shared = match on {
        true => manage::share(&ctx, &url, who, input.write.unwrap_or(true)).await,
        false => manage::unshare(&ctx, &url, who).await,
    };
    shared.map_err(refused)?;
    rewrote(app, &repo).await?;
    let members = manage::members(&ctx, &url).await.map_err(refused)?;
    let writers = members.iter().filter(|m| m.access == "write");
    let collaborators = writers.map(|m| actor(app, &m.did));
    let collaborators = futures::future::join_all(collaborators).await;
    Ok(answer(Collaborators { collaborators }))
}

#[derive(Deserialize)]
pub struct TriagerInput {
    repo: String,
    who: String,
    #[serde(default)]
    scopes: Vec<ScopeInput>,
}

#[derive(Deserialize)]
pub struct ScopeInput {
    name: String,
}

/// `dev.knotless.setTriager`'s and `removeTriager`'s answer.
#[derive(Serialize)]
pub struct Triagers {
    triagers: Vec<super::settings::TriagerView>,
    /// Why a private repository's new triager was not told of it.
    #[serde(skip_serializing_if = "Option::is_none")]
    warning: Option<String>,
}

/// `dev.knotless.setTriager`.
pub async fn set_triager(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<TriagerInput>,
) -> Result<Response> {
    triaging(&app, viewer, &headers, input, true).await
}

/// `dev.knotless.removeTriager`.
pub async fn remove_triager(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<TriagerInput>,
) -> Result<Response> {
    triaging(&app, viewer, &headers, input, false).await
}

/// Names `input.who` a triager of the scopes given, or with `on` false no
/// longer, as `knotless triager add` and `remove` do.
async fn triaging(
    app: &Arc<App>,
    viewer: Viewer,
    headers: &HeaderMap,
    input: TriagerInput,
    on: bool,
) -> Result<Response> {
    let (repo, ctx) = owned(app, viewer, headers, &input.repo, "name its triagers").await?;
    let names: Vec<&str> = input.scopes.iter().map(|s| s.name.trim()).collect();
    let scopes = on.then_some(names.as_slice());
    let set = manage::set_triager(&ctx, &url_of(&repo), input.who.trim(), scopes).await;
    let (set, warning) = set.map_err(refused)?;
    rewrote(app, &repo).await?;
    let mut triagers = Vec::new();
    for t in set {
        triagers.push(super::settings::TriagerView::new(app, t).await);
    }
    Ok(answer(Triagers { triagers, warning }))
}

#[derive(Deserialize)]
pub struct AgentInput {
    repo: String,
    agent: String,
}

/// `dev.knotless.addAgent`'s and `removeAgent`'s answer.
#[derive(Serialize)]
pub struct Agents {
    agents: Vec<ActorView>,
}

/// `dev.knotless.addAgent`.
pub async fn add_agent(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<AgentInput>,
) -> Result<Response> {
    naming_agent(&app, viewer, &headers, input, true).await
}

/// `dev.knotless.removeAgent`.
pub async fn remove_agent(
    State(app): State<Arc<App>>,
    Extension(viewer): Extension<Viewer>,
    headers: HeaderMap,
    axum::Json(input): axum::Json<AgentInput>,
) -> Result<Response> {
    naming_agent(&app, viewer, &headers, input, false).await
}

/// Names `input.agent` among the repository's agents, or with `add` false
/// no longer, as `knotless agent add` and `remove` do.
async fn naming_agent(
    app: &Arc<App>,
    viewer: Viewer,
    headers: &HeaderMap,
    input: AgentInput,
    add: bool,
) -> Result<Response> {
    let (repo, ctx) = owned(app, viewer, headers, &input.repo, "name its agents").await?;
    let url = url_of(&repo);
    let named = manage::set_agent(&ctx, &url, input.agent.trim(), add).await;
    named.map_err(refused)?;
    rewrote(app, &repo).await?;
    let dids = manage::agents(&ctx, &url).await.map_err(refused)?;
    let agents = futures::future::join_all(dids.iter().map(|d| actor(app, d))).await;
    Ok(answer(Agents { agents }))
}