OXYGENHUB
# 1f1eb06a9009 oxygenhub / crates / hub / src / api / mod.rs
67.6 KiB · Rust · last changed Land proposals through the API and oh proposal land 1 month ago source blame history raw
b64e2537 Andy McCall ↶ 1
//! The JSON API (hub spec §12.1) under `/api/v1`: bearer tokens, RFC 7807
2
//! problems, the same shapes as `oxy --json` where one exists. Reads need
3
//! `repo:read` (anonymous for public repositories); writes the scope named
4
//! on each handler.
d23464c0 Andy McCall ↶ 5
b64e2537 Andy McCall ↶ 6
use crate::auth::{self, Access, Role};
7
use crate::db::{self, accounts::Account};
d23464c0 Andy McCall ↶ 8
use crate::repos::{view, RepoError, RepoName};
b64e2537 Andy McCall ↶ 9
use crate::Shared;
10
use axum::extract::{Path, Query, State};
11
use axum::http::{HeaderMap, StatusCode};
d23464c0 Andy McCall ↶ 12
use axum::response::{IntoResponse, Response};
b64e2537 Andy McCall ↶ 13
use axum::routing::{get, post};
d23464c0 Andy McCall ↶ 14
use axum::{Json, Router};
b64e2537 Andy McCall ↶ 15
use hub_api::*;
16
use serde::Deserialize;
d23464c0 Andy McCall ↶ 17
ef20fdc1 Andy McCall ↶ 18
mod projects;
19
90326c96 Andy McCall ↶ 20
/// Any other path under `/api/v1`: a problem document, not the site's page.
21
async fn no_route() -> ApiError {
22
    ApiError(StatusCode::NOT_FOUND, "no such API route".into())
23
}
24
d23464c0 Andy McCall ↶ 25
pub fn router() -> Router<Shared> {
26
    Router::new()
90326c96 Andy McCall ↶ 27
        .fallback(no_route)
42fa9554 Andy McCall ↶ 28
        .route("/flow/claim", post(crate::flow::claim))
29
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
30
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
ef20fdc1 Andy McCall ↶ 31
        .route("/projects/{owner}", get(projects::list).post(projects::create))
32
        .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit))
33
        .route("/projects/{owner}/{number}/cards", post(projects::add_card))
34
        .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card))
b64e2537 Andy McCall ↶ 35
        .route("/user", get(me))
42a0ba4b Andy McCall ↶ 36
        .route("/notifications", get(notifications))
37
        .route("/notifications/read", post(notifications_read_all))
38
        .route("/notifications/{id}/read", post(notification_read))
b64e2537 Andy McCall ↶ 39
        .route("/users/{name}", get(user))
40
        .route("/orgs/{name}", get(org))
41
        .route("/search", get(search))
42
        .route("/repos", get(list_repos).post(create_repo))
d23464c0 Andy McCall ↶ 43
        .route("/repos/{owner}/{name}", get(get_repo))
44
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
45
        .route("/repos/{owner}/{name}/tags", get(tags))
b64e2537 Andy McCall ↶ 46
        .route("/repos/{owner}/{name}/history/{r}", get(history))
47
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
48
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
49
        .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree))
50
        .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file))
51
        .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame))
52
        .route("/repos/{owner}/{name}/conflicts", get(conflicts))
53
        .route("/repos/{owner}/{name}/trust", get(trust))
54
        .route("/repos/{owner}/{name}/pushes", get(pushes))
55
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
de505df5 Andy McCall ↶ 56
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
1f1eb06a Andy McCall ↶ 57
        .route("/repos/{owner}/{name}/proposals/{number}/land", post(land_proposal))
b64e2537 Andy McCall ↶ 58
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
59
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
60
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
3f02e5ea Andy McCall ↶ 61
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
62
        .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content))
b64e2537 Andy McCall ↶ 63
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
76f262e1 Andy McCall ↶ 64
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state).patch(issue_edit))
b64e2537 Andy McCall ↶ 65
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
76f262e1 Andy McCall ↶ 66
        .route("/repos/{owner}/{name}/labels", get(labels).post(create_label))
67
        .route("/repos/{owner}/{name}/labels/{label}", axum::routing::delete(delete_label))
b64e2537 Andy McCall ↶ 68
        .route("/repos/{owner}/{name}/milestones", get(milestones))
69
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
70
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
71
        .route("/repos/{owner}/{name}/checks", post(post_check))
90326c96 Andy McCall ↶ 72
        .route("/repos/{owner}/{name}/flow/runs", get(flow_runs).post(start_flow_run))
73
        .route("/repos/{owner}/{name}/flow/runs/{number}", get(flow_run))
74
        .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/log", get(flow_job_log))
1edac4b6 Andy McCall ↶ 75
        .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run))
961c10be Andy McCall ↶ 76
        .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks))
b64e2537 Andy McCall ↶ 77
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
3f4b0ea6 Andy McCall ↶ 78
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
79
        .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset))
80
}
81
82
async fn releases(
83
    State(app): State<Shared>,
84
    headers: HeaderMap,
85
    Path((owner, name)): Path<(String, String)>,
86
) -> Result<Json<Vec<Release>>, ApiError> {
87
    let c = caller(&app, &headers).await?;
88
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
89
    Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?))
90
}
91
92
async fn create_release(
93
    State(app): State<Shared>,
94
    headers: HeaderMap,
95
    Path((owner, name)): Path<(String, String)>,
96
    Json(body): Json<NewRelease>,
97
) -> Result<(StatusCode, Json<Release>), ApiError> {
98
    let c = caller(&app, &headers).await?;
99
    let me = c.require("repo:write")?.clone();
100
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
101
    crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease)
102
        .await
103
        .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
104
    let r = crate::web::releases::api_views(&app, &repo, &row)
105
        .await?
106
        .into_iter()
107
        .find(|r| r.tag == body.tag)
108
        .ok_or_else(|| not_found("no such release"))?;
109
    Ok((StatusCode::CREATED, Json(r)))
110
}
111
112
#[derive(Deserialize)]
113
struct AssetQuery {
114
    name: String,
de505df5 Andy McCall ↶ 115
}
116
3f4b0ea6 Andy McCall ↶ 117
/// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body.
118
async fn upload_asset(
119
    State(app): State<Shared>,
120
    headers: HeaderMap,
121
    Path((owner, name, tag)): Path<(String, String, String)>,
122
    Query(q): Query<AssetQuery>,
123
    body: axum::body::Bytes,
124
) -> Result<(StatusCode, Json<Release>), ApiError> {
125
    let c = caller(&app, &headers).await?;
126
    c.require("repo:write")?;
127
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
128
    let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?;
129
    let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string();
130
    crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
131
    let r = crate::web::releases::api_views(&app, &repo, &row)
132
        .await?
133
        .into_iter()
134
        .find(|r| r.tag == tag)
135
        .ok_or_else(|| not_found("no such release"))?;
136
    Ok((StatusCode::CREATED, Json(r)))
137
}
138
42fa9554 Andy McCall ↶ 139
pub struct ApiError(pub StatusCode, pub String);
b64e2537 Andy McCall ↶ 140
141
impl IntoResponse for ApiError {
142
    fn into_response(self) -> Response {
143
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
144
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
de505df5 Andy McCall ↶ 145
    }
b64e2537 Andy McCall ↶ 146
}
147
148
impl From<RepoError> for ApiError {
149
    fn from(e: RepoError) -> ApiError {
150
        match e {
151
            RepoError::NotFound => not_found("no such repository"),
152
            RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m),
153
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
154
        }
155
    }
156
}
157
158
impl From<sqlx::Error> for ApiError {
159
    fn from(e: sqlx::Error) -> ApiError {
160
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
161
    }
162
}
163
ef20fdc1 Andy McCall ↶ 164
pub(crate) fn not_found(what: &str) -> ApiError {
b64e2537 Andy McCall ↶ 165
    ApiError(StatusCode::NOT_FOUND, what.into())
166
}
167
ef20fdc1 Andy McCall ↶ 168
pub(crate) fn forbidden(what: &str) -> ApiError {
b64e2537 Andy McCall ↶ 169
    ApiError(StatusCode::FORBIDDEN, what.into())
170
}
171
172
/// Who calls: the account behind a bearer token, with the token's scopes.
173
pub struct Caller {
174
    pub account: Option<Account>,
175
    pub scopes: Vec<String>,
176
    pub site: bool,
177
}
178
179
impl Caller {
180
    fn has(&self, scope: &str) -> bool {
181
        self.site
182
            || self.scopes.iter().any(|s| {
183
                s == scope
184
                    || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin"))
185
                    || (scope == "repo:write" && s == "repo:admin")
186
            })
187
    }
188
189
    fn require(&self, scope: &str) -> Result<&Account, ApiError> {
190
        if !self.has(scope) {
191
            return Err(forbidden(&format!("this token lacks the {scope} scope")));
192
        }
193
        self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into()))
194
    }
195
}
196
197
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
198
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) };
199
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
200
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
201
        return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true });
202
    }
203
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
de505df5 Andy McCall ↶ 204
    match token {
b64e2537 Andy McCall ↶ 205
        Some(tok) if !tok.expired(db::now_ms()) => {
206
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
207
            db::accounts::touch_token(&app.db, tok.id).await.ok();
208
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false })
209
        }
de505df5 Andy McCall ↶ 210
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
211
    }
212
}
213
b64e2537 Andy McCall ↶ 214
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
215
    Repository {
216
        owner: r.owner.clone(),
217
        name: r.name.clone(),
218
        description: r.description.clone(),
219
        default_bookmark: r.default_bookmark.clone(),
220
        visibility: r.visibility(),
221
        created_ms: r.created_ms,
222
        updated_ms: r.updated_ms,
223
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
224
    }
225
}
226
227
/// A repository the caller may reach with `access`, else 404.
228
async fn reach(
de505df5 Andy McCall ↶ 229
    app: &Shared,
b64e2537 Andy McCall ↶ 230
    c: &Caller,
de505df5 Andy McCall ↶ 231
    owner: &str,
232
    name: &str,
b64e2537 Andy McCall ↶ 233
    access: Access,
234
) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> {
235
    let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?;
236
    let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?;
237
    let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
238
    if !auth::roles::allows(role, access) {
239
        return Err(not_found("no such repository"));
240
    }
241
    Ok((repo, row, role.unwrap_or(Role::Read)))
242
}
243
244
fn summary(c: &view::ChangeView) -> ChangeSummary {
245
    ChangeSummary {
246
        hash: c.hash.clone(),
247
        message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
248
        author: Author {
249
            name: c.author.name.clone(),
250
            email: c.author.email.clone(),
251
            key: c.author.key.clone(),
252
            account: c.author.account.clone(),
760a7dd3 Andy McCall ↶ 253
            note: Author::note_for(&c.author.key, &c.author.account),
b64e2537 Andy McCall ↶ 254
        },
255
        when_ms: c.when_ms,
256
        deps: c.deps.clone(),
257
        head: c.head,
258
        signature: match &c.signature {
259
            view::SigView::Unsigned => Signature::Unsigned,
260
            view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked },
261
            view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() },
262
        },
263
    }
264
}
265
266
// ---------------------------------------------------------------- accounts
267
268
async fn me(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
269
    let c = caller(&app, &headers).await?;
270
    let a = c.require("user:read")?;
271
    Ok(Json(user_of(&app, a).await?))
272
}
273
42a0ba4b Andy McCall ↶ 274
#[derive(Deserialize)]
275
struct InboxQuery {
276
    #[serde(default)]
277
    limit: Option<i64>,
278
}
279
280
/// The caller's inbox; reading and clearing it needs `user:read`, since
281
/// it touches nothing but the caller's own notifications.
282
async fn notifications(
283
    State(app): State<Shared>,
284
    headers: HeaderMap,
285
    Query(q): Query<InboxQuery>,
286
) -> Result<Json<hub_api::Inbox>, ApiError> {
287
    let c = caller(&app, &headers).await?;
288
    let a = c.require("user:read")?;
289
    let limit = q.limit.unwrap_or(50).clamp(1, 200);
290
    let base = app.config.base_url().trim_end_matches('/').to_string();
291
    let items = db::notify::inbox(&app.db, a.id, limit)
292
        .await?
293
        .into_iter()
294
        .map(|n| hub_api::Notification {
295
            id: n.id,
296
            at_ms: n.at_ms,
297
            kind: n.kind,
298
            title: n.title,
299
            url: if n.url.starts_with('/') { format!("{base}{}", n.url) } else { n.url },
300
            actor: n.actor,
301
            read: n.read,
302
        })
303
        .collect();
304
    Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items }))
305
}
306
307
async fn notification_read(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
308
    let c = caller(&app, &headers).await?;
309
    let a = c.require("user:read")?;
310
    db::notify::mark_read(&app.db, a.id, Some(id)).await?;
311
    Ok(StatusCode::NO_CONTENT)
312
}
313
314
async fn notifications_read_all(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
315
    let c = caller(&app, &headers).await?;
316
    let a = c.require("user:read")?;
317
    db::notify::mark_read(&app.db, a.id, None).await?;
318
    Ok(StatusCode::NO_CONTENT)
319
}
320
b64e2537 Andy McCall ↶ 321
async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> {
322
    let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect();
323
    Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms })
324
}
325
326
async fn user(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<User>, ApiError> {
327
    let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?;
328
    Ok(Json(user_of(&app, &a).await?))
329
}
330
331
async fn org(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<Organisation>, ApiError> {
332
    let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?;
333
    let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect();
334
    Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members }))
335
}
336
337
#[derive(Deserialize)]
338
struct SearchQuery {
339
    q: String,
340
}
341
342
async fn search(
343
    State(app): State<Shared>,
344
    headers: HeaderMap,
345
    Query(q): Query<SearchQuery>,
346
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
347
    let c = caller(&app, &headers).await?;
348
    let mut out = Vec::new();
349
    for h in db::notify::search(&app.db, &q.q, None, 50).await? {
350
        let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue };
351
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
352
        if !auth::roles::allows(role, Access::Read) {
353
            continue;
354
        }
355
        out.push(
356
            serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }),
357
        );
358
    }
359
    Ok(Json(out))
360
}
361
362
// ------------------------------------------------------------ repositories
363
364
async fn list_repos(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Repository>>, ApiError> {
365
    let c = caller(&app, &headers).await?;
366
    let mut out = Vec::new();
367
    for r in db::repos::list(&app.db).await? {
368
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?;
369
        if auth::roles::allows(role, Access::Read) {
370
            out.push(repository(&app, &r));
371
        }
de505df5 Andy McCall ↶ 372
    }
b64e2537 Andy McCall ↶ 373
    Ok(Json(out))
374
}
375
376
async fn create_repo(
377
    State(app): State<Shared>,
378
    headers: HeaderMap,
379
    Json(body): Json<NewRepository>,
380
) -> Result<(StatusCode, Json<Repository>), ApiError> {
381
    let c = caller(&app, &headers).await?;
382
    let me = c.require("repo:admin")?;
383
    let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?;
384
    if !auth::roles::may_create_under(&app.db, me, &name.owner).await? {
385
        return Err(forbidden("you cannot create repositories under that owner"));
386
    }
387
    if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) {
388
        return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into()));
389
    }
390
    app.repos.create(&name, None)?;
391
    let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?;
392
    db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok();
393
    Ok((StatusCode::CREATED, Json(repository(&app, &row))))
394
}
395
396
async fn get_repo(
397
    State(app): State<Shared>,
398
    headers: HeaderMap,
399
    Path((owner, name)): Path<(String, String)>,
400
) -> Result<Json<Repository>, ApiError> {
401
    let c = caller(&app, &headers).await?;
402
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
403
    Ok(Json(repository(&app, &row)))
404
}
405
406
async fn bookmarks(
407
    State(app): State<Shared>,
408
    headers: HeaderMap,
409
    Path((owner, name)): Path<(String, String)>,
410
) -> Result<Json<Vec<Bookmark>>, ApiError> {
411
    let c = caller(&app, &headers).await?;
412
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
413
    let default = row.default_bookmark.clone();
414
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
415
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
416
}
417
418
async fn tags(
419
    State(app): State<Shared>,
420
    headers: HeaderMap,
421
    Path((owner, name)): Path<(String, String)>,
422
) -> Result<Json<Vec<Tag>>, ApiError> {
423
    let c = caller(&app, &headers).await?;
424
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
425
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
426
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
427
}
428
429
#[derive(Deserialize)]
430
struct HistoryQuery {
431
    path: Option<String>,
432
    offset: Option<usize>,
433
    limit: Option<usize>,
434
}
435
436
async fn history(
437
    State(app): State<Shared>,
438
    headers: HeaderMap,
439
    Path((owner, name, r)): Path<(String, String, String)>,
440
    Query(q): Query<HistoryQuery>,
441
) -> Result<Json<History>, ApiError> {
442
    let c = caller(&app, &headers).await?;
443
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
444
    let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500));
445
    let h = app
446
        .repos
447
        .read(&repo, move |repo, cache| {
448
            let target = view::resolve(repo, cache, &r)?;
449
            view::history(repo, cache, target, q.path.as_deref(), offset, limit)
450
        })
451
        .await?;
452
    let mut entries = h.entries;
453
    crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
454
    Ok(Json(History {
455
        reference: h.target.label,
456
        heads: h.target.heads.iter().map(|x| x.to_hex()).collect(),
457
        total: h.total,
458
        offset: h.offset,
459
        changes: entries.iter().map(summary).collect(),
460
    }))
461
}
462
463
async fn change(
464
    State(app): State<Shared>,
465
    headers: HeaderMap,
466
    Path((owner, name, hash)): Path<(String, String, String)>,
467
) -> Result<Json<ChangeDetail>, ApiError> {
468
    let c = caller(&app, &headers).await?;
469
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
470
    let d = app
471
        .repos
472
        .read(&repo, move |repo, cache| {
473
            let h = view::resolve_change(repo, cache, &hash)?;
474
            view::change(repo, cache, &h)
475
        })
476
        .await?;
477
    let mut v = d.view;
478
    crate::web::attribute(&app, &mut [&mut v]).await;
479
    let files = crate::web::history::render_files(&d.files, &d.bases)
480
        .into_iter()
481
        .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed })
482
        .collect();
483
    Ok(Json(ChangeDetail {
484
        body: v.body.clone(),
485
        change: summary(&v),
486
        dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(),
487
        in_bookmarks: d.in_bookmarks,
488
        in_tags: d.in_tags,
489
        files,
490
    }))
491
}
492
493
fn entry(e: &view::Entry) -> TreeEntry {
494
    TreeEntry {
495
        name: e.name.clone(),
496
        path: e.path.clone(),
497
        kind: match e.kind {
498
            view::EntryKind::Dir => "dir",
499
            view::EntryKind::File => "file",
500
            view::EntryKind::Symlink => "symlink",
501
            view::EntryKind::Subrepo => "subrepo",
502
        }
503
        .into(),
504
        exec: e.exec,
505
        size: e.size,
506
        conflicted: e.conflicted,
507
    }
508
}
509
510
async fn tree_root(
511
    State(app): State<Shared>,
512
    headers: HeaderMap,
513
    Path((owner, name, r)): Path<(String, String, String)>,
514
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
515
    tree(State(app), headers, Path((owner, name, r, String::new()))).await
516
}
517
518
async fn tree(
519
    State(app): State<Shared>,
520
    headers: HeaderMap,
521
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
522
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
523
    let c = caller(&app, &headers).await?;
524
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
525
    let t = app
526
        .repos
527
        .read(&repo, move |repo, cache| {
528
            let target = view::resolve(repo, cache, &r)?;
529
            view::tree(repo, cache, target, &path)
530
        })
531
        .await?;
532
    Ok(Json(t.entries.iter().map(entry).collect()))
533
}
534
535
async fn file(
536
    State(app): State<Shared>,
537
    headers: HeaderMap,
538
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
539
) -> Result<Response, ApiError> {
540
    let c = caller(&app, &headers).await?;
541
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
542
    let limit = app.config.limits.render_limit.0;
543
    let f = app
544
        .repos
545
        .read(&repo, move |repo, cache| {
546
            let target = view::resolve(repo, cache, &r)?;
547
            view::file(repo, cache, target, &path, limit)
548
        })
549
        .await?;
550
    Ok(crate::web::raw(f.bytes, &f.name, false))
551
}
552
553
async fn blame(
554
    State(app): State<Shared>,
555
    headers: HeaderMap,
556
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
557
) -> Result<Json<Vec<BlameLine>>, ApiError> {
558
    let c = caller(&app, &headers).await?;
559
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
560
    let limit = app.config.limits.render_limit.0;
561
    let b = app
562
        .repos
563
        .read(&repo, move |repo, cache| {
564
            let target = view::resolve(repo, cache, &r)?;
565
            view::blame(repo, cache, target, &path, limit)
566
        })
567
        .await?;
568
    Ok(Json(
569
        b.lines
570
            .into_iter()
571
            .map(|l| BlameLine {
572
                line: l.line,
573
                change: l.hash.to_hex(),
574
                index: l.index,
575
                author: l.author,
576
                when_ms: l.when_ms,
577
                text: String::from_utf8_lossy(&l.text).into_owned(),
578
            })
579
            .collect(),
580
    ))
581
}
582
583
async fn conflicts(
584
    State(app): State<Shared>,
585
    headers: HeaderMap,
586
    Path((owner, name)): Path<(String, String)>,
587
) -> Result<Json<Vec<Conflict>>, ApiError> {
588
    let c = caller(&app, &headers).await?;
589
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
590
    let list = app.repos.read(&repo, view::conflicts).await?;
591
    Ok(Json(
592
        list.into_iter()
593
            .map(|c| Conflict {
594
                bookmark: c.bookmark,
595
                path: c.path,
596
                kind: c.kind,
597
                contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(),
598
            })
599
            .collect(),
600
    ))
601
}
602
603
async fn trust(
604
    State(app): State<Shared>,
605
    headers: HeaderMap,
606
    Path((owner, name)): Path<(String, String)>,
607
) -> Result<Json<Trust>, ApiError> {
608
    let c = caller(&app, &headers).await?;
609
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
610
    let key = app.hub_key.key;
611
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?;
612
    Ok(Json(Trust {
613
        policy: t.policy.map(|p| Policy {
614
            serial: p.serial,
615
            expires_ms: p.expires_ms,
616
            roots: p.roots.iter().map(|k| k.key.clone()).collect(),
617
            root_threshold: p.root_threshold,
618
            rules: p
619
                .rules
620
                .iter()
621
                .map(|r| Rule {
622
                    pattern: r.pattern.clone(),
623
                    keys: r.keys.iter().map(|k| k.key.clone()).collect(),
624
                    threshold: r.threshold,
625
                    require: r.require.clone(),
626
                    allow_conflicts: r.allow_conflicts,
627
                })
628
                .collect(),
629
        }),
630
        signed: t.verification.signed,
631
        sealed: t.verification.sealed,
632
        unsigned: t.verification.unsigned,
633
        invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(),
634
        hub_key: t.hub_key,
635
    }))
636
}
637
638
async fn pushes(
639
    State(app): State<Shared>,
640
    headers: HeaderMap,
641
    Path((owner, name)): Path<(String, String)>,
642
) -> Result<Json<Vec<Push>>, ApiError> {
643
    let c = caller(&app, &headers).await?;
644
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
645
    let rows = db::repos::pushes(&app.db, row.id, 200).await?;
646
    Ok(Json(
647
        rows.into_iter()
648
            .map(|p| Push {
649
                at_ms: p.at_ms,
650
                pusher: p.pusher,
651
                reference: p.reference,
652
                ok: p.ok,
653
                reason: p.reason,
654
                old_state: p.old_state,
655
                new_state: p.new_state,
656
                heads: p.heads,
657
                changes: p.changes,
658
            })
659
            .collect(),
660
    ))
de505df5 Andy McCall ↶ 661
}
662
b64e2537 Andy McCall ↶ 663
// --------------------------------------------------------------- proposals
664
de505df5 Andy McCall ↶ 665
async fn proposals(
666
    State(app): State<Shared>,
b64e2537 Andy McCall ↶ 667
    headers: HeaderMap,
de505df5 Andy McCall ↶ 668
    Path((owner, name)): Path<(String, String)>,
b64e2537 Andy McCall ↶ 669
) -> Result<Json<Vec<Proposal>>, ApiError> {
670
    let c = caller(&app, &headers).await?;
671
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
de505df5 Andy McCall ↶ 672
    let mut out = Vec::new();
673
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
674
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
675
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
676
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
677
    }
678
    Ok(Json(out))
679
}
680
681
async fn proposal(
682
    State(app): State<Shared>,
b64e2537 Andy McCall ↶ 683
    headers: HeaderMap,
de505df5 Andy McCall ↶ 684
    Path((owner, name, number)): Path<(String, String, i64)>,
b64e2537 Andy McCall ↶ 685
) -> Result<Json<Proposal>, ApiError> {
686
    let c = caller(&app, &headers).await?;
687
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
688
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
de505df5 Andy McCall ↶ 689
    let source_url = match p.source_repo_id {
690
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
691
        None => None,
692
    };
693
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
694
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
695
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
1f1eb06a Andy McCall ↶ 696
}
697
698
/// `POST /repos/{o}/{n}/proposals/{number}/land` (repo:write): land it as
699
/// the page's Land button does; `409` with the blockers when it cannot.
700
async fn land_proposal(
701
    State(app): State<Shared>,
702
    headers: HeaderMap,
703
    Path((owner, name, number)): Path<(String, String, i64)>,
704
) -> Result<Json<Proposal>, ApiError> {
705
    let c = caller(&app, &headers).await?;
706
    let me = c.require("repo:write")?.clone();
707
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
708
    match crate::web::proposals::land_as(&app, &repo, &row, &me, number).await {
709
        Ok(_) => proposal(State(app), headers, Path((owner, name, number))).await,
710
        Err(crate::web::proposals::Landing::NotFound) => Err(not_found("no such proposal")),
711
        Err(crate::web::proposals::Landing::Refused(m)) => Err(ApiError(StatusCode::CONFLICT, m)),
712
    }
d23464c0 Andy McCall ↶ 713
}
714
3f02e5ea Andy McCall ↶ 715
/// The files a proposal changes (hub spec §12.1).
716
async fn proposal_files(
717
    State(app): State<Shared>,
718
    headers: HeaderMap,
719
    Path((owner, name, number)): Path<(String, String, i64)>,
720
) -> Result<Json<Vec<hub_api::ProposalFile>>, ApiError> {
721
    let c = caller(&app, &headers).await?;
722
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
723
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
724
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
725
    Ok(Json(
726
        loaded
727
            .files
728
            .iter()
729
            .map(|f| hub_api::ProposalFile {
730
                path: f.path.clone(),
731
                from: f.from.clone(),
732
                kind: f.kind.clone(),
733
                added: f.added,
734
                removed: f.removed,
735
            })
736
            .collect(),
737
    ))
738
}
739
740
/// One file's bytes on a side of a proposal: `base` is the target's
741
/// state, `head` the projected state. 404 when the file does not exist
742
/// on that side.
743
async fn proposal_content(
744
    State(app): State<Shared>,
745
    headers: HeaderMap,
746
    Path((owner, name, number, side, path)): Path<(String, String, i64, String, String)>,
747
) -> Result<Response, ApiError> {
748
    let c = caller(&app, &headers).await?;
749
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
750
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
751
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
752
    let heads = match side.as_str() {
753
        "base" => loaded.target_heads,
754
        "head" => loaded.heads,
755
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "the side is base or head".into())),
756
    };
757
    let limit = app.config.limits.render_limit.0;
758
    let target = view::RefTarget { label: side, kind: view::RefKind::Change, heads };
759
    let f = app.repos.read(&repo, move |repo, cache| view::file(repo, cache, target, &path, limit)).await?;
760
    Ok(crate::web::raw(f.bytes, &f.name, false))
761
}
762
b64e2537 Andy McCall ↶ 763
async fn create_proposal(
764
    State(app): State<Shared>,
765
    headers: HeaderMap,
766
    Path((owner, name)): Path<(String, String)>,
767
    Json(body): Json<NewProposal>,
768
) -> Result<(StatusCode, Json<Proposal>), ApiError> {
769
    let c = caller(&app, &headers).await?;
770
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
771
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
772
    let created =
773
        crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft)
774
            .await
775
            .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
776
    let loaded = crate::web::proposals::load(&app, &repo, created).await?;
777
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
778
    Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await)))
779
}
d23464c0 Andy McCall ↶ 780
b64e2537 Andy McCall ↶ 781
fn comment_of(c: &db::proposals::CommentRow) -> Comment {
782
    Comment {
783
        id: c.id,
784
        author: c.author.clone(),
785
        body: c.body.clone(),
786
        created_ms: c.created_ms,
787
        path: if c.path.is_empty() { None } else { Some(c.path.clone()) },
788
        anchor: if c.anchor_change.is_empty() {
789
            None
790
        } else {
791
            Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 })
792
        },
793
        resolved: c.resolved,
d23464c0 Andy McCall ↶ 794
    }
795
}
796
b64e2537 Andy McCall ↶ 797
async fn proposal_comments(
798
    State(app): State<Shared>,
799
    headers: HeaderMap,
800
    Path((owner, name, number)): Path<(String, String, i64)>,
801
) -> Result<Json<Vec<Comment>>, ApiError> {
802
    let c = caller(&app, &headers).await?;
803
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
804
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
805
    Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect()))
806
}
807
808
async fn proposal_comment(
809
    State(app): State<Shared>,
810
    headers: HeaderMap,
811
    Path((owner, name, number)): Path<(String, String, i64)>,
812
    Json(body): Json<NewComment>,
813
) -> Result<(StatusCode, Json<Comment>), ApiError> {
814
    let c = caller(&app, &headers).await?;
815
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
816
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
817
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
818
    if body.body.trim().is_empty() {
819
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
820
    }
3f4b0ea6 Andy McCall ↶ 821
    let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ());
822
    let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) {
823
        (Some(path), Some(line)) if !path.is_empty() => {
824
            let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?;
825
            let (heads, file) = (loaded.heads.clone(), path.to_string());
826
            let atom = app
827
                .repos
828
                .read(&repo, move |r, cache| {
829
                    let projected = cache.load(r, &heads)?;
830
                    Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i)))
831
                })
832
                .await?
833
                .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?;
834
            Some((path.to_string(), atom))
835
        }
836
        _ => None,
837
    };
838
    let row_c = db::proposals::add_comment(
839
        &app.db,
840
        p.id,
841
        Some(me.id),
842
        &me.username,
843
        body.body.trim(),
844
        anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""),
845
        anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)),
846
    )
847
    .await?;
b64e2537 Andy McCall ↶ 848
    crate::notify::deliver(
849
        &app,
850
        crate::notify::Event {
851
            kind: "proposal.comment",
852
            title: format!("{owner}/{name}#{number}: {}", p.title),
853
            url: format!("/{owner}/{name}/proposal/{number}"),
854
            actor: &me,
855
            repo_id: row.id,
856
            thread: Some(("proposal", p.id)),
857
            mention_text: &body.body,
858
            direct: p.author_id.into_iter().collect(),
859
        },
860
    )
861
    .await;
862
    crate::hooks::emit(
863
        &app,
864
        &row,
865
        "comment.proposal",
866
        &me.username,
867
        serde_json::json!({ "number": number, "comment": comment_of(&row_c) }),
868
    )
869
    .await;
870
    Ok((StatusCode::CREATED, Json(comment_of(&row_c))))
871
}
872
873
async fn proposal_reviews(
874
    State(app): State<Shared>,
875
    headers: HeaderMap,
876
    Path((owner, name, number)): Path<(String, String, i64)>,
877
) -> Result<Json<Vec<Review>>, ApiError> {
878
    let c = caller(&app, &headers).await?;
879
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
880
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
881
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
882
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
883
    Ok(Json(
884
        v.reviews
885
            .into_iter()
886
            .map(|r| Review {
887
                id: r.row.id,
888
                author: r.row.author,
889
                verdict: r.row.verdict,
890
                body: r.row.body,
891
                sealed: r.sealed,
892
                created_ms: r.row.created_ms,
893
            })
894
            .collect(),
895
    ))
896
}
897
898
async fn proposal_review(
899
    State(app): State<Shared>,
900
    headers: HeaderMap,
901
    Path((owner, name, number)): Path<(String, String, i64)>,
902
    Json(body): Json<NewReview>,
903
) -> Result<(StatusCode, Json<Review>), ApiError> {
904
    let c = caller(&app, &headers).await?;
905
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
906
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
907
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
908
    if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") {
909
        return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into()));
910
    }
911
    if body.verdict == "approve" && Some(me.id) == p.author_id {
912
        return Err(forbidden("you cannot approve your own proposal"));
913
    }
914
    let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?;
915
    db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok();
916
    crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await;
917
    Ok((
918
        StatusCode::CREATED,
919
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }),
920
    ))
921
}
922
923
async fn proposal_checks(
924
    State(app): State<Shared>,
925
    headers: HeaderMap,
926
    Path((owner, name, number)): Path<(String, String, i64)>,
927
) -> Result<Json<Vec<Check>>, ApiError> {
928
    let c = caller(&app, &headers).await?;
929
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
930
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
931
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
932
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect()))
933
}
934
935
// ------------------------------------------------------------------ issues
936
937
async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result<Issue, ApiError> {
938
    let labels = db::issues::issue_labels(&app.db, i.id)
939
        .await?
940
        .into_iter()
941
        .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
942
        .collect();
943
    let assignees = db::issues::assignees(&app.db, i.id).await?;
944
    let milestone = match i.milestone_id {
945
        Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title),
946
        None => None,
947
    };
948
    let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?;
949
    Ok(Issue {
950
        number: i.number,
951
        title: i.title.clone(),
952
        body: i.body.clone(),
953
        author: i.author.clone(),
954
        state: i.state.clone(),
955
        reason: i.reason.clone(),
956
        labels,
957
        assignees,
958
        milestone,
959
        comments,
960
        locked: i.locked,
961
        pinned: i.pinned,
962
        created_ms: i.created_ms,
963
        updated_ms: i.updated_ms,
964
        closed_ms: i.closed_ms,
965
        url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number),
966
    })
967
}
968
969
#[derive(Deserialize)]
970
struct IssuesQuery {
971
    state: Option<String>,
972
}
973
974
async fn issues(
975
    State(app): State<Shared>,
976
    headers: HeaderMap,
977
    Path((owner, name)): Path<(String, String)>,
978
    Query(q): Query<IssuesQuery>,
979
) -> Result<Json<Vec<Issue>>, ApiError> {
980
    let c = caller(&app, &headers).await?;
981
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
982
    let state = q.state.unwrap_or_else(|| "open".into());
983
    let mut out = Vec::new();
984
    for i in db::issues::list(&app.db, row.id).await? {
985
        if state == "all" || i.state == state {
986
            out.push(issue_of(&app, &row, &i).await?);
d23464c0 Andy McCall ↶ 987
        }
988
    }
b64e2537 Andy McCall ↶ 989
    Ok(Json(out))
d23464c0 Andy McCall ↶ 990
}
991
b64e2537 Andy McCall ↶ 992
async fn create_issue(
993
    State(app): State<Shared>,
994
    headers: HeaderMap,
995
    Path((owner, name)): Path<(String, String)>,
996
    Json(body): Json<NewIssue>,
997
) -> Result<(StatusCode, Json<Issue>), ApiError> {
998
    let c = caller(&app, &headers).await?;
999
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
76f262e1 Andy McCall ↶ 1000
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
b64e2537 Andy McCall ↶ 1001
    if body.title.trim().is_empty() {
1002
        return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
d23464c0 Andy McCall ↶ 1003
    }
76f262e1 Andy McCall ↶ 1004
    let triage = !body.labels.is_empty() || !body.assignees.is_empty() || body.milestone.is_some();
1005
    if triage && role < Role::Triage {
1006
        return Err(forbidden("setting labels, assignees or a milestone needs triage access"));
1007
    }
1008
    // Check the names before anything is created.
1009
    resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?;
b64e2537 Andy McCall ↶ 1010
    let i = db::issues::create(
1011
        &app.db,
1012
        db::issues::NewIssue {
1013
            repo_id: row.id,
1014
            title: body.title.trim(),
1015
            body: body.body.trim(),
1016
            author_id: Some(me.id),
1017
            author: &me.username,
1018
        },
1019
    )
1020
    .await?;
1021
    db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok();
76f262e1 Andy McCall ↶ 1022
    if triage {
1023
        let t = resolve_triage(&app, row.id, Some(&body.labels), Some(&body.assignees), body.milestone.as_deref()).await?;
1024
        apply_triage(&app, &i, &me, t).await?;
1025
    }
1026
    let i = db::issues::get(&app.db, row.id, i.number).await?.unwrap_or(i);
b64e2537 Andy McCall ↶ 1027
    crate::web::issues::reindex(&app, row.id, &i).await;
1028
    let api = issue_of(&app, &row, &i).await?;
1029
    crate::notify::deliver(
1030
        &app,
1031
        crate::notify::Event {
1032
            kind: "issue.opened",
1033
            title: format!("{owner}/{name}#{}: {}", i.number, i.title),
1034
            url: format!("/{owner}/{name}/issue/{}", i.number),
1035
            actor: &me,
1036
            repo_id: row.id,
1037
            thread: Some(("issue", i.id)),
1038
            mention_text: &body.body,
1039
            direct: vec![],
1040
        },
1041
    )
1042
    .await;
1043
    crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await;
1044
    Ok((StatusCode::CREATED, Json(api)))
1045
}
1046
1047
async fn issue(
1048
    State(app): State<Shared>,
1049
    headers: HeaderMap,
1050
    Path((owner, name, number)): Path<(String, String, i64)>,
1051
) -> Result<Json<Issue>, ApiError> {
1052
    let c = caller(&app, &headers).await?;
1053
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1054
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1055
    Ok(Json(issue_of(&app, &row, &i).await?))
1056
}
1057
1058
async fn issue_state(
1059
    State(app): State<Shared>,
1060
    headers: HeaderMap,
1061
    Path((owner, name, number)): Path<(String, String, i64)>,
1062
    Json(body): Json<IssueStateChange>,
1063
) -> Result<Json<Issue>, ApiError> {
1064
    let c = caller(&app, &headers).await?;
1065
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1066
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1067
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1068
    if role < Role::Triage && Some(me.id) != i.author_id {
1069
        return Err(forbidden("only the author or triage may change the state"));
1070
    }
1071
    let (state, reason) = match body.state.as_str() {
1072
        "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }),
1073
        "open" => ("open", ""),
1074
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())),
1075
    };
1076
    db::issues::set_state(&app.db, i.id, state, reason).await?;
1077
    db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok();
1078
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
1079
    let api = issue_of(&app, &row, &fresh).await?;
1080
    crate::hooks::emit(
1081
        &app,
1082
        &row,
1083
        if state == "closed" { "issue.closed" } else { "issue.reopened" },
1084
        &me.username,
1085
        serde_json::json!({ "issue": api }),
1086
    )
1087
    .await;
1088
    Ok(Json(api))
1089
}
1090
1091
async fn issue_comments(
1092
    State(app): State<Shared>,
1093
    headers: HeaderMap,
1094
    Path((owner, name, number)): Path<(String, String, i64)>,
1095
) -> Result<Json<Vec<Comment>>, ApiError> {
1096
    let c = caller(&app, &headers).await?;
1097
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1098
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1099
    Ok(Json(
1100
        db::issues::comments(&app.db, i.id)
1101
            .await?
1102
            .into_iter()
1103
            .map(|c| Comment {
1104
                id: c.id,
1105
                author: c.author,
1106
                body: c.body,
1107
                created_ms: c.created_ms,
1108
                path: None,
1109
                anchor: None,
1110
                resolved: false,
1111
            })
1112
            .collect(),
1113
    ))
1114
}
1115
1116
async fn issue_comment(
1117
    State(app): State<Shared>,
1118
    headers: HeaderMap,
1119
    Path((owner, name, number)): Path<(String, String, i64)>,
1120
    Json(body): Json<NewComment>,
1121
) -> Result<(StatusCode, Json<Comment>), ApiError> {
1122
    let c = caller(&app, &headers).await?;
1123
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1124
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1125
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1126
    if i.locked && role < Role::Triage {
1127
        return Err(forbidden("this issue is locked"));
1128
    }
1129
    if body.body.trim().is_empty() {
1130
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
1131
    }
1132
    let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?;
1133
    crate::web::issues::reindex(&app, row.id, &i).await;
1134
    crate::notify::deliver(
1135
        &app,
1136
        crate::notify::Event {
1137
            kind: "issue.comment",
1138
            title: format!("{owner}/{name}#{number}: {}", i.title),
1139
            url: format!("/{owner}/{name}/issue/{number}"),
1140
            actor: &me,
1141
            repo_id: row.id,
1142
            thread: Some(("issue", i.id)),
1143
            mention_text: &body.body,
1144
            direct: i.author_id.into_iter().collect(),
1145
        },
1146
    )
1147
    .await;
1148
    let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false };
1149
    crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await;
1150
    Ok((StatusCode::CREATED, Json(api)))
76f262e1 Andy McCall ↶ 1151
}
1152
1153
/// Labels, assignees and milestone named in a request, looked up.
1154
struct Triage {
1155
    labels: Option<Vec<db::issues::Label>>,
1156
    assignees: Option<Vec<Account>>,
1157
    /// `Some(None)` clears the milestone.
1158
    milestone: Option<Option<db::issues::Milestone>>,
1159
}
1160
1161
async fn resolve_triage(
1162
    app: &Shared,
1163
    repo_id: i64,
1164
    labels: Option<&[String]>,
1165
    assignees: Option<&[String]>,
1166
    milestone: Option<&str>,
1167
) -> Result<Triage, ApiError> {
1168
    let bad = |m: String| ApiError(StatusCode::BAD_REQUEST, m);
1169
    let labels = match labels {
1170
        Some(names) => {
1171
            let mut out = Vec::new();
1172
            for n in names {
1173
                out.push(
1174
                    db::issues::label_by_name(&app.db, repo_id, n.trim())
1175
                        .await?
1176
                        .ok_or_else(|| bad(format!("no label {n:?}; create it first")))?,
1177
                );
1178
            }
1179
            Some(out)
1180
        }
1181
        None => None,
1182
    };
1183
    let assignees = match assignees {
1184
        Some(names) => {
1185
            let mut out = Vec::new();
1186
            for n in names {
1187
                out.push(db::accounts::account_by_name(&app.db, n.trim()).await?.ok_or_else(|| bad(format!("no account {n:?}")))?);
1188
            }
1189
            Some(out)
1190
        }
1191
        None => None,
1192
    };
1193
    let milestone = match milestone.map(str::trim) {
1194
        None => None,
1195
        Some("") => Some(None),
1196
        Some(title) => Some(Some(
1197
            db::issues::milestones(&app.db, repo_id)
1198
                .await?
1199
                .into_iter()
1200
                .find(|m| m.title == title)
1201
                .ok_or_else(|| bad(format!("no milestone {title:?}")))?,
1202
        )),
1203
    };
1204
    Ok(Triage { labels, assignees, milestone })
1205
}
1206
1207
/// Make an issue's labels, assignees and milestone what `t` says, with
1208
/// timeline events for what changed.
1209
async fn apply_triage(app: &Shared, i: &db::issues::IssueRow, me: &Account, t: Triage) -> Result<(), ApiError> {
1210
    if let Some(want) = t.labels {
1211
        let have = db::issues::issue_labels(&app.db, i.id).await?;
1212
        for l in &have {
1213
            if !want.iter().any(|w| w.id == l.id) {
1214
                db::issues::set_issue_label(&app.db, i.id, l.id, false).await?;
1215
            }
1216
        }
1217
        for w in &want {
1218
            if !have.iter().any(|l| l.id == w.id) {
1219
                db::issues::set_issue_label(&app.db, i.id, w.id, true).await?;
1220
            }
1221
        }
1222
        let names: Vec<&str> = want.iter().map(|l| l.name.as_str()).collect();
1223
        db::issues::add_event(&app.db, i.id, &me.username, "set labels", &names.join(", ")).await.ok();
1224
    }
1225
    if let Some(want) = t.assignees {
1226
        let have = db::issues::assignees(&app.db, i.id).await?;
1227
        for name in &have {
1228
            if !want.iter().any(|a| &a.username == name) {
1229
                if let Some(a) = db::accounts::account_by_name(&app.db, name).await? {
1230
                    db::issues::set_assignee(&app.db, i.id, a.id, false).await?;
1231
                    db::issues::add_event(&app.db, i.id, &me.username, "unassigned", name).await.ok();
1232
                }
1233
            }
1234
        }
1235
        for a in &want {
1236
            if !have.contains(&a.username) {
1237
                db::issues::set_assignee(&app.db, i.id, a.id, true).await?;
1238
                db::issues::add_event(&app.db, i.id, &me.username, "assigned", &a.username).await.ok();
1239
            }
1240
        }
1241
    }
1242
    if let Some(m) = t.milestone {
1243
        db::issues::set_milestone(&app.db, i.id, m.as_ref().map(|m| m.id)).await?;
1244
        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();
1245
    }
1246
    Ok(())
1247
}
1248
1249
/// `PATCH /repos/{o}/{n}/issues/{number}`.
1250
async fn issue_edit(
1251
    State(app): State<Shared>,
1252
    headers: HeaderMap,
1253
    Path((owner, name, number)): Path<(String, String, i64)>,
1254
    Json(body): Json<IssueEdit>,
1255
) -> Result<Json<Issue>, ApiError> {
1256
    let c = caller(&app, &headers).await?;
1257
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1258
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1259
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1260
    let text = body.title.is_some() || body.body.is_some();
1261
    let triage = body.labels.is_some() || body.assignees.is_some() || body.milestone.is_some();
1262
    if text && role < Role::Triage && Some(me.id) != i.author_id {
1263
        return Err(forbidden("only the author or triage may edit the title and body"));
1264
    }
1265
    if triage && role < Role::Triage {
1266
        return Err(forbidden("setting labels, assignees or a milestone needs triage access"));
1267
    }
1268
    if i.locked && role < Role::Triage {
1269
        return Err(forbidden("this issue is locked"));
1270
    }
1271
    let t = resolve_triage(&app, row.id, body.labels.as_deref(), body.assignees.as_deref(), body.milestone.as_deref()).await?;
1272
    if text {
1273
        let title = body.title.as_deref().map(str::trim).unwrap_or(&i.title);
1274
        if title.is_empty() {
1275
            return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
1276
        }
1277
        db::issues::edit(&app.db, i.id, title, body.body.as_deref().unwrap_or(&i.body)).await?;
1278
        db::issues::add_event(&app.db, i.id, &me.username, "edited", "").await.ok();
1279
    }
1280
    apply_triage(&app, &i, &me, t).await?;
1281
    db::issues::touch(&app.db, i.id).await.ok();
1282
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
1283
    crate::web::issues::reindex(&app, row.id, &fresh).await;
1284
    let api = issue_of(&app, &row, &fresh).await?;
1285
    crate::hooks::emit(&app, &row, "issue.edited", &me.username, serde_json::json!({ "issue": api })).await;
1286
    Ok(Json(api))
1287
}
1288
1289
/// `POST /repos/{o}/{n}/labels` (triage).
1290
async fn create_label(
1291
    State(app): State<Shared>,
1292
    headers: HeaderMap,
1293
    Path((owner, name)): Path<(String, String)>,
1294
    Json(body): Json<NewLabel>,
1295
) -> Result<(StatusCode, Json<Label>), ApiError> {
1296
    let c = caller(&app, &headers).await?;
1297
    c.require("issues:write").or_else(|_| c.require("repo:write"))?;
1298
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1299
    if role < Role::Triage {
1300
        return Err(forbidden("labels need triage access"));
1301
    }
1302
    let label = body.name.trim();
1303
    if label.is_empty() || label.len() > 50 {
1304
        return Err(ApiError(StatusCode::BAD_REQUEST, "a label has a name of 1 to 50 characters".into()));
1305
    }
1306
    let colour = match body.colour.as_deref() {
1307
        Some(c) if c.len() == 7 && c.starts_with('#') && c[1..].chars().all(|x| x.is_ascii_hexdigit()) => c.to_string(),
1308
        Some(c) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("colour {c:?} is not #rrggbb"))),
1309
        None => "#5aa8ff".to_string(),
1310
    };
1311
    let l = db::issues::upsert_label(&app.db, row.id, label, &colour, body.description.trim()).await?;
1312
    Ok((StatusCode::CREATED, Json(Label { name: l.name, colour: l.colour, description: l.description })))
1313
}
1314
1315
/// `DELETE /repos/{o}/{n}/labels/{label}` (triage).
1316
async fn delete_label(
1317
    State(app): State<Shared>,
1318
    headers: HeaderMap,
1319
    Path((owner, name, label)): Path<(String, String, String)>,
1320
) -> Result<StatusCode, ApiError> {
1321
    let c = caller(&app, &headers).await?;
1322
    c.require("issues:write").or_else(|_| c.require("repo:write"))?;
1323
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1324
    if role < Role::Triage {
1325
        return Err(forbidden("labels need triage access"));
1326
    }
1327
    let l = db::issues::label_by_name(&app.db, row.id, &label).await?.ok_or_else(|| not_found("no such label"))?;
1328
    db::issues::delete_label(&app.db, l.id).await?;
1329
    Ok(StatusCode::NO_CONTENT)
d23464c0 Andy McCall ↶ 1330
}
1331
b64e2537 Andy McCall ↶ 1332
async fn labels(
1333
    State(app): State<Shared>,
1334
    headers: HeaderMap,
1335
    Path((owner, name)): Path<(String, String)>,
1336
) -> Result<Json<Vec<Label>>, ApiError> {
1337
    let c = caller(&app, &headers).await?;
1338
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1339
    Ok(Json(
1340
        db::issues::labels(&app.db, row.id)
1341
            .await?
1342
            .into_iter()
1343
            .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
1344
            .collect(),
1345
    ))
1346
}
1347
1348
async fn milestones(
1349
    State(app): State<Shared>,
1350
    headers: HeaderMap,
1351
    Path((owner, name)): Path<(String, String)>,
1352
) -> Result<Json<Vec<Milestone>>, ApiError> {
1353
    let c = caller(&app, &headers).await?;
1354
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1355
    let mut out = Vec::new();
1356
    for m in db::issues::milestones(&app.db, row.id).await? {
1357
        let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?;
1358
        out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed });
d23464c0 Andy McCall ↶ 1359
    }
b64e2537 Andy McCall ↶ 1360
    Ok(Json(out))
1361
}
1362
1363
// --------------------------------------------------------- hooks, checks
1364
1365
fn hook_of(h: &db::hooks::Hook) -> Hook {
1366
    Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms }
1367
}
1368
1369
async fn hooks(
1370
    State(app): State<Shared>,
1371
    headers: HeaderMap,
1372
    Path((owner, name)): Path<(String, String)>,
1373
) -> Result<Json<Vec<Hook>>, ApiError> {
1374
    let c = caller(&app, &headers).await?;
1375
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1376
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1377
    Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect()))
d23464c0 Andy McCall ↶ 1378
}
1379
b64e2537 Andy McCall ↶ 1380
async fn create_hook(
1381
    State(app): State<Shared>,
1382
    headers: HeaderMap,
1383
    Path((owner, name)): Path<(String, String)>,
1384
    Json(body): Json<NewHook>,
1385
) -> Result<(StatusCode, Json<Hook>), ApiError> {
1386
    let c = caller(&app, &headers).await?;
1387
    let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone();
1388
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1389
    if !(body.url.starts_with("http://") || body.url.starts_with("https://")) {
1390
        return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into()));
1391
    }
1392
    let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?;
1393
    db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok();
1394
    Ok((StatusCode::CREATED, Json(hook_of(&h))))
d23464c0 Andy McCall ↶ 1395
}
1396
b64e2537 Andy McCall ↶ 1397
async fn delete_hook(
1398
    State(app): State<Shared>,
1399
    headers: HeaderMap,
1400
    Path((owner, name, id)): Path<(String, String, i64)>,
1401
) -> Result<StatusCode, ApiError> {
1402
    let c = caller(&app, &headers).await?;
1403
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1404
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1405
    if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) {
1406
        db::hooks::delete_hook(&app.db, id).await?;
1407
    }
1408
    Ok(StatusCode::NO_CONTENT)
d23464c0 Andy McCall ↶ 1409
}
1410
b64e2537 Andy McCall ↶ 1411
pub fn check_of(c: &db::hooks::Check) -> Check {
1412
    Check {
1413
        state: c.state.clone(),
1414
        name: c.name.clone(),
1415
        status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued),
1416
        summary: c.summary.clone(),
1417
        url: c.url.clone(),
1418
        reporter: c.reporter.clone(),
1419
        seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) },
1420
        started_ms: c.started_ms,
1421
        finished_ms: c.finished_ms,
1422
    }
961c10be Andy McCall ↶ 1423
}
1424
1425
/// `GET /repos/{o}/{n}/checks/required`.
1426
async fn required_checks(
1427
    State(app): State<Shared>,
1428
    headers: HeaderMap,
1429
    Path((owner, name)): Path<(String, String)>,
1430
) -> Result<Json<RequiredChecks>, ApiError> {
1431
    let c = caller(&app, &headers).await?;
1432
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1433
    Ok(Json(RequiredChecks { names: db::hooks::required_checks(&app.db, row.id).await? }))
1434
}
1435
1436
/// `PUT /repos/{o}/{n}/checks/required` (repository admin): the whole list.
1437
async fn set_required_checks(
1438
    State(app): State<Shared>,
1439
    headers: HeaderMap,
1440
    Path((owner, name)): Path<(String, String)>,
1441
    Json(body): Json<RequiredChecks>,
1442
) -> Result<Json<RequiredChecks>, ApiError> {
1443
    let c = caller(&app, &headers).await?;
1444
    c.require("repo:admin")?;
1445
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1446
    let mut names: Vec<String> = body.names.iter().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).collect();
1447
    names.sort();
1448
    names.dedup();
1449
    if names.len() > 32 || names.iter().any(|n| n.len() > 100 || n.contains(',')) {
1450
        return Err(ApiError(StatusCode::BAD_REQUEST, "at most 32 check names, each under 100 characters and without commas".into()));
1451
    }
1452
    db::hooks::set_required_checks(&app.db, row.id, &names).await?;
1453
    db::accounts::audit(&app.db, c.account.as_ref(), "repo.required_checks", &format!("{owner}/{name}"), &names.join(", ")).await.ok();
1454
    Ok(Json(RequiredChecks { names }))
1edac4b6 Andy McCall ↶ 1455
}
1456
90326c96 Andy McCall ↶ 1457
fn flow_run_of(app: &Shared, row: &db::repos::RepoRow, r: &db::flow::Run, jobs: Vec<db::flow::Job>) -> FlowRun {
1458
    FlowRun {
1459
        number: r.number,
1460
        bookmark: r.bookmark.clone(),
1461
        state: r.state.clone(),
1462
        heads: r.head_list().into_iter().map(String::from).collect(),
1463
        status: r.status.clone(),
1464
        trigger: r.trigger.clone(),
1465
        actor: r.actor.clone(),
1466
        pipeline_hash: r.pipeline_hash.clone(),
1467
        error: r.error.clone(),
1468
        created_ms: r.created_ms,
1469
        finished_ms: r.finished_ms,
1470
        url: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, r.number),
1471
        jobs: jobs
1472
            .into_iter()
1473
            .map(|j| FlowJob {
1474
                depends_on: j.depends_on(),
1475
                name: j.name,
1476
                status: j.status,
1477
                runner: j.runner,
1478
                exit_code: j.exit_code,
1479
                error: j.error,
1480
                started_ms: j.started_ms,
1481
                finished_ms: j.finished_ms,
1482
                log_lines: j.log_count,
1483
            })
1484
            .collect(),
1485
    }
1486
}
1487
1edac4b6 Andy McCall ↶ 1488
#[derive(Deserialize)]
90326c96 Andy McCall ↶ 1489
struct RunsPage {
1490
    /// Runs numbered below this one (the next page).
1491
    before: Option<i64>,
1492
    limit: Option<i64>,
1493
}
1494
1495
/// `GET /repos/{o}/{n}/flow/runs`: newest first, `?before=N&limit=` (max 100).
1496
async fn flow_runs(
1497
    State(app): State<Shared>,
1498
    headers: HeaderMap,
1499
    Path((owner, name)): Path<(String, String)>,
1500
    Query(q): Query<RunsPage>,
1501
) -> Result<Json<Vec<FlowRun>>, ApiError> {
1502
    let c = caller(&app, &headers).await?;
1503
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1504
    let limit = q.limit.unwrap_or(30).clamp(1, 100);
1505
    let runs = db::flow::runs_by_number(&app.db, row.id, limit, q.before).await?;
1506
    Ok(Json(runs.iter().map(|r| flow_run_of(&app, &row, r, vec![])).collect()))
1507
}
1508
1509
/// `GET /repos/{o}/{n}/flow/runs/{number}`: a run with its jobs.
1510
async fn flow_run(
1511
    State(app): State<Shared>,
1512
    headers: HeaderMap,
1513
    Path((owner, name, number)): Path<(String, String, i64)>,
1514
) -> Result<Json<FlowRun>, ApiError> {
1515
    let c = caller(&app, &headers).await?;
1516
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1517
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1518
    let jobs = db::flow::jobs(&app.db, run.id).await?;
1519
    Ok(Json(flow_run_of(&app, &row, &run, jobs)))
1520
}
1521
1522
#[derive(Deserialize)]
1523
struct LogPage {
1524
    /// Lines after this `seq`.
1525
    #[serde(default)]
1526
    after: i64,
1527
    limit: Option<i64>,
1528
}
1529
1530
/// `GET /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/log`: `?after=seq&limit=` (max 5000).
1531
async fn flow_job_log(
1532
    State(app): State<Shared>,
1533
    headers: HeaderMap,
1534
    Path((owner, name, number, job)): Path<(String, String, i64, String)>,
1535
    Query(q): Query<LogPage>,
1536
) -> Result<Json<Vec<FlowLogLine>>, ApiError> {
1537
    let c = caller(&app, &headers).await?;
1538
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1539
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1540
    let j = db::flow::jobs(&app.db, run.id)
1541
        .await?
1542
        .into_iter()
1543
        .find(|j| j.name == job)
1544
        .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such job".into()))?;
1545
    let lines = db::flow::log(&app.db, j.id, q.after, q.limit.unwrap_or(1000).clamp(1, 5000)).await?;
1546
    Ok(Json(lines.into_iter().map(|l| FlowLogLine { seq: l.seq, t: l.t, stream: l.stream, text: l.text }).collect()))
1547
}
1548
1549
#[derive(Deserialize)]
1edac4b6 Andy McCall ↶ 1550
struct StartRun {
1551
    /// Default: the repository's default bookmark.
1552
    #[serde(default)]
1553
    bookmark: Option<String>,
1554
}
1555
1556
fn started(app: &Shared, row: &db::repos::RepoRow, number: i64) -> (StatusCode, Json<serde_json::Value>) {
1557
    let url = format!("{}/{}/{}/flow/{number}", app.config.base_url(), row.owner, row.name);
1558
    (StatusCode::CREATED, Json(serde_json::json!({ "number": number, "url": url })))
1559
}
1560
1561
/// `POST /repos/{o}/{n}/flow/runs` (repo:write): run a bookmark's current
1562
/// state without moving it (hub spec §13.2).
1563
async fn start_flow_run(
1564
    State(app): State<Shared>,
1565
    headers: HeaderMap,
1566
    Path((owner, name)): Path<(String, String)>,
1567
    Json(body): Json<StartRun>,
1568
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
1569
    let c = caller(&app, &headers).await?;
1570
    let me = c.require("repo:write")?.clone();
1571
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1572
    let bookmark = body.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
1573
    let n = crate::flow::run_bookmark(&app, &row, &bookmark, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1574
    db::accounts::audit(&app.db, Some(&me), "flow.run", &format!("{owner}/{name}"), &format!("#{n} {bookmark}")).await.ok();
1575
    Ok(started(&app, &row, n))
1576
}
1577
1578
/// `POST /repos/{o}/{n}/flow/runs/{number}/rerun` (repo:write).
1579
async fn rerun_flow_run(
1580
    State(app): State<Shared>,
1581
    headers: HeaderMap,
1582
    Path((owner, name, number)): Path<(String, String, i64)>,
1583
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
1584
    let c = caller(&app, &headers).await?;
1585
    let me = c.require("repo:write")?.clone();
1586
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
1587
    let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?;
1588
    let n = crate::flow::rerun(&app, &row, &run, &me.username).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1589
    db::accounts::audit(&app.db, Some(&me), "flow.rerun", &format!("{owner}/{name}"), &format!("#{n} of #{number}")).await.ok();
1590
    Ok(started(&app, &row, n))
d23464c0 Andy McCall ↶ 1591
}
1592
b64e2537 Andy McCall ↶ 1593
/// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may
1594
/// report; the state hash names what was checked.
1595
async fn post_check(
1596
    State(app): State<Shared>,
1597
    headers: HeaderMap,
1598
    Path((owner, name)): Path<(String, String)>,
1599
    Json(body): Json<NewCheck>,
1600
) -> Result<(StatusCode, Json<Check>), ApiError> {
1601
    let c = caller(&app, &headers).await?;
1602
    let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone();
1603
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1604
    if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) {
1605
        return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into()));
1606
    }
1607
    if body.name.trim().is_empty() || body.name.len() > 100 {
1608
        return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into()));
1609
    }
1610
    let ch = db::hooks::upsert_check(
1611
        &app.db,
1612
        row.id,
1613
        &body.state,
1614
        body.name.trim(),
1615
        body.status.as_str(),
1616
        &body.summary,
1617
        &body.url,
1618
        Some((me.id, &me.username)),
1619
        body.seal.as_deref().unwrap_or(""),
1620
    )
1621
    .await?;
1622
    let api = check_of(&ch);
1623
    app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1624
    crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await;
1625
    Ok((StatusCode::CREATED, Json(api)))
d23464c0 Andy McCall ↶ 1626
}
1627
b64e2537 Andy McCall ↶ 1628
async fn checks_for_state(
1629
    State(app): State<Shared>,
1630
    headers: HeaderMap,
1631
    Path((owner, name, state)): Path<(String, String, String)>,
1632
) -> Result<Json<Vec<Check>>, ApiError> {
1633
    let c = caller(&app, &headers).await?;
90326c96 Andy McCall ↶ 1634
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1635
    let state = state_key(&app, &repo, &state).await?;
b64e2537 Andy McCall ↶ 1636
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect()))
90326c96 Andy McCall ↶ 1637
}
1638
1639
/// The state hash checks are keyed by, from what a client has: a state
1640
/// hash, its heads (full change hashes joined with `+`, or the one head of
1641
/// a single-head state, which is what `oxy log` shows), or a bookmark.
1642
async fn state_key(app: &Shared, repo: &RepoName, s: &str) -> Result<String, ApiError> {
1643
    use oxy_core::hash::{hash_object, ChangeHash, ObjectType};
1644
    let hexes: Vec<&str> = s.split('+').collect();
1645
    let all_hashes = hexes.iter().all(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()));
1646
    if all_hashes {
1647
        let row = db::repos::get(&app.db, &repo.owner, &repo.name).await?.ok_or_else(|| not_found("no such repository"))?;
1648
        if hexes.len() == 1 && !db::hooks::checks_for_state(&app.db, row.id, s).await?.is_empty() {
1649
            return Ok(s.to_string());
1650
        }
1651
        let heads: Vec<ChangeHash> = hexes.iter().filter_map(|h| ChangeHash::from_hex(h)).collect();
1652
        return Ok(hash_object(ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode()).to_hex());
1653
    }
1654
    let b = s.to_string();
1655
    let found = app.repos.read(repo, move |r, _| Ok(r.view()?.bookmarks.get(&b).copied())).await?;
1656
    found.map(|st| st.to_hex()).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("{s:?} is not a state hash, heads or a bookmark")))
d23464c0 Andy McCall ↶ 1657
}