//! The JSON API (hub spec §12.1) under `/api/v1`: bearer tokens, RFC 7807 //! problems, the same shapes as `oxy --json` where one exists. Reads need //! `repo:read` (anonymous for public repositories); writes the scope named //! on each handler. use crate::auth::{self, Access, Role}; use crate::db::{self, accounts::Account}; use crate::repos::{view, RepoError, RepoName}; use crate::Shared; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use hub_api::*; use serde::Deserialize; mod projects; /// Any other path under `/api/v1`: a problem document, not the site's page. async fn no_route() -> ApiError { ApiError(StatusCode::NOT_FOUND, "no such API route".into()) } pub fn router() -> Router { Router::new() .fallback(no_route) .route("/flow/claim", post(crate::flow::claim)) .route("/flow/jobs/{id}/log", post(crate::flow::log)) .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) .route("/projects/{owner}", get(projects::list).post(projects::create)) .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) .route("/projects/{owner}/{number}/cards", post(projects::add_card)) .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) .route("/user", get(me)) .route("/notifications", get(notifications)) .route("/notifications/read", post(notifications_read_all)) .route("/notifications/{id}/read", post(notification_read)) .route("/users/{name}", get(user)) .route("/orgs/{name}", get(org)) .route("/search", get(search)) .route("/repos", get(list_repos).post(create_repo)) .route("/repos/{owner}/{name}", get(get_repo)) .route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) .route("/repos/{owner}/{name}/tags", get(tags)) .route("/repos/{owner}/{name}/history/{r}", get(history)) .route("/repos/{owner}/{name}/changes/{hash}", get(change)) .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree)) .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file)) .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame)) .route("/repos/{owner}/{name}/conflicts", get(conflicts)) .route("/repos/{owner}/{name}/trust", get(trust)) .route("/repos/{owner}/{name}/pushes", get(pushes)) .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal)) .route("/repos/{owner}/{name}/proposals/{number}", get(proposal)) .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks)) .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files)) .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content)) .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue)) .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state).patch(issue_edit)) .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment)) .route("/repos/{owner}/{name}/labels", get(labels).post(create_label)) .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label)) .route("/repos/{owner}/{name}/milestones", get(milestones)) .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) .route("/repos/{owner}/{name}/checks", post(post_check)) .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run)) .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run)) .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log)) .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset)) } async fn releases( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?)) } async fn create_release( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease) .await .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; let r = crate::web::releases::api_views(&app, &repo, &row) .await? .into_iter() .find(|r| r.tag == body.tag) .ok_or_else(|| not_found("no such release"))?; Ok((StatusCode::CREATED, Json(r))) } #[derive(Deserialize)] struct AssetQuery { name: String, } /// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body. async fn upload_asset( State(app): State, headers: HeaderMap, Path((owner, name, tag)): Path<(String, String, String)>, Query(q): Query, body: axum::body::Bytes, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; c.require("repo:write")?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string(); crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; let r = crate::web::releases::api_views(&app, &repo, &row) .await? .into_iter() .find(|r| r.tag == tag) .ok_or_else(|| not_found("no such release"))?; Ok((StatusCode::CREATED, Json(r))) } pub struct ApiError(pub StatusCode, pub String); impl IntoResponse for ApiError { fn into_response(self) -> Response { let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 }; (self.0, [("content-type", "application/problem+json")], Json(body)).into_response() } } impl From for ApiError { fn from(e: RepoError) -> ApiError { match e { RepoError::NotFound => not_found("no such repository"), RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m), other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), } } } impl From for ApiError { fn from(e: sqlx::Error) -> ApiError { ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } } pub(crate) fn not_found(what: &str) -> ApiError { ApiError(StatusCode::NOT_FOUND, what.into()) } pub(crate) fn forbidden(what: &str) -> ApiError { ApiError(StatusCode::FORBIDDEN, what.into()) } /// Who calls: the account behind a bearer token, with the token's scopes. pub struct Caller { pub account: Option, pub scopes: Vec, pub site: bool, } impl Caller { fn has(&self, scope: &str) -> bool { self.site || self.scopes.iter().any(|s| { s == scope || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin")) || (scope == "repo:write" && s == "repo:admin") }) } fn require(&self, scope: &str) -> Result<&Account, ApiError> { if !self.has(scope) { return Err(forbidden(&format!("this token lacks the {scope} scope"))); } self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into())) } } pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result { let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) }; if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) { let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin); return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true }); } let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?; match token { Some(tok) if !tok.expired(db::now_ms()) => { let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned); db::accounts::touch_token(&app.db, tok.id).await.ok(); Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false }) } _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), } } fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository { Repository { owner: r.owner.clone(), name: r.name.clone(), description: r.description.clone(), default_bookmark: r.default_bookmark.clone(), visibility: r.visibility(), created_ms: r.created_ms, updated_ms: r.updated_ms, clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name), } } /// A repository the caller may reach with `access`, else 404. async fn reach( app: &Shared, c: &Caller, owner: &str, name: &str, access: Access, ) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> { let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?; let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?; let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; if !auth::roles::allows(role, access) { return Err(not_found("no such repository")); } Ok((repo, row, role.unwrap_or(Role::Read))) } fn summary(c: &view::ChangeView) -> ChangeSummary { ChangeSummary { hash: c.hash.clone(), message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) }, author: Author { name: c.author.name.clone(), email: c.author.email.clone(), key: c.author.key.clone(), account: c.author.account.clone(), note: Author::note_for(&c.author.key, &c.author.account), }, when_ms: c.when_ms, deps: c.deps.clone(), head: c.head, signature: match &c.signature { view::SigView::Unsigned => Signature::Unsigned, view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }, view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() }, }, } } // ---------------------------------------------------------------- accounts async fn me(State(app): State, headers: HeaderMap) -> Result, ApiError> { let c = caller(&app, &headers).await?; let a = c.require("user:read")?; Ok(Json(user_of(&app, a).await?)) } #[derive(Deserialize)] struct InboxQuery { #[serde(default)] limit: Option, } /// The caller's inbox; reading and clearing it needs `user:read`, since /// it touches nothing but the caller's own notifications. async fn notifications( State(app): State, headers: HeaderMap, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let a = c.require("user:read")?; let limit = q.limit.unwrap_or(50).clamp(1, 200); let base = app.config.base_url().trim_end_matches('/').to_string(); let items = db::notify::inbox(&app.db, a.id, limit) .await? .into_iter() .map(|n| hub_api::Notification { id: n.id, at_ms: n.at_ms, kind: n.kind, title: n.title, url: if n.url.starts_with('/') { format!("{base}{}", n.url) } else { n.url }, actor: n.actor, read: n.read, }) .collect(); Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items })) } async fn notification_read(State(app): State, headers: HeaderMap, Path(id): Path) -> Result { let c = caller(&app, &headers).await?; let a = c.require("user:read")?; db::notify::mark_read(&app.db, a.id, Some(id)).await?; Ok(StatusCode::NO_CONTENT) } async fn notifications_read_all(State(app): State, headers: HeaderMap) -> Result { let c = caller(&app, &headers).await?; let a = c.require("user:read")?; db::notify::mark_read(&app.db, a.id, None).await?; Ok(StatusCode::NO_CONTENT) } async fn user_of(app: &Shared, a: &Account) -> Result { let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect(); Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms }) } async fn user(State(app): State, Path(name): Path) -> Result, ApiError> { let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?; Ok(Json(user_of(&app, &a).await?)) } async fn org(State(app): State, Path(name): Path) -> Result, ApiError> { let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?; let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect(); Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members })) } #[derive(Deserialize)] struct SearchQuery { q: String, } async fn search( State(app): State, headers: HeaderMap, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let mut out = Vec::new(); for h in db::notify::search(&app.db, &q.q, None, 50).await? { let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue }; let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; if !auth::roles::allows(role, Access::Read) { continue; } out.push( serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }), ); } Ok(Json(out)) } // ------------------------------------------------------------ repositories async fn list_repos(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let mut out = Vec::new(); for r in db::repos::list(&app.db).await? { let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?; if auth::roles::allows(role, Access::Read) { out.push(repository(&app, &r)); } } Ok(Json(out)) } async fn create_repo( State(app): State, headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:admin")?; let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?; if !auth::roles::may_create_under(&app.db, me, &name.owner).await? { return Err(forbidden("you cannot create repositories under that owner")); } if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) { return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into())); } app.repos.create(&name, None)?; let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?; db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok(); Ok((StatusCode::CREATED, Json(repository(&app, &row)))) } async fn get_repo( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; Ok(Json(repository(&app, &row))) } async fn bookmarks( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let default = row.default_bookmark.clone(); let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) } async fn tags( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) } #[derive(Deserialize)] struct HistoryQuery { path: Option, offset: Option, limit: Option, } async fn history( State(app): State, headers: HeaderMap, Path((owner, name, r)): Path<(String, String, String)>, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500)); let h = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::history(repo, cache, target, q.path.as_deref(), offset, limit) }) .await?; let mut entries = h.entries; crate::web::attribute(&app, &mut entries.iter_mut().collect::>()).await; Ok(Json(History { reference: h.target.label, heads: h.target.heads.iter().map(|x| x.to_hex()).collect(), total: h.total, offset: h.offset, changes: entries.iter().map(summary).collect(), })) } async fn change( State(app): State, headers: HeaderMap, Path((owner, name, hash)): Path<(String, String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let d = app .repos .read(&repo, move |repo, cache| { let h = view::resolve_change(repo, cache, &hash)?; view::change(repo, cache, &h) }) .await?; let mut v = d.view; crate::web::attribute(&app, &mut [&mut v]).await; let files = crate::web::history::render_files(&d.files, &d.bases) .into_iter() .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed }) .collect(); Ok(Json(ChangeDetail { body: v.body.clone(), change: summary(&v), dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(), in_bookmarks: d.in_bookmarks, in_tags: d.in_tags, files, })) } fn entry(e: &view::Entry) -> TreeEntry { TreeEntry { name: e.name.clone(), path: e.path.clone(), kind: match e.kind { view::EntryKind::Dir => "dir", view::EntryKind::File => "file", view::EntryKind::Symlink => "symlink", view::EntryKind::Subrepo => "subrepo", } .into(), exec: e.exec, size: e.size, conflicted: e.conflicted, } } async fn tree_root( State(app): State, headers: HeaderMap, Path((owner, name, r)): Path<(String, String, String)>, ) -> Result>, ApiError> { tree(State(app), headers, Path((owner, name, r, String::new()))).await } async fn tree( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let t = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::tree(repo, cache, target, &path) }) .await?; Ok(Json(t.entries.iter().map(entry).collect())) } async fn file( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let limit = app.config.limits.render_limit.0; let f = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::file(repo, cache, target, &path, limit) }) .await?; Ok(crate::web::raw(f.bytes, &f.name, false)) } async fn blame( State(app): State, headers: HeaderMap, Path((owner, name, r, path)): Path<(String, String, String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let limit = app.config.limits.render_limit.0; let b = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; view::blame(repo, cache, target, &path, limit) }) .await?; Ok(Json( b.lines .into_iter() .map(|l| BlameLine { line: l.line, change: l.hash.to_hex(), index: l.index, author: l.author, when_ms: l.when_ms, text: String::from_utf8_lossy(&l.text).into_owned(), }) .collect(), )) } async fn conflicts( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let list = app.repos.read(&repo, view::conflicts).await?; Ok(Json( list.into_iter() .map(|c| Conflict { bookmark: c.bookmark, path: c.path, kind: c.kind, contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(), }) .collect(), )) } async fn trust( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let key = app.hub_key.key; let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?; Ok(Json(Trust { policy: t.policy.map(|p| Policy { serial: p.serial, expires_ms: p.expires_ms, roots: p.roots.iter().map(|k| k.key.clone()).collect(), root_threshold: p.root_threshold, rules: p .rules .iter() .map(|r| Rule { pattern: r.pattern.clone(), keys: r.keys.iter().map(|k| k.key.clone()).collect(), threshold: r.threshold, require: r.require.clone(), allow_conflicts: r.allow_conflicts, }) .collect(), }), signed: t.verification.signed, sealed: t.verification.sealed, unsigned: t.verification.unsigned, invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(), hub_key: t.hub_key, })) } async fn pushes( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let rows = db::repos::pushes(&app.db, row.id, 200).await?; Ok(Json( rows.into_iter() .map(|p| Push { at_ms: p.at_ms, pusher: p.pusher, reference: p.reference, ok: p.ok, reason: p.reason, old_state: p.old_state, new_state: p.new_state, heads: p.heads, changes: p.changes, }) .collect(), )) } // --------------------------------------------------------------- proposals async fn proposals( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let mut out = Vec::new(); for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? { let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await); } Ok(Json(out)) } async fn proposal( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let source_url = match p.source_repo_id { Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), None => None, }; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) } /// The files a proposal changes (hub spec §12.1). async fn proposal_files( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; Ok(Json( loaded .files .iter() .map(|f| hub_api::ProposalFile { path: f.path.clone(), from: f.from.clone(), kind: f.kind.clone(), added: f.added, removed: f.removed, }) .collect(), )) } /// One file's bytes on a side of a proposal: `base` is the target's /// state, `head` the projected state. 404 when the file does not exist /// on that side. async fn proposal_content( State(app): State, headers: HeaderMap, Path((owner, name, number, side, path)): Path<(String, String, i64, String, String)>, ) -> Result { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let heads = match side.as_str() { "base" => loaded.target_heads, "head" => loaded.heads, _ => return Err(ApiError(StatusCode::BAD_REQUEST, "the side is base or head".into())), }; let limit = app.config.limits.render_limit.0; let target = view::RefTarget { label: side, kind: view::RefKind::Change, heads }; let f = app.repos.read(&repo, move |repo, cache| view::file(repo, cache, target, &path, limit)).await?; Ok(crate::web::raw(f.bytes, &f.name, false)) } async fn create_proposal( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let created = crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft) .await .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; let loaded = crate::web::proposals::load(&app, &repo, created).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await))) } fn comment_of(c: &db::proposals::CommentRow) -> Comment { Comment { id: c.id, author: c.author.clone(), body: c.body.clone(), created_ms: c.created_ms, path: if c.path.is_empty() { None } else { Some(c.path.clone()) }, anchor: if c.anchor_change.is_empty() { None } else { Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 }) }, resolved: c.resolved, } } async fn proposal_comments( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect())) } async fn proposal_comment( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; if body.body.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); } let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ()); let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) { (Some(path), Some(line)) if !path.is_empty() => { let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?; let (heads, file) = (loaded.heads.clone(), path.to_string()); let atom = app .repos .read(&repo, move |r, cache| { let projected = cache.load(r, &heads)?; Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i))) }) .await? .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?; Some((path.to_string(), atom)) } _ => None, }; let row_c = db::proposals::add_comment( &app.db, p.id, Some(me.id), &me.username, body.body.trim(), anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""), anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)), ) .await?; crate::notify::deliver( &app, crate::notify::Event { kind: "proposal.comment", title: format!("{owner}/{name}#{number}: {}", p.title), url: format!("/{owner}/{name}/proposal/{number}"), actor: &me, repo_id: row.id, thread: Some(("proposal", p.id)), mention_text: &body.body, direct: p.author_id.into_iter().collect(), }, ) .await; crate::hooks::emit( &app, &row, "comment.proposal", &me.username, serde_json::json!({ "number": number, "comment": comment_of(&row_c) }), ) .await; Ok((StatusCode::CREATED, Json(comment_of(&row_c)))) } async fn proposal_reviews( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; Ok(Json( v.reviews .into_iter() .map(|r| Review { id: r.row.id, author: r.row.author, verdict: r.row.verdict, body: r.row.body, sealed: r.sealed, created_ms: r.row.created_ms, }) .collect(), )) } async fn proposal_review( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") { return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into())); } if body.verdict == "approve" && Some(me.id) == p.author_id { return Err(forbidden("you cannot approve your own proposal")); } let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?; db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok(); crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await; Ok(( StatusCode::CREATED, Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }), )) } async fn proposal_checks( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; let loaded = crate::web::proposals::load(&app, &repo, p).await?; Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect())) } // ------------------------------------------------------------------ issues async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result { let labels = db::issues::issue_labels(&app.db, i.id) .await? .into_iter() .map(|l| Label { name: l.name, colour: l.colour, description: l.description }) .collect(); let assignees = db::issues::assignees(&app.db, i.id).await?; let milestone = match i.milestone_id { Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title), None => None, }; let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?; Ok(Issue { number: i.number, title: i.title.clone(), body: i.body.clone(), author: i.author.clone(), state: i.state.clone(), reason: i.reason.clone(), labels, assignees, milestone, comments, locked: i.locked, pinned: i.pinned, created_ms: i.created_ms, updated_ms: i.updated_ms, closed_ms: i.closed_ms, url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number), }) } #[derive(Deserialize)] struct IssuesQuery { state: Option, } async fn issues( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let state = q.state.unwrap_or_else(|| "open".into()); let mut out = Vec::new(); for i in db::issues::list(&app.db, row.id).await? { if state == "all" || i.state == state { out.push(issue_of(&app, &row, &i).await?); } } Ok(Json(out)) } async fn create_issue( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; if body.title.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into())); } let triage = !body.labels.is_empty() || !body.assignees.is_empty() || body.milestone.is_some(); if triage && role < Role::Triage { return Err(forbidden("setting labels, assignees or a milestone needs triage access")); } // Check the names before anything is created. resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?; let i = db::issues::create( &app.db, db::issues::NewIssue { repo_id: row.id, title: body.title.trim(), body: body.body.trim(), author_id: Some(me.id), author: &me.username, }, ) .await?; db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok(); if triage { let t = resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?; apply_triage(&app, &i, &me, t).await?; } let i = db::issues::get(&app.db, row.id, i.number).await?.unwrap_or(i); crate::web::issues::reindex(&app, row.id, &i).await; let api = issue_of(&app, &row, &i).await?; crate::notify::deliver( &app, crate::notify::Event { kind: "issue.opened", title: format!("{owner}/{name}#{}: {}", i.number, i.title), url: format!("/{owner}/{name}/issue/{}", i.number), actor: &me, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: &body.body, direct: vec![], }, ) .await; crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await; Ok((StatusCode::CREATED, Json(api))) } async fn issue( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; Ok(Json(issue_of(&app, &row, &i).await?)) } async fn issue_state( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; if role < Role::Triage && Some(me.id) != i.author_id { return Err(forbidden("only the author or triage may change the state")); } let (state, reason) = match body.state.as_str() { "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }), "open" => ("open", ""), _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())), }; db::issues::set_state(&app.db, i.id, state, reason).await?; db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok(); let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i); let api = issue_of(&app, &row, &fresh).await?; crate::hooks::emit( &app, &row, if state == "closed" { "issue.closed" } else { "issue.reopened" }, &me.username, serde_json::json!({ "issue": api }), ) .await; Ok(Json(api)) } async fn issue_comments( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; Ok(Json( db::issues::comments(&app.db, i.id) .await? .into_iter() .map(|c| Comment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms, path: None, anchor: None, resolved: false, }) .collect(), )) } async fn issue_comment( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; if i.locked && role < Role::Triage { return Err(forbidden("this issue is locked")); } if body.body.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); } let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?; crate::web::issues::reindex(&app, row.id, &i).await; crate::notify::deliver( &app, crate::notify::Event { kind: "issue.comment", title: format!("{owner}/{name}#{number}: {}", i.title), url: format!("/{owner}/{name}/issue/{number}"), actor: &me, repo_id: row.id, thread: Some(("issue", i.id)), mention_text: &body.body, direct: i.author_id.into_iter().collect(), }, ) .await; let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false }; crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await; Ok((StatusCode::CREATED, Json(api))) } /// Labels, assignees and milestone named in a request, looked up. struct Triage { labels: Option>, assignees: Option>, /// `Some(None)` clears the milestone. milestone: Option>, } async fn resolve_triage( app: &Shared, repo_id: i64, labels: Option<&[String]>, assignees: Option<&[String]>, milestone: Option<&str>, ) -> Result { let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m); let labels = match labels { Some(names) => { let mut out = Vec::new(); for n in names { out.push( db::issues::label_by_name(&app.db, repo_id, n.trim()) .await? .ok_or_else(|| bad(format!("no label {n:?}; create it first")))?, ); } Some(out) } None => None, }; let assignees = match assignees { Some(names) => { let mut out = Vec::new(); for n in names { out.push(db::accounts::account_by_name(&app.db, n.trim()).await?.ok_or_else(|| bad(format!("no account {n:?}")))?); } Some(out) } None => None, }; let milestone = match milestone.map(str::trim) { None => None, Some("") => Some(None), Some(title) => Some(Some( db::issues::milestones(&app.db, repo_id) .await? .into_iter() .find(|m| m.title == title) .ok_or_else(|| bad(format!("no milestone {title:?}")))?, )), }; Ok(Triage { labels, assignees, milestone }) } /// Make an issue's labels, assignees and milestone what `t` says, with /// timeline events for what changed. async fn apply_triage(app: &Shared, i: &db::issues::IssueRow, me: &Account, t: Triage) -> Result<(), ApiError> { if let Some(want) = t.labels { let have = db::issues::issue_labels(&app.db, i.id).await?; for l in &have { if !want.iter().any(|w| w.id == l.id) { db::issues::set_issue_label(&app.db, i.id, l.id, false).await?; } } for w in &want { if !have.iter().any(|l| l.id == w.id) { db::issues::set_issue_label(&app.db, i.id, w.id, true).await?; } } let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); db::issues::add_event(&app.db, i.id, &me.username, "set labels", &names.join(", ")).await.ok(); } if let Some(want) = t.assignees { let have = db::issues::assignees(&app.db, i.id).await?; for name in &have { if !want.iter().any(|a| &a.username == name) { if let Some(a) = db::accounts::account_by_name(&app.db, name).await? { db::issues::set_assignee(&app.db, i.id, a.id, false).await?; db::issues::add_event(&app.db, i.id, &me.username, "unassigned", name).await.ok(); } } } for a in &want { if !have.contains(&a.username) { db::issues::set_assignee(&app.db, i.id, a.id, true).await?; db::issues::add_event(&app.db, i.id, &me.username, "assigned", &a.username).await.ok(); } } } if let Some(m) = t.milestone { db::issues::set_milestone(&app.db, i.id, m.as_ref().map(|m| m.id)).await?; db::issues::add_event(&app.db, i.id, &me.username, "set milestone", m.as_ref().map(|m| m.title.as_str()).unwrap_or("")).await.ok(); } Ok(()) } /// `PATCH /repos/{o}/{n}/issues/{number}`. async fn issue_edit( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, Json(body): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; let text = body.title.is_some() || body.body.is_some(); let triage = body.labels.is_some() || body.assignees.is_some() || body.milestone.is_some(); if text && role < Role::Triage && Some(me.id) != i.author_id { return Err(forbidden("only the author or triage may edit the title and body")); } if triage && role < Role::Triage { return Err(forbidden("setting labels, assignees or a milestone needs triage access")); } if i.locked && role < Role::Triage { return Err(forbidden("this issue is locked")); } let t = resolve_triage(&app, row.id, body.labels.as_deref(), body.assignees.as_deref(), body.milestone.as_deref()).await?; if text { let title = body.title.as_deref().map(str::trim).unwrap_or(&i.title); if title.is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into())); } db::issues::edit(&app.db, i.id, title, body.body.as_deref().unwrap_or(&i.body)).await?; db::issues::add_event(&app.db, i.id, &me.username, "edited", "").await.ok(); } apply_triage(&app, &i, &me, t).await?; db::issues::touch(&app.db, i.id).await.ok(); let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i); crate::web::issues::reindex(&app, row.id, &fresh).await; let api = issue_of(&app, &row, &fresh).await?; crate::hooks::emit(&app, &row, "issue.edited", &me.username, serde_json::json!({ "issue": api })).await; Ok(Json(api)) } /// `POST /repos/{o}/{n}/labels` (triage). async fn create_label( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(body): Json, ) -> Result<(StatusCode, Json