//! 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 backups; pub mod device; mod drops; mod images; mod projects; mod reports; /// Any other path under `/api/v1`: a problem document, not the site's page. /// `GET /api/v1/version`: the hub's version and build, and the Oxygen and /// OxygenFlow library versions it is built on. async fn version() -> Json { Json(crate::web::home::versions()) } async fn no_route() -> ApiError { ApiError(StatusCode::NOT_FOUND, "no such API route".into()) } /// A known route asked with a method it does not take: a problem body /// rather than axum's empty 405 (oxygenhub#115). async fn wrong_method(method: axum::http::Method, uri: axum::http::Uri) -> ApiError { ApiError(StatusCode::METHOD_NOT_ALLOWED, format!("{} is an API route, but not for {method}", uri.path())) } /// The API, with release assets up to `asset_limit` bytes (the web form's /// limit too; axum's default of 2 MiB refused ordinary builds). pub fn router(asset_limit: usize) -> Router { Router::new() .fallback(no_route) .route("/keyless/certify", post(crate::keyless::certify)) .route("/agents/grants", get(crate::keyless::grants_list).post(crate::keyless::grants_create)) .route( "/repos/{owner}/{name}/terraform/state/{state}", axum::routing::any(crate::terraform::state) .layer(axum::extract::DefaultBodyLimit::max(crate::terraform::MAX_STATE_BYTES + 4096)), ) .route("/repos/{owner}/{name}/terraform/state/{state}/versions", axum::routing::get(crate::terraform::versions)) .route("/repos/{owner}/{name}/terraform/state/{state}/versions/{id}", axum::routing::get(crate::terraform::version)) .route( "/repos/{owner}/{name}/terraform/state/{state}/versions/{id}/restore", axum::routing::post(crate::terraform::restore_version), ) .route("/agents/grants/{id}", axum::routing::delete(crate::keyless::grants_revoke)) .route("/flow/claim", post(crate::flow::claim)) .route("/flow/runners/register", post(crate::flow::register)) .route("/flow/runners/heartbeat", post(crate::flow::heartbeat)) .route("/flow/runner/update", get(crate::flow::runner_update).post(crate::flow::report_update)) .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES))) .route("/flow/swarm/announce", post(crate::swarm::announce)) .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk)) .route( "/flow/images/{hash}", axum::routing::put(images::put_manifest).get(images::get_manifest).layer(axum::extract::DefaultBodyLimit::max(64 << 20)), ) .route("/flow/jobs/{id}/log", post(crate::flow::log)) .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) .route("/flow/jobs/{id}/decline", post(crate::flow::decline)) .route("/flow/jobs/{id}/release", post(crate::flow::release)) .route( "/flow/jobs/{id}/checkpoint", post(crate::flow::save_checkpoint) .get(crate::flow::checkpoint) .layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), ) .route( "/flow/jobs/{id}/artifacts/{name}", post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), ) .route("/flow/jobs/{id}/inputs/{index}", get(crate::flow::input)) .route( "/flow/jobs/{id}/reports/{name}/files", post(crate::flow::report_file).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), ) .route("/projects/{owner}", get(projects::list).post(projects::create)) .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) .route("/projects/{owner}/{number}/cards/archive", post(projects::archive_cards)) .route("/projects/{owner}/{number}/cards/{id}/restore", post(projects::restore_card)) .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) .route("/device/code", post(device::code).delete(device::cancel)) .route("/device/token", post(device::token)) .route("/runners", get(runners)) .route("/runners/queue", get(runner_queue)) .route("/runners/{id}/jobs", get(runner_jobs)) .route("/user", get(me)) .route("/user/repos", get(user_repos)) .route("/user/orgs", get(user_orgs)) .route("/user/proposals", get(user_proposals)) .route("/user/runner-token", post(user_runner_token)) .route("/user/token", axum::routing::delete(revoke_own_token)) .route("/version", get(version)) .route("/notifications", get(notifications)) .route("/notifications/read", post(notifications_read_all)) .route("/notifications/{id}/read", post(notification_read)) .route("/users/{name}", get(user)) .route("/users/{name}/drops", get(drops::of_user)) .route("/drops", get(drops::mine).post(drops::create)) .route("/drops/{id}", get(drops::view).patch(drops::edit).delete(drops::delete)) .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).post(create_tag)) .route("/repos/{owner}/{name}/tags/{tag}", axum::routing::delete(withdraw_tag)) .route("/repos/{owner}/{name}/consumers", get(consumers)) .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion)) .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup)) .route("/user/backups", get(backups::user_get).put(backups::user_put)) .route("/user/backups/test", post(backups::user_test)) .route("/user/backups/run", post(backups::user_run)) .route("/user/backups/runs", get(backups::user_runs)) .route("/orgs/{name}/backups", get(backups::org_get).put(backups::org_put)) .route("/orgs/{name}/backups/test", post(backups::org_test)) .route("/orgs/{name}/backups/run", post(backups::org_run)) .route("/orgs/{name}/backups/runs", get(backups::org_runs)) .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}/releases/trust/{*tag}", get(release_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).patch(edit_proposal)) .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal)) .route("/repos/{owner}/{name}/proposals/{number}/auto-land", post(auto_land_on).delete(auto_land_off)) .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}/reviewers", post(request_reviewers)) .route("/repos/{owner}/{name}/proposals/{number}/review-requests", post(request_assistant)) .route("/repos/{owner}/{name}/proposals/{number}/reviewers/{username}", axum::routing::delete(withdraw_reviewer)) .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).post(create_milestone)) .route( "/repos/{owner}/{name}/coverage", get(reports::coverage_list) .post(reports::coverage_upload) .layer(axum::extract::DefaultBodyLimit::max(crate::coverage::MAX_REPORT + (1 << 20))), ) .route("/repos/{owner}/{name}/coverage/{id}", get(reports::coverage_one)) .route("/repos/{owner}/{name}/tests", get(reports::tests_list)) .route("/repos/{owner}/{name}/tests/{id}", get(reports::tests_one)) .route("/repos/{owner}/{name}/milestones/{title}", axum::routing::patch(edit_milestone)) .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) .route("/repos/{owner}/{name}/assistants", get(repo_assistants).post(repo_assistant_put)) .route("/repos/{owner}/{name}/assistants/prompt", get(repo_prompt).put(repo_prompt_put)) .route("/repos/{owner}/{name}/assistants/{assistant}", axum::routing::delete(repo_assistant_delete)) .route("/orgs/{org}/assistants", get(org_assistants).post(org_assistant_put)) .route("/orgs/{org}/assistants/prompt", get(org_prompt).put(org_prompt_put)) .route("/orgs/{org}/assistants/{assistant}", axum::routing::delete(org_assistant_delete)) .route("/admin/runner-updates", get(runner_updates)) .route("/admin/runner-updates/settings", axum::routing::put(runner_update_setting)) .route("/admin/runner-updates/holds/{version}/lift", post(runner_update_lift)) .route("/admin/assistants", get(site_assistants).post(site_assistant_put)) .route("/admin/assistants/prompt", get(site_prompt).put(site_prompt_put)) .route("/admin/assistants/{assistant}", axum::routing::delete(site_assistant_delete)) .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}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job)) .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run)) .route("/repos/{owner}/{name}/deployments", get(list_deployments)) .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approve", post(approve_flow_job)) .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reject", post(reject_flow_job)) .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approval", get(flow_job_statement)) .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/seal", post(seal_flow_job)) .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}", axum::routing::patch(edit_release)) .route( "/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset).layer(axum::extract::DefaultBodyLimit::max(asset_limit + (1 << 20))), ) // Last: it applies to the routes registered before it. .method_not_allowed_fallback(wrong_method) } 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))) } /// `PATCH …/releases/{tag}` (oxygenhub#72): change a release's title, /// notes or prerelease flag, as the release page's Edit does. async fn edit_release( State(app): State, headers: HeaderMap, Path((owner, name, tag)): Path<(String, String, String)>, Json(body): Json, ) -> Result, 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?; let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&r.title); let notes = body.notes.as_deref().map(str::trim).unwrap_or(&r.notes); db::releases::edit(&app.db, r.id, title, notes, body.prerelease.unwrap_or(r.prerelease)).await?; db::accounts::audit(&app.db, Some(&me), "release.edited", &format!("{owner}/{name}@{tag}"), "").await.ok(); 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(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()) } /// The address a request came from: the client's, as the proxy reports /// it when the hub is behind one (`server.behind_proxy`), else the peer's. /// Only an address's characters are kept, so it is safe to show. pub fn client_addr(app: &Shared, headers: &HeaderMap, extensions: &axum::http::Extensions) -> String { let from_proxy = || { ["cf-connecting-ip", "x-forwarded-for", "x-real-ip"] .iter() .find_map(|h| headers.get(*h).and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next()).map(|v| v.trim().to_string())) }; let addr = if app.config.server.behind_proxy { from_proxy() } else { extensions.get::>().map(|c| c.0.ip().to_string()) }; addr.map(|a| a.chars().filter(|c| c.is_ascii_hexdigit() || matches!(c, '.' | ':')).take(45).collect()).unwrap_or_default() } /// `DELETE /api/v1/user/token`: revoke the token this request is made /// with, as `oxy auth logout` does. async fn revoke_own_token(State(app): State, headers: HeaderMap) -> Result { let c = caller(&app, &headers).await?; let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else { return Err(ApiError(StatusCode::UNAUTHORIZED, "revoking needs the personal access token to revoke".into())); }; db::accounts::delete_token(&app.db, account.id, id).await?; db::accounts::audit(&app.db, Some(account), "token.revoke", &c.token, "").await.ok(); Ok(StatusCode::NO_CONTENT) } /// 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, /// The token's name, for records of what a call did. pub token: String, /// The personal access token's id, when one is used. pub token_id: Option, /// The token is an agent's (oxygenhub#183): what it does says so. pub agent: 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> { // No token is 401, sign in; a token without the scope is 403 (#303). let Some(account) = self.account.as_ref() else { return Err(ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required: sign in (`oh auth login`)".into())); }; if !self.has(scope) { return Err(forbidden(&format!("this token lacks the {scope} scope"))); } Ok(account) } } 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, token: String::new(), token_id: None, agent: 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, token: "site token".into(), token_id: None, agent: false, }); } 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.blocked()); 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, token: tok.name.clone(), token_id: Some(tok.id), agent: tok.agent, }) } _ => 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. pub(super) 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"))?; // A drop is reached through `/drops` only (#279). if row.is_drop() { return Err(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, via: c.via.as_ref().map(|v| Via { name: v.name.clone(), key: v.key.clone(), scope: v.scope.clone(), expires_ms: v.expires_ms, revoked: v.revoked, }), 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 rows = db::notify::inbox(&app.db, a.id, limit).await?; // Repositories by id, each looked up once (#305). let mut repos: std::collections::HashMap> = std::collections::HashMap::new(); for id in rows.iter().filter_map(|n| n.repo_id) { if let std::collections::hash_map::Entry::Vacant(e) = repos.entry(id) { e.insert(db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", r.owner, r.name))); } } let absolute = |u: String| if u.starts_with('/') { format!("{base}{u}") } else { u }; let items = rows .into_iter() .map(|n| hub_api::Notification { id: n.id, at_ms: n.at_ms, label: crate::notify::label(&n.kind).map(String::from), kind: n.kind, title: n.title, url: absolute(n.url), actor: n.actor, read: n.read, repository: n.repo_id.and_then(|id| repos.get(&id).cloned().flatten()), reason: n.reason, thread: if n.thread.is_empty() { String::new() } else { absolute(n.thread) }, }) .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)) } #[derive(Deserialize)] struct UserReposQuery { /// Which: `owner`, `organization`, `collaborator`, comma-separated (all three by default). #[serde(default)] affiliation: Option, /// The last `owner/name` of the previous page. #[serde(default)] after: Option, #[serde(default)] limit: Option, } /// `GET /user/repos` (#302): the caller's repositories, their /// organisations' and those they collaborate on, by `owner/name`, a page /// at a time (`?after=owner/name&limit=`, at most 100, default 50). /// `POST /user/runner-token` (repo:write): a one-time registration token /// for a runner that serves the caller's own repositories and no one /// else's, as a phone registers itself (oxygenhub-android#8). Like the /// settings pages' tokens it lasts a day. async fn user_runner_token(State(app): State, headers: HeaderMap) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let token = format!("flowreg_{}", crate::auth::random_hex(24)); let scope = db::runners::Scope { owner: me.username.clone(), repo_id: None }; db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope).await?; db::accounts::audit(&app.db, Some(&me), "flow.runner.token", &me.username, "registration token for the caller's repositories (API)") .await .ok(); Ok((StatusCode::CREATED, Json(RunnerToken { token, owner: me.username }))) } /// `GET /user/proposals` (repo:read): what the dashboard lists, for apps; /// with `?landable=true`, also what the caller can land now (#311). async fn user_proposals( State(app): State, headers: HeaderMap, Query(q): Query, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:read")?.clone(); // As the dashboard: reviews in repositories the caller belongs to, its // own proposals in any it can still see. let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; let seen: Vec = db::repos::list(&app.db) .await? .into_iter() .filter(|r| me.is_admin || r.visibility() != Visibility::Private || member.contains(&r.id)) .map(|r| r.id) .collect(); let base = app.config.base_url().trim_end_matches('/').to_string(); let brief = |p: db::activity::ProposalLine| ProposalBrief { url: format!("{base}/{}/{}/proposal/{}", p.owner, p.name, p.number), repository: format!("{}/{}", p.owner, p.name), number: p.number, title: p.title, author: p.author, state: match p.state.as_str() { "draft" => ProposalState::Draft, "landed" => ProposalState::Landed, "closed" => ProposalState::Closed, _ => ProposalState::Open, }, conflicted: p.conflicted, updated_ms: p.updated_ms, }; let landable = if q.landable { landable_for(&app, &c, &member, &base).await? } else { Vec::new() }; Ok(Json(UserProposals { review: db::activity::awaiting_review(&app.db, me.id, &member).await?.into_iter().map(brief).collect(), authored: db::activity::authored(&app.db, me.id, &seen).await?.into_iter().map(brief).collect(), landable, })) } /// The open proposals the caller could land now (#311): in repositories /// it belongs to and can write to, whose verdict has no blockers. Each /// projected state is worked out four at a time, as `proposals` does /// (#171); one that cannot be is left out rather than failing the list. async fn landable_for(app: &Shared, c: &Caller, member: &[i64], base: &str) -> Result, ApiError> { use futures::StreamExt; let mut todo = Vec::new(); for row in db::repos::list(&app.db).await?.into_iter().filter(|r| member.contains(&r.id) && !r.is_drop()) { let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; if !auth::roles::allows(role, Access::Write) { continue; } let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue }; for p in db::proposals::list(&app.db, row.id, &["open"]).await? { todo.push((repo.clone(), row.clone(), p)); } } let found: Vec> = futures::stream::iter(todo) .map(|(repo, row, p)| { let (app, base) = (app.clone(), base.to_string()); async move { let (number, title, author, updated_ms) = (p.number, p.title.clone(), p.author.clone(), p.updated_ms); let loaded = crate::web::proposals::load_summary(&app, &repo, p).await.ok()?; let v = crate::web::proposals::verdict(&app, &loaded, row.id).await.ok()?; v.blockers.is_empty().then(|| ProposalBrief { url: format!("{base}/{}/{}/proposal/{number}", row.owner, row.name), repository: format!("{}/{}", row.owner, row.name), number, title, author, state: ProposalState::Open, conflicted: loaded.conflicted, updated_ms, }) } }) .buffered(4) .collect() .await; Ok(found.into_iter().flatten().collect()) } async fn user_repos( State(app): State, headers: HeaderMap, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:read")?.clone(); let wanted: Vec = q .affiliation .as_deref() .unwrap_or("owner,organization,collaborator") .split(',') .map(|s| s.trim().to_ascii_lowercase()) .filter(|s| !s.is_empty()) .collect(); if let Some(bad) = wanted.iter().find(|w| !["owner", "organization", "collaborator"].contains(&w.as_str())) { return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown affiliation {bad:?}: owner, organization or collaborator"))); } let orgs: Vec = db::accounts::orgs_of(&app.db, me.id).await?.into_iter().map(|(o, _)| o.name).collect(); let limit = q.limit.unwrap_or(50).clamp(1, 100); let mut rows = db::repos::list(&app.db).await?; rows.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name))); let mut out = Vec::new(); for r in rows { if let Some(after) = q.after.as_deref() { if format!("{}/{}", r.owner, r.name).as_str() <= after { continue; } } let mine = (wanted.iter().any(|w| w == "owner") && r.owner == me.username) || (wanted.iter().any(|w| w == "organization") && orgs.contains(&r.owner)) || (wanted.iter().any(|w| w == "collaborator") && db::accounts::collaborator_role(&app.db, r.id, me.id).await?.is_some()); if !mine || !auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { continue; } out.push(repository(&app, &r)); if out.len() == limit { break; } } Ok(Json(out)) } /// `GET /user/orgs` (#302): the organisations the caller belongs to. async fn user_orgs(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("user:read")?; let orgs = db::accounts::orgs_of(&app.db, me.id).await?; Ok(Json(orgs.into_iter().map(|(o, role)| hub_api::UserOrg { name: o.name, display_name: o.display_name, role }).collect())) } 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 name.name.ends_with(crate::wiki::SUFFIX) { return Err(ApiError(StatusCode::BAD_REQUEST, "a name cannot end in .wiki: that is the address of a repository's wiki".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())) } #[derive(serde::Deserialize)] struct BackupSetting { /// `owner` (with its owner's), `hourly`, `daily` or `off`. backup: String, } /// `PUT /repos/{owner}/{name}/backup {"backup": ...}`: this repository's /// backup (docs/backups.md §5). Owners and admins. async fn set_backup( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(b): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; let setting = match b.backup.as_str() { "owner" => "", "hourly" | "daily" | "off" => b.backup.as_str(), _ => return Err(ApiError(StatusCode::BAD_REQUEST, "backup is owner, hourly, daily or off".into())), }; db::repos::set_backup(&app.db, row.id, setting).await?; db::accounts::audit(&app.db, c.account.as_ref(), "repo.backup", &format!("{owner}/{name}"), &b.backup).await.ok(); Ok(Json(serde_json::json!({ "backup": b.backup }))) } #[derive(serde::Deserialize)] struct DiffusionSetting { /// `off`, `green`, `any`, or `default` (the site's). follow: String, /// `auto` or `propose`; absent keeps it as it is. #[serde(default)] land: Option, } /// `PUT /repos/{owner}/{name}/diffusion {"follow": ...}`: whether this /// repository follows the upstreams it pins (#148). Owners and admins. async fn set_diffusion( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Json(d): Json, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; let setting = match d.follow.as_str() { "off" | "green" | "any" => d.follow.as_str(), "default" => "", _ => return Err(ApiError(StatusCode::BAD_REQUEST, "follow is off, green, any or default".into())), }; let land = match d.land.as_deref() { Some(l @ ("auto" | "propose")) => Some(l), None => None, Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "land is auto or propose".into())), }; db::repos::set_diffusion(&app.db, row.id, setting).await?; if let Some(l) = land { db::repos::set_diffusion_land(&app.db, row.id, l).await?; } db::accounts::audit( &app.db, c.account.as_ref(), "repo.diffusion", &format!("{owner}/{name}"), if setting.is_empty() { "default" } else { setting }, ) .await .ok(); let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row); let rule = crate::diffusion::rule(&app.config.diffusion.follow, &row, None, ""); // Turned on: bumped at once to where its upstreams are (#148). if rule.follow != crate::diffusion::Follow::Off { let (app, row) = (app.clone(), row.clone()); tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await }); } if land == Some("auto") { let (app, row) = (app.clone(), row.clone()); tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await }); } Ok(Json( serde_json::json!({ "follow": rule.follow, "from": rule.from, "land": if row.diffusion_land == "auto" { "auto" } else { "propose" } }), )) } /// `GET /repos/{owner}/{name}/consumers`: the repositories that pin this /// one as a subrepo and that the caller may read, each with its pin and /// how far behind this repository's default bookmark it is (#148). async fn consumers( 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(crate::diffusion::consumers(&app, &row, c.account.as_ref()).await?)) } /// `GET /repos/{owner}/{name}/diffusion`: what this repository takes from /// and what it diffuses to, as its Diffusion tab shows them (#153). async fn diffusion( 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 slug = format!("{owner}/{name}"); let takes_from = crate::diffusion::follows(&app, &row, c.account.as_ref()) .await .iter() .map(|(up, p)| p.api(&format!("{}/{}", up.owner, up.name))) .collect(); let diffuses_to = crate::diffusion::consumers(&app, &row, c.account.as_ref()).await?.iter().map(|p| p.api(&slug)).collect(); Ok(Json(hub_api::Diffusion { takes_from, diffuses_to })) } 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())) } /// `POST …/tags` (#295): what the Tags page's Create does. async fn create_tag( 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?; let made = crate::tags::create( &app, &row, &repo, &me, &body.name, body.target.as_deref().unwrap_or(""), body.message.as_deref().unwrap_or(""), ) .await .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; let t = app .repos .read(&repo, |r, _| view::tags(r)) .await? .into_iter() .find(|t| t.name == made.name) .ok_or_else(|| not_found("no such tag"))?; Ok((StatusCode::CREATED, Json(Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }))) } #[derive(Deserialize)] struct WithdrawQuery { #[serde(default)] reason: String, } /// `DELETE …/tags/{tag}?reason=…` (#301): what the Tags page's Withdraw /// does. Maintainers; refused (409) while a release uses the tag. async fn withdraw_tag( State(app): State, headers: HeaderMap, Path((owner, name, tag)): Path<(String, String, String)>, Query(q): Query, ) -> Result { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; if db::releases::get(&app.db, row.id, &tag).await?.is_some() { return Err(ApiError(StatusCode::CONFLICT, format!("a release uses {tag}: delete the release first"))); } crate::tags::withdraw(&app, &row, &repo, &me, &tag, &q.reason).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] struct HistoryQuery { path: Option, offset: Option, limit: Option, /// A revset (Oxygen's docs/revset_query_language.md) in place of the /// reference's whole history; the reference still gives the labels. r: 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 revset = q.r.clone().filter(|s| !s.trim().is_empty()); let by_revset = revset.is_some(); let h = app .repos .read(&repo, move |repo, cache| { let target = view::resolve(repo, cache, &r)?; match &revset { Some(rs) => view::history_revset(repo, cache, target, rs, offset, limit), None => view::history(repo, cache, target, q.path.as_deref(), offset, limit), } }) .await .map_err(|e| { // A revset that does not parse, names nothing, or goes past the // budget is the caller's to fix. if by_revset { ApiError(StatusCode::BAD_REQUEST, e.to_string()) } else { e.into() } })?; 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(file_change).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 file_change(f: crate::web::history::FileRows) -> FileChange { use crate::render::diff::Kind; let truncated = f.lines.len() > hub_api::DIFF_LINES; let lines = f .lines .into_iter() .take(hub_api::DIFF_LINES) .map(|l| DiffLine { kind: match l.kind { Kind::Context => "context", Kind::Add => "add", Kind::Del => "del", Kind::Gap => "gap", } .into(), old: l.old_no, new: l.new_no, text: l.text, }) .collect(); FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed, lines, truncated } } 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(), )) } /// `GET /repos/{o}/{n}/releases/trust/{tag}`: a release's trust report /// (hub spec §9.1), as the page's JSON export. async fn release_trust( State(app): State, headers: HeaderMap, Path((owner, name, tag)): Path<(String, String, String)>, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; match crate::web::release_trust::report(&app, &repo, &row, &tag).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))? { Some(t) => Ok(Json(t)), None => Err(ApiError(StatusCode::NOT_FOUND, format!("no release {tag}"))), } } 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 /// `GET /repos/{o}/{n}/proposals?state=open|draft|landed|closed|all&limit=N`: /// open ones (drafts too) by default, newest first, the first `limit` if /// given (#331). async fn proposals( State(app): State, headers: HeaderMap, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; let states: &[&str] = match q.state.as_deref().unwrap_or("open") { "open" => &["open", "draft"], "draft" => &["draft"], "landed" => &["landed"], "closed" => &["closed"], "all" => &["open", "draft", "landed", "closed"], other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state is open, draft, landed, closed or all, not {other:?}"))), }; // Each proposal's projected state is worked out from the store, which // for a bucket-backed repository is many slow round trips: four at a // time, in order, each under the repository's read lock only. One at // a time took 28 s for a repository on GCS (oxygenhub#171). A landed // or closed one is as recorded, not projected again (#331). use futures::StreamExt; let mut rows = db::proposals::list(&app.db, row.id, states).await?; if let Some(limit) = q.limit { rows.truncate(limit); } let out: Vec> = futures::stream::iter(rows) .map(|p| { let (app, repo, owner, name, id) = (app.clone(), repo.clone(), owner.clone(), name.clone(), row.id); async move { let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?; let v = crate::web::proposals::verdict(&app, &loaded, id).await?; Ok(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await) } }) .buffered(4) .collect() .await; Ok(Json(out.into_iter().collect::, _>>()?)) } 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_for_api(&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)) } /// `PATCH /repos/{o}/{n}/proposals/{number}`: change the title or body, /// keeping what is not given; the author or a maintainer, as on the page /// (oxygenhub#76). The target is changed on the page, which re-projects. async fn edit_proposal( 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("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (repo, row, role) = 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 text = body.title.is_some() || body.body.is_some(); if text && Some(me.id) != p.author_id && role < Role::Maintain { return Err(ApiError(StatusCode::FORBIDDEN, "only the proposal's author or a maintainer can edit it".into())); } if body.labels.is_some() && role < Role::Triage { return Err(forbidden("setting labels needs triage access")); } // Every name is checked before anything changes. let labels = resolve_triage(&app, row.id, body.labels.as_deref(), None, None).await?.labels; if text { let title = match body.title.as_deref().map(str::trim) { Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())), Some(t) => t, None => p.title.as_str(), }; let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body); db::proposals::edit(&app.db, p.id, title, text, &p.target).await?; db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok(); } if let Some(want) = labels { let have = db::issues::proposal_labels(&app.db, p.id).await?; for l in &have { if !want.iter().any(|w| w.id == l.id) { db::issues::set_proposal_label(&app.db, p.id, l.id, false).await?; } } for w in &want { if !have.iter().any(|l| l.id == w.id) { db::issues::set_proposal_label(&app.db, p.id, w.id, true).await?; } } let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); db::proposals::add_event(&app.db, p.id, &me.username, "set labels", &names.join(", ")).await.ok(); } // `draft` says the same as `state`, in the words of creation (#313). let want = match (body.state.as_deref(), body.draft) { (Some(s), Some(d)) if (s == "draft") != d => { return Err(ApiError(StatusCode::BAD_REQUEST, format!("state {s:?} and draft {d} disagree"))); } (Some(s), _) => Some(s), (None, Some(true)) => Some("draft"), (None, Some(false)) => Some("open"), (None, None) => None, }; if let Some(want) = want { if Some(me.id) != p.author_id && role < Role::Triage { return Err(ApiError( StatusCode::FORBIDDEN, "only the proposal's author or someone with triage access can close, reopen or change whether it is a draft".into(), )); } // The page's transitions: a draft is made ready (open), and an open // proposal turned back into a draft. let new_state = match (want, p.state.as_str()) { ("closed", "open" | "draft") => "closed", ("open", "closed" | "draft") => "open", ("draft", "open") => "draft", (w, now) if w == now => now, ("closed" | "open" | "draft", now) => { return Err(ApiError(StatusCode::CONFLICT, format!("a proposal that is {now} cannot become {want}"))) } _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is closed, open or draft".into())), }; if new_state != p.state { db::proposals::set_state(&app.db, p.id, new_state, "").await?; db::proposals::add_event(&app.db, p.id, &me.username, new_state, "").await.ok(); crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.username, serde_json::json!({ "number": number })).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_for_api(&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)) } /// `POST /repos/{o}/{n}/proposals/{number}/land` (repo:write): land it as /// the page's Land button does; `409` with the blockers when it cannot. /// `{"over_checks": "reason"}` lands over required checks that have not /// passed, for maintainers (#261). async fn land_proposal( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, body: axum::body::Bytes, ) -> Result, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let over_checks: Option = if body.iter().all(u8::is_ascii_whitespace) { None } else { serde_json::from_slice::(&body) .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "the body is JSON: {} or {\"over_checks\": \"reason\"}".into()))? .get("over_checks") .and_then(|v| v.as_str()) .map(String::from) }; let need = if over_checks.is_some() { Access::Maintain } else { Access::Write }; let (repo, row, _) = reach(&app, &c, &owner, &name, need).await?; let via = format!("api (token {})", c.token); match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via, false, over_checks.as_deref()).await { Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await, Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")), Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)), } } /// `POST /repos/{o}/{n}/proposals/{number}/auto-land` (repo:write, #261): /// land it when its checks pass; at once if they already have. async fn auto_land_on( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { auto_land_set(&app, &headers, &owner, &name, number, true).await?; proposal(State(app), headers, Path((owner, name, number))).await } /// `DELETE …/auto-land`: it will not land by itself. async fn auto_land_off( State(app): State, headers: HeaderMap, Path((owner, name, number)): Path<(String, String, i64)>, ) -> Result, ApiError> { auto_land_set(&app, &headers, &owner, &name, number, false).await?; proposal(State(app), headers, Path((owner, name, number))).await } async fn auto_land_set(app: &Shared, headers: &HeaderMap, owner: &str, name: &str, number: i64, on: bool) -> Result<(), ApiError> { let c = caller(app, headers).await?; let me = c.require("repo:write")?.clone(); let (_, row, _) = reach(app, &c, owner, name, Access::Write).await?; let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; if !p.is_open() { return Err(ApiError(StatusCode::CONFLICT, format!("the proposal is {}", p.state))); } crate::autoland::set(app, &row, &p, &me, on).await?; Ok(()) } /// 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_summary(&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_summary(&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 }) }, line: None, 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"))?; let mut out: Vec = db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect(); // Place anchored comments on their line of the projected state. let paths: std::collections::BTreeSet = out.iter().filter(|c| c.anchor.is_some()).filter_map(|c| c.path.clone()).collect(); if !paths.is_empty() { let repo = RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?; let heads = crate::web::proposals::load_summary(&app, &repo, p).await?.heads; let lines = app .repos .read(&repo, move |r, _| { let mut at = std::collections::HashMap::new(); for path in paths { // A file the projection removed has no lines to place a comment on. for l in r.blame_in(&path, &heads).unwrap_or_default() { at.insert((path.clone(), l.hash.to_hex(), l.index), l.line); } } Ok(at) }) .await?; for c in &mut out { if let (Some(path), Some(a)) = (&c.path, &c.anchor) { c.line = lines.get(&(path.clone(), a.change.clone(), a.index)).copied(); } } } Ok(Json(out)) } 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; let mut created = comment_of(&row_c); created.line = created.anchor.as_ref().and(body.line); Ok((StatusCode::CREATED, Json(created))) } 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_summary(&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, via: r.row.via, }) .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::web::proposals::reviewed(&app, &row, &p, &me, &body.verdict).await; 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, via: r.via }), )) } /// `POST .../proposals/{number}/reviewers`: ask accounts to review (the /// author, or a writer). Answers with everyone asked now. async fn request_reviewers( 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("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = 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 Some(me.id) != p.author_id && role < Role::Write { return Err(forbidden("only the author or a writer asks for reviews")); } if !p.is_open() { return Err(ApiError(StatusCode::CONFLICT, "the proposal is not open".into())); } crate::web::proposals::request_reviewers(&app, &row, &p, &me, &body.reviewers) .await .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; Ok(Json(db::proposals::review_requests(&app.db, p.id).await?)) } /// `DELETE .../proposals/{number}/reviewers/{username}`: withdraw a request. async fn withdraw_reviewer( State(app): State, headers: HeaderMap, Path((owner, name, number, username)): Path<(String, String, i64, String)>, ) -> Result { let c = caller(&app, &headers).await?; let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); let (_, row, role) = 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 Some(me.id) != p.author_id && role < Role::Write { return Err(forbidden("only the author or a writer withdraws review requests")); } let a = db::accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| not_found("no such account"))?; if db::proposals::unrequest_review(&app.db, p.id, a.id).await? { db::proposals::add_event(&app.db, p.id, &me.username, "review request withdrawn", &a.username).await.ok(); } Ok(StatusCode::NO_CONTENT) } 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_summary(&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 = $1").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, } #[derive(Deserialize)] struct ProposalsQuery { state: Option, limit: 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, line: 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, line: 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