| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use crate::auth::{self, Access, Role}; |
| 7 | | use crate::db::{self, accounts::Account}; |
| 8 | | use crate::repos::{view, RepoError, RepoName}; |
| 9 | | use crate::Shared; |
| 10 | | use axum::extract::{Path, Query, State}; |
| 11 | | use axum::http::{HeaderMap, StatusCode}; |
| 12 | | use axum::response::{IntoResponse, Response}; |
| 13 | | use axum::routing::{get, post}; |
| 14 | | use axum::{Json, Router}; |
| 15 | | use hub_api::*; |
| 16 | | use serde::Deserialize; |
| 17 | |
|
| 18 | | mod attachments; |
| 19 | | mod backups; |
| 20 | | pub mod device; |
| 21 | | pub use attachments::image_type as attachment_image_type; |
| 22 | | mod drops; |
| 23 | | mod images; |
| 24 | | mod projects; |
| 25 | | mod reports; |
| 26 | |
|
| 27 | |
|
| 28 | |
|
| 29 | |
|
| 30 | 1× | async fn version() -> Json<serde_json::Value> { |
| 31 | 1× | Json(crate::web::home::versions()) |
| 32 | 1× | } |
| 33 | |
|
| 34 | 1× | async fn no_route() -> ApiError { |
| 35 | 1× | ApiError(StatusCode::NOT_FOUND, "no such API route".into()) |
| 36 | 1× | } |
| 37 | |
|
| 38 | |
|
| 39 | |
|
| 40 | 1× | async fn wrong_method(method: axum::http::Method, uri: axum::http::Uri) -> ApiError { |
| 41 | 1× | ApiError(StatusCode::METHOD_NOT_ALLOWED, format!("{} is an API route, but not for {method}", uri.path())) |
| 42 | 1× | } |
| 43 | |
|
| 44 | |
|
| 45 | |
|
| 46 | 2853× | pub fn router(asset_limit: usize) -> Router<Shared> { |
| 47 | 2853× | Router::new() |
| 48 | 2853× | .fallback(no_route) |
| 49 | 2853× | .route("/keyless/certify", post(crate::keyless::certify)) |
| 50 | 2853× | .route("/agents/grants", get(crate::keyless::grants_list).post(crate::keyless::grants_create)) |
| 51 | 2853× | .route( |
| 52 | 2853× | "/repos/{owner}/{name}/terraform/state/{state}", |
| 53 | 2853× | axum::routing::any(crate::terraform::state) |
| 54 | 2853× | .layer(axum::extract::DefaultBodyLimit::max(crate::terraform::MAX_STATE_BYTES + 4096)), |
| 55 | | ) |
| 56 | 2853× | .route("/repos/{owner}/{name}/terraform/state/{state}/versions", axum::routing::get(crate::terraform::versions)) |
| 57 | 2853× | .route("/repos/{owner}/{name}/terraform/state/{state}/versions/{id}", axum::routing::get(crate::terraform::version)) |
| 58 | 2853× | .route( |
| 59 | 2853× | "/repos/{owner}/{name}/terraform/state/{state}/versions/{id}/restore", |
| 60 | 2853× | axum::routing::post(crate::terraform::restore_version), |
| 61 | | ) |
| 62 | 2853× | .route("/agents/grants/{id}", axum::routing::delete(crate::keyless::grants_revoke)) |
| 63 | 2853× | .route("/flow/claim", post(crate::flow::claim)) |
| 64 | 2853× | .route("/flow/runners/register", post(crate::flow::register)) |
| 65 | 2853× | .route("/flow/runners/heartbeat", post(crate::flow::heartbeat)) |
| 66 | 2853× | .route("/flow/runner/update", get(crate::flow::runner_update).post(crate::flow::report_update)) |
| 67 | 2853× | .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES))) |
| 68 | 2853× | .route("/flow/images/held", post(images::held)) |
| 69 | 2853× | .route("/flow/swarm/announce", post(crate::swarm::announce)) |
| 70 | 2853× | .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk)) |
| 71 | 2853× | .route( |
| 72 | 2853× | "/flow/images/{hash}", |
| 73 | 2853× | axum::routing::put(images::put_manifest).get(images::get_manifest).layer(axum::extract::DefaultBodyLimit::max(64 << 20)), |
| 74 | | ) |
| 75 | | |
| 76 | | |
| 77 | 2853× | .route("/flow/images/{hash}/held", axum::routing::put(images::put_held).layer(axum::extract::DefaultBodyLimit::max(64 << 20))) |
| 78 | 2853× | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
| 79 | 2853× | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
| 80 | 2853× | .route("/flow/jobs/{id}/decline", post(crate::flow::decline)) |
| 81 | 2853× | .route("/flow/jobs/{id}/release", post(crate::flow::release)) |
| 82 | 2853× | .route( |
| 83 | 2853× | "/flow/jobs/{id}/checkpoint", |
| 84 | 2853× | post(crate::flow::save_checkpoint) |
| 85 | 2853× | .get(crate::flow::checkpoint) |
| 86 | 2853× | .layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
| 87 | | ) |
| 88 | 2853× | .route( |
| 89 | 2853× | "/flow/jobs/{id}/artifacts/{name}", |
| 90 | 2853× | post(crate::flow::artifact).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
| 91 | | ) |
| 92 | 2853× | .route("/flow/jobs/{id}/inputs/{index}", get(crate::flow::input)) |
| 93 | 2853× | .route( |
| 94 | 2853× | "/flow/jobs/{id}/reports/{name}/files", |
| 95 | 2853× | post(crate::flow::report_file).layer(axum::extract::DefaultBodyLimit::max(crate::flow::MAX_ARTIFACT_BYTES + (1 << 20))), |
| 96 | | ) |
| 97 | 2853× | .route("/projects/{owner}", get(projects::list).post(projects::create)) |
| 98 | 2853× | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
| 99 | 2853× | .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) |
| 100 | 2853× | .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) |
| 101 | 2853× | .route("/projects/{owner}/{number}/cards/archive", post(projects::archive_cards)) |
| 102 | 2853× | .route("/projects/{owner}/{number}/cards/{id}/restore", post(projects::restore_card)) |
| 103 | 2853× | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
| 104 | 2853× | .route("/device/code", post(device::code).delete(device::cancel)) |
| 105 | 2853× | .route("/device/token", post(device::token)) |
| 106 | 2853× | .route("/runners", get(runners)) |
| 107 | 2853× | .route("/runners/queue", get(runner_queue)) |
| 108 | 2853× | .route("/runners/{id}/jobs", get(runner_jobs)) |
| 109 | 2853× | .route("/user", get(me)) |
| 110 | 2853× | .route("/user/repos", get(user_repos)) |
| 111 | 2853× | .route("/user/orgs", get(user_orgs)) |
| 112 | 2853× | .route("/user/proposals", get(user_proposals)) |
| 113 | 2853× | .route("/user/issues", get(user_issues)) |
| 114 | 2853× | .route("/user/sky", get(user_sky)) |
| 115 | 2853× | .route("/user/starter", get(user_starter)) |
| 116 | 2853× | .route("/user/flow/runs", get(user_flow_runs)) |
| 117 | 2853× | .route("/user/runner-token", post(user_runner_token)) |
| 118 | 2853× | .route("/user/token", axum::routing::delete(revoke_own_token)) |
| 119 | 2853× | .route("/user/push", axum::routing::put(register_push).delete(forget_push)) |
| 120 | 2853× | .route("/version", get(version)) |
| 121 | 2853× | .route("/notifications", get(notifications)) |
| 122 | 2853× | .route("/notifications/read", post(notifications_read_all)) |
| 123 | 2853× | .route("/notifications/{id}/read", post(notification_read)) |
| 124 | 2853× | .route("/users/{name}", get(user)) |
| 125 | 2853× | .route("/users/{name}/drops", get(drops::of_user)) |
| 126 | 2853× | .route("/drops", get(drops::mine).post(drops::create)) |
| 127 | 2853× | .route("/drops/{id}", get(drops::view).patch(drops::edit).delete(drops::delete)) |
| 128 | 2853× | .route("/drops/{id}/fork", post(drops::fork)) |
| 129 | 2853× | .route( |
| 130 | 2853× | "/repos/{owner}/{name}/attachments", |
| 131 | 2853× | post(attachments::upload).layer(axum::extract::DefaultBodyLimit::max(attachments::ATTACHMENT_MAX + 4096)), |
| 132 | | ) |
| 133 | 2853× | .route("/drops/{id}/star", axum::routing::put(drops::star).delete(drops::unstar)) |
| 134 | 2853× | .route("/drops/{id}/comments", get(drops::comments).post(drops::add_comment)) |
| 135 | 2853× | .route("/drops/{id}/comments/{n}", axum::routing::delete(drops::delete_comment)) |
| 136 | 2853× | .route("/orgs/{name}", get(org)) |
| 137 | 2853× | .route("/search", get(search)) |
| 138 | 2853× | .route("/repos", get(list_repos).post(create_repo)) |
| 139 | 2853× | .route("/repos/{owner}/{name}", get(get_repo)) |
| 140 | 2853× | .route("/repos/{owner}/{name}/bookmarks", get(bookmarks)) |
| 141 | 2853× | .route("/repos/{owner}/{name}/tags", get(tags).post(create_tag)) |
| 142 | 2853× | .route("/repos/{owner}/{name}/tags/{tag}", axum::routing::delete(withdraw_tag)) |
| 143 | 2853× | .route("/repos/{owner}/{name}/consumers", get(consumers)) |
| 144 | 2853× | .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion)) |
| 145 | 2853× | .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup)) |
| 146 | 2853× | .route("/user/backups", get(backups::user_get).put(backups::user_put)) |
| 147 | 2853× | .route("/user/backups/test", post(backups::user_test)) |
| 148 | 2853× | .route("/user/backups/run", post(backups::user_run)) |
| 149 | 2853× | .route("/user/backups/runs", get(backups::user_runs)) |
| 150 | 2853× | .route("/orgs/{name}/backups", get(backups::org_get).put(backups::org_put)) |
| 151 | 2853× | .route("/orgs/{name}/backups/test", post(backups::org_test)) |
| 152 | 2853× | .route("/orgs/{name}/backups/run", post(backups::org_run)) |
| 153 | 2853× | .route("/orgs/{name}/backups/runs", get(backups::org_runs)) |
| 154 | 2853× | .route("/repos/{owner}/{name}/history/{r}", get(history)) |
| 155 | 2853× | .route("/repos/{owner}/{name}/changes/{hash}", get(change)) |
| 156 | 2853× | .route("/repos/{owner}/{name}/tree/{r}", get(tree_root)) |
| 157 | 2853× | .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree)) |
| 158 | 2853× | .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file)) |
| 159 | 2853× | .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame)) |
| 160 | 2853× | .route("/repos/{owner}/{name}/conflicts", get(conflicts)) |
| 161 | 2853× | .route("/repos/{owner}/{name}/trust", get(trust)) |
| 162 | 2853× | .route("/repos/{owner}/{name}/releases/trust/{*tag}", get(release_trust)) |
| 163 | 2853× | .route("/repos/{owner}/{name}/pushes", get(pushes)) |
| 164 | 2853× | .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal)) |
| 165 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}", get(proposal).patch(edit_proposal)) |
| 166 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal)) |
| 167 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/auto-land", post(auto_land_on).delete(auto_land_off)) |
| 168 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment)) |
| 169 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review)) |
| 170 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/reviewers", post(request_reviewers)) |
| 171 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/review-requests", post(request_assistant)) |
| 172 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/reviewers/{username}", axum::routing::delete(withdraw_reviewer)) |
| 173 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks)) |
| 174 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files)) |
| 175 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/diff", get(proposal_diff)) |
| 176 | 2853× | .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content)) |
| 177 | 2853× | .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue)) |
| 178 | 2853× | .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state).patch(issue_edit)) |
| 179 | 2853× | .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment)) |
| 180 | 2853× | .route("/repos/{owner}/{name}/labels", get(labels).post(create_label)) |
| 181 | 2853× | .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label)) |
| 182 | 2853× | .route("/repos/{owner}/{name}/milestones", get(milestones).post(create_milestone)) |
| 183 | 2853× | .route( |
| 184 | 2853× | "/repos/{owner}/{name}/coverage", |
| 185 | 2853× | get(reports::coverage_list) |
| 186 | 2853× | .post(reports::coverage_upload) |
| 187 | 2853× | .layer(axum::extract::DefaultBodyLimit::max(crate::coverage::MAX_REPORT + (1 << 20))), |
| 188 | | ) |
| 189 | 2853× | .route("/repos/{owner}/{name}/coverage/{id}", get(reports::coverage_one)) |
| 190 | 2853× | .route("/repos/{owner}/{name}/tests", get(reports::tests_list)) |
| 191 | 2853× | .route("/repos/{owner}/{name}/tests/{id}", get(reports::tests_one)) |
| 192 | 2853× | .route("/repos/{owner}/{name}/milestones/{title}", axum::routing::patch(edit_milestone)) |
| 193 | 2853× | .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook)) |
| 194 | 2853× | .route("/repos/{owner}/{name}/assistants", get(repo_assistants).post(repo_assistant_put)) |
| 195 | 2853× | .route("/repos/{owner}/{name}/assistants/prompt", get(repo_prompt).put(repo_prompt_put)) |
| 196 | 2853× | .route("/repos/{owner}/{name}/assistants/{assistant}", axum::routing::delete(repo_assistant_delete)) |
| 197 | 2853× | .route("/orgs/{org}/assistants", get(org_assistants).post(org_assistant_put)) |
| 198 | 2853× | .route("/orgs/{org}/assistants/prompt", get(org_prompt).put(org_prompt_put)) |
| 199 | 2853× | .route("/orgs/{org}/assistants/{assistant}", axum::routing::delete(org_assistant_delete)) |
| 200 | 2853× | .route("/admin/runner-updates", get(runner_updates)) |
| 201 | 2853× | .route("/admin/runner-updates/settings", axum::routing::put(runner_update_setting)) |
| 202 | 2853× | .route("/admin/runner-updates/holds/{version}/lift", post(runner_update_lift)) |
| 203 | 2853× | .route("/admin/assistants", get(site_assistants).post(site_assistant_put)) |
| 204 | 2853× | .route("/admin/assistants/prompt", get(site_prompt).put(site_prompt_put)) |
| 205 | 2853× | .route("/admin/assistants/{assistant}", axum::routing::delete(site_assistant_delete)) |
| 206 | 2853× | .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) |
| 207 | 2853× | .route("/repos/{owner}/{name}/checks", post(post_check)) |
| 208 | 2853× | .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run)) |
| 209 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run)) |
| 210 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log)) |
| 211 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reports", get(reports::job_reports)) |
| 212 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report", get(reports::job_report_one)) |
| 213 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/report/{*path}", get(reports::job_report_file)) |
| 214 | 2853× | .route("/repos/{owner}/{name}/reports", get(reports::latest_reports)) |
| 215 | 2853× | .route("/repos/{owner}/{name}/reports/{report}", get(reports::named_report_one)) |
| 216 | 2853× | .route("/repos/{owner}/{name}/reports/{report}/files/{*path}", get(reports::named_report_file)) |
| 217 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
| 218 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job)) |
| 219 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run)) |
| 220 | 2853× | .route("/repos/{owner}/{name}/deployments", get(list_deployments)) |
| 221 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/expedite", post(expedite_flow_run)) |
| 222 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approve", post(approve_flow_job)) |
| 223 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reject", post(reject_flow_job)) |
| 224 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approval", get(flow_job_statement)) |
| 225 | 2853× | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/seal", post(seal_flow_job)) |
| 226 | 2853× | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
| 227 | 2853× | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
| 228 | 2853× | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
| 229 | 2853× | .route("/repos/{owner}/{name}/sky", get(repo_sky)) |
| 230 | 2853× | .route("/repos/{owner}/{name}/starter", get(starter_status).post(start_starter)) |
| 231 | 2853× | .route("/repos/{owner}/{name}/releases/{tag}", axum::routing::patch(edit_release)) |
| 232 | 2853× | .route( |
| 233 | 2853× | "/repos/{owner}/{name}/releases/{tag}/assets", |
| 234 | 2853× | post(upload_asset).layer(axum::extract::DefaultBodyLimit::max(asset_limit + (1 << 20))), |
| 235 | | ) |
| 236 | | |
| 237 | 2853× | .method_not_allowed_fallback(wrong_method) |
| 238 | 2853× | } |
| 239 | |
|
| 240 | 3× | async fn releases( |
| 241 | 3× | State(app): State<Shared>, |
| 242 | 3× | headers: HeaderMap, |
| 243 | 3× | Path((owner, name)): Path<(String, String)>, |
| 244 | 3× | ) -> Result<Json<Vec<Release>>, ApiError> { |
| 245 | 3× | let c = caller(&app, &headers).await?; |
| 246 | 3× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 247 | 3× | Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?)) |
| 248 | 3× | } |
| 249 | |
|
| 250 | |
|
| 251 | |
|
| 252 | |
|
| 253 | 2× | async fn repo_sky( |
| 254 | 2× | State(app): State<Shared>, |
| 255 | 2× | headers: HeaderMap, |
| 256 | 2× | Path((owner, name)): Path<(String, String)>, |
| 257 | 2× | ) -> Result<Json<hub_api::UserSky>, ApiError> { |
| 258 | 2× | let c = caller(&app, &headers).await?; |
| 259 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 260 | 1× | let now = db::now_ms(); |
| 261 | 1× | let since = now - crate::web::home::SKY_DAYS * 86_400_000; |
| 262 | 1× | let events = db::activity::sky(&app.db, &[row.id], since, crate::web::home::SKY_EVENTS).await?; |
| 263 | | Ok(Json(hub_api::UserSky { |
| 264 | 1× | now, |
| 265 | 1× | repos: vec![format!("{}/{}", row.owner, row.name)], |
| 266 | 5× | events: events.iter().map(|e| [0, e.kind, e.at_ms, e.n]).collect(), |
| 267 | | })) |
| 268 | 2× | } |
| 269 | |
|
| 270 | 3× | async fn create_release( |
| 271 | 3× | State(app): State<Shared>, |
| 272 | 3× | headers: HeaderMap, |
| 273 | 3× | Path((owner, name)): Path<(String, String)>, |
| 274 | 3× | Json(body): Json<NewRelease>, |
| 275 | 3× | ) -> Result<(StatusCode, Json<Release>), ApiError> { |
| 276 | 3× | let c = caller(&app, &headers).await?; |
| 277 | 3× | let me = c.require("repo:write")?.clone(); |
| 278 | 3× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 279 | 3× | crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease) |
| 280 | 3× | .await |
| 281 | 3× | .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
| 282 | 2× | let r = crate::web::releases::api_views(&app, &repo, &row) |
| 283 | 2× | .await? |
| 284 | 2× | .into_iter() |
| 285 | 2× | .find(|r| r.tag == body.tag) |
| 286 | 2× | .ok_or_else(|| not_found("no such release"))?; |
| 287 | 2× | Ok((StatusCode::CREATED, Json(r))) |
| 288 | 3× | } |
| 289 | |
|
| 290 | |
|
| 291 | |
|
| 292 | 2× | async fn edit_release( |
| 293 | 2× | State(app): State<Shared>, |
| 294 | 2× | headers: HeaderMap, |
| 295 | 2× | Path((owner, name, tag)): Path<(String, String, String)>, |
| 296 | 2× | Json(body): Json<ReleaseEdit>, |
| 297 | 2× | ) -> Result<Json<Release>, ApiError> { |
| 298 | 2× | let c = caller(&app, &headers).await?; |
| 299 | 2× | let me = c.require("repo:write")?.clone(); |
| 300 | 2× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 301 | 2× | let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; |
| 302 | 1× | let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&r.title); |
| 303 | 1× | let notes = body.notes.as_deref().map(str::trim).unwrap_or(&r.notes); |
| 304 | 1× | db::releases::edit(&app.db, r.id, title, notes, body.prerelease.unwrap_or(r.prerelease)).await?; |
| 305 | 1× | db::accounts::audit(&app.db, Some(&me), "release.edited", &format!("{owner}/{name}@{tag}"), "").await.ok(); |
| 306 | 1× | let r = crate::web::releases::api_views(&app, &repo, &row) |
| 307 | 1× | .await? |
| 308 | 1× | .into_iter() |
| 309 | 1× | .find(|r| r.tag == tag) |
| 310 | 1× | .ok_or_else(|| not_found("no such release"))?; |
| 311 | 1× | Ok(Json(r)) |
| 312 | 2× | } |
| 313 | |
|
| 314 | | #[derive(Deserialize)] |
| 315 | | struct AssetQuery { |
| 316 | | name: String, |
| 317 | | } |
| 318 | |
|
| 319 | |
|
| 320 | 3× | async fn upload_asset( |
| 321 | 3× | State(app): State<Shared>, |
| 322 | 3× | headers: HeaderMap, |
| 323 | 3× | Path((owner, name, tag)): Path<(String, String, String)>, |
| 324 | 3× | Query(q): Query<AssetQuery>, |
| 325 | 3× | body: axum::body::Bytes, |
| 326 | 3× | ) -> Result<(StatusCode, Json<Release>), ApiError> { |
| 327 | 3× | let c = caller(&app, &headers).await?; |
| 328 | 3× | c.require("repo:write")?; |
| 329 | 3× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 330 | 3× | let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; |
| 331 | 3× | let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string(); |
| 332 | 3× | crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
| 333 | 3× | let r = crate::web::releases::api_views(&app, &repo, &row) |
| 334 | 3× | .await? |
| 335 | 3× | .into_iter() |
| 336 | 3× | .find(|r| r.tag == tag) |
| 337 | 3× | .ok_or_else(|| not_found("no such release"))?; |
| 338 | 3× | Ok((StatusCode::CREATED, Json(r))) |
| 339 | 3× | } |
| 340 | |
|
| 341 | | pub struct ApiError(pub StatusCode, pub String); |
| 342 | |
|
| 343 | | impl IntoResponse for ApiError { |
| 344 | 192× | fn into_response(self) -> Response { |
| 345 | 192× | let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 }; |
| 346 | 192× | (self.0, [("content-type", "application/problem+json")], Json(body)).into_response() |
| 347 | 192× | } |
| 348 | | } |
| 349 | |
|
| 350 | | impl From<RepoError> for ApiError { |
| 351 | 1× | fn from(e: RepoError) -> ApiError { |
| 352 | 0× | match e { |
| 353 | 1× | RepoError::NotFound => not_found("no such repository"), |
| 354 | 0× | RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m), |
| 355 | 0× | other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), |
| 356 | | } |
| 357 | 1× | } |
| 358 | | } |
| 359 | |
|
| 360 | | impl From<sqlx::Error> for ApiError { |
| 361 | 0× | fn from(e: sqlx::Error) -> ApiError { |
| 362 | 0× | ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) |
| 363 | 0× | } |
| 364 | | } |
| 365 | |
|
| 366 | 32× | pub(crate) fn not_found(what: &str) -> ApiError { |
| 367 | 32× | ApiError(StatusCode::NOT_FOUND, what.into()) |
| 368 | 32× | } |
| 369 | |
|
| 370 | 82× | pub(crate) fn forbidden(what: &str) -> ApiError { |
| 371 | 82× | ApiError(StatusCode::FORBIDDEN, what.into()) |
| 372 | 82× | } |
| 373 | |
|
| 374 | |
|
| 375 | |
|
| 376 | |
|
| 377 | 1166× | pub fn client_addr(app: &Shared, headers: &HeaderMap, extensions: &axum::http::Extensions) -> String { |
| 378 | 1166× | let from_proxy = || { |
| 379 | 30× | ["cf-connecting-ip", "x-forwarded-for", "x-real-ip"] |
| 380 | 30× | .iter() |
| 381 | 60× | .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())) |
| 382 | 30× | }; |
| 383 | 1166× | let addr = if app.config.server.behind_proxy { |
| 384 | 30× | from_proxy() |
| 385 | | } else { |
| 386 | 1136× | extensions.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>().map(|c| c.0.ip().to_string()) |
| 387 | | }; |
| 388 | 1166× | addr.map(|a| a.chars().filter(|c| c.is_ascii_hexdigit() || matches!(c, '.' | ':')).take(45).collect()).unwrap_or_default() |
| 389 | 1166× | } |
| 390 | |
|
| 391 | |
|
| 392 | |
|
| 393 | 3× | async fn revoke_own_token(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
| 394 | 3× | let c = caller(&app, &headers).await?; |
| 395 | 3× | let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else { |
| 396 | 1× | return Err(ApiError(StatusCode::UNAUTHORIZED, "revoking needs the personal access token to revoke".into())); |
| 397 | | }; |
| 398 | 2× | db::accounts::delete_token(&app.db, account.id, id).await?; |
| 399 | 2× | db::accounts::audit(&app.db, Some(account), "token.revoke", &c.token, "").await.ok(); |
| 400 | 2× | Ok(StatusCode::NO_CONTENT) |
| 401 | 3× | } |
| 402 | |
|
| 403 | | #[derive(Deserialize)] |
| 404 | | struct PushRegistration { |
| 405 | | |
| 406 | | fcm_token: String, |
| 407 | | |
| 408 | | public_key: String, |
| 409 | | } |
| 410 | |
|
| 411 | |
|
| 412 | |
|
| 413 | |
|
| 414 | 4× | async fn register_push( |
| 415 | 4× | State(app): State<Shared>, |
| 416 | 4× | headers: HeaderMap, |
| 417 | 4× | Json(r): Json<PushRegistration>, |
| 418 | 4× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 419 | 4× | let c = caller(&app, &headers).await?; |
| 420 | 4× | let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else { |
| 421 | 0× | return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone registers with the token it signed in with".into())); |
| 422 | | }; |
| 423 | 4× | let key = r.public_key.trim().to_ascii_lowercase(); |
| 424 | 4× | if key.len() != 64 || hex::decode(&key).is_err() { |
| 425 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is 32 bytes of hex".into())); |
| 426 | 3× | } |
| 427 | 3× | let fcm = r.fcm_token.trim(); |
| 428 | 3× | if fcm.is_empty() || fcm.len() > 4096 { |
| 429 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "fcm_token is the phone's registration token".into())); |
| 430 | 3× | } |
| 431 | 3× | db::push::register(&app.db, id, account.id, fcm, &key).await?; |
| 432 | 3× | Ok(Json(serde_json::json!({ "alerts": crate::push::enabled(&app) }))) |
| 433 | 4× | } |
| 434 | |
|
| 435 | |
|
| 436 | 1× | async fn forget_push(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
| 437 | 1× | let c = caller(&app, &headers).await?; |
| 438 | 1× | let Some(id) = c.token_id.filter(|_| c.account.is_some()) else { |
| 439 | 0× | return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone unregisters with the token it signed in with".into())); |
| 440 | | }; |
| 441 | 1× | db::push::forget(&app.db, id).await?; |
| 442 | 1× | Ok(StatusCode::NO_CONTENT) |
| 443 | 1× | } |
| 444 | |
|
| 445 | |
|
| 446 | | pub struct Caller { |
| 447 | | pub account: Option<Account>, |
| 448 | | pub scopes: Vec<String>, |
| 449 | | pub site: bool, |
| 450 | | |
| 451 | | pub token: String, |
| 452 | | |
| 453 | | pub token_id: Option<i64>, |
| 454 | | |
| 455 | | pub agent: bool, |
| 456 | | } |
| 457 | |
|
| 458 | | impl Caller { |
| 459 | 360× | fn has(&self, scope: &str) -> bool { |
| 460 | 360× | self.site |
| 461 | 364× | || self.scopes.iter().any(|s| { |
| 462 | 364× | s == scope |
| 463 | 235× | || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin")) |
| 464 | 229× | || (scope == "repo:write" && s == "repo:admin") |
| 465 | 364× | }) |
| 466 | 360× | } |
| 467 | |
|
| 468 | 376× | fn require(&self, scope: &str) -> Result<&Account, ApiError> { |
| 469 | | |
| 470 | 376× | let Some(account) = self.account.as_ref() else { |
| 471 | 16× | return Err(ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required: sign in (`oh auth login`)".into())); |
| 472 | | }; |
| 473 | 360× | if !self.has(scope) { |
| 474 | 68× | return Err(forbidden(&format!("this token lacks the {scope} scope"))); |
| 475 | 292× | } |
| 476 | 292× | Ok(account) |
| 477 | 376× | } |
| 478 | | } |
| 479 | |
|
| 480 | 525× | pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> { |
| 481 | 525× | let Some(t) = oxy_server::bearer_token(headers) else { |
| 482 | 116× | return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new(), token_id: None, agent: false }); |
| 483 | | }; |
| 484 | 409× | if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) { |
| 485 | 172× | let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin); |
| 486 | 172× | return Ok(Caller { |
| 487 | 172× | account: admin, |
| 488 | 172× | scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], |
| 489 | 172× | site: true, |
| 490 | 172× | token: "site token".into(), |
| 491 | 172× | token_id: None, |
| 492 | 172× | agent: false, |
| 493 | 172× | }); |
| 494 | 237× | } |
| 495 | | |
| 496 | | |
| 497 | | |
| 498 | 237× | if t.starts_with(crate::keyless::GRANT_TOKEN_PREFIX) { |
| 499 | 3× | let grant = db::accounts::agent_grant_by_token(&app.db, &auth::token_hash(t)).await?.filter(|g| g.live(db::now_ms())); |
| 500 | 3× | let Some(g) = grant else { |
| 501 | 0× | return Err(ApiError(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into())); |
| 502 | | }; |
| 503 | 3× | let account = db::accounts::account_by_id(&app.db, g.account_id).await?.filter(|a| !a.blocked()); |
| 504 | 3× | db::accounts::touch_agent_grant(&app.db, g.id).await.ok(); |
| 505 | 3× | let mut scopes = vec!["repo:read".to_string(), "user:read".to_string()]; |
| 506 | 6× | if g.scope.split(',').any(|s| s.trim() == "refs") { |
| 507 | 3× | scopes.push("repo:write".into()); |
| 508 | 3× | } |
| 509 | 3× | return Ok(Caller { account, scopes, site: false, token: g.agent.clone(), token_id: None, agent: true }); |
| 510 | 234× | } |
| 511 | 234× | let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?; |
| 512 | 230× | match token { |
| 513 | 230× | Some(tok) if !tok.expired(db::now_ms()) => { |
| 514 | 230× | let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.blocked()); |
| 515 | 230× | db::accounts::touch_token(&app.db, tok.id).await.ok(); |
| 516 | | Ok(Caller { |
| 517 | 230× | account, |
| 518 | 340× | scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), |
| 519 | | site: false, |
| 520 | 230× | token: tok.name.clone(), |
| 521 | 230× | token_id: Some(tok.id), |
| 522 | 230× | agent: tok.agent, |
| 523 | | }) |
| 524 | | } |
| 525 | 4× | _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), |
| 526 | | } |
| 527 | 525× | } |
| 528 | |
|
| 529 | 17× | fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository { |
| 530 | 17× | Repository { |
| 531 | 17× | owner: r.owner.clone(), |
| 532 | 17× | name: r.name.clone(), |
| 533 | 17× | description: r.description.clone(), |
| 534 | 17× | default_bookmark: r.default_bookmark.clone(), |
| 535 | 17× | visibility: r.visibility(), |
| 536 | 17× | created_ms: r.created_ms, |
| 537 | 17× | updated_ms: r.updated_ms, |
| 538 | 17× | clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name), |
| 539 | 17× | } |
| 540 | 17× | } |
| 541 | |
|
| 542 | |
|
| 543 | 305× | pub(super) async fn reach( |
| 544 | 305× | app: &Shared, |
| 545 | 305× | c: &Caller, |
| 546 | 305× | owner: &str, |
| 547 | 305× | name: &str, |
| 548 | 305× | access: Access, |
| 549 | 305× | ) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> { |
| 550 | | |
| 551 | | |
| 552 | | |
| 553 | 305× | if let Some(base) = name.strip_suffix(".wiki") { |
| 554 | 2× | if let Some(row) = db::repos::get(&app.db, owner, base).await? { |
| 555 | 1× | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
| 556 | 1× | if auth::roles::allows(role, Access::Read) { |
| 557 | 1× | return Err(not_found(&format!( |
| 558 | 1× | "{owner}/{name} is the wiki of {owner}/{base}: it is cloned and pushed with oxy, and takes no proposals, \ |
| 559 | 1× | issues or runs; to publish a bookmark, merge it into main (oxy merge BOOKMARK on main) and push main" |
| 560 | 1× | ))); |
| 561 | 0× | } |
| 562 | 1× | } |
| 563 | 303× | } |
| 564 | 304× | let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?; |
| 565 | 304× | let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?; |
| 566 | | |
| 567 | 302× | if row.is_drop() { |
| 568 | 1× | return Err(not_found("no such repository")); |
| 569 | 301× | } |
| 570 | 301× | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
| 571 | 301× | if !auth::roles::allows(role, access) { |
| 572 | 1× | return Err(not_found("no such repository")); |
| 573 | 300× | } |
| 574 | 300× | Ok((repo, row, role.unwrap_or(Role::Read))) |
| 575 | 305× | } |
| 576 | |
|
| 577 | 10× | fn summary(c: &view::ChangeView) -> ChangeSummary { |
| 578 | | ChangeSummary { |
| 579 | 10× | hash: c.hash.clone(), |
| 580 | 10× | message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) }, |
| 581 | 10× | author: Author { |
| 582 | 10× | name: c.author.name.clone(), |
| 583 | 10× | email: c.author.email.clone(), |
| 584 | 10× | key: c.author.key.clone(), |
| 585 | 10× | account: c.author.account.clone(), |
| 586 | 10× | note: Author::note_for(&c.author.key, &c.author.account), |
| 587 | 10× | }, |
| 588 | 10× | when_ms: c.when_ms, |
| 589 | 10× | deps: c.deps.clone(), |
| 590 | 10× | head: c.head, |
| 591 | 10× | via: c.via.as_ref().map(|v| Via { |
| 592 | 4× | name: v.name.clone(), |
| 593 | 4× | key: v.key.clone(), |
| 594 | 4× | scope: v.scope.clone(), |
| 595 | 4× | expires_ms: v.expires_ms, |
| 596 | 4× | revoked: v.revoked, |
| 597 | 4× | }), |
| 598 | 10× | signature: match &c.signature { |
| 599 | 3× | view::SigView::Unsigned => Signature::Unsigned, |
| 600 | 7× | view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked }, |
| 601 | 0× | view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() }, |
| 602 | | }, |
| 603 | | } |
| 604 | 10× | } |
| 605 | |
|
| 606 | |
|
| 607 | |
|
| 608 | 6× | async fn me(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<User>, ApiError> { |
| 609 | 6× | let c = caller(&app, &headers).await?; |
| 610 | 5× | let a = c.require("user:read")?; |
| 611 | 3× | Ok(Json(user_of(&app, a).await?)) |
| 612 | 6× | } |
| 613 | |
|
| 614 | | #[derive(Deserialize)] |
| 615 | | struct InboxQuery { |
| 616 | | #[serde(default)] |
| 617 | | limit: Option<i64>, |
| 618 | | } |
| 619 | |
|
| 620 | |
|
| 621 | |
|
| 622 | 7× | async fn notifications( |
| 623 | 7× | State(app): State<Shared>, |
| 624 | 7× | headers: HeaderMap, |
| 625 | 7× | Query(q): Query<InboxQuery>, |
| 626 | 7× | ) -> Result<Json<hub_api::Inbox>, ApiError> { |
| 627 | 7× | let c = caller(&app, &headers).await?; |
| 628 | 7× | let a = c.require("user:read")?; |
| 629 | 5× | let limit = q.limit.unwrap_or(50).clamp(1, 200); |
| 630 | 5× | let base = app.config.base_url().trim_end_matches('/').to_string(); |
| 631 | 5× | let rows = db::notify::inbox(&app.db, a.id, limit).await?; |
| 632 | | |
| 633 | 5× | let mut repos: std::collections::HashMap<i64, Option<String>> = std::collections::HashMap::new(); |
| 634 | 10× | for id in rows.iter().filter_map(|n| n.repo_id) { |
| 635 | 10× | if let std::collections::hash_map::Entry::Vacant(e) = repos.entry(id) { |
| 636 | 5× | e.insert(db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", r.owner, r.name))); |
| 637 | 5× | } |
| 638 | | } |
| 639 | 30× | let absolute = |u: String| if u.starts_with('/') { format!("{base}{u}") } else { u }; |
| 640 | 5× | let items = rows |
| 641 | 5× | .into_iter() |
| 642 | 5× | .map(|n| hub_api::Notification { |
| 643 | 15× | id: n.id, |
| 644 | 15× | at_ms: n.at_ms, |
| 645 | 15× | label: crate::notify::label(&n.kind).map(String::from), |
| 646 | 15× | kind: n.kind, |
| 647 | 15× | title: n.title, |
| 648 | 15× | url: absolute(n.url), |
| 649 | 15× | actor: n.actor, |
| 650 | 15× | read: n.read, |
| 651 | 15× | repository: n.repo_id.and_then(|id| repos.get(&id).cloned().flatten()), |
| 652 | 15× | reason: n.reason, |
| 653 | 15× | thread: if n.thread.is_empty() { String::new() } else { absolute(n.thread) }, |
| 654 | 15× | }) |
| 655 | 5× | .collect(); |
| 656 | 5× | Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items })) |
| 657 | 7× | } |
| 658 | |
|
| 659 | 1× | async fn notification_read(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> { |
| 660 | 1× | let c = caller(&app, &headers).await?; |
| 661 | 1× | let a = c.require("user:read")?; |
| 662 | 1× | db::notify::mark_read(&app.db, a.id, Some(id)).await?; |
| 663 | 1× | Ok(StatusCode::NO_CONTENT) |
| 664 | 1× | } |
| 665 | |
|
| 666 | 1× | async fn notifications_read_all(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
| 667 | 1× | let c = caller(&app, &headers).await?; |
| 668 | 1× | let a = c.require("user:read")?; |
| 669 | 1× | db::notify::mark_read(&app.db, a.id, None).await?; |
| 670 | 1× | Ok(StatusCode::NO_CONTENT) |
| 671 | 1× | } |
| 672 | |
|
| 673 | 4× | async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> { |
| 674 | 4× | let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect(); |
| 675 | 4× | Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms }) |
| 676 | 4× | } |
| 677 | |
|
| 678 | 1× | async fn user(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<User>, ApiError> { |
| 679 | 1× | let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?; |
| 680 | 1× | Ok(Json(user_of(&app, &a).await?)) |
| 681 | 1× | } |
| 682 | |
|
| 683 | 0× | async fn org(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<Organisation>, ApiError> { |
| 684 | 0× | let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?; |
| 685 | 0× | let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect(); |
| 686 | 0× | Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members })) |
| 687 | 0× | } |
| 688 | |
|
| 689 | | #[derive(Deserialize)] |
| 690 | | struct SearchQuery { |
| 691 | | q: String, |
| 692 | | } |
| 693 | |
|
| 694 | 1× | async fn search( |
| 695 | 1× | State(app): State<Shared>, |
| 696 | 1× | headers: HeaderMap, |
| 697 | 1× | Query(q): Query<SearchQuery>, |
| 698 | 1× | ) -> Result<Json<Vec<serde_json::Value>>, ApiError> { |
| 699 | 1× | let c = caller(&app, &headers).await?; |
| 700 | 1× | let mut out = Vec::new(); |
| 701 | 1× | for h in db::notify::search(&app.db, &q.q, None, 50).await? { |
| 702 | 1× | let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue }; |
| 703 | 1× | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
| 704 | 1× | if !auth::roles::allows(role, Access::Read) { |
| 705 | 0× | continue; |
| 706 | 1× | } |
| 707 | 1× | out.push( |
| 708 | 1× | serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }), |
| 709 | | ); |
| 710 | | } |
| 711 | 1× | Ok(Json(out)) |
| 712 | 1× | } |
| 713 | |
|
| 714 | |
|
| 715 | |
|
| 716 | 3× | async fn list_repos(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Repository>>, ApiError> { |
| 717 | 3× | let c = caller(&app, &headers).await?; |
| 718 | 3× | let mut out = Vec::new(); |
| 719 | 3× | for r in db::repos::list(&app.db).await? { |
| 720 | 1× | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?; |
| 721 | 1× | if auth::roles::allows(role, Access::Read) { |
| 722 | 1× | out.push(repository(&app, &r)); |
| 723 | 1× | } |
| 724 | | } |
| 725 | 3× | Ok(Json(out)) |
| 726 | 3× | } |
| 727 | |
|
| 728 | | #[derive(Deserialize)] |
| 729 | | struct UserReposQuery { |
| 730 | | |
| 731 | | #[serde(default)] |
| 732 | | affiliation: Option<String>, |
| 733 | | |
| 734 | | #[serde(default)] |
| 735 | | after: Option<String>, |
| 736 | | #[serde(default)] |
| 737 | | limit: Option<usize>, |
| 738 | | } |
| 739 | |
|
| 740 | |
|
| 741 | |
|
| 742 | |
|
| 743 | |
|
| 744 | |
|
| 745 | |
|
| 746 | |
|
| 747 | 3× | async fn user_runner_token(State(app): State<Shared>, headers: HeaderMap) -> Result<(StatusCode, Json<RunnerToken>), ApiError> { |
| 748 | 3× | let c = caller(&app, &headers).await?; |
| 749 | 3× | let me = c.require("repo:write")?.clone(); |
| 750 | 1× | let token = format!("flowreg_{}", crate::auth::random_hex(24)); |
| 751 | 1× | let scope = db::runners::Scope { owner: me.username.clone(), repo_id: None }; |
| 752 | 1× | db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope).await?; |
| 753 | 1× | db::accounts::audit(&app.db, Some(&me), "flow.runner.token", &me.username, "registration token for the caller's repositories (API)") |
| 754 | 1× | .await |
| 755 | 1× | .ok(); |
| 756 | 1× | Ok((StatusCode::CREATED, Json(RunnerToken { token, owner: me.username }))) |
| 757 | 3× | } |
| 758 | |
|
| 759 | |
|
| 760 | |
|
| 761 | 3× | async fn user_issues(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<IssueBrief>>, ApiError> { |
| 762 | 3× | let c = caller(&app, &headers).await?; |
| 763 | 3× | let me = c.require("repo:read")?.clone(); |
| 764 | 2× | let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; |
| 765 | 2× | let seen: Vec<i64> = db::repos::list(&app.db) |
| 766 | 2× | .await? |
| 767 | 2× | .into_iter() |
| 768 | 4× | .filter(|r| me.is_admin || r.visibility() != Visibility::Private || member.contains(&r.id)) |
| 769 | 2× | .map(|r| r.id) |
| 770 | 2× | .collect(); |
| 771 | 2× | let base = app.config.base_url().trim_end_matches('/').to_string(); |
| 772 | | Ok(Json( |
| 773 | 2× | db::activity::assigned(&app.db, me.id, &seen) |
| 774 | 2× | .await? |
| 775 | 2× | .into_iter() |
| 776 | 2× | .map(|i| IssueBrief { |
| 777 | 1× | url: format!("{base}/{}/{}/issue/{}", i.owner, i.name, i.number), |
| 778 | 1× | repository: format!("{}/{}", i.owner, i.name), |
| 779 | 1× | number: i.number, |
| 780 | 1× | title: i.title, |
| 781 | 1× | updated_ms: i.updated_ms, |
| 782 | 1× | }) |
| 783 | 2× | .collect(), |
| 784 | | )) |
| 785 | 3× | } |
| 786 | |
|
| 787 | |
|
| 788 | |
|
| 789 | 6× | async fn user_proposals( |
| 790 | 6× | State(app): State<Shared>, |
| 791 | 6× | headers: HeaderMap, |
| 792 | 6× | Query(q): Query<UserProposalsQuery>, |
| 793 | 6× | ) -> Result<Json<UserProposals>, ApiError> { |
| 794 | 6× | let c = caller(&app, &headers).await?; |
| 795 | 6× | let me = c.require("repo:read")?.clone(); |
| 796 | | |
| 797 | | |
| 798 | 5× | let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; |
| 799 | 5× | let seen: Vec<i64> = db::repos::list(&app.db) |
| 800 | 5× | .await? |
| 801 | 5× | .into_iter() |
| 802 | 7× | .filter(|r| me.is_admin || r.visibility() != Visibility::Private || member.contains(&r.id)) |
| 803 | 5× | .map(|r| r.id) |
| 804 | 5× | .collect(); |
| 805 | 5× | let base = app.config.base_url().trim_end_matches('/').to_string(); |
| 806 | 5× | let brief = |p: db::activity::ProposalLine| ProposalBrief { |
| 807 | 5× | url: format!("{base}/{}/{}/proposal/{}", p.owner, p.name, p.number), |
| 808 | 5× | repository: format!("{}/{}", p.owner, p.name), |
| 809 | 5× | number: p.number, |
| 810 | 5× | title: p.title, |
| 811 | 5× | author: p.author, |
| 812 | 5× | state: match p.state.as_str() { |
| 813 | 5× | "draft" => ProposalState::Draft, |
| 814 | 5× | "landed" => ProposalState::Landed, |
| 815 | 5× | "closed" => ProposalState::Closed, |
| 816 | 5× | _ => ProposalState::Open, |
| 817 | | }, |
| 818 | 5× | conflicted: p.conflicted, |
| 819 | 5× | updated_ms: p.updated_ms, |
| 820 | 5× | }; |
| 821 | 5× | let landable = if q.landable { landable_for(&app, &c, &member, &base).await? } else { Vec::new() }; |
| 822 | | Ok(Json(UserProposals { |
| 823 | 5× | review: db::activity::awaiting_review(&app.db, me.id, &member).await?.into_iter().map(brief).collect(), |
| 824 | 5× | authored: db::activity::authored(&app.db, me.id, &seen).await?.into_iter().map(brief).collect(), |
| 825 | 5× | landable, |
| 826 | | })) |
| 827 | 6× | } |
| 828 | |
|
| 829 | |
|
| 830 | |
|
| 831 | |
|
| 832 | |
|
| 833 | 2× | async fn landable_for(app: &Shared, c: &Caller, member: &[i64], base: &str) -> Result<Vec<ProposalBrief>, ApiError> { |
| 834 | | use futures::StreamExt; |
| 835 | 2× | let mut todo = Vec::new(); |
| 836 | 2× | for row in db::repos::list(&app.db).await?.into_iter().filter(|r| member.contains(&r.id) && !r.is_drop()) { |
| 837 | 2× | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
| 838 | 2× | if !auth::roles::allows(role, Access::Write) { |
| 839 | 0× | continue; |
| 840 | 2× | } |
| 841 | 2× | let Some(repo) = RepoName::new(&row.owner, &row.name) else { continue }; |
| 842 | 2× | for p in db::proposals::list(&app.db, row.id, &["open"]).await? { |
| 843 | 2× | todo.push((repo.clone(), row.clone(), p)); |
| 844 | 2× | } |
| 845 | | } |
| 846 | 2× | let found: Vec<Option<ProposalBrief>> = futures::stream::iter(todo) |
| 847 | 2× | .map(|(repo, row, p)| { |
| 848 | 2× | let (app, base) = (app.clone(), base.to_string()); |
| 849 | 2× | async move { |
| 850 | 2× | let (number, title, author, updated_ms) = (p.number, p.title.clone(), p.author.clone(), p.updated_ms); |
| 851 | 2× | let loaded = crate::web::proposals::load_summary(&app, &repo, p).await.ok()?; |
| 852 | 2× | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await.ok()?; |
| 853 | 2× | v.blockers.is_empty().then(|| ProposalBrief { |
| 854 | 1× | url: format!("{base}/{}/{}/proposal/{number}", row.owner, row.name), |
| 855 | 1× | repository: format!("{}/{}", row.owner, row.name), |
| 856 | 1× | number, |
| 857 | 1× | title, |
| 858 | 1× | author, |
| 859 | 1× | state: ProposalState::Open, |
| 860 | 1× | conflicted: loaded.conflicted, |
| 861 | 1× | updated_ms, |
| 862 | 1× | }) |
| 863 | 2× | } |
| 864 | 2× | }) |
| 865 | 2× | .buffered(4) |
| 866 | 2× | .collect() |
| 867 | 2× | .await; |
| 868 | 2× | Ok(found.into_iter().flatten().collect()) |
| 869 | 2× | } |
| 870 | |
|
| 871 | |
|
| 872 | |
|
| 873 | |
|
| 874 | |
|
| 875 | 2× | async fn user_sky(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<hub_api::UserSky>, ApiError> { |
| 876 | 2× | let c = caller(&app, &headers).await?; |
| 877 | 2× | let me = c.require("repo:read")?.clone(); |
| 878 | 1× | let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; |
| 879 | 1× | let mut mine = Vec::new(); |
| 880 | 1× | for r in db::repos::list(&app.db).await? { |
| 881 | 1× | if member.contains(&r.id) && auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { |
| 882 | 1× | mine.push(r); |
| 883 | 1× | } |
| 884 | | } |
| 885 | 1× | mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); |
| 886 | 1× | let ids: Vec<i64> = mine.iter().map(|r| r.id).collect(); |
| 887 | 1× | let now = db::now_ms(); |
| 888 | 1× | let since = now - crate::web::home::SKY_DAYS * 86_400_000; |
| 889 | 1× | let events = db::activity::sky(&app.db, &ids, since, crate::web::home::SKY_EVENTS).await?; |
| 890 | 5× | let events = events.iter().filter_map(|e| Some([ids.iter().position(|&id| id == e.repo_id)? as i64, e.kind, e.at_ms, e.n])).collect(); |
| 891 | 1× | Ok(Json(hub_api::UserSky { now, repos: mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(), events })) |
| 892 | 2× | } |
| 893 | |
|
| 894 | |
|
| 895 | |
|
| 896 | |
|
| 897 | 3× | async fn user_flow_runs( |
| 898 | 3× | State(app): State<Shared>, |
| 899 | 3× | headers: HeaderMap, |
| 900 | 3× | Query(q): Query<hub_api::UserFlowRunsQuery>, |
| 901 | 3× | ) -> Result<Json<Vec<hub_api::UserFlowRun>>, ApiError> { |
| 902 | 3× | let c = caller(&app, &headers).await?; |
| 903 | 3× | let me = c.require("repo:read")?.clone(); |
| 904 | 2× | let member = db::accounts::visible_repo_ids(&app.db, me.id).await?; |
| 905 | 2× | let mut mine = std::collections::HashMap::new(); |
| 906 | 2× | for r in db::repos::list(&app.db).await? { |
| 907 | 2× | if member.contains(&r.id) && auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { |
| 908 | 2× | mine.insert(r.id, r); |
| 909 | 2× | } |
| 910 | | } |
| 911 | 2× | let ids: Vec<i64> = mine.keys().copied().collect(); |
| 912 | 2× | let limit = q.limit.unwrap_or(30).clamp(1, 100); |
| 913 | 2× | let runs = db::activity::recent_runs(&app.db, &ids, q.before.unwrap_or(i64::MAX), limit).await?; |
| 914 | 2× | let queue = if runs.iter().any(|r| r.live()) { db::flow::queue(&app.db).await? } else { vec![] }; |
| 915 | 2× | let mut out = Vec::with_capacity(runs.len()); |
| 916 | 3× | for run in &runs { |
| 917 | 3× | let Some(row) = mine.get(&run.repo_id) else { continue }; |
| 918 | 3× | let jobs = db::flow::jobs(&app.db, run.id).await?; |
| 919 | 3× | out.push(hub_api::UserFlowRun { |
| 920 | 3× | repository: format!("{}/{}", row.owner, row.name), |
| 921 | 3× | run: flow_run_of(&app, row, run, jobs, &queue), |
| 922 | 3× | }); |
| 923 | | } |
| 924 | 2× | Ok(Json(out)) |
| 925 | 3× | } |
| 926 | |
|
| 927 | | #[derive(Deserialize)] |
| 928 | | struct StarterQuery { |
| 929 | | owner: String, |
| 930 | | } |
| 931 | |
|
| 932 | |
|
| 933 | |
|
| 934 | |
|
| 935 | 3× | async fn user_starter( |
| 936 | 3× | State(app): State<Shared>, |
| 937 | 3× | headers: HeaderMap, |
| 938 | 3× | Query(q): Query<StarterQuery>, |
| 939 | 3× | ) -> Result<Json<hub_api::StarterAssistants>, ApiError> { |
| 940 | 3× | let c = caller(&app, &headers).await?; |
| 941 | 3× | let me = c.require("repo:read")?; |
| 942 | 3× | if !auth::roles::may_create_under(&app.db, me, q.owner.trim()).await? { |
| 943 | 1× | return Ok(Json(hub_api::StarterAssistants { assistants: vec![] })); |
| 944 | 2× | } |
| 945 | 2× | let found = crate::starter::assistants_for(&app.db, q.owner.trim()).await?; |
| 946 | | Ok(Json(hub_api::StarterAssistants { |
| 947 | 2× | assistants: found |
| 948 | 2× | .into_iter() |
| 949 | 2× | .map(|a| hub_api::StarterAssistant { |
| 950 | 1× | name: a.assistant.name, |
| 951 | 1× | model: a.assistant.model, |
| 952 | 1× | from: if a.from.is_empty() { "the site".into() } else { a.from }, |
| 953 | 1× | }) |
| 954 | 2× | .collect(), |
| 955 | | })) |
| 956 | 3× | } |
| 957 | |
|
| 958 | 6× | fn starter_status_of(s: Option<crate::starter::State>) -> hub_api::StarterStatus { |
| 959 | | use crate::starter::State; |
| 960 | 6× | let mut out = hub_api::StarterStatus { |
| 961 | 6× | status: "none".into(), |
| 962 | 6× | assistant: String::new(), |
| 963 | 6× | change: String::new(), |
| 964 | 6× | files: vec![], |
| 965 | 6× | error: String::new(), |
| 966 | 6× | }; |
| 967 | 6× | match s { |
| 968 | 0× | None => {} |
| 969 | 3× | Some(State::Running { assistant }) => (out.status, out.assistant) = ("running".into(), assistant), |
| 970 | 1× | Some(State::Done { assistant, change, files }) => { |
| 971 | 1× | (out.status, out.assistant, out.change, out.files) = ("done".into(), assistant, change, files) |
| 972 | | } |
| 973 | 2× | Some(State::Failed { assistant, error }) => (out.status, out.assistant, out.error) = ("failed".into(), assistant, error), |
| 974 | | } |
| 975 | 6× | out |
| 976 | 6× | } |
| 977 | |
|
| 978 | |
|
| 979 | 4× | async fn starter_status( |
| 980 | 4× | State(app): State<Shared>, |
| 981 | 4× | headers: HeaderMap, |
| 982 | 4× | Path((owner, name)): Path<(String, String)>, |
| 983 | 4× | ) -> Result<Json<hub_api::StarterStatus>, ApiError> { |
| 984 | 4× | let c = caller(&app, &headers).await?; |
| 985 | 4× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 986 | 4× | Ok(Json(starter_status_of(crate::starter::state(&app, &repo)))) |
| 987 | 4× | } |
| 988 | |
|
| 989 | |
|
| 990 | |
|
| 991 | |
|
| 992 | 6× | async fn start_starter( |
| 993 | 6× | State(app): State<Shared>, |
| 994 | 6× | headers: HeaderMap, |
| 995 | 6× | Path((owner, name)): Path<(String, String)>, |
| 996 | 6× | Json(body): Json<hub_api::StarterRequest>, |
| 997 | 6× | ) -> Result<(StatusCode, Json<hub_api::StarterStatus>), ApiError> { |
| 998 | 6× | let c = caller(&app, &headers).await?; |
| 999 | 6× | let me = c.require("repo:admin")?.clone(); |
| 1000 | 6× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
| 1001 | 6× | let description = body.description.trim(); |
| 1002 | 6× | if description.is_empty() { |
| 1003 | 1× | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, "say what the repository is for (description)".into())); |
| 1004 | 5× | } |
| 1005 | 5× | if description.chars().count() > crate::starter::MAX_DESCRIPTION { |
| 1006 | 0× | return Err(ApiError( |
| 1007 | 0× | StatusCode::UNPROCESSABLE_ENTITY, |
| 1008 | 0× | format!("a description is at most {} characters", crate::starter::MAX_DESCRIPTION), |
| 1009 | 0× | )); |
| 1010 | 5× | } |
| 1011 | 5× | if matches!(crate::starter::state(&app, &repo), Some(crate::starter::State::Running { .. })) { |
| 1012 | 0× | return Err(ApiError(StatusCode::CONFLICT, "a starter is being drafted for it already".into())); |
| 1013 | 5× | } |
| 1014 | 5× | if !crate::starter::is_empty(&app, &repo, &row.default_bookmark).await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e))? { |
| 1015 | 1× | return Err(ApiError(StatusCode::CONFLICT, "the repository has files already; a starter is only for an empty one".into())); |
| 1016 | 4× | } |
| 1017 | 4× | let available = crate::starter::assistants_for(&app.db, &row.owner).await?; |
| 1018 | 4× | let chosen = match body.assistant.as_deref().map(str::trim).filter(|n| !n.is_empty()) { |
| 1019 | 0× | Some(n) => available.into_iter().find(|a| a.assistant.name.eq_ignore_ascii_case(n)), |
| 1020 | 4× | None => available.into_iter().next(), |
| 1021 | | }; |
| 1022 | 4× | let Some(a) = chosen else { |
| 1023 | 1× | return Err(ApiError( |
| 1024 | 1× | StatusCode::UNPROCESSABLE_ENTITY, |
| 1025 | 1× | "no assistant is set up for this owner (Admin › Assistants, or the organisation's settings)".into(), |
| 1026 | 1× | )); |
| 1027 | | }; |
| 1028 | 3× | if let Err(why) = crate::web::edit::signer_for(&app, me.id).await { |
| 1029 | 1× | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, why)); |
| 1030 | 2× | } |
| 1031 | 2× | crate::starter::start(app.clone(), row, me, a.assistant, description.to_string()); |
| 1032 | 2× | Ok((StatusCode::ACCEPTED, Json(starter_status_of(crate::starter::state(&app, &repo))))) |
| 1033 | 6× | } |
| 1034 | |
|
| 1035 | 6× | async fn user_repos( |
| 1036 | 6× | State(app): State<Shared>, |
| 1037 | 6× | headers: HeaderMap, |
| 1038 | 6× | Query(q): Query<UserReposQuery>, |
| 1039 | 6× | ) -> Result<Json<Vec<Repository>>, ApiError> { |
| 1040 | 6× | let c = caller(&app, &headers).await?; |
| 1041 | 6× | let me = c.require("repo:read")?.clone(); |
| 1042 | 5× | let wanted: Vec<String> = q |
| 1043 | 5× | .affiliation |
| 1044 | 5× | .as_deref() |
| 1045 | 5× | .unwrap_or("owner,organization,collaborator") |
| 1046 | 5× | .split(',') |
| 1047 | 13× | .map(|s| s.trim().to_ascii_lowercase()) |
| 1048 | 13× | .filter(|s| !s.is_empty()) |
| 1049 | 5× | .collect(); |
| 1050 | 13× | if let Some(bad) = wanted.iter().find(|w| !["owner", "organization", "collaborator"].contains(&w.as_str())) { |
| 1051 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown affiliation {bad:?}: owner, organization or collaborator"))); |
| 1052 | 4× | } |
| 1053 | 4× | let orgs: Vec<String> = db::accounts::orgs_of(&app.db, me.id).await?.into_iter().map(|(o, _)| o.name).collect(); |
| 1054 | 4× | let limit = q.limit.unwrap_or(50).clamp(1, 100); |
| 1055 | 4× | let mut rows = db::repos::list(&app.db).await?; |
| 1056 | 4× | rows.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name))); |
| 1057 | 4× | let mut out = Vec::new(); |
| 1058 | 7× | for r in rows { |
| 1059 | 7× | if let Some(after) = q.after.as_deref() { |
| 1060 | 2× | if format!("{}/{}", r.owner, r.name).as_str() <= after { |
| 1061 | 1× | continue; |
| 1062 | 1× | } |
| 1063 | 5× | } |
| 1064 | 6× | let mine = (wanted.iter().any(|w| w == "owner") && r.owner == me.username) |
| 1065 | 4× | || (wanted.iter().any(|w| w == "organization") && orgs.contains(&r.owner)) |
| 1066 | 6× | || (wanted.iter().any(|w| w == "collaborator") && db::accounts::collaborator_role(&app.db, r.id, me.id).await?.is_some()); |
| 1067 | 6× | if !mine || !auth::roles::allows(auth::roles::role_for(&app.db, Some(&me), &r).await?, Access::Read) { |
| 1068 | 2× | continue; |
| 1069 | 4× | } |
| 1070 | 4× | out.push(repository(&app, &r)); |
| 1071 | 4× | if out.len() == limit { |
| 1072 | 1× | break; |
| 1073 | 3× | } |
| 1074 | | } |
| 1075 | 4× | Ok(Json(out)) |
| 1076 | 6× | } |
| 1077 | |
|
| 1078 | |
|
| 1079 | 2× | async fn user_orgs(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::UserOrg>>, ApiError> { |
| 1080 | 2× | let c = caller(&app, &headers).await?; |
| 1081 | 2× | let me = c.require("user:read")?; |
| 1082 | 1× | let orgs = db::accounts::orgs_of(&app.db, me.id).await?; |
| 1083 | 1× | Ok(Json(orgs.into_iter().map(|(o, role)| hub_api::UserOrg { name: o.name, display_name: o.display_name, role }).collect())) |
| 1084 | 2× | } |
| 1085 | |
|
| 1086 | 12× | async fn create_repo( |
| 1087 | 12× | State(app): State<Shared>, |
| 1088 | 12× | headers: HeaderMap, |
| 1089 | 12× | Json(body): Json<NewRepository>, |
| 1090 | 12× | ) -> Result<(StatusCode, Json<Repository>), ApiError> { |
| 1091 | 12× | let c = caller(&app, &headers).await?; |
| 1092 | 12× | let me = c.require("repo:admin")?; |
| 1093 | 11× | let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?; |
| 1094 | 11× | if name.name.ends_with(crate::wiki::SUFFIX) { |
| 1095 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a name cannot end in .wiki: that is the address of a repository's wiki".into())); |
| 1096 | 11× | } |
| 1097 | 11× | if !auth::roles::may_create_under(&app.db, me, &name.owner).await? { |
| 1098 | 0× | return Err(forbidden("you cannot create repositories under that owner")); |
| 1099 | 11× | } |
| 1100 | 11× | if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) { |
| 1101 | 1× | return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into())); |
| 1102 | 10× | } |
| 1103 | 10× | app.repos.create_async(&name, None).await?; |
| 1104 | 10× | let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?; |
| 1105 | 10× | db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok(); |
| 1106 | 10× | Ok((StatusCode::CREATED, Json(repository(&app, &row)))) |
| 1107 | 12× | } |
| 1108 | |
|
| 1109 | 3× | async fn get_repo( |
| 1110 | 3× | State(app): State<Shared>, |
| 1111 | 3× | headers: HeaderMap, |
| 1112 | 3× | Path((owner, name)): Path<(String, String)>, |
| 1113 | 3× | ) -> Result<Json<Repository>, ApiError> { |
| 1114 | 3× | let c = caller(&app, &headers).await?; |
| 1115 | 3× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1116 | 2× | Ok(Json(repository(&app, &row))) |
| 1117 | 3× | } |
| 1118 | |
|
| 1119 | 3× | async fn bookmarks( |
| 1120 | 3× | State(app): State<Shared>, |
| 1121 | 3× | headers: HeaderMap, |
| 1122 | 3× | Path((owner, name)): Path<(String, String)>, |
| 1123 | 3× | ) -> Result<Json<Vec<Bookmark>>, ApiError> { |
| 1124 | 3× | let c = caller(&app, &headers).await?; |
| 1125 | 3× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1126 | 3× | let default = row.default_bookmark.clone(); |
| 1127 | 3× | let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?; |
| 1128 | 6× | Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect())) |
| 1129 | 3× | } |
| 1130 | |
|
| 1131 | | #[derive(serde::Deserialize)] |
| 1132 | | struct BackupSetting { |
| 1133 | | |
| 1134 | | backup: String, |
| 1135 | | } |
| 1136 | |
|
| 1137 | |
|
| 1138 | |
|
| 1139 | 0× | async fn set_backup( |
| 1140 | 0× | State(app): State<Shared>, |
| 1141 | 0× | headers: HeaderMap, |
| 1142 | 0× | Path((owner, name)): Path<(String, String)>, |
| 1143 | 0× | Json(b): Json<BackupSetting>, |
| 1144 | 0× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 1145 | 0× | let c = caller(&app, &headers).await?; |
| 1146 | 0× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
| 1147 | 0× | let setting = match b.backup.as_str() { |
| 1148 | 0× | "owner" => "", |
| 1149 | 0× | "hourly" | "daily" | "off" => b.backup.as_str(), |
| 1150 | 0× | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "backup is owner, hourly, daily or off".into())), |
| 1151 | | }; |
| 1152 | 0× | db::repos::set_backup(&app.db, row.id, setting).await?; |
| 1153 | 0× | db::accounts::audit(&app.db, c.account.as_ref(), "repo.backup", &format!("{owner}/{name}"), &b.backup).await.ok(); |
| 1154 | 0× | Ok(Json(serde_json::json!({ "backup": b.backup }))) |
| 1155 | 0× | } |
| 1156 | |
|
| 1157 | | #[derive(serde::Deserialize)] |
| 1158 | | struct DiffusionSetting { |
| 1159 | | |
| 1160 | | follow: String, |
| 1161 | | |
| 1162 | | #[serde(default)] |
| 1163 | | land: Option<String>, |
| 1164 | | } |
| 1165 | |
|
| 1166 | |
|
| 1167 | |
|
| 1168 | 10× | async fn set_diffusion( |
| 1169 | 10× | State(app): State<Shared>, |
| 1170 | 10× | headers: HeaderMap, |
| 1171 | 10× | Path((owner, name)): Path<(String, String)>, |
| 1172 | 10× | Json(d): Json<DiffusionSetting>, |
| 1173 | 10× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 1174 | 10× | let c = caller(&app, &headers).await?; |
| 1175 | 10× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
| 1176 | 9× | let setting = match d.follow.as_str() { |
| 1177 | 9× | "off" | "green" | "any" => d.follow.as_str(), |
| 1178 | 2× | "default" => "", |
| 1179 | 1× | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "follow is off, green, any or default".into())), |
| 1180 | | }; |
| 1181 | 8× | let land = match d.land.as_deref() { |
| 1182 | 2× | Some(l @ ("auto" | "propose")) => Some(l), |
| 1183 | 6× | None => None, |
| 1184 | 1× | Some(_) => return Err(ApiError(StatusCode::BAD_REQUEST, "land is auto or propose".into())), |
| 1185 | | }; |
| 1186 | 7× | db::repos::set_diffusion(&app.db, row.id, setting).await?; |
| 1187 | 7× | if let Some(l) = land { |
| 1188 | 1× | db::repos::set_diffusion_land(&app.db, row.id, l).await?; |
| 1189 | 6× | } |
| 1190 | 7× | db::accounts::audit( |
| 1191 | 7× | &app.db, |
| 1192 | 7× | c.account.as_ref(), |
| 1193 | 7× | "repo.diffusion", |
| 1194 | 7× | &format!("{owner}/{name}"), |
| 1195 | 7× | if setting.is_empty() { "default" } else { setting }, |
| 1196 | | ) |
| 1197 | 7× | .await |
| 1198 | 7× | .ok(); |
| 1199 | 7× | let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row); |
| 1200 | 7× | let rule = crate::diffusion::rule(&app.config.diffusion.follow, &row, None, ""); |
| 1201 | | |
| 1202 | 7× | if rule.follow != crate::diffusion::Follow::Off { |
| 1203 | 6× | let (app, row) = (app.clone(), row.clone()); |
| 1204 | 6× | tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await }); |
| 1205 | 1× | } |
| 1206 | 7× | if land == Some("auto") { |
| 1207 | 1× | let (app, row) = (app.clone(), row.clone()); |
| 1208 | 1× | tokio::spawn(async move { crate::diffusion::land_ready(&app, &row).await }); |
| 1209 | 6× | } |
| 1210 | | Ok(Json( |
| 1211 | 7× | serde_json::json!({ "follow": rule.follow, "from": rule.from, "land": if row.diffusion_land == "auto" { "auto" } else { "propose" } }), |
| 1212 | | )) |
| 1213 | 10× | } |
| 1214 | |
|
| 1215 | |
|
| 1216 | |
|
| 1217 | |
|
| 1218 | 7× | async fn consumers( |
| 1219 | 7× | State(app): State<Shared>, |
| 1220 | 7× | headers: HeaderMap, |
| 1221 | 7× | Path((owner, name)): Path<(String, String)>, |
| 1222 | 7× | ) -> Result<Json<Vec<crate::diffusion::Consumer>>, ApiError> { |
| 1223 | 7× | let c = caller(&app, &headers).await?; |
| 1224 | 7× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1225 | 7× | Ok(Json(crate::diffusion::consumers(&app, &row, c.account.as_ref()).await?)) |
| 1226 | 7× | } |
| 1227 | |
|
| 1228 | |
|
| 1229 | |
|
| 1230 | 2× | async fn diffusion( |
| 1231 | 2× | State(app): State<Shared>, |
| 1232 | 2× | headers: HeaderMap, |
| 1233 | 2× | Path((owner, name)): Path<(String, String)>, |
| 1234 | 2× | ) -> Result<Json<hub_api::Diffusion>, ApiError> { |
| 1235 | 2× | let c = caller(&app, &headers).await?; |
| 1236 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1237 | 2× | let slug = format!("{owner}/{name}"); |
| 1238 | 2× | let takes_from = crate::diffusion::follows(&app, &row, c.account.as_ref()) |
| 1239 | 2× | .await |
| 1240 | 2× | .iter() |
| 1241 | 2× | .map(|(up, p)| p.api(&format!("{}/{}", up.owner, up.name))) |
| 1242 | 2× | .collect(); |
| 1243 | 2× | let diffuses_to = crate::diffusion::consumers(&app, &row, c.account.as_ref()).await?.iter().map(|p| p.api(&slug)).collect(); |
| 1244 | 2× | Ok(Json(hub_api::Diffusion { takes_from, diffuses_to })) |
| 1245 | 2× | } |
| 1246 | |
|
| 1247 | 0× | async fn tags( |
| 1248 | 0× | State(app): State<Shared>, |
| 1249 | 0× | headers: HeaderMap, |
| 1250 | 0× | Path((owner, name)): Path<(String, String)>, |
| 1251 | 0× | ) -> Result<Json<Vec<Tag>>, ApiError> { |
| 1252 | 0× | let c = caller(&app, &headers).await?; |
| 1253 | 0× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1254 | 0× | let list = app.repos.read(&repo, |r, _| view::tags(r)).await?; |
| 1255 | 0× | Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect())) |
| 1256 | 0× | } |
| 1257 | |
|
| 1258 | |
|
| 1259 | 0× | async fn create_tag( |
| 1260 | 0× | State(app): State<Shared>, |
| 1261 | 0× | headers: HeaderMap, |
| 1262 | 0× | Path((owner, name)): Path<(String, String)>, |
| 1263 | 0× | Json(body): Json<NewTag>, |
| 1264 | 0× | ) -> Result<(StatusCode, Json<Tag>), ApiError> { |
| 1265 | 0× | let c = caller(&app, &headers).await?; |
| 1266 | 0× | let me = c.require("repo:write")?.clone(); |
| 1267 | 0× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 1268 | 0× | let made = crate::tags::create( |
| 1269 | 0× | &app, |
| 1270 | 0× | &row, |
| 1271 | 0× | &repo, |
| 1272 | 0× | &me, |
| 1273 | 0× | &body.name, |
| 1274 | 0× | body.target.as_deref().unwrap_or(""), |
| 1275 | 0× | body.message.as_deref().unwrap_or(""), |
| 1276 | 0× | ) |
| 1277 | 0× | .await |
| 1278 | 0× | .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
| 1279 | 0× | let t = app |
| 1280 | 0× | .repos |
| 1281 | 0× | .read(&repo, |r, _| view::tags(r)) |
| 1282 | 0× | .await? |
| 1283 | 0× | .into_iter() |
| 1284 | 0× | .find(|t| t.name == made.name) |
| 1285 | 0× | .ok_or_else(|| not_found("no such tag"))?; |
| 1286 | 0× | Ok((StatusCode::CREATED, Json(Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }))) |
| 1287 | 0× | } |
| 1288 | |
|
| 1289 | | #[derive(Deserialize)] |
| 1290 | | struct WithdrawQuery { |
| 1291 | | #[serde(default)] |
| 1292 | | reason: String, |
| 1293 | | } |
| 1294 | |
|
| 1295 | |
|
| 1296 | |
|
| 1297 | 0× | async fn withdraw_tag( |
| 1298 | 0× | State(app): State<Shared>, |
| 1299 | 0× | headers: HeaderMap, |
| 1300 | 0× | Path((owner, name, tag)): Path<(String, String, String)>, |
| 1301 | 0× | Query(q): Query<WithdrawQuery>, |
| 1302 | 0× | ) -> Result<StatusCode, ApiError> { |
| 1303 | 0× | let c = caller(&app, &headers).await?; |
| 1304 | 0× | let me = c.require("repo:write")?.clone(); |
| 1305 | 0× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
| 1306 | 0× | if db::releases::get(&app.db, row.id, &tag).await?.is_some() { |
| 1307 | 0× | return Err(ApiError(StatusCode::CONFLICT, format!("a release uses {tag}: delete the release first"))); |
| 1308 | 0× | } |
| 1309 | 0× | crate::tags::withdraw(&app, &row, &repo, &me, &tag, &q.reason).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
| 1310 | 0× | Ok(StatusCode::NO_CONTENT) |
| 1311 | 0× | } |
| 1312 | |
|
| 1313 | | #[derive(Deserialize)] |
| 1314 | | struct HistoryQuery { |
| 1315 | | path: Option<String>, |
| 1316 | | offset: Option<usize>, |
| 1317 | | limit: Option<usize>, |
| 1318 | | |
| 1319 | | |
| 1320 | | r: Option<String>, |
| 1321 | | } |
| 1322 | |
|
| 1323 | 7× | async fn history( |
| 1324 | 7× | State(app): State<Shared>, |
| 1325 | 7× | headers: HeaderMap, |
| 1326 | 7× | Path((owner, name, r)): Path<(String, String, String)>, |
| 1327 | 7× | Query(q): Query<HistoryQuery>, |
| 1328 | 7× | ) -> Result<Json<History>, ApiError> { |
| 1329 | 7× | let c = caller(&app, &headers).await?; |
| 1330 | 7× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1331 | 7× | let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500)); |
| 1332 | 7× | let revset = q.r.clone().filter(|s| !s.trim().is_empty()); |
| 1333 | 7× | let by_revset = revset.is_some(); |
| 1334 | 7× | let h = app |
| 1335 | 7× | .repos |
| 1336 | 7× | .read(&repo, move |repo, cache| { |
| 1337 | 7× | let target = view::resolve(repo, cache, &r)?; |
| 1338 | 7× | match &revset { |
| 1339 | 4× | Some(rs) => view::history_revset(repo, cache, target, rs, offset, limit), |
| 1340 | 3× | None => view::history(repo, cache, target, q.path.as_deref(), offset, limit), |
| 1341 | | } |
| 1342 | 7× | }) |
| 1343 | 7× | .await |
| 1344 | 7× | .map_err(|e| { |
| 1345 | | |
| 1346 | | |
| 1347 | 2× | if by_revset { |
| 1348 | 2× | ApiError(StatusCode::BAD_REQUEST, e.to_string()) |
| 1349 | | } else { |
| 1350 | 0× | e.into() |
| 1351 | | } |
| 1352 | 2× | })?; |
| 1353 | 5× | let mut entries = h.entries; |
| 1354 | 5× | crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await; |
| 1355 | | Ok(Json(History { |
| 1356 | 5× | reference: h.target.label, |
| 1357 | 6× | heads: h.target.heads.iter().map(|x| x.to_hex()).collect(), |
| 1358 | 5× | total: h.total, |
| 1359 | 5× | offset: h.offset, |
| 1360 | 5× | changes: entries.iter().map(summary).collect(), |
| 1361 | | })) |
| 1362 | 7× | } |
| 1363 | |
|
| 1364 | 4× | async fn change( |
| 1365 | 4× | State(app): State<Shared>, |
| 1366 | 4× | headers: HeaderMap, |
| 1367 | 4× | Path((owner, name, hash)): Path<(String, String, String)>, |
| 1368 | 4× | ) -> Result<Json<ChangeDetail>, ApiError> { |
| 1369 | 4× | let c = caller(&app, &headers).await?; |
| 1370 | 4× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1371 | 4× | let d = app |
| 1372 | 4× | .repos |
| 1373 | 4× | .read(&repo, move |repo, cache| { |
| 1374 | 4× | let h = view::resolve_change(repo, cache, &hash)?; |
| 1375 | 4× | view::change(repo, cache, &h) |
| 1376 | 4× | }) |
| 1377 | 4× | .await?; |
| 1378 | 4× | let mut v = d.view; |
| 1379 | 4× | crate::web::attribute(&app, &mut [&mut v]).await; |
| 1380 | 4× | let files = crate::web::history::render_files(&d.files, &d.bases).into_iter().map(file_change).collect(); |
| 1381 | | Ok(Json(ChangeDetail { |
| 1382 | 4× | body: v.body.clone(), |
| 1383 | 4× | change: summary(&v), |
| 1384 | 4× | dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(), |
| 1385 | 4× | in_bookmarks: d.in_bookmarks, |
| 1386 | 4× | in_tags: d.in_tags, |
| 1387 | 4× | files, |
| 1388 | | })) |
| 1389 | 4× | } |
| 1390 | |
|
| 1391 | 7× | fn file_change(f: crate::web::history::FileRows) -> FileChange { |
| 1392 | | use crate::render::diff::Kind; |
| 1393 | 7× | let truncated = f.lines.len() > hub_api::DIFF_LINES; |
| 1394 | 7× | let lines = f |
| 1395 | 7× | .lines |
| 1396 | 7× | .into_iter() |
| 1397 | 7× | .take(hub_api::DIFF_LINES) |
| 1398 | 7× | .map(|l| DiffLine { |
| 1399 | 8× | kind: match l.kind { |
| 1400 | 1× | Kind::Context => "context", |
| 1401 | 5× | Kind::Add => "add", |
| 1402 | 0× | Kind::Del => "del", |
| 1403 | 2× | Kind::Gap => "gap", |
| 1404 | | } |
| 1405 | 8× | .into(), |
| 1406 | 8× | old: l.old_no, |
| 1407 | 8× | new: l.new_no, |
| 1408 | 8× | text: l.text, |
| 1409 | 8× | }) |
| 1410 | 7× | .collect(); |
| 1411 | 7× | FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed, lines, truncated } |
| 1412 | 7× | } |
| 1413 | |
|
| 1414 | 1× | fn entry(e: &view::Entry) -> TreeEntry { |
| 1415 | | TreeEntry { |
| 1416 | 1× | name: e.name.clone(), |
| 1417 | 1× | path: e.path.clone(), |
| 1418 | 1× | kind: match e.kind { |
| 1419 | 0× | view::EntryKind::Dir => "dir", |
| 1420 | 1× | view::EntryKind::File => "file", |
| 1421 | 0× | view::EntryKind::Symlink => "symlink", |
| 1422 | 0× | view::EntryKind::Subrepo => "subrepo", |
| 1423 | | } |
| 1424 | 1× | .into(), |
| 1425 | 1× | exec: e.exec, |
| 1426 | 1× | size: e.size, |
| 1427 | 1× | conflicted: e.conflicted, |
| 1428 | | } |
| 1429 | 1× | } |
| 1430 | |
|
| 1431 | 1× | async fn tree_root( |
| 1432 | 1× | State(app): State<Shared>, |
| 1433 | 1× | headers: HeaderMap, |
| 1434 | 1× | Path((owner, name, r)): Path<(String, String, String)>, |
| 1435 | 1× | ) -> Result<Json<Vec<TreeEntry>>, ApiError> { |
| 1436 | 1× | tree(State(app), headers, Path((owner, name, r, String::new()))).await |
| 1437 | 1× | } |
| 1438 | |
|
| 1439 | 1× | async fn tree( |
| 1440 | 1× | State(app): State<Shared>, |
| 1441 | 1× | headers: HeaderMap, |
| 1442 | 1× | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
| 1443 | 1× | ) -> Result<Json<Vec<TreeEntry>>, ApiError> { |
| 1444 | 1× | let c = caller(&app, &headers).await?; |
| 1445 | 1× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1446 | 1× | let t = app |
| 1447 | 1× | .repos |
| 1448 | 1× | .read(&repo, move |repo, cache| { |
| 1449 | 1× | let target = view::resolve(repo, cache, &r)?; |
| 1450 | 1× | view::tree(repo, cache, target, &path) |
| 1451 | 1× | }) |
| 1452 | 1× | .await?; |
| 1453 | 1× | Ok(Json(t.entries.iter().map(entry).collect())) |
| 1454 | 1× | } |
| 1455 | |
|
| 1456 | 2× | async fn file( |
| 1457 | 2× | State(app): State<Shared>, |
| 1458 | 2× | headers: HeaderMap, |
| 1459 | 2× | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
| 1460 | 2× | ) -> Result<Response, ApiError> { |
| 1461 | 2× | let c = caller(&app, &headers).await?; |
| 1462 | 2× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1463 | 2× | let limit = app.config.limits.render_limit.0; |
| 1464 | 2× | let f = app |
| 1465 | 2× | .repos |
| 1466 | 2× | .read(&repo, move |repo, cache| { |
| 1467 | 2× | let target = view::resolve(repo, cache, &r)?; |
| 1468 | 2× | view::file(repo, cache, target, &path, limit) |
| 1469 | 2× | }) |
| 1470 | 2× | .await?; |
| 1471 | 2× | Ok(crate::web::raw(f.bytes, &f.name, false)) |
| 1472 | 2× | } |
| 1473 | |
|
| 1474 | 1× | async fn blame( |
| 1475 | 1× | State(app): State<Shared>, |
| 1476 | 1× | headers: HeaderMap, |
| 1477 | 1× | Path((owner, name, r, path)): Path<(String, String, String, String)>, |
| 1478 | 1× | ) -> Result<Json<Vec<BlameLine>>, ApiError> { |
| 1479 | 1× | let c = caller(&app, &headers).await?; |
| 1480 | 1× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1481 | 1× | let limit = app.config.limits.render_limit.0; |
| 1482 | 1× | let b = app |
| 1483 | 1× | .repos |
| 1484 | 1× | .read(&repo, move |repo, cache| { |
| 1485 | 1× | let target = view::resolve(repo, cache, &r)?; |
| 1486 | 1× | view::blame(repo, cache, target, &path, limit) |
| 1487 | 1× | }) |
| 1488 | 1× | .await?; |
| 1489 | | Ok(Json( |
| 1490 | 1× | b.lines |
| 1491 | 1× | .into_iter() |
| 1492 | 1× | .map(|l| BlameLine { |
| 1493 | 1× | line: l.line, |
| 1494 | 1× | change: l.hash.to_hex(), |
| 1495 | 1× | index: l.index, |
| 1496 | 1× | author: l.author, |
| 1497 | 1× | when_ms: l.when_ms, |
| 1498 | 1× | text: String::from_utf8_lossy(&l.text).into_owned(), |
| 1499 | 1× | }) |
| 1500 | 1× | .collect(), |
| 1501 | | )) |
| 1502 | 1× | } |
| 1503 | |
|
| 1504 | 0× | async fn conflicts( |
| 1505 | 0× | State(app): State<Shared>, |
| 1506 | 0× | headers: HeaderMap, |
| 1507 | 0× | Path((owner, name)): Path<(String, String)>, |
| 1508 | 0× | ) -> Result<Json<Vec<Conflict>>, ApiError> { |
| 1509 | 0× | let c = caller(&app, &headers).await?; |
| 1510 | 0× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1511 | 0× | let list = app.repos.read(&repo, view::conflicts).await?; |
| 1512 | | Ok(Json( |
| 1513 | 0× | list.into_iter() |
| 1514 | 0× | .map(|c| Conflict { |
| 1515 | 0× | bookmark: c.bookmark, |
| 1516 | 0× | path: c.path, |
| 1517 | 0× | kind: c.kind, |
| 1518 | 0× | contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(), |
| 1519 | 0× | }) |
| 1520 | 0× | .collect(), |
| 1521 | | )) |
| 1522 | 0× | } |
| 1523 | |
|
| 1524 | |
|
| 1525 | |
|
| 1526 | 3× | async fn release_trust( |
| 1527 | 3× | State(app): State<Shared>, |
| 1528 | 3× | headers: HeaderMap, |
| 1529 | 3× | Path((owner, name, tag)): Path<(String, String, String)>, |
| 1530 | 3× | ) -> Result<Json<crate::web::release_trust::ReleaseTrust>, ApiError> { |
| 1531 | 3× | let c = caller(&app, &headers).await?; |
| 1532 | 3× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1533 | 3× | match crate::web::release_trust::report(&app, &repo, &row, &tag).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))? { |
| 1534 | 2× | Some(t) => Ok(Json(t)), |
| 1535 | 1× | None => Err(ApiError(StatusCode::NOT_FOUND, format!("no release {tag}"))), |
| 1536 | | } |
| 1537 | 3× | } |
| 1538 | |
|
| 1539 | 1× | async fn trust( |
| 1540 | 1× | State(app): State<Shared>, |
| 1541 | 1× | headers: HeaderMap, |
| 1542 | 1× | Path((owner, name)): Path<(String, String)>, |
| 1543 | 1× | ) -> Result<Json<Trust>, ApiError> { |
| 1544 | 1× | let c = caller(&app, &headers).await?; |
| 1545 | 1× | let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1546 | 1× | let key = app.hub_key.key; |
| 1547 | 1× | let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?; |
| 1548 | | Ok(Json(Trust { |
| 1549 | 1× | policy: t.policy.map(|p| Policy { |
| 1550 | 0× | serial: p.serial, |
| 1551 | 0× | expires_ms: p.expires_ms, |
| 1552 | 0× | roots: p.roots.iter().map(|k| k.key.clone()).collect(), |
| 1553 | 0× | root_threshold: p.root_threshold, |
| 1554 | 0× | rules: p |
| 1555 | 0× | .rules |
| 1556 | 0× | .iter() |
| 1557 | 0× | .map(|r| Rule { |
| 1558 | 0× | pattern: r.pattern.clone(), |
| 1559 | 0× | keys: r.keys.iter().map(|k| k.key.clone()).collect(), |
| 1560 | 0× | threshold: r.threshold, |
| 1561 | 0× | require: r.require.clone(), |
| 1562 | 0× | allow_conflicts: r.allow_conflicts, |
| 1563 | 0× | }) |
| 1564 | 0× | .collect(), |
| 1565 | 0× | }), |
| 1566 | 1× | signed: t.verification.signed, |
| 1567 | 1× | sealed: t.verification.sealed, |
| 1568 | 1× | unsigned: t.verification.unsigned, |
| 1569 | 1× | invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(), |
| 1570 | 1× | hub_key: t.hub_key, |
| 1571 | | })) |
| 1572 | 1× | } |
| 1573 | |
|
| 1574 | 1× | async fn pushes( |
| 1575 | 1× | State(app): State<Shared>, |
| 1576 | 1× | headers: HeaderMap, |
| 1577 | 1× | Path((owner, name)): Path<(String, String)>, |
| 1578 | 1× | ) -> Result<Json<Vec<Push>>, ApiError> { |
| 1579 | 1× | let c = caller(&app, &headers).await?; |
| 1580 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1581 | 1× | let rows = db::repos::pushes(&app.db, row.id, 200).await?; |
| 1582 | | Ok(Json( |
| 1583 | 1× | rows.into_iter() |
| 1584 | 1× | .map(|p| Push { |
| 1585 | 1× | at_ms: p.at_ms, |
| 1586 | 1× | pusher: p.pusher, |
| 1587 | 1× | reference: p.reference, |
| 1588 | 1× | ok: p.ok, |
| 1589 | 1× | reason: p.reason, |
| 1590 | 1× | old_state: p.old_state, |
| 1591 | 1× | new_state: p.new_state, |
| 1592 | 1× | heads: p.heads, |
| 1593 | 1× | changes: p.changes, |
| 1594 | 1× | }) |
| 1595 | 1× | .collect(), |
| 1596 | | )) |
| 1597 | 1× | } |
| 1598 | |
|
| 1599 | |
|
| 1600 | |
|
| 1601 | |
|
| 1602 | |
|
| 1603 | |
|
| 1604 | 6× | async fn proposals( |
| 1605 | 6× | State(app): State<Shared>, |
| 1606 | 6× | headers: HeaderMap, |
| 1607 | 6× | Path((owner, name)): Path<(String, String)>, |
| 1608 | 6× | Query(q): Query<ProposalsQuery>, |
| 1609 | 6× | ) -> Result<Json<Vec<Proposal>>, ApiError> { |
| 1610 | 6× | let c = caller(&app, &headers).await?; |
| 1611 | 6× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1612 | 5× | let states: &[&str] = match q.state.as_deref().unwrap_or("open") { |
| 1613 | 5× | "open" => &["open", "draft"], |
| 1614 | 4× | "draft" => &["draft"], |
| 1615 | 4× | "landed" => &["landed"], |
| 1616 | 2× | "closed" => &["closed"], |
| 1617 | 2× | "all" => &["open", "draft", "landed", "closed"], |
| 1618 | 0× | other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state is open, draft, landed, closed or all, not {other:?}"))), |
| 1619 | | }; |
| 1620 | | |
| 1621 | | |
| 1622 | | |
| 1623 | | |
| 1624 | | |
| 1625 | | use futures::StreamExt; |
| 1626 | 5× | let mut rows = db::proposals::list(&app.db, row.id, states).await?; |
| 1627 | 5× | if let Some(limit) = q.limit { |
| 1628 | 1× | rows.truncate(limit); |
| 1629 | 4× | } |
| 1630 | 5× | let out: Vec<Result<Proposal, ApiError>> = futures::stream::iter(rows) |
| 1631 | 5× | .map(|p| { |
| 1632 | 3× | let (app, repo, owner, name, id) = (app.clone(), repo.clone(), owner.clone(), name.clone(), row.id); |
| 1633 | 3× | async move { |
| 1634 | 3× | let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?; |
| 1635 | 3× | let v = crate::web::proposals::verdict(&app, &loaded, id).await?; |
| 1636 | 3× | Ok(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await) |
| 1637 | 3× | } |
| 1638 | 3× | }) |
| 1639 | 5× | .buffered(4) |
| 1640 | 5× | .collect() |
| 1641 | 5× | .await; |
| 1642 | 5× | Ok(Json(out.into_iter().collect::<Result<Vec<_>, _>>()?)) |
| 1643 | 6× | } |
| 1644 | |
|
| 1645 | 15× | async fn proposal( |
| 1646 | 15× | State(app): State<Shared>, |
| 1647 | 15× | headers: HeaderMap, |
| 1648 | 15× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1649 | 15× | ) -> Result<Json<Proposal>, ApiError> { |
| 1650 | 15× | let c = caller(&app, &headers).await?; |
| 1651 | 15× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1652 | 15× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1653 | 15× | let source_url = match p.source_repo_id { |
| 1654 | 0× | Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), |
| 1655 | 15× | None => None, |
| 1656 | | }; |
| 1657 | 15× | let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?; |
| 1658 | 15× | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 1659 | 15× | Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) |
| 1660 | 15× | } |
| 1661 | |
|
| 1662 | |
|
| 1663 | |
|
| 1664 | |
|
| 1665 | 23× | async fn edit_proposal( |
| 1666 | 23× | State(app): State<Shared>, |
| 1667 | 23× | headers: HeaderMap, |
| 1668 | 23× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1669 | 23× | Json(body): Json<hub_api::ProposalEdit>, |
| 1670 | 23× | ) -> Result<Json<Proposal>, ApiError> { |
| 1671 | 23× | let c = caller(&app, &headers).await?; |
| 1672 | 23× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 1673 | 20× | let (repo, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1674 | 20× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1675 | 19× | let text = body.title.is_some() || body.body.is_some(); |
| 1676 | 19× | if text && Some(me.id) != p.author_id && role < Role::Maintain { |
| 1677 | 1× | return Err(ApiError(StatusCode::FORBIDDEN, "only the proposal's author or a maintainer can edit it".into())); |
| 1678 | 18× | } |
| 1679 | 18× | if body.labels.is_some() && role < Role::Triage { |
| 1680 | 1× | return Err(forbidden("setting labels needs triage access")); |
| 1681 | 17× | } |
| 1682 | | |
| 1683 | 17× | let labels = resolve_triage(&app, row.id, body.labels.as_deref(), None, None).await?.labels; |
| 1684 | 16× | if text { |
| 1685 | 3× | let title = match body.title.as_deref().map(str::trim) { |
| 1686 | 2× | Some("") => return Err(ApiError(StatusCode::BAD_REQUEST, "a proposal needs a title".into())), |
| 1687 | 1× | Some(t) => t, |
| 1688 | 1× | None => p.title.as_str(), |
| 1689 | | }; |
| 1690 | 2× | let text = body.body.as_deref().map(str::trim).unwrap_or(&p.body); |
| 1691 | 2× | db::proposals::edit(&app.db, p.id, title, text, &p.target).await?; |
| 1692 | 2× | db::proposals::add_event(&app.db, p.id, &me.username, "edited", "").await.ok(); |
| 1693 | 13× | } |
| 1694 | 15× | if let Some(want) = labels { |
| 1695 | 2× | let have = db::issues::proposal_labels(&app.db, p.id).await?; |
| 1696 | 2× | for l in &have { |
| 1697 | 2× | if !want.iter().any(|w| w.id == l.id) { |
| 1698 | 1× | db::issues::set_proposal_label(&app.db, p.id, l.id, false).await?; |
| 1699 | 1× | } |
| 1700 | | } |
| 1701 | 3× | for w in &want { |
| 1702 | 3× | if !have.iter().any(|l| l.id == w.id) { |
| 1703 | 2× | db::issues::set_proposal_label(&app.db, p.id, w.id, true).await?; |
| 1704 | 1× | } |
| 1705 | | } |
| 1706 | 3× | let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); |
| 1707 | 2× | db::proposals::add_event(&app.db, p.id, &me.username, "set labels", &names.join(", ")).await.ok(); |
| 1708 | 13× | } |
| 1709 | | |
| 1710 | 15× | let want = match (body.state.as_deref(), body.draft) { |
| 1711 | 1× | (Some(s), Some(d)) if (s == "draft") != d => { |
| 1712 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, format!("state {s:?} and draft {d} disagree"))); |
| 1713 | | } |
| 1714 | 7× | (Some(s), _) => Some(s), |
| 1715 | 2× | (None, Some(true)) => Some("draft"), |
| 1716 | 1× | (None, Some(false)) => Some("open"), |
| 1717 | 4× | (None, None) => None, |
| 1718 | | }; |
| 1719 | 14× | if let Some(want) = want { |
| 1720 | 10× | if Some(me.id) != p.author_id && role < Role::Triage { |
| 1721 | 0× | return Err(ApiError( |
| 1722 | 0× | StatusCode::FORBIDDEN, |
| 1723 | 0× | "only the proposal's author or someone with triage access can close, reopen or change whether it is a draft".into(), |
| 1724 | 0× | )); |
| 1725 | 10× | } |
| 1726 | | |
| 1727 | | |
| 1728 | 10× | let new_state = match (want, p.state.as_str()) { |
| 1729 | 10× | ("closed", "open" | "draft") => "closed", |
| 1730 | 7× | ("open", "closed" | "draft") => "open", |
| 1731 | 4× | ("draft", "open") => "draft", |
| 1732 | 3× | (w, now) if w == now => now, |
| 1733 | 2× | ("closed" | "open" | "draft", now) => { |
| 1734 | 1× | return Err(ApiError(StatusCode::CONFLICT, format!("a proposal that is {now} cannot become {want}"))) |
| 1735 | | } |
| 1736 | 1× | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is closed, open or draft".into())), |
| 1737 | | }; |
| 1738 | 8× | if new_state != p.state { |
| 1739 | 7× | db::proposals::set_state(&app.db, p.id, new_state, "").await?; |
| 1740 | 7× | db::proposals::add_event(&app.db, p.id, &me.username, new_state, "").await.ok(); |
| 1741 | 7× | crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.username, serde_json::json!({ "number": number })).await; |
| 1742 | 1× | } |
| 1743 | 4× | } |
| 1744 | 12× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1745 | 12× | let source_url = match p.source_repo_id { |
| 1746 | 0× | Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())), |
| 1747 | 12× | None => None, |
| 1748 | | }; |
| 1749 | 12× | let loaded = crate::web::proposals::load_for_api(&app, &repo, p).await?; |
| 1750 | 12× | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 1751 | 12× | Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await)) |
| 1752 | 23× | } |
| 1753 | |
|
| 1754 | |
|
| 1755 | |
|
| 1756 | |
|
| 1757 | |
|
| 1758 | 5× | async fn land_proposal( |
| 1759 | 5× | State(app): State<Shared>, |
| 1760 | 5× | headers: HeaderMap, |
| 1761 | 5× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1762 | 5× | body: axum::body::Bytes, |
| 1763 | 5× | ) -> Result<Json<Proposal>, ApiError> { |
| 1764 | 5× | let c = caller(&app, &headers).await?; |
| 1765 | 5× | let me = c.require("repo:write")?.clone(); |
| 1766 | 5× | let over_checks: Option<String> = if body.iter().all(u8::is_ascii_whitespace) { |
| 1767 | 0× | None |
| 1768 | | } else { |
| 1769 | 5× | serde_json::from_slice::<serde_json::Value>(&body) |
| 1770 | 5× | .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "the body is JSON: {} or {\"over_checks\": \"reason\"}".into()))? |
| 1771 | 5× | .get("over_checks") |
| 1772 | 5× | .and_then(|v| v.as_str()) |
| 1773 | 5× | .map(String::from) |
| 1774 | | }; |
| 1775 | 5× | let need = if over_checks.is_some() { Access::Maintain } else { Access::Write }; |
| 1776 | 5× | let (repo, row, _) = reach(&app, &c, &owner, &name, need).await?; |
| 1777 | 5× | let via = format!("api (token {})", c.token); |
| 1778 | 5× | match crate::web::proposals::land_as(&app, &repo, &row, &me, number, &via, false, over_checks.as_deref()).await { |
| 1779 | 4× | Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await, |
| 1780 | 0× | Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")), |
| 1781 | 1× | Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)), |
| 1782 | | } |
| 1783 | 5× | } |
| 1784 | |
|
| 1785 | |
|
| 1786 | |
|
| 1787 | 0× | async fn auto_land_on( |
| 1788 | 0× | State(app): State<Shared>, |
| 1789 | 0× | headers: HeaderMap, |
| 1790 | 0× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1791 | 0× | ) -> Result<Json<Proposal>, ApiError> { |
| 1792 | 0× | auto_land_set(&app, &headers, &owner, &name, number, true).await?; |
| 1793 | 0× | proposal(State(app), headers, Path((owner, name, number))).await |
| 1794 | 0× | } |
| 1795 | |
|
| 1796 | |
|
| 1797 | 0× | async fn auto_land_off( |
| 1798 | 0× | State(app): State<Shared>, |
| 1799 | 0× | headers: HeaderMap, |
| 1800 | 0× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1801 | 0× | ) -> Result<Json<Proposal>, ApiError> { |
| 1802 | 0× | auto_land_set(&app, &headers, &owner, &name, number, false).await?; |
| 1803 | 0× | proposal(State(app), headers, Path((owner, name, number))).await |
| 1804 | 0× | } |
| 1805 | |
|
| 1806 | 0× | async fn auto_land_set(app: &Shared, headers: &HeaderMap, owner: &str, name: &str, number: i64, on: bool) -> Result<(), ApiError> { |
| 1807 | 0× | let c = caller(app, headers).await?; |
| 1808 | 0× | let me = c.require("repo:write")?.clone(); |
| 1809 | 0× | let (_, row, _) = reach(app, &c, owner, name, Access::Write).await?; |
| 1810 | 0× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1811 | 0× | if !p.is_open() { |
| 1812 | 0× | return Err(ApiError(StatusCode::CONFLICT, format!("the proposal is {}", p.state))); |
| 1813 | 0× | } |
| 1814 | 0× | crate::autoland::set(app, &row, &p, &me, on).await?; |
| 1815 | 0× | Ok(()) |
| 1816 | 0× | } |
| 1817 | |
|
| 1818 | |
|
| 1819 | 1× | async fn proposal_files( |
| 1820 | 1× | State(app): State<Shared>, |
| 1821 | 1× | headers: HeaderMap, |
| 1822 | 1× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1823 | 1× | ) -> Result<Json<Vec<hub_api::ProposalFile>>, ApiError> { |
| 1824 | 1× | let c = caller(&app, &headers).await?; |
| 1825 | 1× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1826 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1827 | 1× | let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
| 1828 | | Ok(Json( |
| 1829 | 1× | loaded |
| 1830 | 1× | .files |
| 1831 | 1× | .iter() |
| 1832 | 1× | .map(|f| hub_api::ProposalFile { |
| 1833 | 2× | path: f.path.clone(), |
| 1834 | 2× | from: f.from.clone(), |
| 1835 | 2× | kind: f.kind.clone(), |
| 1836 | 2× | added: f.added, |
| 1837 | 2× | removed: f.removed, |
| 1838 | 2× | }) |
| 1839 | 1× | .collect(), |
| 1840 | | )) |
| 1841 | 1× | } |
| 1842 | |
|
| 1843 | |
|
| 1844 | |
|
| 1845 | |
|
| 1846 | 2× | async fn proposal_diff( |
| 1847 | 2× | State(app): State<Shared>, |
| 1848 | 2× | headers: HeaderMap, |
| 1849 | 2× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1850 | 2× | ) -> Result<Json<Vec<FileChange>>, ApiError> { |
| 1851 | 2× | let c = caller(&app, &headers).await?; |
| 1852 | 2× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1853 | 2× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1854 | 1× | let loaded = crate::web::proposals::load(&app, &repo, p).await?; |
| 1855 | 1× | Ok(Json(loaded.files.into_iter().map(file_change).collect())) |
| 1856 | 2× | } |
| 1857 | |
|
| 1858 | |
|
| 1859 | |
|
| 1860 | |
|
| 1861 | 5× | async fn proposal_content( |
| 1862 | 5× | State(app): State<Shared>, |
| 1863 | 5× | headers: HeaderMap, |
| 1864 | 5× | Path((owner, name, number, side, path)): Path<(String, String, i64, String, String)>, |
| 1865 | 5× | ) -> Result<Response, ApiError> { |
| 1866 | 5× | let c = caller(&app, &headers).await?; |
| 1867 | 5× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1868 | 5× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1869 | 5× | let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?; |
| 1870 | 5× | let heads = match side.as_str() { |
| 1871 | 5× | "base" => loaded.target_heads, |
| 1872 | 3× | "head" => loaded.heads, |
| 1873 | 1× | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "the side is base or head".into())), |
| 1874 | | }; |
| 1875 | 4× | let limit = app.config.limits.render_limit.0; |
| 1876 | 4× | let target = view::RefTarget { label: side, kind: view::RefKind::Change, heads }; |
| 1877 | 4× | let f = app.repos.read(&repo, move |repo, cache| view::file(repo, cache, target, &path, limit)).await?; |
| 1878 | 3× | Ok(crate::web::raw(f.bytes, &f.name, false)) |
| 1879 | 5× | } |
| 1880 | |
|
| 1881 | 12× | async fn create_proposal( |
| 1882 | 12× | State(app): State<Shared>, |
| 1883 | 12× | headers: HeaderMap, |
| 1884 | 12× | Path((owner, name)): Path<(String, String)>, |
| 1885 | 12× | Json(body): Json<NewProposal>, |
| 1886 | 12× | ) -> Result<(StatusCode, Json<Proposal>), ApiError> { |
| 1887 | 12× | let c = caller(&app, &headers).await?; |
| 1888 | 12× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 1889 | 12× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1890 | 10× | let created = |
| 1891 | 11× | crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft) |
| 1892 | 11× | .await |
| 1893 | 11× | .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; |
| 1894 | 10× | let loaded = crate::web::proposals::load_summary(&app, &repo, created).await?; |
| 1895 | 10× | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 1896 | 10× | Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await))) |
| 1897 | 12× | } |
| 1898 | |
|
| 1899 | 5× | fn comment_of(c: &db::proposals::CommentRow) -> Comment { |
| 1900 | | Comment { |
| 1901 | 5× | id: c.id, |
| 1902 | 5× | author: c.author.clone(), |
| 1903 | 5× | body: c.body.clone(), |
| 1904 | 5× | created_ms: c.created_ms, |
| 1905 | 5× | path: if c.path.is_empty() { None } else { Some(c.path.clone()) }, |
| 1906 | 5× | anchor: if c.anchor_change.is_empty() { |
| 1907 | 0× | None |
| 1908 | | } else { |
| 1909 | 5× | Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 }) |
| 1910 | | }, |
| 1911 | 5× | line: None, |
| 1912 | 5× | resolved: c.resolved, |
| 1913 | | } |
| 1914 | 5× | } |
| 1915 | |
|
| 1916 | 1× | async fn proposal_comments( |
| 1917 | 1× | State(app): State<Shared>, |
| 1918 | 1× | headers: HeaderMap, |
| 1919 | 1× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1920 | 1× | ) -> Result<Json<Vec<Comment>>, ApiError> { |
| 1921 | 1× | let c = caller(&app, &headers).await?; |
| 1922 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1923 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1924 | 1× | let mut out: Vec<Comment> = db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect(); |
| 1925 | | |
| 1926 | 1× | let paths: std::collections::BTreeSet<String> = out.iter().filter(|c| c.anchor.is_some()).filter_map(|c| c.path.clone()).collect(); |
| 1927 | 1× | if !paths.is_empty() { |
| 1928 | 1× | let repo = RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?; |
| 1929 | 1× | let heads = crate::web::proposals::load_summary(&app, &repo, p).await?.heads; |
| 1930 | 1× | let lines = app |
| 1931 | 1× | .repos |
| 1932 | 1× | .read(&repo, move |r, _| { |
| 1933 | 1× | let mut at = std::collections::HashMap::new(); |
| 1934 | 1× | for path in paths { |
| 1935 | | |
| 1936 | 2× | for l in r.blame_in(&path, &heads).unwrap_or_default() { |
| 1937 | 2× | at.insert((path.clone(), l.hash.to_hex(), l.index), l.line); |
| 1938 | 2× | } |
| 1939 | | } |
| 1940 | 1× | Ok(at) |
| 1941 | 1× | }) |
| 1942 | 1× | .await?; |
| 1943 | 1× | for c in &mut out { |
| 1944 | 1× | if let (Some(path), Some(a)) = (&c.path, &c.anchor) { |
| 1945 | 1× | c.line = lines.get(&(path.clone(), a.change.clone(), a.index)).copied(); |
| 1946 | 1× | } |
| 1947 | | } |
| 1948 | 0× | } |
| 1949 | 1× | Ok(Json(out)) |
| 1950 | 1× | } |
| 1951 | |
|
| 1952 | 3× | async fn proposal_comment( |
| 1953 | 3× | State(app): State<Shared>, |
| 1954 | 3× | headers: HeaderMap, |
| 1955 | 3× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 1956 | 3× | Json(body): Json<NewComment>, |
| 1957 | 3× | ) -> Result<(StatusCode, Json<Comment>), ApiError> { |
| 1958 | 3× | let c = caller(&app, &headers).await?; |
| 1959 | 3× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 1960 | 3× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 1961 | 3× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 1962 | 3× | if body.body.trim().is_empty() { |
| 1963 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); |
| 1964 | 3× | } |
| 1965 | 3× | let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ()); |
| 1966 | 3× | let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) { |
| 1967 | 3× | (Some(path), Some(line)) if !path.is_empty() => { |
| 1968 | 3× | let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?; |
| 1969 | 3× | let (heads, file) = (loaded.heads.clone(), path.to_string()); |
| 1970 | 3× | let atom = app |
| 1971 | 3× | .repos |
| 1972 | 3× | .read(&repo, move |r, cache| { |
| 1973 | 3× | let projected = cache.load(r, &heads)?; |
| 1974 | 3× | Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i))) |
| 1975 | 3× | }) |
| 1976 | 3× | .await? |
| 1977 | 3× | .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?; |
| 1978 | 2× | Some((path.to_string(), atom)) |
| 1979 | | } |
| 1980 | 0× | _ => None, |
| 1981 | | }; |
| 1982 | 2× | let row_c = db::proposals::add_comment( |
| 1983 | 2× | &app.db, |
| 1984 | 2× | p.id, |
| 1985 | 2× | Some(me.id), |
| 1986 | 2× | &me.username, |
| 1987 | 2× | body.body.trim(), |
| 1988 | 2× | anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""), |
| 1989 | 2× | anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)), |
| 1990 | | ) |
| 1991 | 2× | .await?; |
| 1992 | 2× | crate::notify::deliver( |
| 1993 | 2× | &app, |
| 1994 | 2× | crate::notify::Event { |
| 1995 | 2× | kind: "proposal.comment", |
| 1996 | 2× | title: format!("{owner}/{name}#{number}: {}", p.title), |
| 1997 | 2× | url: format!("/{owner}/{name}/proposal/{number}"), |
| 1998 | 2× | actor: &me, |
| 1999 | 2× | repo_id: row.id, |
| 2000 | 2× | thread: Some(("proposal", p.id)), |
| 2001 | 2× | mention_text: &body.body, |
| 2002 | 2× | direct: p.author_id.into_iter().collect(), |
| 2003 | 2× | }, |
| 2004 | 2× | ) |
| 2005 | 2× | .await; |
| 2006 | 2× | crate::hooks::emit( |
| 2007 | 2× | &app, |
| 2008 | 2× | &row, |
| 2009 | 2× | "comment.proposal", |
| 2010 | 2× | &me.username, |
| 2011 | 2× | serde_json::json!({ "number": number, "comment": comment_of(&row_c) }), |
| 2012 | 2× | ) |
| 2013 | 2× | .await; |
| 2014 | 2× | let mut created = comment_of(&row_c); |
| 2015 | 2× | created.line = created.anchor.as_ref().and(body.line); |
| 2016 | 2× | Ok((StatusCode::CREATED, Json(created))) |
| 2017 | 3× | } |
| 2018 | |
|
| 2019 | 1× | async fn proposal_reviews( |
| 2020 | 1× | State(app): State<Shared>, |
| 2021 | 1× | headers: HeaderMap, |
| 2022 | 1× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2023 | 1× | ) -> Result<Json<Vec<Review>>, ApiError> { |
| 2024 | 1× | let c = caller(&app, &headers).await?; |
| 2025 | 1× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2026 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 2027 | 1× | let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?; |
| 2028 | 1× | let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?; |
| 2029 | | Ok(Json( |
| 2030 | 1× | v.reviews |
| 2031 | 1× | .into_iter() |
| 2032 | 1× | .map(|r| Review { |
| 2033 | 2× | id: r.row.id, |
| 2034 | 2× | author: r.row.author, |
| 2035 | 2× | verdict: r.row.verdict, |
| 2036 | 2× | body: r.row.body, |
| 2037 | 2× | sealed: r.sealed, |
| 2038 | 2× | created_ms: r.row.created_ms, |
| 2039 | 2× | via: r.row.via, |
| 2040 | 2× | }) |
| 2041 | 1× | .collect(), |
| 2042 | | )) |
| 2043 | 1× | } |
| 2044 | |
|
| 2045 | 1× | async fn proposal_review( |
| 2046 | 1× | State(app): State<Shared>, |
| 2047 | 1× | headers: HeaderMap, |
| 2048 | 1× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2049 | 1× | Json(body): Json<NewReview>, |
| 2050 | 1× | ) -> Result<(StatusCode, Json<Review>), ApiError> { |
| 2051 | 1× | let c = caller(&app, &headers).await?; |
| 2052 | 1× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2053 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2054 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 2055 | 1× | if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") { |
| 2056 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into())); |
| 2057 | 1× | } |
| 2058 | 1× | if body.verdict == "approve" && Some(me.id) == p.author_id { |
| 2059 | 0× | return Err(forbidden("you cannot approve your own proposal")); |
| 2060 | 1× | } |
| 2061 | 1× | let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?; |
| 2062 | 1× | db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok(); |
| 2063 | 1× | crate::web::proposals::reviewed(&app, &row, &p, &me, &body.verdict).await; |
| 2064 | 1× | crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await; |
| 2065 | 1× | Ok(( |
| 2066 | 1× | StatusCode::CREATED, |
| 2067 | 1× | Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms, via: r.via }), |
| 2068 | 1× | )) |
| 2069 | 1× | } |
| 2070 | |
|
| 2071 | |
|
| 2072 | |
|
| 2073 | 4× | async fn request_reviewers( |
| 2074 | 4× | State(app): State<Shared>, |
| 2075 | 4× | headers: HeaderMap, |
| 2076 | 4× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2077 | 4× | Json(body): Json<ReviewRequest>, |
| 2078 | 4× | ) -> Result<Json<Vec<String>>, ApiError> { |
| 2079 | 4× | let c = caller(&app, &headers).await?; |
| 2080 | 4× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2081 | 4× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2082 | 4× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 2083 | 4× | if Some(me.id) != p.author_id && role < Role::Write { |
| 2084 | 0× | return Err(forbidden("only the author or a writer asks for reviews")); |
| 2085 | 4× | } |
| 2086 | 4× | if !p.is_open() { |
| 2087 | 0× | return Err(ApiError(StatusCode::CONFLICT, "the proposal is not open".into())); |
| 2088 | 4× | } |
| 2089 | 4× | crate::web::proposals::request_reviewers(&app, &row, &p, &me, &body.reviewers) |
| 2090 | 4× | .await |
| 2091 | 4× | .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; |
| 2092 | 2× | Ok(Json(db::proposals::review_requests(&app.db, p.id).await?)) |
| 2093 | 4× | } |
| 2094 | |
|
| 2095 | |
|
| 2096 | 1× | async fn withdraw_reviewer( |
| 2097 | 1× | State(app): State<Shared>, |
| 2098 | 1× | headers: HeaderMap, |
| 2099 | 1× | Path((owner, name, number, username)): Path<(String, String, i64, String)>, |
| 2100 | 1× | ) -> Result<StatusCode, ApiError> { |
| 2101 | 1× | let c = caller(&app, &headers).await?; |
| 2102 | 1× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2103 | 1× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2104 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 2105 | 1× | if Some(me.id) != p.author_id && role < Role::Write { |
| 2106 | 0× | return Err(forbidden("only the author or a writer withdraws review requests")); |
| 2107 | 1× | } |
| 2108 | 1× | let a = db::accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| not_found("no such account"))?; |
| 2109 | 1× | if db::proposals::unrequest_review(&app.db, p.id, a.id).await? { |
| 2110 | 1× | db::proposals::add_event(&app.db, p.id, &me.username, "review request withdrawn", &a.username).await.ok(); |
| 2111 | 0× | } |
| 2112 | 1× | Ok(StatusCode::NO_CONTENT) |
| 2113 | 1× | } |
| 2114 | |
|
| 2115 | 1× | async fn proposal_checks( |
| 2116 | 1× | State(app): State<Shared>, |
| 2117 | 1× | headers: HeaderMap, |
| 2118 | 1× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2119 | 1× | ) -> Result<Json<Vec<Check>>, ApiError> { |
| 2120 | 1× | let c = caller(&app, &headers).await?; |
| 2121 | 1× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2122 | 1× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 2123 | 1× | let loaded = crate::web::proposals::load_summary(&app, &repo, p).await?; |
| 2124 | 1× | Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect())) |
| 2125 | 1× | } |
| 2126 | |
|
| 2127 | |
|
| 2128 | |
|
| 2129 | 34× | async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result<Issue, ApiError> { |
| 2130 | 34× | let labels = db::issues::issue_labels(&app.db, i.id) |
| 2131 | 34× | .await? |
| 2132 | 34× | .into_iter() |
| 2133 | 34× | .map(|l| Label { name: l.name, colour: l.colour, description: l.description }) |
| 2134 | 34× | .collect(); |
| 2135 | 34× | let assignees = db::issues::assignees(&app.db, i.id).await?; |
| 2136 | 34× | let milestone = match i.milestone_id { |
| 2137 | 0× | Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title), |
| 2138 | 34× | None => None, |
| 2139 | | }; |
| 2140 | 34× | let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = $1").bind(i.id).fetch_one(&app.db).await?; |
| 2141 | 34× | Ok(Issue { |
| 2142 | 34× | number: i.number, |
| 2143 | 34× | title: i.title.clone(), |
| 2144 | 34× | body: i.body.clone(), |
| 2145 | 34× | author: i.author.clone(), |
| 2146 | 34× | state: i.state.clone(), |
| 2147 | 34× | reason: i.reason.clone(), |
| 2148 | 34× | labels, |
| 2149 | 34× | assignees, |
| 2150 | 34× | milestone, |
| 2151 | 34× | comments, |
| 2152 | 34× | locked: i.locked, |
| 2153 | 34× | pinned: i.pinned, |
| 2154 | 34× | created_ms: i.created_ms, |
| 2155 | 34× | updated_ms: i.updated_ms, |
| 2156 | 34× | closed_ms: i.closed_ms, |
| 2157 | 34× | url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number), |
| 2158 | 34× | }) |
| 2159 | 34× | } |
| 2160 | |
|
| 2161 | | #[derive(Deserialize)] |
| 2162 | | struct IssuesQuery { |
| 2163 | | state: Option<String>, |
| 2164 | | } |
| 2165 | |
|
| 2166 | | #[derive(Deserialize)] |
| 2167 | | struct ProposalsQuery { |
| 2168 | | state: Option<String>, |
| 2169 | | limit: Option<usize>, |
| 2170 | | } |
| 2171 | |
|
| 2172 | 7× | async fn issues( |
| 2173 | 7× | State(app): State<Shared>, |
| 2174 | 7× | headers: HeaderMap, |
| 2175 | 7× | Path((owner, name)): Path<(String, String)>, |
| 2176 | 7× | Query(q): Query<IssuesQuery>, |
| 2177 | 7× | ) -> Result<Json<Vec<Issue>>, ApiError> { |
| 2178 | 7× | let c = caller(&app, &headers).await?; |
| 2179 | 7× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2180 | 7× | let state = q.state.unwrap_or_else(|| "open".into()); |
| 2181 | 7× | let mut out = Vec::new(); |
| 2182 | 7× | for i in db::issues::list(&app.db, row.id).await? { |
| 2183 | 7× | if state == "all" || i.state == state { |
| 2184 | 6× | out.push(issue_of(&app, &row, &i).await?); |
| 2185 | 1× | } |
| 2186 | | } |
| 2187 | 7× | Ok(Json(out)) |
| 2188 | 7× | } |
| 2189 | |
|
| 2190 | 22× | async fn create_issue( |
| 2191 | 22× | State(app): State<Shared>, |
| 2192 | 22× | headers: HeaderMap, |
| 2193 | 22× | Path((owner, name)): Path<(String, String)>, |
| 2194 | 22× | Json(body): Json<NewIssue>, |
| 2195 | 22× | ) -> Result<(StatusCode, Json<Issue>), ApiError> { |
| 2196 | 22× | let c = caller(&app, &headers).await?; |
| 2197 | 21× | let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2198 | 21× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2199 | 21× | if body.title.trim().is_empty() { |
| 2200 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into())); |
| 2201 | 21× | } |
| 2202 | 21× | let triage = !body.labels.is_empty() || !body.assignees.is_empty() || body.milestone.is_some(); |
| 2203 | 21× | if triage && role < Role::Triage { |
| 2204 | 1× | return Err(forbidden("setting labels, assignees or a milestone needs triage access")); |
| 2205 | 20× | } |
| 2206 | | |
| 2207 | 20× | resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?; |
| 2208 | 19× | let i = db::issues::create( |
| 2209 | 19× | &app.db, |
| 2210 | 19× | db::issues::NewIssue { |
| 2211 | 19× | repo_id: row.id, |
| 2212 | 19× | title: body.title.trim(), |
| 2213 | 19× | body: body.body.trim(), |
| 2214 | 19× | author_id: Some(me.id), |
| 2215 | 19× | author: &me.username, |
| 2216 | 19× | }, |
| 2217 | 19× | ) |
| 2218 | 19× | .await?; |
| 2219 | 19× | db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok(); |
| 2220 | 19× | if triage { |
| 2221 | 2× | let t = resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?; |
| 2222 | 2× | apply_triage(&app, &i, &me, t).await?; |
| 2223 | 17× | } |
| 2224 | 19× | let i = db::issues::get(&app.db, row.id, i.number).await?.unwrap_or(i); |
| 2225 | 19× | crate::web::issues::reindex(&app, row.id, &i).await; |
| 2226 | 19× | let api = issue_of(&app, &row, &i).await?; |
| 2227 | 19× | crate::notify::deliver( |
| 2228 | 19× | &app, |
| 2229 | 19× | crate::notify::Event { |
| 2230 | 19× | kind: "issue.opened", |
| 2231 | 19× | title: format!("{owner}/{name}#{}: {}", i.number, i.title), |
| 2232 | 19× | url: format!("/{owner}/{name}/issue/{}", i.number), |
| 2233 | 19× | actor: &me, |
| 2234 | 19× | repo_id: row.id, |
| 2235 | 19× | thread: Some(("issue", i.id)), |
| 2236 | 19× | mention_text: &body.body, |
| 2237 | 19× | direct: vec![], |
| 2238 | 19× | }, |
| 2239 | 19× | ) |
| 2240 | 19× | .await; |
| 2241 | 19× | crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await; |
| 2242 | 19× | Ok((StatusCode::CREATED, Json(api))) |
| 2243 | 22× | } |
| 2244 | |
|
| 2245 | 4× | async fn issue( |
| 2246 | 4× | State(app): State<Shared>, |
| 2247 | 4× | headers: HeaderMap, |
| 2248 | 4× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2249 | 4× | ) -> Result<Json<Issue>, ApiError> { |
| 2250 | 4× | let c = caller(&app, &headers).await?; |
| 2251 | 4× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2252 | 4× | let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; |
| 2253 | 3× | Ok(Json(issue_of(&app, &row, &i).await?)) |
| 2254 | 4× | } |
| 2255 | |
|
| 2256 | 2× | async fn issue_state( |
| 2257 | 2× | State(app): State<Shared>, |
| 2258 | 2× | headers: HeaderMap, |
| 2259 | 2× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2260 | 2× | Json(body): Json<IssueStateChange>, |
| 2261 | 2× | ) -> Result<Json<Issue>, ApiError> { |
| 2262 | 2× | let c = caller(&app, &headers).await?; |
| 2263 | 2× | let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2264 | 2× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2265 | 2× | let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; |
| 2266 | 2× | if role < Role::Triage && Some(me.id) != i.author_id { |
| 2267 | 0× | return Err(forbidden("only the author or triage may change the state")); |
| 2268 | 2× | } |
| 2269 | 2× | let (state, reason) = match body.state.as_str() { |
| 2270 | 2× | "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }), |
| 2271 | 0× | "open" => ("open", ""), |
| 2272 | 0× | _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())), |
| 2273 | | }; |
| 2274 | 2× | db::issues::set_state(&app.db, i.id, state, reason).await?; |
| 2275 | 2× | db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok(); |
| 2276 | 2× | let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i); |
| 2277 | 2× | let api = issue_of(&app, &row, &fresh).await?; |
| 2278 | 2× | crate::hooks::emit( |
| 2279 | 2× | &app, |
| 2280 | 2× | &row, |
| 2281 | 2× | if state == "closed" { "issue.closed" } else { "issue.reopened" }, |
| 2282 | 2× | &me.username, |
| 2283 | 2× | serde_json::json!({ "issue": api }), |
| 2284 | | ) |
| 2285 | 2× | .await; |
| 2286 | 2× | Ok(Json(api)) |
| 2287 | 2× | } |
| 2288 | |
|
| 2289 | 2× | async fn issue_comments( |
| 2290 | 2× | State(app): State<Shared>, |
| 2291 | 2× | headers: HeaderMap, |
| 2292 | 2× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2293 | 2× | ) -> Result<Json<Vec<Comment>>, ApiError> { |
| 2294 | 2× | let c = caller(&app, &headers).await?; |
| 2295 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2296 | 2× | let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; |
| 2297 | | Ok(Json( |
| 2298 | 2× | db::issues::comments(&app.db, i.id) |
| 2299 | 2× | .await? |
| 2300 | 2× | .into_iter() |
| 2301 | 2× | .map(|c| Comment { |
| 2302 | 3× | id: c.id, |
| 2303 | 3× | author: c.author, |
| 2304 | 3× | body: c.body, |
| 2305 | 3× | created_ms: c.created_ms, |
| 2306 | 3× | path: None, |
| 2307 | 3× | anchor: None, |
| 2308 | 3× | line: None, |
| 2309 | | resolved: false, |
| 2310 | 3× | }) |
| 2311 | 2× | .collect(), |
| 2312 | | )) |
| 2313 | 2× | } |
| 2314 | |
|
| 2315 | 4× | async fn issue_comment( |
| 2316 | 4× | State(app): State<Shared>, |
| 2317 | 4× | headers: HeaderMap, |
| 2318 | 4× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2319 | 4× | Json(body): Json<NewComment>, |
| 2320 | 4× | ) -> Result<(StatusCode, Json<Comment>), ApiError> { |
| 2321 | 4× | let c = caller(&app, &headers).await?; |
| 2322 | 4× | let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2323 | 4× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2324 | 4× | let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; |
| 2325 | 4× | if i.locked && role < Role::Triage { |
| 2326 | 0× | return Err(forbidden("this issue is locked")); |
| 2327 | 4× | } |
| 2328 | 4× | if body.body.trim().is_empty() { |
| 2329 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); |
| 2330 | 4× | } |
| 2331 | 4× | let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?; |
| 2332 | 4× | crate::web::issues::reindex(&app, row.id, &i).await; |
| 2333 | 4× | crate::notify::deliver( |
| 2334 | 4× | &app, |
| 2335 | 4× | crate::notify::Event { |
| 2336 | 4× | kind: "issue.comment", |
| 2337 | 4× | title: format!("{owner}/{name}#{number}: {}", i.title), |
| 2338 | 4× | url: format!("/{owner}/{name}/issue/{number}"), |
| 2339 | 4× | actor: &me, |
| 2340 | 4× | repo_id: row.id, |
| 2341 | 4× | thread: Some(("issue", i.id)), |
| 2342 | 4× | mention_text: &body.body, |
| 2343 | 4× | direct: i.author_id.into_iter().collect(), |
| 2344 | 4× | }, |
| 2345 | 4× | ) |
| 2346 | 4× | .await; |
| 2347 | 4× | let api = Comment { |
| 2348 | 4× | id: cm.id, |
| 2349 | 4× | author: cm.author, |
| 2350 | 4× | body: cm.body, |
| 2351 | 4× | created_ms: cm.created_ms, |
| 2352 | 4× | path: None, |
| 2353 | 4× | anchor: None, |
| 2354 | 4× | line: None, |
| 2355 | 4× | resolved: false, |
| 2356 | 4× | }; |
| 2357 | 4× | crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await; |
| 2358 | 4× | Ok((StatusCode::CREATED, Json(api))) |
| 2359 | 4× | } |
| 2360 | |
|
| 2361 | |
|
| 2362 | | struct Triage { |
| 2363 | | labels: Option<Vec<db::issues::Label>>, |
| 2364 | | assignees: Option<Vec<Account>>, |
| 2365 | | |
| 2366 | | milestone: Option<Option<db::issues::Milestone>>, |
| 2367 | | } |
| 2368 | |
|
| 2369 | 45× | async fn resolve_triage( |
| 2370 | 45× | app: &Shared, |
| 2371 | 45× | repo_id: i64, |
| 2372 | 45× | labels: Option<&[String]>, |
| 2373 | 45× | assignees: Option<&[String]>, |
| 2374 | 45× | milestone: Option<&str>, |
| 2375 | 45× | ) -> Result<Triage, ApiError> { |
| 2376 | 45× | let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m); |
| 2377 | 45× | let labels = match labels { |
| 2378 | 28× | Some(names) => { |
| 2379 | 28× | let mut out = Vec::new(); |
| 2380 | 28× | for n in names { |
| 2381 | 10× | out.push( |
| 2382 | 10× | db::issues::label_by_name(&app.db, repo_id, n.trim()) |
| 2383 | 10× | .await? |
| 2384 | 10× | .ok_or_else(|| bad(format!("no label {n:?}; create it first")))?, |
| 2385 | | ); |
| 2386 | | } |
| 2387 | 26× | Some(out) |
| 2388 | | } |
| 2389 | 17× | None => None, |
| 2390 | | }; |
| 2391 | 43× | let assignees = match assignees { |
| 2392 | 24× | Some(names) => { |
| 2393 | 24× | let mut out = Vec::new(); |
| 2394 | 24× | for n in names { |
| 2395 | 5× | out.push(db::accounts::account_by_name(&app.db, n.trim()).await?.ok_or_else(|| bad(format!("no account {n:?}")))?); |
| 2396 | | } |
| 2397 | 24× | Some(out) |
| 2398 | | } |
| 2399 | 19× | None => None, |
| 2400 | | }; |
| 2401 | 43× | let milestone = match milestone.map(str::trim) { |
| 2402 | 42× | None => None, |
| 2403 | 1× | Some("") => Some(None), |
| 2404 | 1× | Some(title) => Some(Some( |
| 2405 | 1× | db::issues::milestones(&app.db, repo_id) |
| 2406 | 1× | .await? |
| 2407 | 1× | .into_iter() |
| 2408 | 1× | .find(|m| m.title == title) |
| 2409 | 1× | .ok_or_else(|| bad(format!("no milestone {title:?}")))?, |
| 2410 | | )), |
| 2411 | | }; |
| 2412 | 42× | Ok(Triage { labels, assignees, milestone }) |
| 2413 | 45× | } |
| 2414 | |
|
| 2415 | |
|
| 2416 | |
|
| 2417 | 6× | async fn apply_triage(app: &Shared, i: &db::issues::IssueRow, me: &Account, t: Triage) -> Result<(), ApiError> { |
| 2418 | 6× | if let Some(want) = t.labels { |
| 2419 | 5× | let have = db::issues::issue_labels(&app.db, i.id).await?; |
| 2420 | 5× | for l in &have { |
| 2421 | 2× | if !want.iter().any(|w| w.id == l.id) { |
| 2422 | 2× | db::issues::set_issue_label(&app.db, i.id, l.id, false).await?; |
| 2423 | 0× | } |
| 2424 | | } |
| 2425 | 5× | for w in &want { |
| 2426 | 3× | if !have.iter().any(|l| l.id == w.id) { |
| 2427 | 3× | db::issues::set_issue_label(&app.db, i.id, w.id, true).await?; |
| 2428 | 0× | } |
| 2429 | | } |
| 2430 | 5× | let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect(); |
| 2431 | 5× | db::issues::add_event(&app.db, i.id, &me.username, "set labels", &names.join(", ")).await.ok(); |
| 2432 | 1× | } |
| 2433 | 6× | if let Some(want) = t.assignees { |
| 2434 | 5× | let have = db::issues::assignees(&app.db, i.id).await?; |
| 2435 | 5× | for name in &have { |
| 2436 | 2× | if !want.iter().any(|a| &a.username == name) { |
| 2437 | 2× | if let Some(a) = db::accounts::account_by_name(&app.db, name).await? { |
| 2438 | 2× | db::issues::set_assignee(&app.db, i.id, a.id, false).await?; |
| 2439 | 2× | db::issues::add_event(&app.db, i.id, &me.username, "unassigned", name).await.ok(); |
| 2440 | 0× | } |
| 2441 | 0× | } |
| 2442 | | } |
| 2443 | 5× | for a in &want { |
| 2444 | 3× | if !have.contains(&a.username) { |
| 2445 | 3× | db::issues::set_assignee(&app.db, i.id, a.id, true).await?; |
| 2446 | 3× | db::issues::add_event(&app.db, i.id, &me.username, "assigned", &a.username).await.ok(); |
| 2447 | 0× | } |
| 2448 | | } |
| 2449 | 1× | } |
| 2450 | 6× | if let Some(m) = t.milestone { |
| 2451 | 0× | db::issues::set_milestone(&app.db, i.id, m.as_ref().map(|m| m.id)).await?; |
| 2452 | 0× | 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(); |
| 2453 | 6× | } |
| 2454 | 6× | Ok(()) |
| 2455 | 6× | } |
| 2456 | |
|
| 2457 | |
|
| 2458 | 8× | async fn issue_edit( |
| 2459 | 8× | State(app): State<Shared>, |
| 2460 | 8× | headers: HeaderMap, |
| 2461 | 8× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2462 | 8× | Json(body): Json<IssueEdit>, |
| 2463 | 8× | ) -> Result<Json<Issue>, ApiError> { |
| 2464 | 8× | let c = caller(&app, &headers).await?; |
| 2465 | 8× | let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 2466 | 8× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2467 | 8× | let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?; |
| 2468 | 8× | let text = body.title.is_some() || body.body.is_some(); |
| 2469 | 8× | let triage = body.labels.is_some() || body.assignees.is_some() || body.milestone.is_some(); |
| 2470 | 8× | if text && role < Role::Triage && Some(me.id) != i.author_id { |
| 2471 | 1× | return Err(forbidden("only the author or triage may edit the title and body")); |
| 2472 | 7× | } |
| 2473 | 7× | if triage && role < Role::Triage { |
| 2474 | 1× | return Err(forbidden("setting labels, assignees or a milestone needs triage access")); |
| 2475 | 6× | } |
| 2476 | 6× | if i.locked && role < Role::Triage { |
| 2477 | 0× | return Err(forbidden("this issue is locked")); |
| 2478 | 6× | } |
| 2479 | 6× | let t = resolve_triage(&app, row.id, body.labels.as_deref(), body.assignees.as_deref(), body.milestone.as_deref()).await?; |
| 2480 | 5× | if text { |
| 2481 | 3× | let title = body.title.as_deref().map(str::trim).unwrap_or(&i.title); |
| 2482 | 3× | if title.is_empty() { |
| 2483 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into())); |
| 2484 | 2× | } |
| 2485 | 2× | db::issues::edit(&app.db, i.id, title, body.body.as_deref().unwrap_or(&i.body)).await?; |
| 2486 | 2× | db::issues::add_event(&app.db, i.id, &me.username, "edited", "").await.ok(); |
| 2487 | 2× | } |
| 2488 | 4× | apply_triage(&app, &i, &me, t).await?; |
| 2489 | 4× | db::issues::touch(&app.db, i.id).await.ok(); |
| 2490 | 4× | let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i); |
| 2491 | 4× | crate::web::issues::reindex(&app, row.id, &fresh).await; |
| 2492 | 4× | let api = issue_of(&app, &row, &fresh).await?; |
| 2493 | 4× | crate::hooks::emit(&app, &row, "issue.edited", &me.username, serde_json::json!({ "issue": api })).await; |
| 2494 | 4× | Ok(Json(api)) |
| 2495 | 8× | } |
| 2496 | |
|
| 2497 | |
|
| 2498 | 6× | async fn create_label( |
| 2499 | 6× | State(app): State<Shared>, |
| 2500 | 6× | headers: HeaderMap, |
| 2501 | 6× | Path((owner, name)): Path<(String, String)>, |
| 2502 | 6× | Json(body): Json<NewLabel>, |
| 2503 | 6× | ) -> Result<(StatusCode, Json<Label>), ApiError> { |
| 2504 | 6× | let c = caller(&app, &headers).await?; |
| 2505 | 6× | c.require("issues:write").or_else(|_| c.require("repo:write"))?; |
| 2506 | 6× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2507 | 6× | if role < Role::Triage { |
| 2508 | 1× | return Err(forbidden("labels need triage access")); |
| 2509 | 5× | } |
| 2510 | 5× | let label = body.name.trim(); |
| 2511 | 5× | if label.is_empty() || label.len() > 50 { |
| 2512 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a label has a name of 1 to 50 characters".into())); |
| 2513 | 5× | } |
| 2514 | 5× | let colour = match body.colour.as_deref() { |
| 2515 | 6× | Some(c) if c.len() == 7 && c.starts_with('#') && c[1..].chars().all(|x| x.is_ascii_hexdigit()) => c.to_string(), |
| 2516 | 1× | Some(c) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("colour {c:?} is not #rrggbb"))), |
| 2517 | 3× | None => "#5aa8ff".to_string(), |
| 2518 | | }; |
| 2519 | 4× | let l = db::issues::upsert_label(&app.db, row.id, label, &colour, body.description.trim()).await?; |
| 2520 | 4× | Ok((StatusCode::CREATED, Json(Label { name: l.name, colour: l.colour, description: l.description }))) |
| 2521 | 6× | } |
| 2522 | |
|
| 2523 | |
|
| 2524 | 2× | async fn delete_label( |
| 2525 | 2× | State(app): State<Shared>, |
| 2526 | 2× | headers: HeaderMap, |
| 2527 | 2× | Path((owner, name, label)): Path<(String, String, String)>, |
| 2528 | 2× | ) -> Result<StatusCode, ApiError> { |
| 2529 | 2× | let c = caller(&app, &headers).await?; |
| 2530 | 2× | c.require("issues:write").or_else(|_| c.require("repo:write"))?; |
| 2531 | 2× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2532 | 2× | if role < Role::Triage { |
| 2533 | 0× | return Err(forbidden("labels need triage access")); |
| 2534 | 2× | } |
| 2535 | 2× | let l = db::issues::label_by_name(&app.db, row.id, &label).await?.ok_or_else(|| not_found("no such label"))?; |
| 2536 | 1× | db::issues::delete_label(&app.db, l.id).await?; |
| 2537 | 1× | Ok(StatusCode::NO_CONTENT) |
| 2538 | 2× | } |
| 2539 | |
|
| 2540 | 2× | async fn labels( |
| 2541 | 2× | State(app): State<Shared>, |
| 2542 | 2× | headers: HeaderMap, |
| 2543 | 2× | Path((owner, name)): Path<(String, String)>, |
| 2544 | 2× | ) -> Result<Json<Vec<Label>>, ApiError> { |
| 2545 | 2× | let c = caller(&app, &headers).await?; |
| 2546 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2547 | | Ok(Json( |
| 2548 | 2× | db::issues::labels(&app.db, row.id) |
| 2549 | 2× | .await? |
| 2550 | 2× | .into_iter() |
| 2551 | 3× | .map(|l| Label { name: l.name, colour: l.colour, description: l.description }) |
| 2552 | 2× | .collect(), |
| 2553 | | )) |
| 2554 | 2× | } |
| 2555 | |
|
| 2556 | 1× | async fn milestones( |
| 2557 | 1× | State(app): State<Shared>, |
| 2558 | 1× | headers: HeaderMap, |
| 2559 | 1× | Path((owner, name)): Path<(String, String)>, |
| 2560 | 1× | ) -> Result<Json<Vec<Milestone>>, ApiError> { |
| 2561 | 1× | let c = caller(&app, &headers).await?; |
| 2562 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2563 | 1× | let mut out = Vec::new(); |
| 2564 | 1× | for m in db::issues::milestones(&app.db, row.id).await? { |
| 2565 | 1× | let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?; |
| 2566 | 1× | out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed }); |
| 2567 | | } |
| 2568 | 1× | Ok(Json(out)) |
| 2569 | 1× | } |
| 2570 | |
|
| 2571 | 2× | fn milestone_of(m: db::issues::Milestone, open: i64, closed: i64) -> Milestone { |
| 2572 | 2× | Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed } |
| 2573 | 2× | } |
| 2574 | |
|
| 2575 | |
|
| 2576 | 3× | fn due_ms_of(due: &str) -> Result<Option<i64>, ApiError> { |
| 2577 | 3× | let due = due.trim(); |
| 2578 | 3× | if due.is_empty() { |
| 2579 | 1× | return Ok(None); |
| 2580 | 2× | } |
| 2581 | 2× | time::Date::parse(due, &time::macros::format_description!("[year]-[month]-[day]")) |
| 2582 | 2× | .map(|d| Some(d.midnight().assume_utc().unix_timestamp() * 1000)) |
| 2583 | 2× | .map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("due {due:?} is not YYYY-MM-DD"))) |
| 2584 | 3× | } |
| 2585 | |
|
| 2586 | 6× | async fn triage_for_milestones(app: &Shared, headers: &HeaderMap, owner: &str, name: &str) -> Result<db::repos::RepoRow, ApiError> { |
| 2587 | 6× | let c = caller(app, headers).await?; |
| 2588 | 6× | c.require("issues:write").or_else(|_| c.require("repo:write"))?; |
| 2589 | 6× | let (_, row, role) = reach(app, &c, owner, name, Access::Read).await?; |
| 2590 | 6× | if role < Role::Triage { |
| 2591 | 1× | return Err(forbidden("milestones need triage access")); |
| 2592 | 5× | } |
| 2593 | 5× | Ok(row) |
| 2594 | 6× | } |
| 2595 | |
|
| 2596 | |
|
| 2597 | |
|
| 2598 | |
|
| 2599 | 3× | async fn create_milestone( |
| 2600 | 3× | State(app): State<Shared>, |
| 2601 | 3× | headers: HeaderMap, |
| 2602 | 3× | Path((owner, name)): Path<(String, String)>, |
| 2603 | 3× | Json(body): Json<NewMilestone>, |
| 2604 | 3× | ) -> Result<(StatusCode, Json<Milestone>), ApiError> { |
| 2605 | 3× | let row = triage_for_milestones(&app, &headers, &owner, &name).await?; |
| 2606 | 2× | let title = body.title.trim(); |
| 2607 | 2× | if title.is_empty() || title.len() > 100 { |
| 2608 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a milestone has a title of 1 to 100 characters".into())); |
| 2609 | 2× | } |
| 2610 | 2× | let due_ms = due_ms_of(body.due.as_deref().unwrap_or(""))?; |
| 2611 | 1× | let m = db::issues::upsert_milestone(&app.db, row.id, title, body.description.trim(), due_ms).await?; |
| 2612 | 1× | let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?; |
| 2613 | 1× | Ok((StatusCode::CREATED, Json(milestone_of(m, open, closed)))) |
| 2614 | 3× | } |
| 2615 | |
|
| 2616 | |
|
| 2617 | |
|
| 2618 | 3× | async fn edit_milestone( |
| 2619 | 3× | State(app): State<Shared>, |
| 2620 | 3× | headers: HeaderMap, |
| 2621 | 3× | Path((owner, name, title)): Path<(String, String, String)>, |
| 2622 | 3× | Json(body): Json<MilestoneEdit>, |
| 2623 | 3× | ) -> Result<Json<Milestone>, ApiError> { |
| 2624 | 3× | let row = triage_for_milestones(&app, &headers, &owner, &name).await?; |
| 2625 | 3× | let m = db::issues::milestones(&app.db, row.id) |
| 2626 | 3× | .await? |
| 2627 | 3× | .into_iter() |
| 2628 | 3× | .find(|m| m.title == title) |
| 2629 | 3× | .ok_or_else(|| not_found("no such milestone"))?; |
| 2630 | 2× | let new_title = body.title.as_deref().map(str::trim).unwrap_or(&m.title); |
| 2631 | 2× | if new_title.is_empty() || new_title.len() > 100 { |
| 2632 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a milestone has a title of 1 to 100 characters".into())); |
| 2633 | 2× | } |
| 2634 | 2× | let due_ms = match body.due.as_deref() { |
| 2635 | 1× | Some(d) => due_ms_of(d)?, |
| 2636 | 1× | None => m.due_ms, |
| 2637 | | }; |
| 2638 | 2× | let description = body.description.as_deref().map(str::trim).unwrap_or(&m.description); |
| 2639 | 2× | if body.title.is_some() || body.description.is_some() || body.due.is_some() { |
| 2640 | 1× | db::issues::update_milestone(&app.db, m.id, new_title, description, due_ms).await?; |
| 2641 | 1× | } |
| 2642 | 2× | if let Some(state) = body.state.as_deref() { |
| 2643 | 2× | match state { |
| 2644 | 2× | "open" | "closed" => db::issues::set_milestone_state(&app.db, m.id, state).await?, |
| 2645 | 1× | other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("state {other:?} is open or closed"))), |
| 2646 | | } |
| 2647 | 0× | } |
| 2648 | 1× | let fresh = db::issues::milestone(&app.db, m.id).await?.ok_or_else(|| not_found("no such milestone"))?; |
| 2649 | 1× | let (open, closed) = db::issues::milestone_counts(&app.db, fresh.id).await?; |
| 2650 | 1× | Ok(Json(milestone_of(fresh, open, closed))) |
| 2651 | 3× | } |
| 2652 | |
|
| 2653 | |
|
| 2654 | |
|
| 2655 | 2× | fn hook_of(h: &db::hooks::Hook) -> Hook { |
| 2656 | 2× | Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms } |
| 2657 | 2× | } |
| 2658 | |
|
| 2659 | 1× | async fn hooks( |
| 2660 | 1× | State(app): State<Shared>, |
| 2661 | 1× | headers: HeaderMap, |
| 2662 | 1× | Path((owner, name)): Path<(String, String)>, |
| 2663 | 1× | ) -> Result<Json<Vec<Hook>>, ApiError> { |
| 2664 | 1× | let c = caller(&app, &headers).await?; |
| 2665 | 1× | c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?; |
| 2666 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
| 2667 | 1× | Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect())) |
| 2668 | 1× | } |
| 2669 | |
|
| 2670 | 1× | async fn create_hook( |
| 2671 | 1× | State(app): State<Shared>, |
| 2672 | 1× | headers: HeaderMap, |
| 2673 | 1× | Path((owner, name)): Path<(String, String)>, |
| 2674 | 1× | Json(body): Json<NewHook>, |
| 2675 | 1× | ) -> Result<(StatusCode, Json<Hook>), ApiError> { |
| 2676 | 1× | let c = caller(&app, &headers).await?; |
| 2677 | 1× | let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone(); |
| 2678 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
| 2679 | 1× | if !(body.url.starts_with("http://") || body.url.starts_with("https://")) { |
| 2680 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into())); |
| 2681 | 1× | } |
| 2682 | 1× | let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?; |
| 2683 | 1× | db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok(); |
| 2684 | 1× | Ok((StatusCode::CREATED, Json(hook_of(&h)))) |
| 2685 | 1× | } |
| 2686 | |
|
| 2687 | 0× | async fn delete_hook( |
| 2688 | 0× | State(app): State<Shared>, |
| 2689 | 0× | headers: HeaderMap, |
| 2690 | 0× | Path((owner, name, id)): Path<(String, String, i64)>, |
| 2691 | 0× | ) -> Result<StatusCode, ApiError> { |
| 2692 | 0× | let c = caller(&app, &headers).await?; |
| 2693 | 0× | c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?; |
| 2694 | 0× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
| 2695 | 0× | if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) { |
| 2696 | 0× | db::hooks::delete_hook(&app.db, id).await?; |
| 2697 | 0× | } |
| 2698 | 0× | Ok(StatusCode::NO_CONTENT) |
| 2699 | 0× | } |
| 2700 | |
|
| 2701 | 732× | pub fn check_of(c: &db::hooks::Check) -> Check { |
| 2702 | | Check { |
| 2703 | 732× | state: c.state.clone(), |
| 2704 | 732× | name: c.name.clone(), |
| 2705 | 732× | status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued), |
| 2706 | 732× | summary: c.summary.clone(), |
| 2707 | 732× | url: c.url.clone(), |
| 2708 | 732× | reporter: c.reporter.clone(), |
| 2709 | 732× | seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) }, |
| 2710 | 732× | started_ms: c.started_ms, |
| 2711 | 732× | finished_ms: c.finished_ms, |
| 2712 | | } |
| 2713 | 732× | } |
| 2714 | |
|
| 2715 | |
|
| 2716 | 2× | async fn required_checks( |
| 2717 | 2× | State(app): State<Shared>, |
| 2718 | 2× | headers: HeaderMap, |
| 2719 | 2× | Path((owner, name)): Path<(String, String)>, |
| 2720 | 2× | ) -> Result<Json<RequiredChecks>, ApiError> { |
| 2721 | 2× | let c = caller(&app, &headers).await?; |
| 2722 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2723 | 2× | Ok(Json(RequiredChecks { names: db::hooks::required_checks(&app.db, row.id).await? })) |
| 2724 | 2× | } |
| 2725 | |
|
| 2726 | |
|
| 2727 | 3× | async fn set_required_checks( |
| 2728 | 3× | State(app): State<Shared>, |
| 2729 | 3× | headers: HeaderMap, |
| 2730 | 3× | Path((owner, name)): Path<(String, String)>, |
| 2731 | 3× | Json(body): Json<RequiredChecks>, |
| 2732 | 3× | ) -> Result<Json<RequiredChecks>, ApiError> { |
| 2733 | 3× | let c = caller(&app, &headers).await?; |
| 2734 | 3× | c.require("repo:admin")?; |
| 2735 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?; |
| 2736 | 5× | let mut names: Vec<String> = body.names.iter().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).collect(); |
| 2737 | 2× | names.sort(); |
| 2738 | 2× | names.dedup(); |
| 2739 | 3× | if names.len() > 32 || names.iter().any(|n| n.len() > 100 || n.contains(',')) { |
| 2740 | 1× | return Err(ApiError(StatusCode::BAD_REQUEST, "at most 32 check names, each under 100 characters and without commas".into())); |
| 2741 | 1× | } |
| 2742 | 1× | db::hooks::set_required_checks(&app.db, row.id, &names).await?; |
| 2743 | 1× | db::accounts::audit(&app.db, c.account.as_ref(), "repo.required_checks", &format!("{owner}/{name}"), &names.join(", ")).await.ok(); |
| 2744 | 1× | Ok(Json(RequiredChecks { names })) |
| 2745 | 3× | } |
| 2746 | |
|
| 2747 | 15× | fn flow_run_of( |
| 2748 | 15× | app: &Shared, |
| 2749 | 15× | row: &db::repos::RepoRow, |
| 2750 | 15× | r: &db::flow::Run, |
| 2751 | 15× | jobs: Vec<db::flow::Job>, |
| 2752 | 15× | queue: &[db::flow::QueueEntry], |
| 2753 | 15× | ) -> FlowRun { |
| 2754 | | FlowRun { |
| 2755 | 15× | queue_position: crate::flow::queue_info(queue, row.id, r.id).position.map(|p| p as i64), |
| 2756 | 15× | number: r.number, |
| 2757 | 15× | bookmark: r.bookmark.clone(), |
| 2758 | 15× | state: r.state.clone(), |
| 2759 | 15× | heads: r.head_list().into_iter().map(String::from).collect(), |
| 2760 | 15× | status: r.status.clone(), |
| 2761 | 15× | trigger: r.trigger_label(), |
| 2762 | 15× | actor: r.actor.clone(), |
| 2763 | 15× | pipeline_hash: r.pipeline_hash.clone(), |
| 2764 | 15× | error: r.error.clone(), |
| 2765 | 15× | created_ms: r.created_ms, |
| 2766 | 15× | finished_ms: r.finished_ms, |
| 2767 | 15× | url: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, r.number), |
| 2768 | 15× | jobs: jobs |
| 2769 | 15× | .into_iter() |
| 2770 | 15× | .map(|j| FlowJob { |
| 2771 | 25× | phases: j.phase_list().into_iter().map(|(name, ms)| hub_api::FlowPhase { name, ms }).collect(), |
| 2772 | 25× | queued_ms: j.queued_ms, |
| 2773 | 25× | ready_ms: j.ready_ms, |
| 2774 | 25× | peak_disk_mib: j.peak_disk_mib, |
| 2775 | 25× | peak_memory_mib: j.peak_memory_mib, |
| 2776 | 25× | depends_on: j.depends_on(), |
| 2777 | 25× | name: j.name, |
| 2778 | 25× | status: j.status, |
| 2779 | 25× | runner: j.runner, |
| 2780 | 25× | exit_code: j.exit_code, |
| 2781 | 25× | error: j.error, |
| 2782 | 25× | started_ms: j.started_ms, |
| 2783 | 25× | finished_ms: j.finished_ms, |
| 2784 | 25× | log_lines: j.log_count, |
| 2785 | 25× | waiting: j.waiting, |
| 2786 | 25× | placed: j.placed, |
| 2787 | 25× | approval: j.approval, |
| 2788 | 25× | }) |
| 2789 | 15× | .collect(), |
| 2790 | | } |
| 2791 | 15× | } |
| 2792 | |
|
| 2793 | | #[derive(Deserialize)] |
| 2794 | | struct RunsPage { |
| 2795 | | |
| 2796 | | before: Option<i64>, |
| 2797 | | limit: Option<i64>, |
| 2798 | | } |
| 2799 | |
|
| 2800 | |
|
| 2801 | 2× | async fn flow_runs( |
| 2802 | 2× | State(app): State<Shared>, |
| 2803 | 2× | headers: HeaderMap, |
| 2804 | 2× | Path((owner, name)): Path<(String, String)>, |
| 2805 | 2× | Query(q): Query<RunsPage>, |
| 2806 | 2× | ) -> Result<Json<Vec<FlowRun>>, ApiError> { |
| 2807 | 2× | let c = caller(&app, &headers).await?; |
| 2808 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2809 | 2× | let limit = q.limit.unwrap_or(30).clamp(1, 100); |
| 2810 | 2× | let runs = db::flow::runs_by_number(&app.db, row.id, limit, q.before).await?; |
| 2811 | 2× | let queue = if runs.iter().any(|r| r.live()) { db::flow::queue(&app.db).await? } else { vec![] }; |
| 2812 | 3× | Ok(Json(runs.iter().map(|r| flow_run_of(&app, &row, r, vec![], &queue)).collect())) |
| 2813 | 2× | } |
| 2814 | |
|
| 2815 | |
|
| 2816 | 10× | async fn flow_run( |
| 2817 | 10× | State(app): State<Shared>, |
| 2818 | 10× | headers: HeaderMap, |
| 2819 | 10× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2820 | 10× | ) -> Result<Json<FlowRun>, ApiError> { |
| 2821 | 10× | let c = caller(&app, &headers).await?; |
| 2822 | 10× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2823 | 10× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2824 | 9× | let jobs = db::flow::jobs(&app.db, run.id).await?; |
| 2825 | 9× | let queue = if run.live() { db::flow::queue(&app.db).await? } else { vec![] }; |
| 2826 | 9× | Ok(Json(flow_run_of(&app, &row, &run, jobs, &queue))) |
| 2827 | 10× | } |
| 2828 | |
|
| 2829 | | #[derive(Deserialize)] |
| 2830 | | struct LogPage { |
| 2831 | | |
| 2832 | | #[serde(default)] |
| 2833 | | after: i64, |
| 2834 | | limit: Option<i64>, |
| 2835 | | } |
| 2836 | |
|
| 2837 | |
|
| 2838 | 3× | async fn flow_job_log( |
| 2839 | 3× | State(app): State<Shared>, |
| 2840 | 3× | headers: HeaderMap, |
| 2841 | 3× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 2842 | 3× | Query(q): Query<LogPage>, |
| 2843 | 3× | ) -> Result<Json<Vec<FlowLogLine>>, ApiError> { |
| 2844 | 3× | let c = caller(&app, &headers).await?; |
| 2845 | 3× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2846 | 3× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2847 | 3× | let j = db::flow::jobs(&app.db, run.id) |
| 2848 | 3× | .await? |
| 2849 | 3× | .into_iter() |
| 2850 | 5× | .find(|j| j.name == job) |
| 2851 | 3× | .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?; |
| 2852 | 2× | let lines = db::flow::log(&app.db, j.id, q.after, q.limit.unwrap_or(1000).clamp(1, 5000)).await?; |
| 2853 | 2× | Ok(Json(lines.into_iter().map(|l| FlowLogLine { seq: l.seq, t: l.t, stream: l.stream, text: l.text }).collect())) |
| 2854 | 3× | } |
| 2855 | |
|
| 2856 | | #[derive(Deserialize)] |
| 2857 | | struct StartRun { |
| 2858 | | |
| 2859 | | #[serde(default)] |
| 2860 | | bookmark: Option<String>, |
| 2861 | | |
| 2862 | | |
| 2863 | | #[serde(default)] |
| 2864 | | change: Option<String>, |
| 2865 | | |
| 2866 | | |
| 2867 | | #[serde(default)] |
| 2868 | | jobs: Vec<String>, |
| 2869 | | } |
| 2870 | |
|
| 2871 | 10× | fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) { |
| 2872 | 10× | let url = format!("{}/{}/{}/flow/{number}", app.config.base_url(), row.owner, row.name); |
| 2873 | 10× | (StatusCode::CREATED, Json(serde_json::json!({ "number": number, "url": url }))) |
| 2874 | 10× | } |
| 2875 | |
|
| 2876 | |
|
| 2877 | |
|
| 2878 | 14× | async fn start_flow_run( |
| 2879 | 14× | State(app): State<Shared>, |
| 2880 | 14× | headers: HeaderMap, |
| 2881 | 14× | Path((owner, name)): Path<(String, String)>, |
| 2882 | 14× | Json(body): Json<StartRun>, |
| 2883 | 14× | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 2884 | 14× | let c = caller(&app, &headers).await?; |
| 2885 | 14× | let me = c.require("repo:write")?.clone(); |
| 2886 | 13× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 2887 | 13× | let (n, what) = match body.change.clone().filter(|c| !c.trim().is_empty()) { |
| 2888 | 2× | Some(change) => { |
| 2889 | 2× | let n = crate::flow::run_change(&app, &row, &change, &me.username, &body.jobs) |
| 2890 | 2× | .await |
| 2891 | 2× | .map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 2892 | 1× | (n, format!("change {}", change.trim())) |
| 2893 | | } |
| 2894 | | None => { |
| 2895 | 11× | let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
| 2896 | | ( |
| 2897 | 11× | crate::flow::run_bookmark(&app, &row, &bookmark, &me.username, &body.jobs) |
| 2898 | 11× | .await |
| 2899 | 11× | .map_err(|m| ApiError(StatusCode::CONFLICT, m))?, |
| 2900 | 8× | bookmark, |
| 2901 | | ) |
| 2902 | | } |
| 2903 | | }; |
| 2904 | 9× | db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {what}")).await.ok(); |
| 2905 | 9× | Ok(started(&app, &row, n)) |
| 2906 | 14× | } |
| 2907 | |
|
| 2908 | |
|
| 2909 | 3× | async fn rerun_flow_run( |
| 2910 | 3× | State(app): State<Shared>, |
| 2911 | 3× | headers: HeaderMap, |
| 2912 | 3× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2913 | 3× | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 2914 | 3× | let c = caller(&app, &headers).await?; |
| 2915 | 3× | let me = c.require("repo:write")?.clone(); |
| 2916 | 3× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 2917 | 3× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2918 | 2× | let n = crate::flow::rerun(&app, &row, &run, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 2919 | 1× | db::accounts::audit(&app.db, Some(&me), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}")).await.ok(); |
| 2920 | 1× | Ok(started(&app, &row, n)) |
| 2921 | 3× | } |
| 2922 | |
|
| 2923 | |
|
| 2924 | |
|
| 2925 | |
|
| 2926 | 4× | async fn rerun_flow_job( |
| 2927 | 4× | State(app): State<Shared>, |
| 2928 | 4× | headers: HeaderMap, |
| 2929 | 4× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 2930 | 4× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 2931 | 4× | let c = caller(&app, &headers).await?; |
| 2932 | 4× | let me = c.require("repo:write")?.clone(); |
| 2933 | 4× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 2934 | 4× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2935 | 4× | let n = crate::flow::rerun_job(&app, &row, &run, &job, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 2936 | 2× | db::accounts::audit(&app.db, Some(&me), "flow.rerun", &format!("{owner}/{name}"), &format!("{job} of #{number}")).await.ok(); |
| 2937 | 2× | Ok(Json(serde_json::json!({ "run": number, "job": job, "queued": n }))) |
| 2938 | 4× | } |
| 2939 | |
|
| 2940 | |
|
| 2941 | |
|
| 2942 | 3× | async fn cancel_flow_run( |
| 2943 | 3× | State(app): State<Shared>, |
| 2944 | 3× | headers: HeaderMap, |
| 2945 | 3× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2946 | 3× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 2947 | 3× | let c = caller(&app, &headers).await?; |
| 2948 | 3× | let me = c.require("repo:write")?.clone(); |
| 2949 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
| 2950 | 2× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 2951 | 2× | crate::flow::cancel(&app, &row, &run, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 2952 | 1× | db::accounts::audit(&app.db, Some(&me), "flow.cancel", &format!("{owner}/{name}"), &format!("#{number}")).await.ok(); |
| 2953 | 1× | Ok(Json(serde_json::json!({ "number": number, "status": "cancelled" }))) |
| 2954 | 3× | } |
| 2955 | |
|
| 2956 | |
|
| 2957 | |
|
| 2958 | 1× | async fn list_deployments( |
| 2959 | 1× | State(app): State<Shared>, |
| 2960 | 1× | headers: HeaderMap, |
| 2961 | 1× | Path((owner, name)): Path<(String, String)>, |
| 2962 | 1× | ) -> Result<Json<Vec<serde_json::Value>>, ApiError> { |
| 2963 | 1× | let c = caller(&app, &headers).await?; |
| 2964 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 2965 | 1× | let list = db::flow::deployments(&app.db, row.id, 100).await?; |
| 2966 | | Ok(Json( |
| 2967 | 1× | list.into_iter() |
| 2968 | 1× | .map(|d| { |
| 2969 | 1× | serde_json::json!({ "environment": d.environment, "job": d.job, "run": d.run_number, "state": d.state, |
| 2970 | 1× | "bookmark": d.bookmark, "actor": d.actor, "approved_by": d.approved_by, "finished_ms": d.finished_ms }) |
| 2971 | 1× | }) |
| 2972 | 1× | .collect(), |
| 2973 | | )) |
| 2974 | 1× | } |
| 2975 | |
|
| 2976 | | #[derive(Deserialize)] |
| 2977 | | struct ExpediteBody { |
| 2978 | | reason: String, |
| 2979 | | } |
| 2980 | |
|
| 2981 | |
|
| 2982 | |
|
| 2983 | |
|
| 2984 | 2× | async fn expedite_flow_run( |
| 2985 | 2× | State(app): State<Shared>, |
| 2986 | 2× | headers: HeaderMap, |
| 2987 | 2× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 2988 | 2× | Json(body): Json<ExpediteBody>, |
| 2989 | 2× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 2990 | 2× | let c = caller(&app, &headers).await?; |
| 2991 | 2× | let me = c.require("repo:write")?.clone(); |
| 2992 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Maintain).await?; |
| 2993 | 2× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such run"))?; |
| 2994 | 2× | crate::flow::expedite(&app, &row, &run, &me, &body.reason).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 2995 | 1× | Ok(Json(serde_json::json!({ "number": number, "expedited": true }))) |
| 2996 | 2× | } |
| 2997 | |
|
| 2998 | |
|
| 2999 | |
|
| 3000 | 0× | async fn approve_flow_job( |
| 3001 | 0× | State(app): State<Shared>, |
| 3002 | 0× | headers: HeaderMap, |
| 3003 | 0× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 3004 | 0× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3005 | 0× | decide_flow_job(&app, &headers, &owner, &name, number, &job, true).await |
| 3006 | 0× | } |
| 3007 | |
|
| 3008 | 2× | async fn reject_flow_job( |
| 3009 | 2× | State(app): State<Shared>, |
| 3010 | 2× | headers: HeaderMap, |
| 3011 | 2× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 3012 | 2× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3013 | 2× | decide_flow_job(&app, &headers, &owner, &name, number, &job, false).await |
| 3014 | 2× | } |
| 3015 | |
|
| 3016 | |
|
| 3017 | |
|
| 3018 | 1× | async fn flow_job_statement( |
| 3019 | 1× | State(app): State<Shared>, |
| 3020 | 1× | headers: HeaderMap, |
| 3021 | 1× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 3022 | 1× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3023 | 1× | let c = caller(&app, &headers).await?; |
| 3024 | 1× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 3025 | 1× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 3026 | 1× | let (s, j) = crate::flow::approval_statement(&app, &row, &run, &job).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 3027 | 1× | Ok(Json(serde_json::json!({ |
| 3028 | 1× | "repo": hex::encode(s.repo), |
| 3029 | 1× | "run": s.run, |
| 3030 | 1× | "job": s.job, |
| 3031 | 1× | "job_hash": s.job_hash.to_hex(), |
| 3032 | 1× | "state": s.state.to_hex(), |
| 3033 | 1× | "inputs": s.inputs.iter().map(|i| i.to_hex()).collect::<Vec<_>>(), |
| 3034 | 1× | "environment": s.environment, |
| 3035 | 1× | "waiting": j.approval == "waiting" && j.status == "queued", |
| 3036 | | }))) |
| 3037 | 1× | } |
| 3038 | |
|
| 3039 | | #[derive(Deserialize)] |
| 3040 | | struct SealBody { |
| 3041 | | signed: String, |
| 3042 | | } |
| 3043 | |
|
| 3044 | |
|
| 3045 | |
|
| 3046 | |
|
| 3047 | 4× | async fn seal_flow_job( |
| 3048 | 4× | State(app): State<Shared>, |
| 3049 | 4× | headers: HeaderMap, |
| 3050 | 4× | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
| 3051 | 4× | Json(body): Json<SealBody>, |
| 3052 | 4× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3053 | 4× | let c = caller(&app, &headers).await?; |
| 3054 | 4× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 3055 | 4× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 3056 | 4× | let said = crate::flow::decide_sealed(&app, &row, &run, &job, &body.signed).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 3057 | 1× | Ok(Json(serde_json::json!({ "number": number, "job": job, "said": said }))) |
| 3058 | 4× | } |
| 3059 | |
|
| 3060 | 2× | async fn decide_flow_job( |
| 3061 | 2× | app: &Shared, |
| 3062 | 2× | headers: &HeaderMap, |
| 3063 | 2× | owner: &str, |
| 3064 | 2× | name: &str, |
| 3065 | 2× | number: i64, |
| 3066 | 2× | job: &str, |
| 3067 | 2× | approve: bool, |
| 3068 | 2× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3069 | 2× | let c = caller(app, headers).await?; |
| 3070 | 2× | let me = c.require("repo:write")?.clone(); |
| 3071 | 2× | let (_, row, _) = reach(app, &c, owner, name, Access::Read).await?; |
| 3072 | 2× | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
| 3073 | 2× | let via = c.agent.then_some(c.token.as_str()); |
| 3074 | 2× | let said = crate::flow::decide(app, &row, &run, job, &me, approve, via).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
| 3075 | 1× | Ok(Json(serde_json::json!({ "number": number, "job": job, "decision": if approve { "approved" } else { "rejected" }, "said": said }))) |
| 3076 | 2× | } |
| 3077 | |
|
| 3078 | |
|
| 3079 | |
|
| 3080 | 3× | async fn post_check( |
| 3081 | 3× | State(app): State<Shared>, |
| 3082 | 3× | headers: HeaderMap, |
| 3083 | 3× | Path((owner, name)): Path<(String, String)>, |
| 3084 | 3× | Json(body): Json<NewCheck>, |
| 3085 | 3× | ) -> Result<(StatusCode, Json<Check>), ApiError> { |
| 3086 | 3× | let c = caller(&app, &headers).await?; |
| 3087 | 3× | let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 3088 | 2× | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 3089 | 128× | if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) { |
| 3090 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into())); |
| 3091 | 2× | } |
| 3092 | 2× | if body.name.trim().is_empty() || body.name.len() > 100 { |
| 3093 | 0× | return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into())); |
| 3094 | 2× | } |
| 3095 | 2× | let ch = db::hooks::upsert_check( |
| 3096 | 2× | &app.db, |
| 3097 | 2× | row.id, |
| 3098 | 2× | db::hooks::CheckReport { |
| 3099 | 2× | state: &body.state, |
| 3100 | 2× | name: body.name.trim(), |
| 3101 | 2× | status: body.status.as_str(), |
| 3102 | 2× | summary: &body.summary, |
| 3103 | 2× | url: &body.url, |
| 3104 | 2× | reporter: Some((me.id, &me.username)), |
| 3105 | 2× | seal: body.seal.as_deref().unwrap_or(""), |
| 3106 | 2× | }, |
| 3107 | 2× | ) |
| 3108 | 2× | .await?; |
| 3109 | 2× | let api = check_of(&ch); |
| 3110 | 2× | app.bus.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
| 3111 | 2× | crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await; |
| 3112 | 2× | Ok((StatusCode::CREATED, Json(api))) |
| 3113 | 3× | } |
| 3114 | |
|
| 3115 | 4× | async fn checks_for_state( |
| 3116 | 4× | State(app): State<Shared>, |
| 3117 | 4× | headers: HeaderMap, |
| 3118 | 4× | Path((owner, name, state)): Path<(String, String, String)>, |
| 3119 | 4× | ) -> Result<Json<Vec<Check>>, ApiError> { |
| 3120 | 4× | let c = caller(&app, &headers).await?; |
| 3121 | 4× | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 3122 | 4× | let state = state_key(&app, &repo, &state).await?; |
| 3123 | 3× | Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect())) |
| 3124 | 4× | } |
| 3125 | |
|
| 3126 | |
|
| 3127 | |
|
| 3128 | |
|
| 3129 | 4× | async fn state_key(app: &Shared, repo: &RepoName, s: &str) -> Result<String, ApiError> { |
| 3130 | | use oxy_core::hash::{hash_object, ChangeHash, ObjectType}; |
| 3131 | 4× | let hexes: Vec<&str> = s.split('+').collect(); |
| 3132 | 128× | let all_hashes = hexes.iter().all(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())); |
| 3133 | 4× | if all_hashes { |
| 3134 | 2× | let row = db::repos::get(&app.db, &repo.owner, &repo.name).await?.ok_or_else(|| not_found("no such repository"))?; |
| 3135 | 2× | if hexes.len() == 1 && !db::hooks::checks_for_state(&app.db, row.id, s).await?.is_empty() { |
| 3136 | 1× | return Ok(s.to_string()); |
| 3137 | 1× | } |
| 3138 | 1× | let heads: Vec<ChangeHash> = hexes.iter().filter_map(|h| ChangeHash::from_hex(h)).collect(); |
| 3139 | 1× | return Ok(hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode()).to_hex()); |
| 3140 | 2× | } |
| 3141 | 2× | let b = s.to_string(); |
| 3142 | 2× | let found = app.repos.read(repo, move |r, _| Ok(r.view()?.bookmarks.get(&b).copied())).await?; |
| 3143 | 2× | found.map(|st| st.to_hex()).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{s:?} is not a state hash, heads or a bookmark"))) |
| 3144 | 4× | } |
| 3145 | |
|
| 3146 | |
|
| 3147 | |
|
| 3148 | |
|
| 3149 | 14× | fn require_site_admin(c: &Caller) -> Result<&Account, ApiError> { |
| 3150 | 14× | match c.account.as_ref() { |
| 3151 | 13× | Some(a) if a.is_admin => Ok(a), |
| 3152 | 1× | _ => Err(not_found("no such API route")), |
| 3153 | | } |
| 3154 | 14× | } |
| 3155 | |
|
| 3156 | |
|
| 3157 | 3× | async fn runner_updates(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<RunnerUpdates>, ApiError> { |
| 3158 | 3× | let c = caller(&app, &headers).await?; |
| 3159 | 2× | require_site_admin(&c)?; |
| 3160 | | use crate::web::runner_updates::{views, Reach}; |
| 3161 | 2× | let v = views(&app, &Reach::Site).await?; |
| 3162 | 2× | let setting = |p: &crate::web::runner_updates::PolicyView| UpdateSetting { |
| 3163 | 8× | scope: p.scope.clone(), |
| 3164 | 8× | key: p.key.clone(), |
| 3165 | 8× | updates: match p.updates { |
| 3166 | 8× | "on" => Some(true), |
| 3167 | 4× | "off" => Some(false), |
| 3168 | 2× | _ => None, |
| 3169 | | }, |
| 3170 | 8× | channel: Some(p.channel.clone()).filter(|c| !c.is_empty()), |
| 3171 | 8× | window: Some(p.window.clone()).filter(|w| !w.is_empty()), |
| 3172 | 8× | dry_run: match p.dry_run { |
| 3173 | 8× | "on" => Some(true), |
| 3174 | 5× | "off" => Some(false), |
| 3175 | 5× | _ => None, |
| 3176 | | }, |
| 3177 | 8× | }; |
| 3178 | 2× | let mut settings = vec![setting(&v.site)]; |
| 3179 | 2× | settings.extend(v.owners.iter().map(setting)); |
| 3180 | 2× | settings.extend(v.groups.iter().map(|g| setting(&g.policy))); |
| 3181 | 4× | settings.extend(v.runners.iter().map(|r| setting(&r.policy))); |
| 3182 | 8× | settings.retain(|s| s.scope == "site" || s.updates.is_some() || s.channel.is_some() || s.window.is_some() || s.dry_run.is_some()); |
| 3183 | | Ok(Json(RunnerUpdates { |
| 3184 | 2× | repo: db::accounts::setting(&app.db, crate::runner_updates::SETTING_REPO).await?.unwrap_or_default(), |
| 3185 | 2× | settings, |
| 3186 | 2× | groups: v |
| 3187 | 2× | .groups |
| 3188 | 2× | .iter() |
| 3189 | 2× | .map(|g| RunnerGroup { |
| 3190 | 2× | id: g.g.id, |
| 3191 | 2× | owner: g.g.owner.clone(), |
| 3192 | 2× | name: g.g.name.clone(), |
| 3193 | 2× | percent: g.g.percent, |
| 3194 | 2× | members: g.members.clone(), |
| 3195 | 2× | wave: g.g.wave, |
| 3196 | 2× | }) |
| 3197 | 2× | .collect(), |
| 3198 | 2× | runners: v |
| 3199 | 2× | .runners |
| 3200 | 2× | .iter() |
| 3201 | 2× | .map(|r| RunnerUpdate { |
| 3202 | 4× | id: r.r.id, |
| 3203 | 4× | name: r.r.name.clone(), |
| 3204 | 4× | owner: r.r.scope_owner.clone(), |
| 3205 | 4× | version: r.r.version.clone(), |
| 3206 | 4× | groups: r.groups.clone(), |
| 3207 | 4× | updates: r.effective.updates, |
| 3208 | 4× | channel: r.effective.channel.clone(), |
| 3209 | 4× | window: r.effective.window.map(|(a, b)| format!("{a:02}-{b:02}")), |
| 3210 | 4× | dry_run: r.effective.dry_run, |
| 3211 | 4× | from: r.effective.from.clone(), |
| 3212 | 4× | now: r.offer.clone(), |
| 3213 | 4× | said: r.r.update_note.clone(), |
| 3214 | 4× | }) |
| 3215 | 2× | .collect(), |
| 3216 | 2× | holds: db::runner_updates::holds(&app.db) |
| 3217 | 2× | .await? |
| 3218 | 2× | .into_iter() |
| 3219 | 2× | .map(|h| HeldBuild { version: h.version, runner: h.runner, why: h.why, at_ms: h.at_ms }) |
| 3220 | 2× | .collect(), |
| 3221 | | })) |
| 3222 | 3× | } |
| 3223 | |
|
| 3224 | |
|
| 3225 | |
|
| 3226 | 2× | async fn runner_update_setting( |
| 3227 | 2× | State(app): State<Shared>, |
| 3228 | 2× | headers: HeaderMap, |
| 3229 | 2× | Json(u): Json<UpdateSetting>, |
| 3230 | 2× | ) -> Result<Json<serde_json::Value>, ApiError> { |
| 3231 | 2× | let c = caller(&app, &headers).await?; |
| 3232 | 2× | let me = require_site_admin(&c)?.clone(); |
| 3233 | | use crate::web::runner_updates::{apply_policy, PolicyForm, Reach}; |
| 3234 | 2× | match apply_policy(&app, &me, &Reach::Site, &PolicyForm::of(&u)).await? { |
| 3235 | 1× | Ok(notice) => Ok(Json(serde_json::json!({ "notice": notice }))), |
| 3236 | 1× | Err(why) => Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, why)), |
| 3237 | | } |
| 3238 | 2× | } |
| 3239 | |
|
| 3240 | |
|
| 3241 | 1× | async fn runner_update_lift(State(app): State<Shared>, headers: HeaderMap, Path(version): Path<String>) -> Result<StatusCode, ApiError> { |
| 3242 | 1× | let c = caller(&app, &headers).await?; |
| 3243 | 1× | let me = require_site_admin(&c)?.clone(); |
| 3244 | 1× | if !db::runner_updates::lift(&app.db, &version).await? { |
| 3245 | 1× | return Err(not_found("that build is not held")); |
| 3246 | 0× | } |
| 3247 | 0× | db::accounts::audit(&app.db, Some(&me), "runner.update.lifted", &version, "offered again").await.ok(); |
| 3248 | 0× | Ok(StatusCode::NO_CONTENT) |
| 3249 | 1× | } |
| 3250 | |
|
| 3251 | 5× | async fn runner_of(app: &Shared, r: db::runners::Runner, running: i64) -> hub_api::Runner { |
| 3252 | 5× | let serves = match r.scope_repo_id { |
| 3253 | 0× | Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().map(|p| p.slug()).unwrap_or_default(), |
| 3254 | 5× | None => r.scope_owner.clone(), |
| 3255 | | }; |
| 3256 | 5× | hub_api::Runner { |
| 3257 | 5× | id: r.id, |
| 3258 | 5× | status: r.status().to_string(), |
| 3259 | 5× | serves, |
| 3260 | 5× | labels: r.label_list().into_iter().map(String::from).collect(), |
| 3261 | 5× | name: r.name, |
| 3262 | 5× | arch: r.arch, |
| 3263 | 5× | os: r.os, |
| 3264 | 5× | version: r.version, |
| 3265 | 5× | max_jobs: r.max_jobs, |
| 3266 | 5× | memory_mib: r.memory_mib, |
| 3267 | 5× | cpus: r.cpus, |
| 3268 | 5× | key: r.key, |
| 3269 | 5× | source: r.source, |
| 3270 | 5× | last_seen_ms: r.last_seen_ms, |
| 3271 | 5× | running, |
| 3272 | 5× | free_disk_mib: r.free_disk_mib, |
| 3273 | 5× | load_x100: r.load_x100, |
| 3274 | 5× | speed: r.speed, |
| 3275 | 5× | speed_class: r.speed_class, |
| 3276 | 5× | kernel: r.kernel, |
| 3277 | 5× | } |
| 3278 | 5× | } |
| 3279 | |
|
| 3280 | |
|
| 3281 | 4× | async fn runners(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::Runner>>, ApiError> { |
| 3282 | 4× | let c = caller(&app, &headers).await?; |
| 3283 | 4× | require_site_admin(&c)?; |
| 3284 | 3× | let running: std::collections::HashMap<i64, i64> = db::runners::running(&app.db).await?.into_iter().collect(); |
| 3285 | 3× | let mut out = Vec::new(); |
| 3286 | 5× | for r in db::runners::list(&app.db).await? { |
| 3287 | 5× | let n = running.get(&r.id).copied().unwrap_or(0); |
| 3288 | 5× | out.push(runner_of(&app, r, n).await); |
| 3289 | | } |
| 3290 | 3× | Ok(Json(out)) |
| 3291 | 4× | } |
| 3292 | |
|
| 3293 | | #[derive(Deserialize)] |
| 3294 | | struct BeforeQuery { |
| 3295 | | #[serde(default)] |
| 3296 | | before: Option<i64>, |
| 3297 | | #[serde(default)] |
| 3298 | | limit: Option<i64>, |
| 3299 | | } |
| 3300 | |
|
| 3301 | |
|
| 3302 | 1× | async fn runner_jobs( |
| 3303 | 1× | State(app): State<Shared>, |
| 3304 | 1× | headers: HeaderMap, |
| 3305 | 1× | Path(id): Path<i64>, |
| 3306 | 1× | Query(q): Query<BeforeQuery>, |
| 3307 | 1× | ) -> Result<Json<Vec<hub_api::RunnerJob>>, ApiError> { |
| 3308 | 1× | let c = caller(&app, &headers).await?; |
| 3309 | 1× | require_site_admin(&c)?; |
| 3310 | 1× | db::runners::get(&app.db, id).await?.ok_or_else(|| not_found("no such runner"))?; |
| 3311 | 1× | let jobs = db::runners::history(&app.db, id, q.limit.unwrap_or(50).clamp(1, 200), q.before).await?; |
| 3312 | | Ok(Json( |
| 3313 | 1× | jobs.into_iter() |
| 3314 | 1× | .map(|j| hub_api::RunnerJob { |
| 3315 | 1× | repo: format!("{}/{}", j.owner, j.repo), |
| 3316 | 1× | run: j.run_number, |
| 3317 | 1× | job: j.job, |
| 3318 | 1× | status: j.status, |
| 3319 | 1× | started_ms: j.started_ms, |
| 3320 | 1× | finished_ms: j.finished_ms, |
| 3321 | 1× | }) |
| 3322 | 1× | .collect(), |
| 3323 | | )) |
| 3324 | 1× | } |
| 3325 | |
|
| 3326 | |
|
| 3327 | 1× | async fn runner_queue(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<hub_api::QueueItem>>, ApiError> { |
| 3328 | 1× | let c = caller(&app, &headers).await?; |
| 3329 | 1× | require_site_admin(&c)?; |
| 3330 | | Ok(Json( |
| 3331 | 1× | db::flow::queue(&app.db) |
| 3332 | 1× | .await? |
| 3333 | 1× | .into_iter() |
| 3334 | 1× | .map(|e| hub_api::QueueItem { |
| 3335 | 3× | repo: format!("{}/{}", e.owner, e.name), |
| 3336 | 3× | run: e.run_number, |
| 3337 | 3× | job: e.job, |
| 3338 | 3× | status: e.status, |
| 3339 | 3× | runner: e.runner, |
| 3340 | 3× | }) |
| 3341 | 1× | .collect(), |
| 3342 | | )) |
| 3343 | 1× | } |
| 3344 | |
|
| 3345 | |
|
| 3346 | |
|
| 3347 | |
|
| 3348 | 0× | async fn request_assistant( |
| 3349 | 0× | State(app): State<Shared>, |
| 3350 | 0× | headers: HeaderMap, |
| 3351 | 0× | Path((owner, name, number)): Path<(String, String, i64)>, |
| 3352 | 0× | Json(body): Json<hub_api::AssistantRequest>, |
| 3353 | 0× | ) -> Result<(StatusCode, Json<hub_api::AssistantRequest>), ApiError> { |
| 3354 | 0× | let c = caller(&app, &headers).await?; |
| 3355 | 0× | let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone(); |
| 3356 | 0× | let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?; |
| 3357 | 0× | let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?; |
| 3358 | 0× | if Some(me.id) != p.author_id && role < Role::Write { |
| 3359 | 0× | return Err(forbidden("only the author or a writer asks for reviews")); |
| 3360 | 0× | } |
| 3361 | 0× | if !p.is_open() { |
| 3362 | 0× | return Err(ApiError(StatusCode::CONFLICT, "the proposal is not open".into())); |
| 3363 | 0× | } |
| 3364 | 0× | let a = crate::assistants::enabled_for_repo(&app.db, &row) |
| 3365 | 0× | .await? |
| 3366 | 0× | .into_iter() |
| 3367 | 0× | .find(|a| a.name.eq_ignore_ascii_case(body.assistant.trim())) |
| 3368 | 0× | .ok_or_else(|| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("no assistant called {} here", body.assistant.trim())))?; |
| 3369 | 0× | let asked = hub_api::AssistantRequest { assistant: a.name.clone() }; |
| 3370 | 0× | crate::assistants::request_review(&app, &row, &p, &me, a).await.map_err(|e| ApiError(StatusCode::CONFLICT, e))?; |
| 3371 | 0× | Ok((StatusCode::ACCEPTED, Json(asked))) |
| 3372 | 0× | } |
| 3373 | |
|
| 3374 | |
|
| 3375 | |
|
| 3376 | | use crate::levels::At; |
| 3377 | | use crate::web::assistants as aa; |
| 3378 | |
|
| 3379 | |
|
| 3380 | 8× | async fn repo_level(app: &Shared, c: &Caller, owner: &str, name: &str) -> Result<(Account, At), ApiError> { |
| 3381 | 8× | let me = c.require("repo:admin")?.clone(); |
| 3382 | 7× | let (_, row, _) = reach(app, c, owner, name, Access::Admin).await?; |
| 3383 | 7× | Ok((me, At::repo(&row))) |
| 3384 | 8× | } |
| 3385 | |
|
| 3386 | |
|
| 3387 | 0× | async fn org_level(app: &Shared, c: &Caller, org: &str) -> Result<(Account, At), ApiError> { |
| 3388 | 0× | let me = c.require("repo:admin")?.clone(); |
| 3389 | 0× | let o = db::accounts::org_by_name(&app.db, org).await?.ok_or_else(|| not_found("no such organisation"))?; |
| 3390 | 0× | if !auth::roles::is_org_owner(&app.db, &me, o.id).await? { |
| 3391 | 0× | return Err(not_found("no such organisation")); |
| 3392 | 0× | } |
| 3393 | 0× | Ok((me, At::org(&o))) |
| 3394 | 0× | } |
| 3395 | |
|
| 3396 | |
|
| 3397 | 3× | fn site_level(c: &Caller) -> Result<(Account, At), ApiError> { |
| 3398 | 3× | Ok((require_site_admin(c)?.clone(), At::site())) |
| 3399 | 3× | } |
| 3400 | |
|
| 3401 | 4× | fn put_outcome(r: Result<String, String>) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 3402 | 4× | match r { |
| 3403 | 4× | Ok(msg) => Ok((StatusCode::OK, Json(serde_json::json!({ "ok": msg })))), |
| 3404 | 0× | Err(e) => Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, e)), |
| 3405 | | } |
| 3406 | 4× | } |
| 3407 | |
|
| 3408 | | macro_rules! assistant_routes { |
| 3409 | | ($list:ident, $put:ident, $del:ident, $prompt:ident, $prompt_put:ident, ($($p:ident: $t:ty),*), $level:expr) => { |
| 3410 | 2× | async fn $list(State(app): State<Shared>, headers: HeaderMap, Path(($($p),*)): Path<($($t),*)>) -> Result<Json<Vec<Assistant>>, ApiError> { |
| 3411 | 2× | let c = caller(&app, &headers).await?; |
| 3412 | 2× | let (_, at) = $level(&app, &c, $(&$p),*).await?; |
| 3413 | 2× | Ok(Json(aa::api_list(&app, &at).await?)) |
| 3414 | 2× | } |
| 3415 | 3× | async fn $put( |
| 3416 | 3× | State(app): State<Shared>, |
| 3417 | 3× | headers: HeaderMap, |
| 3418 | 3× | Path(($($p),*)): Path<($($t),*)>, |
| 3419 | 3× | Json(body): Json<NewAssistant>, |
| 3420 | 3× | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 3421 | 3× | let c = caller(&app, &headers).await?; |
| 3422 | 3× | let (me, at) = $level(&app, &c, $(&$p),*).await?; |
| 3423 | 2× | put_outcome(aa::api_put(&app, &me, &at, body).await?) |
| 3424 | 3× | } |
| 3425 | 0× | async fn $prompt(State(app): State<Shared>, headers: HeaderMap, Path(($($p),*)): Path<($($t),*)>) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3426 | 0× | let c = caller(&app, &headers).await?; |
| 3427 | 0× | let (_, at) = $level(&app, &c, $(&$p),*).await?; |
| 3428 | 0× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3429 | 0× | } |
| 3430 | 1× | async fn $prompt_put( |
| 3431 | 1× | State(app): State<Shared>, |
| 3432 | 1× | headers: HeaderMap, |
| 3433 | 1× | Path(($($p),*)): Path<($($t),*)>, |
| 3434 | 1× | Json(body): Json<AssistantPrompt>, |
| 3435 | 1× | ) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3436 | 1× | let c = caller(&app, &headers).await?; |
| 3437 | 1× | let (me, at) = $level(&app, &c, $(&$p),*).await?; |
| 3438 | 1× | aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; |
| 3439 | 1× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3440 | 1× | } |
| 3441 | | }; |
| 3442 | | } |
| 3443 | |
|
| 3444 | | assistant_routes!(repo_assistants, repo_assistant_put, _rd, repo_prompt, repo_prompt_put, (owner: String, name: String), repo_level); |
| 3445 | |
|
| 3446 | 2× | async fn repo_assistant_delete( |
| 3447 | 2× | State(app): State<Shared>, |
| 3448 | 2× | headers: HeaderMap, |
| 3449 | 2× | Path((owner, name, assistant)): Path<(String, String, String)>, |
| 3450 | 2× | ) -> Result<StatusCode, ApiError> { |
| 3451 | 2× | let c = caller(&app, &headers).await?; |
| 3452 | 2× | let (me, at) = repo_level(&app, &c, &owner, &name).await?; |
| 3453 | 2× | if aa::api_delete(&app, &me, &at, &assistant).await? { |
| 3454 | 1× | Ok(StatusCode::NO_CONTENT) |
| 3455 | | } else { |
| 3456 | 1× | Err(not_found("no such assistant at this level")) |
| 3457 | | } |
| 3458 | 2× | } |
| 3459 | |
|
| 3460 | 0× | async fn org_level_one(app: &Shared, c: &Caller, org: &str) -> Result<(Account, At), ApiError> { |
| 3461 | 0× | org_level(app, c, org).await |
| 3462 | 0× | } |
| 3463 | |
|
| 3464 | 0× | async fn org_assistants(State(app): State<Shared>, headers: HeaderMap, Path(org): Path<String>) -> Result<Json<Vec<Assistant>>, ApiError> { |
| 3465 | 0× | let c = caller(&app, &headers).await?; |
| 3466 | 0× | let (_, at) = org_level_one(&app, &c, &org).await?; |
| 3467 | 0× | Ok(Json(aa::api_list(&app, &at).await?)) |
| 3468 | 0× | } |
| 3469 | |
|
| 3470 | 0× | async fn org_assistant_put( |
| 3471 | 0× | State(app): State<Shared>, |
| 3472 | 0× | headers: HeaderMap, |
| 3473 | 0× | Path(org): Path<String>, |
| 3474 | 0× | Json(body): Json<NewAssistant>, |
| 3475 | 0× | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 3476 | 0× | let c = caller(&app, &headers).await?; |
| 3477 | 0× | let (me, at) = org_level_one(&app, &c, &org).await?; |
| 3478 | 0× | put_outcome(aa::api_put(&app, &me, &at, body).await?) |
| 3479 | 0× | } |
| 3480 | |
|
| 3481 | 0× | async fn org_assistant_delete( |
| 3482 | 0× | State(app): State<Shared>, |
| 3483 | 0× | headers: HeaderMap, |
| 3484 | 0× | Path((org, assistant)): Path<(String, String)>, |
| 3485 | 0× | ) -> Result<StatusCode, ApiError> { |
| 3486 | 0× | let c = caller(&app, &headers).await?; |
| 3487 | 0× | let (me, at) = org_level_one(&app, &c, &org).await?; |
| 3488 | 0× | if aa::api_delete(&app, &me, &at, &assistant).await? { |
| 3489 | 0× | Ok(StatusCode::NO_CONTENT) |
| 3490 | | } else { |
| 3491 | 0× | Err(not_found("no such assistant at this level")) |
| 3492 | | } |
| 3493 | 0× | } |
| 3494 | |
|
| 3495 | 0× | async fn org_prompt(State(app): State<Shared>, headers: HeaderMap, Path(org): Path<String>) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3496 | 0× | let c = caller(&app, &headers).await?; |
| 3497 | 0× | let (_, at) = org_level_one(&app, &c, &org).await?; |
| 3498 | 0× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3499 | 0× | } |
| 3500 | |
|
| 3501 | 0× | async fn org_prompt_put( |
| 3502 | 0× | State(app): State<Shared>, |
| 3503 | 0× | headers: HeaderMap, |
| 3504 | 0× | Path(org): Path<String>, |
| 3505 | 0× | Json(body): Json<AssistantPrompt>, |
| 3506 | 0× | ) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3507 | 0× | let c = caller(&app, &headers).await?; |
| 3508 | 0× | let (me, at) = org_level_one(&app, &c, &org).await?; |
| 3509 | 0× | aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; |
| 3510 | 0× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3511 | 0× | } |
| 3512 | |
|
| 3513 | 0× | async fn site_assistants(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Assistant>>, ApiError> { |
| 3514 | 0× | let c = caller(&app, &headers).await?; |
| 3515 | 0× | let (_, at) = site_level(&c)?; |
| 3516 | 0× | Ok(Json(aa::api_list(&app, &at).await?)) |
| 3517 | 0× | } |
| 3518 | |
|
| 3519 | 2× | async fn site_assistant_put( |
| 3520 | 2× | State(app): State<Shared>, |
| 3521 | 2× | headers: HeaderMap, |
| 3522 | 2× | Json(body): Json<NewAssistant>, |
| 3523 | 2× | ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { |
| 3524 | 2× | let c = caller(&app, &headers).await?; |
| 3525 | 2× | let (me, at) = site_level(&c)?; |
| 3526 | 2× | put_outcome(aa::api_put(&app, &me, &at, body).await?) |
| 3527 | 2× | } |
| 3528 | |
|
| 3529 | 0× | async fn site_assistant_delete( |
| 3530 | 0× | State(app): State<Shared>, |
| 3531 | 0× | headers: HeaderMap, |
| 3532 | 0× | Path(assistant): Path<String>, |
| 3533 | 0× | ) -> Result<StatusCode, ApiError> { |
| 3534 | 0× | let c = caller(&app, &headers).await?; |
| 3535 | 0× | let (me, at) = site_level(&c)?; |
| 3536 | 0× | if aa::api_delete(&app, &me, &at, &assistant).await? { |
| 3537 | 0× | Ok(StatusCode::NO_CONTENT) |
| 3538 | | } else { |
| 3539 | 0× | Err(not_found("no such assistant at this level")) |
| 3540 | | } |
| 3541 | 0× | } |
| 3542 | |
|
| 3543 | 0× | async fn site_prompt(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3544 | 0× | let c = caller(&app, &headers).await?; |
| 3545 | 0× | let (_, at) = site_level(&c)?; |
| 3546 | 0× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3547 | 0× | } |
| 3548 | |
|
| 3549 | 1× | async fn site_prompt_put( |
| 3550 | 1× | State(app): State<Shared>, |
| 3551 | 1× | headers: HeaderMap, |
| 3552 | 1× | Json(body): Json<AssistantPrompt>, |
| 3553 | 1× | ) -> Result<Json<AssistantPrompt>, ApiError> { |
| 3554 | 1× | let c = caller(&app, &headers).await?; |
| 3555 | 1× | let (me, at) = site_level(&c)?; |
| 3556 | 1× | aa::api_set_prompt(&app, &me, &at, &body.prompt).await?.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, e))?; |
| 3557 | 1× | Ok(Json(aa::api_prompt(&app, &at).await?)) |
| 3558 | 1× | } |