OXYGENHUB
# 42a0ba4bac4e oxygenhub / crates / hub / src / api / mod.rs
48.7 KiB · Rust · last changed API: the notifications inbox 1 month ago source blame history raw
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.
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
pub fn router() -> Router<Shared> {
19
    Router::new()
20
        .route("/user", get(me))
21
        .route("/notifications", get(notifications))
22
        .route("/notifications/read", post(notifications_read_all))
23
        .route("/notifications/{id}/read", post(notification_read))
24
        .route("/users/{name}", get(user))
25
        .route("/orgs/{name}", get(org))
26
        .route("/search", get(search))
27
        .route("/repos", get(list_repos).post(create_repo))
28
        .route("/repos/{owner}/{name}", get(get_repo))
29
        .route("/repos/{owner}/{name}/bookmarks", get(bookmarks))
30
        .route("/repos/{owner}/{name}/tags", get(tags))
31
        .route("/repos/{owner}/{name}/history/{r}", get(history))
32
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
33
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
34
        .route("/repos/{owner}/{name}/tree/{r}/{*path}", get(tree))
35
        .route("/repos/{owner}/{name}/file/{r}/{*path}", get(file))
36
        .route("/repos/{owner}/{name}/blame/{r}/{*path}", get(blame))
37
        .route("/repos/{owner}/{name}/conflicts", get(conflicts))
38
        .route("/repos/{owner}/{name}/trust", get(trust))
39
        .route("/repos/{owner}/{name}/pushes", get(pushes))
40
        .route("/repos/{owner}/{name}/proposals", get(proposals).post(create_proposal))
41
        .route("/repos/{owner}/{name}/proposals/{number}", get(proposal))
42
        .route("/repos/{owner}/{name}/proposals/{number}/comments", get(proposal_comments).post(proposal_comment))
43
        .route("/repos/{owner}/{name}/proposals/{number}/reviews", get(proposal_reviews).post(proposal_review))
44
        .route("/repos/{owner}/{name}/proposals/{number}/checks", get(proposal_checks))
45
        .route("/repos/{owner}/{name}/proposals/{number}/files", get(proposal_files))
46
        .route("/repos/{owner}/{name}/proposals/{number}/content/{side}/{*path}", get(proposal_content))
47
        .route("/repos/{owner}/{name}/issues", get(issues).post(create_issue))
48
        .route("/repos/{owner}/{name}/issues/{number}", get(issue).post(issue_state))
49
        .route("/repos/{owner}/{name}/issues/{number}/comments", get(issue_comments).post(issue_comment))
50
        .route("/repos/{owner}/{name}/labels", get(labels))
51
        .route("/repos/{owner}/{name}/milestones", get(milestones))
52
        .route("/repos/{owner}/{name}/hooks", get(hooks).post(create_hook))
53
        .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook))
54
        .route("/repos/{owner}/{name}/checks", post(post_check))
55
        .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state))
56
        .route("/repos/{owner}/{name}/releases", get(releases).post(create_release))
57
        .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset))
58
}
59
60
async fn releases(
61
    State(app): State<Shared>,
62
    headers: HeaderMap,
63
    Path((owner, name)): Path<(String, String)>,
64
) -> Result<Json<Vec<Release>>, ApiError> {
65
    let c = caller(&app, &headers).await?;
66
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
67
    Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?))
68
}
69
70
async fn create_release(
71
    State(app): State<Shared>,
72
    headers: HeaderMap,
73
    Path((owner, name)): Path<(String, String)>,
74
    Json(body): Json<NewRelease>,
75
) -> Result<(StatusCode, Json<Release>), ApiError> {
76
    let c = caller(&app, &headers).await?;
77
    let me = c.require("repo:write")?.clone();
78
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
79
    crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease)
80
        .await
81
        .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
82
    let r = crate::web::releases::api_views(&app, &repo, &row)
83
        .await?
84
        .into_iter()
85
        .find(|r| r.tag == body.tag)
86
        .ok_or_else(|| not_found("no such release"))?;
87
    Ok((StatusCode::CREATED, Json(r)))
88
}
89
90
#[derive(Deserialize)]
91
struct AssetQuery {
92
    name: String,
93
}
94
95
/// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body.
96
async fn upload_asset(
97
    State(app): State<Shared>,
98
    headers: HeaderMap,
99
    Path((owner, name, tag)): Path<(String, String, String)>,
100
    Query(q): Query<AssetQuery>,
101
    body: axum::body::Bytes,
102
) -> Result<(StatusCode, Json<Release>), ApiError> {
103
    let c = caller(&app, &headers).await?;
104
    c.require("repo:write")?;
105
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?;
106
    let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?;
107
    let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string();
108
    crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
109
    let r = crate::web::releases::api_views(&app, &repo, &row)
110
        .await?
111
        .into_iter()
112
        .find(|r| r.tag == tag)
113
        .ok_or_else(|| not_found("no such release"))?;
114
    Ok((StatusCode::CREATED, Json(r)))
115
}
116
117
pub struct ApiError(StatusCode, String);
118
119
impl IntoResponse for ApiError {
120
    fn into_response(self) -> Response {
121
        let body = Problem { status: self.0.as_u16(), title: self.0.canonical_reason().unwrap_or("error").into(), detail: self.1 };
122
        (self.0, [("content-type", "application/problem+json")], Json(body)).into_response()
123
    }
124
}
125
126
impl From<RepoError> for ApiError {
127
    fn from(e: RepoError) -> ApiError {
128
        match e {
129
            RepoError::NotFound => not_found("no such repository"),
130
            RepoError::Oxy(oxy_core::repo::RepoError::Msg(m)) => ApiError(StatusCode::NOT_FOUND, m),
131
            other => ApiError(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
132
        }
133
    }
134
}
135
136
impl From<sqlx::Error> for ApiError {
137
    fn from(e: sqlx::Error) -> ApiError {
138
        ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
139
    }
140
}
141
142
fn not_found(what: &str) -> ApiError {
143
    ApiError(StatusCode::NOT_FOUND, what.into())
144
}
145
146
fn forbidden(what: &str) -> ApiError {
147
    ApiError(StatusCode::FORBIDDEN, what.into())
148
}
149
150
/// Who calls: the account behind a bearer token, with the token's scopes.
151
pub struct Caller {
152
    pub account: Option<Account>,
153
    pub scopes: Vec<String>,
154
    pub site: bool,
155
}
156
157
impl Caller {
158
    fn has(&self, scope: &str) -> bool {
159
        self.site
160
            || self.scopes.iter().any(|s| {
161
                s == scope
162
                    || (scope == "repo:read" && (s == "repo:write" || s == "repo:admin"))
163
                    || (scope == "repo:write" && s == "repo:admin")
164
            })
165
    }
166
167
    fn require(&self, scope: &str) -> Result<&Account, ApiError> {
168
        if !self.has(scope) {
169
            return Err(forbidden(&format!("this token lacks the {scope} scope")));
170
        }
171
        self.account.as_ref().ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "a personal access token is required".into()))
172
    }
173
}
174
175
pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> {
176
    let Some(t) = oxy_server::bearer_token(headers) else { return Ok(Caller { account: None, scopes: vec![], site: false }) };
177
    if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) {
178
        let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin);
179
        return Ok(Caller { account: admin, scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], site: true });
180
    }
181
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
182
    match token {
183
        Some(tok) if !tok.expired(db::now_ms()) => {
184
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
185
            db::accounts::touch_token(&app.db, tok.id).await.ok();
186
            Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false })
187
        }
188
        _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())),
189
    }
190
}
191
192
fn repository(app: &Shared, r: &db::repos::RepoRow) -> Repository {
193
    Repository {
194
        owner: r.owner.clone(),
195
        name: r.name.clone(),
196
        description: r.description.clone(),
197
        default_bookmark: r.default_bookmark.clone(),
198
        visibility: r.visibility(),
199
        created_ms: r.created_ms,
200
        updated_ms: r.updated_ms,
201
        clone_url: format!("{}/{}/{}", app.config.base_url(), r.owner, r.name),
202
    }
203
}
204
205
/// A repository the caller may reach with `access`, else 404.
206
async fn reach(
207
    app: &Shared,
208
    c: &Caller,
209
    owner: &str,
210
    name: &str,
211
    access: Access,
212
) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> {
213
    let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?;
214
    let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?;
215
    let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
216
    if !auth::roles::allows(role, access) {
217
        return Err(not_found("no such repository"));
218
    }
219
    Ok((repo, row, role.unwrap_or(Role::Read)))
220
}
221
222
fn summary(c: &view::ChangeView) -> ChangeSummary {
223
    ChangeSummary {
224
        hash: c.hash.clone(),
225
        message: if c.body.is_empty() { c.title.clone() } else { format!("{}\n\n{}", c.title, c.body) },
226
        author: Author {
227
            name: c.author.name.clone(),
228
            email: c.author.email.clone(),
229
            key: c.author.key.clone(),
230
            account: c.author.account.clone(),
231
        },
232
        when_ms: c.when_ms,
233
        deps: c.deps.clone(),
234
        head: c.head,
235
        signature: match &c.signature {
236
            view::SigView::Unsigned => Signature::Unsigned,
237
            view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked },
238
            view::SigView::Invalid(why) => Signature::Invalid { reason: why.clone() },
239
        },
240
    }
241
}
242
243
// ---------------------------------------------------------------- accounts
244
245
async fn me(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
246
    let c = caller(&app, &headers).await?;
247
    let a = c.require("user:read")?;
248
    Ok(Json(user_of(&app, a).await?))
249
}
250
251
#[derive(Deserialize)]
252
struct InboxQuery {
253
    #[serde(default)]
254
    limit: Option<i64>,
255
}
256
257
/// The caller's inbox; reading and clearing it needs `user:read`, since
258
/// it touches nothing but the caller's own notifications.
259
async fn notifications(
260
    State(app): State<Shared>,
261
    headers: HeaderMap,
262
    Query(q): Query<InboxQuery>,
263
) -> Result<Json<hub_api::Inbox>, ApiError> {
264
    let c = caller(&app, &headers).await?;
265
    let a = c.require("user:read")?;
266
    let limit = q.limit.unwrap_or(50).clamp(1, 200);
267
    let base = app.config.base_url().trim_end_matches('/').to_string();
268
    let items = db::notify::inbox(&app.db, a.id, limit)
269
        .await?
270
        .into_iter()
271
        .map(|n| hub_api::Notification {
272
            id: n.id,
273
            at_ms: n.at_ms,
274
            kind: n.kind,
275
            title: n.title,
276
            url: if n.url.starts_with('/') { format!("{base}{}", n.url) } else { n.url },
277
            actor: n.actor,
278
            read: n.read,
279
        })
280
        .collect();
281
    Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items }))
282
}
283
284
async fn notification_read(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
285
    let c = caller(&app, &headers).await?;
286
    let a = c.require("user:read")?;
287
    db::notify::mark_read(&app.db, a.id, Some(id)).await?;
288
    Ok(StatusCode::NO_CONTENT)
289
}
290
291
async fn notifications_read_all(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
292
    let c = caller(&app, &headers).await?;
293
    let a = c.require("user:read")?;
294
    db::notify::mark_read(&app.db, a.id, None).await?;
295
    Ok(StatusCode::NO_CONTENT)
296
}
297
298
async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> {
299
    let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect();
300
    Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms })
301
}
302
303
async fn user(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<User>, ApiError> {
304
    let a = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such account"))?;
305
    Ok(Json(user_of(&app, &a).await?))
306
}
307
308
async fn org(State(app): State<Shared>, Path(name): Path<String>) -> Result<Json<Organisation>, ApiError> {
309
    let o = db::accounts::org_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such organisation"))?;
310
    let members = db::accounts::org_members(&app.db, o.id).await?.into_iter().map(|(a, _)| a.username).collect();
311
    Ok(Json(Organisation { name: o.name, display_name: o.display_name, description: o.description, members }))
312
}
313
314
#[derive(Deserialize)]
315
struct SearchQuery {
316
    q: String,
317
}
318
319
async fn search(
320
    State(app): State<Shared>,
321
    headers: HeaderMap,
322
    Query(q): Query<SearchQuery>,
323
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
324
    let c = caller(&app, &headers).await?;
325
    let mut out = Vec::new();
326
    for h in db::notify::search(&app.db, &q.q, None, 50).await? {
327
        let Some(row) = db::repos::get_by_id(&app.db, h.repo_id).await? else { continue };
328
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?;
329
        if !auth::roles::allows(role, Access::Read) {
330
            continue;
331
        }
332
        out.push(
333
            serde_json::json!({ "kind": h.kind, "repository": row.slug(), "number": h.number, "title": h.title, "snippet": h.snippet }),
334
        );
335
    }
336
    Ok(Json(out))
337
}
338
339
// ------------------------------------------------------------ repositories
340
341
async fn list_repos(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Repository>>, ApiError> {
342
    let c = caller(&app, &headers).await?;
343
    let mut out = Vec::new();
344
    for r in db::repos::list(&app.db).await? {
345
        let role = auth::roles::role_for(&app.db, c.account.as_ref(), &r).await?;
346
        if auth::roles::allows(role, Access::Read) {
347
            out.push(repository(&app, &r));
348
        }
349
    }
350
    Ok(Json(out))
351
}
352
353
async fn create_repo(
354
    State(app): State<Shared>,
355
    headers: HeaderMap,
356
    Json(body): Json<NewRepository>,
357
) -> Result<(StatusCode, Json<Repository>), ApiError> {
358
    let c = caller(&app, &headers).await?;
359
    let me = c.require("repo:admin")?;
360
    let name = RepoName::new(&body.owner, &body.name).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "invalid owner or name".into()))?;
361
    if !auth::roles::may_create_under(&app.db, me, &name.owner).await? {
362
        return Err(forbidden("you cannot create repositories under that owner"));
363
    }
364
    if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() || app.repos.exists(&name) {
365
        return Err(ApiError(StatusCode::CONFLICT, "that repository exists".into()));
366
    }
367
    app.repos.create(&name, None)?;
368
    let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?;
369
    db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok();
370
    Ok((StatusCode::CREATED, Json(repository(&app, &row))))
371
}
372
373
async fn get_repo(
374
    State(app): State<Shared>,
375
    headers: HeaderMap,
376
    Path((owner, name)): Path<(String, String)>,
377
) -> Result<Json<Repository>, ApiError> {
378
    let c = caller(&app, &headers).await?;
379
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
380
    Ok(Json(repository(&app, &row)))
381
}
382
383
async fn bookmarks(
384
    State(app): State<Shared>,
385
    headers: HeaderMap,
386
    Path((owner, name)): Path<(String, String)>,
387
) -> Result<Json<Vec<Bookmark>>, ApiError> {
388
    let c = caller(&app, &headers).await?;
389
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
390
    let default = row.default_bookmark.clone();
391
    let list = app.repos.read(&repo, move |r, cache| view::bookmarks(r, cache, &default)).await?;
392
    Ok(Json(list.into_iter().map(|b| Bookmark { name: b.name, heads: b.heads, changes: b.changes, default: b.default }).collect()))
393
}
394
395
async fn tags(
396
    State(app): State<Shared>,
397
    headers: HeaderMap,
398
    Path((owner, name)): Path<(String, String)>,
399
) -> Result<Json<Vec<Tag>>, ApiError> {
400
    let c = caller(&app, &headers).await?;
401
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
402
    let list = app.repos.read(&repo, |r, _| view::tags(r)).await?;
403
    Ok(Json(list.into_iter().map(|t| Tag { name: t.name, heads: t.heads, message: t.message, signer: t.signer }).collect()))
404
}
405
406
#[derive(Deserialize)]
407
struct HistoryQuery {
408
    path: Option<String>,
409
    offset: Option<usize>,
410
    limit: Option<usize>,
411
}
412
413
async fn history(
414
    State(app): State<Shared>,
415
    headers: HeaderMap,
416
    Path((owner, name, r)): Path<(String, String, String)>,
417
    Query(q): Query<HistoryQuery>,
418
) -> Result<Json<History>, ApiError> {
419
    let c = caller(&app, &headers).await?;
420
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
421
    let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(50).min(500));
422
    let h = app
423
        .repos
424
        .read(&repo, move |repo, cache| {
425
            let target = view::resolve(repo, cache, &r)?;
426
            view::history(repo, cache, target, q.path.as_deref(), offset, limit)
427
        })
428
        .await?;
429
    let mut entries = h.entries;
430
    crate::web::attribute(&app, &mut entries.iter_mut().collect::<Vec<_>>()).await;
431
    Ok(Json(History {
432
        reference: h.target.label,
433
        heads: h.target.heads.iter().map(|x| x.to_hex()).collect(),
434
        total: h.total,
435
        offset: h.offset,
436
        changes: entries.iter().map(summary).collect(),
437
    }))
438
}
439
440
async fn change(
441
    State(app): State<Shared>,
442
    headers: HeaderMap,
443
    Path((owner, name, hash)): Path<(String, String, String)>,
444
) -> Result<Json<ChangeDetail>, ApiError> {
445
    let c = caller(&app, &headers).await?;
446
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
447
    let d = app
448
        .repos
449
        .read(&repo, move |repo, cache| {
450
            let h = view::resolve_change(repo, cache, &hash)?;
451
            view::change(repo, cache, &h)
452
        })
453
        .await?;
454
    let mut v = d.view;
455
    crate::web::attribute(&app, &mut [&mut v]).await;
456
    let files = crate::web::history::render_files(&d.files, &d.bases)
457
        .into_iter()
458
        .map(|f| FileChange { path: f.path, kind: f.kind, from: f.from, added: f.added, removed: f.removed })
459
        .collect();
460
    Ok(Json(ChangeDetail {
461
        body: v.body.clone(),
462
        change: summary(&v),
463
        dependents: d.dependents.iter().map(|x| x.hash.clone()).collect(),
464
        in_bookmarks: d.in_bookmarks,
465
        in_tags: d.in_tags,
466
        files,
467
    }))
468
}
469
470
fn entry(e: &view::Entry) -> TreeEntry {
471
    TreeEntry {
472
        name: e.name.clone(),
473
        path: e.path.clone(),
474
        kind: match e.kind {
475
            view::EntryKind::Dir => "dir",
476
            view::EntryKind::File => "file",
477
            view::EntryKind::Symlink => "symlink",
478
            view::EntryKind::Subrepo => "subrepo",
479
        }
480
        .into(),
481
        exec: e.exec,
482
        size: e.size,
483
        conflicted: e.conflicted,
484
    }
485
}
486
487
async fn tree_root(
488
    State(app): State<Shared>,
489
    headers: HeaderMap,
490
    Path((owner, name, r)): Path<(String, String, String)>,
491
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
492
    tree(State(app), headers, Path((owner, name, r, String::new()))).await
493
}
494
495
async fn tree(
496
    State(app): State<Shared>,
497
    headers: HeaderMap,
498
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
499
) -> Result<Json<Vec<TreeEntry>>, ApiError> {
500
    let c = caller(&app, &headers).await?;
501
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
502
    let t = app
503
        .repos
504
        .read(&repo, move |repo, cache| {
505
            let target = view::resolve(repo, cache, &r)?;
506
            view::tree(repo, cache, target, &path)
507
        })
508
        .await?;
509
    Ok(Json(t.entries.iter().map(entry).collect()))
510
}
511
512
async fn file(
513
    State(app): State<Shared>,
514
    headers: HeaderMap,
515
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
516
) -> Result<Response, ApiError> {
517
    let c = caller(&app, &headers).await?;
518
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
519
    let limit = app.config.limits.render_limit.0;
520
    let f = app
521
        .repos
522
        .read(&repo, move |repo, cache| {
523
            let target = view::resolve(repo, cache, &r)?;
524
            view::file(repo, cache, target, &path, limit)
525
        })
526
        .await?;
527
    Ok(crate::web::raw(f.bytes, &f.name, false))
528
}
529
530
async fn blame(
531
    State(app): State<Shared>,
532
    headers: HeaderMap,
533
    Path((owner, name, r, path)): Path<(String, String, String, String)>,
534
) -> Result<Json<Vec<BlameLine>>, ApiError> {
535
    let c = caller(&app, &headers).await?;
536
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
537
    let limit = app.config.limits.render_limit.0;
538
    let b = app
539
        .repos
540
        .read(&repo, move |repo, cache| {
541
            let target = view::resolve(repo, cache, &r)?;
542
            view::blame(repo, cache, target, &path, limit)
543
        })
544
        .await?;
545
    Ok(Json(
546
        b.lines
547
            .into_iter()
548
            .map(|l| BlameLine {
549
                line: l.line,
550
                change: l.hash.to_hex(),
551
                index: l.index,
552
                author: l.author,
553
                when_ms: l.when_ms,
554
                text: String::from_utf8_lossy(&l.text).into_owned(),
555
            })
556
            .collect(),
557
    ))
558
}
559
560
async fn conflicts(
561
    State(app): State<Shared>,
562
    headers: HeaderMap,
563
    Path((owner, name)): Path<(String, String)>,
564
) -> Result<Json<Vec<Conflict>>, ApiError> {
565
    let c = caller(&app, &headers).await?;
566
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
567
    let list = app.repos.read(&repo, view::conflicts).await?;
568
    Ok(Json(
569
        list.into_iter()
570
            .map(|c| Conflict {
571
                bookmark: c.bookmark,
572
                path: c.path,
573
                kind: c.kind,
574
                contributors: c.contributors.iter().map(|x| x.hash.clone()).collect(),
575
            })
576
            .collect(),
577
    ))
578
}
579
580
async fn trust(
581
    State(app): State<Shared>,
582
    headers: HeaderMap,
583
    Path((owner, name)): Path<(String, String)>,
584
) -> Result<Json<Trust>, ApiError> {
585
    let c = caller(&app, &headers).await?;
586
    let (repo, _, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
587
    let key = app.hub_key.key;
588
    let t = app.repos.read(&repo, move |r, cache| view::trust(r, cache, &key)).await?;
589
    Ok(Json(Trust {
590
        policy: t.policy.map(|p| Policy {
591
            serial: p.serial,
592
            expires_ms: p.expires_ms,
593
            roots: p.roots.iter().map(|k| k.key.clone()).collect(),
594
            root_threshold: p.root_threshold,
595
            rules: p
596
                .rules
597
                .iter()
598
                .map(|r| Rule {
599
                    pattern: r.pattern.clone(),
600
                    keys: r.keys.iter().map(|k| k.key.clone()).collect(),
601
                    threshold: r.threshold,
602
                    require: r.require.clone(),
603
                    allow_conflicts: r.allow_conflicts,
604
                })
605
                .collect(),
606
        }),
607
        signed: t.verification.signed,
608
        sealed: t.verification.sealed,
609
        unsigned: t.verification.unsigned,
610
        invalid: t.verification.invalid.iter().map(|(h, _)| h.clone()).collect(),
611
        hub_key: t.hub_key,
612
    }))
613
}
614
615
async fn pushes(
616
    State(app): State<Shared>,
617
    headers: HeaderMap,
618
    Path((owner, name)): Path<(String, String)>,
619
) -> Result<Json<Vec<Push>>, ApiError> {
620
    let c = caller(&app, &headers).await?;
621
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
622
    let rows = db::repos::pushes(&app.db, row.id, 200).await?;
623
    Ok(Json(
624
        rows.into_iter()
625
            .map(|p| Push {
626
                at_ms: p.at_ms,
627
                pusher: p.pusher,
628
                reference: p.reference,
629
                ok: p.ok,
630
                reason: p.reason,
631
                old_state: p.old_state,
632
                new_state: p.new_state,
633
                heads: p.heads,
634
                changes: p.changes,
635
            })
636
            .collect(),
637
    ))
638
}
639
640
// --------------------------------------------------------------- proposals
641
642
async fn proposals(
643
    State(app): State<Shared>,
644
    headers: HeaderMap,
645
    Path((owner, name)): Path<(String, String)>,
646
) -> Result<Json<Vec<Proposal>>, ApiError> {
647
    let c = caller(&app, &headers).await?;
648
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
649
    let mut out = Vec::new();
650
    for p in db::proposals::list(&app.db, row.id, &["open", "draft"]).await? {
651
        let loaded = crate::web::proposals::load(&app, &repo, p).await?;
652
        let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
653
        out.push(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await);
654
    }
655
    Ok(Json(out))
656
}
657
658
async fn proposal(
659
    State(app): State<Shared>,
660
    headers: HeaderMap,
661
    Path((owner, name, number)): Path<(String, String, i64)>,
662
) -> Result<Json<Proposal>, ApiError> {
663
    let c = caller(&app, &headers).await?;
664
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
665
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
666
    let source_url = match p.source_repo_id {
667
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|r| format!("{}/{}", app.config.base_url(), r.slug())),
668
        None => None,
669
    };
670
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
671
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
672
    Ok(Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, source_url).await))
673
}
674
675
/// The files a proposal changes (hub spec §12.1).
676
async fn proposal_files(
677
    State(app): State<Shared>,
678
    headers: HeaderMap,
679
    Path((owner, name, number)): Path<(String, String, i64)>,
680
) -> Result<Json<Vec<hub_api::ProposalFile>>, ApiError> {
681
    let c = caller(&app, &headers).await?;
682
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
683
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
684
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
685
    Ok(Json(
686
        loaded
687
            .files
688
            .iter()
689
            .map(|f| hub_api::ProposalFile {
690
                path: f.path.clone(),
691
                from: f.from.clone(),
692
                kind: f.kind.clone(),
693
                added: f.added,
694
                removed: f.removed,
695
            })
696
            .collect(),
697
    ))
698
}
699
700
/// One file's bytes on a side of a proposal: `base` is the target's
701
/// state, `head` the projected state. 404 when the file does not exist
702
/// on that side.
703
async fn proposal_content(
704
    State(app): State<Shared>,
705
    headers: HeaderMap,
706
    Path((owner, name, number, side, path)): Path<(String, String, i64, String, String)>,
707
) -> Result<Response, ApiError> {
708
    let c = caller(&app, &headers).await?;
709
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
710
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
711
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
712
    let heads = match side.as_str() {
713
        "base" => loaded.target_heads,
714
        "head" => loaded.heads,
715
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "the side is base or head".into())),
716
    };
717
    let limit = app.config.limits.render_limit.0;
718
    let target = view::RefTarget { label: side, kind: view::RefKind::Change, heads };
719
    let f = app.repos.read(&repo, move |repo, cache| view::file(repo, cache, target, &path, limit)).await?;
720
    Ok(crate::web::raw(f.bytes, &f.name, false))
721
}
722
723
async fn create_proposal(
724
    State(app): State<Shared>,
725
    headers: HeaderMap,
726
    Path((owner, name)): Path<(String, String)>,
727
    Json(body): Json<NewProposal>,
728
) -> Result<(StatusCode, Json<Proposal>), ApiError> {
729
    let c = caller(&app, &headers).await?;
730
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
731
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
732
    let created =
733
        crate::web::proposals::open_proposal(&app, &repo, &row, &me, &body.title, &body.body, &body.target, &body.source, body.draft)
734
            .await
735
            .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
736
    let loaded = crate::web::proposals::load(&app, &repo, created).await?;
737
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
738
    Ok((StatusCode::CREATED, Json(crate::web::proposals::to_api(&app, &owner, &name, &loaded, &v, None).await)))
739
}
740
741
fn comment_of(c: &db::proposals::CommentRow) -> Comment {
742
    Comment {
743
        id: c.id,
744
        author: c.author.clone(),
745
        body: c.body.clone(),
746
        created_ms: c.created_ms,
747
        path: if c.path.is_empty() { None } else { Some(c.path.clone()) },
748
        anchor: if c.anchor_change.is_empty() {
749
            None
750
        } else {
751
            Some(Atom { change: c.anchor_change.clone(), index: c.anchor_index as u32 })
752
        },
753
        resolved: c.resolved,
754
    }
755
}
756
757
async fn proposal_comments(
758
    State(app): State<Shared>,
759
    headers: HeaderMap,
760
    Path((owner, name, number)): Path<(String, String, i64)>,
761
) -> Result<Json<Vec<Comment>>, ApiError> {
762
    let c = caller(&app, &headers).await?;
763
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
764
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
765
    Ok(Json(db::proposals::comments(&app.db, p.id).await?.iter().map(comment_of).collect()))
766
}
767
768
async fn proposal_comment(
769
    State(app): State<Shared>,
770
    headers: HeaderMap,
771
    Path((owner, name, number)): Path<(String, String, i64)>,
772
    Json(body): Json<NewComment>,
773
) -> Result<(StatusCode, Json<Comment>), ApiError> {
774
    let c = caller(&app, &headers).await?;
775
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
776
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
777
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
778
    if body.body.trim().is_empty() {
779
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
780
    }
781
    let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ());
782
    let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) {
783
        (Some(path), Some(line)) if !path.is_empty() => {
784
            let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?;
785
            let (heads, file) = (loaded.heads.clone(), path.to_string());
786
            let atom = app
787
                .repos
788
                .read(&repo, move |r, cache| {
789
                    let projected = cache.load(r, &heads)?;
790
                    Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i)))
791
                })
792
                .await?
793
                .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?;
794
            Some((path.to_string(), atom))
795
        }
796
        _ => None,
797
    };
798
    let row_c = db::proposals::add_comment(
799
        &app.db,
800
        p.id,
801
        Some(me.id),
802
        &me.username,
803
        body.body.trim(),
804
        anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""),
805
        anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)),
806
    )
807
    .await?;
808
    crate::notify::deliver(
809
        &app,
810
        crate::notify::Event {
811
            kind: "proposal.comment",
812
            title: format!("{owner}/{name}#{number}: {}", p.title),
813
            url: format!("/{owner}/{name}/proposal/{number}"),
814
            actor: &me,
815
            repo_id: row.id,
816
            thread: Some(("proposal", p.id)),
817
            mention_text: &body.body,
818
            direct: p.author_id.into_iter().collect(),
819
        },
820
    )
821
    .await;
822
    crate::hooks::emit(
823
        &app,
824
        &row,
825
        "comment.proposal",
826
        &me.username,
827
        serde_json::json!({ "number": number, "comment": comment_of(&row_c) }),
828
    )
829
    .await;
830
    Ok((StatusCode::CREATED, Json(comment_of(&row_c))))
831
}
832
833
async fn proposal_reviews(
834
    State(app): State<Shared>,
835
    headers: HeaderMap,
836
    Path((owner, name, number)): Path<(String, String, i64)>,
837
) -> Result<Json<Vec<Review>>, ApiError> {
838
    let c = caller(&app, &headers).await?;
839
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
840
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
841
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
842
    let v = crate::web::proposals::verdict(&app, &loaded, row.id).await?;
843
    Ok(Json(
844
        v.reviews
845
            .into_iter()
846
            .map(|r| Review {
847
                id: r.row.id,
848
                author: r.row.author,
849
                verdict: r.row.verdict,
850
                body: r.row.body,
851
                sealed: r.sealed,
852
                created_ms: r.row.created_ms,
853
            })
854
            .collect(),
855
    ))
856
}
857
858
async fn proposal_review(
859
    State(app): State<Shared>,
860
    headers: HeaderMap,
861
    Path((owner, name, number)): Path<(String, String, i64)>,
862
    Json(body): Json<NewReview>,
863
) -> Result<(StatusCode, Json<Review>), ApiError> {
864
    let c = caller(&app, &headers).await?;
865
    let me = c.require("proposals:write").or_else(|_| c.require("repo:write"))?.clone();
866
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
867
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
868
    if !matches!(body.verdict.as_str(), "comment" | "changes" | "approve") {
869
        return Err(ApiError(StatusCode::BAD_REQUEST, "verdict is comment, changes or approve".into()));
870
    }
871
    if body.verdict == "approve" && Some(me.id) == p.author_id {
872
        return Err(forbidden("you cannot approve your own proposal"));
873
    }
874
    let r = db::proposals::add_review(&app.db, p.id, me.id, &me.username, &body.verdict, body.body.trim()).await?;
875
    db::proposals::add_event(&app.db, p.id, &me.username, &format!("review.{}", body.verdict), "").await.ok();
876
    crate::hooks::emit(&app, &row, "review", &me.username, serde_json::json!({ "number": number, "verdict": body.verdict })).await;
877
    Ok((
878
        StatusCode::CREATED,
879
        Json(Review { id: r.id, author: r.author, verdict: r.verdict, body: r.body, sealed: false, created_ms: r.created_ms }),
880
    ))
881
}
882
883
async fn proposal_checks(
884
    State(app): State<Shared>,
885
    headers: HeaderMap,
886
    Path((owner, name, number)): Path<(String, String, i64)>,
887
) -> Result<Json<Vec<Check>>, ApiError> {
888
    let c = caller(&app, &headers).await?;
889
    let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
890
    let p = db::proposals::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such proposal"))?;
891
    let loaded = crate::web::proposals::load(&app, &repo, p).await?;
892
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &loaded.state).await?.iter().map(check_of).collect()))
893
}
894
895
// ------------------------------------------------------------------ issues
896
897
async fn issue_of(app: &Shared, row: &db::repos::RepoRow, i: &db::issues::IssueRow) -> Result<Issue, ApiError> {
898
    let labels = db::issues::issue_labels(&app.db, i.id)
899
        .await?
900
        .into_iter()
901
        .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
902
        .collect();
903
    let assignees = db::issues::assignees(&app.db, i.id).await?;
904
    let milestone = match i.milestone_id {
905
        Some(id) => db::issues::milestone(&app.db, id).await?.map(|m| m.title),
906
        None => None,
907
    };
908
    let comments: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_comments WHERE issue_id = ?").bind(i.id).fetch_one(&app.db).await?;
909
    Ok(Issue {
910
        number: i.number,
911
        title: i.title.clone(),
912
        body: i.body.clone(),
913
        author: i.author.clone(),
914
        state: i.state.clone(),
915
        reason: i.reason.clone(),
916
        labels,
917
        assignees,
918
        milestone,
919
        comments,
920
        locked: i.locked,
921
        pinned: i.pinned,
922
        created_ms: i.created_ms,
923
        updated_ms: i.updated_ms,
924
        closed_ms: i.closed_ms,
925
        url: format!("{}/{}/issue/{}", app.config.base_url(), row.slug(), i.number),
926
    })
927
}
928
929
#[derive(Deserialize)]
930
struct IssuesQuery {
931
    state: Option<String>,
932
}
933
934
async fn issues(
935
    State(app): State<Shared>,
936
    headers: HeaderMap,
937
    Path((owner, name)): Path<(String, String)>,
938
    Query(q): Query<IssuesQuery>,
939
) -> Result<Json<Vec<Issue>>, ApiError> {
940
    let c = caller(&app, &headers).await?;
941
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
942
    let state = q.state.unwrap_or_else(|| "open".into());
943
    let mut out = Vec::new();
944
    for i in db::issues::list(&app.db, row.id).await? {
945
        if state == "all" || i.state == state {
946
            out.push(issue_of(&app, &row, &i).await?);
947
        }
948
    }
949
    Ok(Json(out))
950
}
951
952
async fn create_issue(
953
    State(app): State<Shared>,
954
    headers: HeaderMap,
955
    Path((owner, name)): Path<(String, String)>,
956
    Json(body): Json<NewIssue>,
957
) -> Result<(StatusCode, Json<Issue>), ApiError> {
958
    let c = caller(&app, &headers).await?;
959
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
960
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
961
    if body.title.trim().is_empty() {
962
        return Err(ApiError(StatusCode::BAD_REQUEST, "an issue needs a title".into()));
963
    }
964
    let i = db::issues::create(
965
        &app.db,
966
        db::issues::NewIssue {
967
            repo_id: row.id,
968
            title: body.title.trim(),
969
            body: body.body.trim(),
970
            author_id: Some(me.id),
971
            author: &me.username,
972
        },
973
    )
974
    .await?;
975
    db::issues::add_event(&app.db, i.id, &me.username, "opened", "").await.ok();
976
    crate::web::issues::reindex(&app, row.id, &i).await;
977
    let api = issue_of(&app, &row, &i).await?;
978
    crate::notify::deliver(
979
        &app,
980
        crate::notify::Event {
981
            kind: "issue.opened",
982
            title: format!("{owner}/{name}#{}: {}", i.number, i.title),
983
            url: format!("/{owner}/{name}/issue/{}", i.number),
984
            actor: &me,
985
            repo_id: row.id,
986
            thread: Some(("issue", i.id)),
987
            mention_text: &body.body,
988
            direct: vec![],
989
        },
990
    )
991
    .await;
992
    crate::hooks::emit(&app, &row, "issue.opened", &me.username, serde_json::json!({ "issue": api })).await;
993
    Ok((StatusCode::CREATED, Json(api)))
994
}
995
996
async fn issue(
997
    State(app): State<Shared>,
998
    headers: HeaderMap,
999
    Path((owner, name, number)): Path<(String, String, i64)>,
1000
) -> Result<Json<Issue>, ApiError> {
1001
    let c = caller(&app, &headers).await?;
1002
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1003
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1004
    Ok(Json(issue_of(&app, &row, &i).await?))
1005
}
1006
1007
async fn issue_state(
1008
    State(app): State<Shared>,
1009
    headers: HeaderMap,
1010
    Path((owner, name, number)): Path<(String, String, i64)>,
1011
    Json(body): Json<IssueStateChange>,
1012
) -> Result<Json<Issue>, ApiError> {
1013
    let c = caller(&app, &headers).await?;
1014
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1015
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1016
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1017
    if role < Role::Triage && Some(me.id) != i.author_id {
1018
        return Err(forbidden("only the author or triage may change the state"));
1019
    }
1020
    let (state, reason) = match body.state.as_str() {
1021
        "closed" => ("closed", if body.reason == "not_planned" { "not_planned" } else { "completed" }),
1022
        "open" => ("open", ""),
1023
        _ => return Err(ApiError(StatusCode::BAD_REQUEST, "state is open or closed".into())),
1024
    };
1025
    db::issues::set_state(&app.db, i.id, state, reason).await?;
1026
    db::issues::add_event(&app.db, i.id, &me.username, if state == "closed" { "closed" } else { "reopened" }, "").await.ok();
1027
    let fresh = db::issues::get(&app.db, row.id, number).await?.unwrap_or(i);
1028
    let api = issue_of(&app, &row, &fresh).await?;
1029
    crate::hooks::emit(
1030
        &app,
1031
        &row,
1032
        if state == "closed" { "issue.closed" } else { "issue.reopened" },
1033
        &me.username,
1034
        serde_json::json!({ "issue": api }),
1035
    )
1036
    .await;
1037
    Ok(Json(api))
1038
}
1039
1040
async fn issue_comments(
1041
    State(app): State<Shared>,
1042
    headers: HeaderMap,
1043
    Path((owner, name, number)): Path<(String, String, i64)>,
1044
) -> Result<Json<Vec<Comment>>, ApiError> {
1045
    let c = caller(&app, &headers).await?;
1046
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1047
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1048
    Ok(Json(
1049
        db::issues::comments(&app.db, i.id)
1050
            .await?
1051
            .into_iter()
1052
            .map(|c| Comment {
1053
                id: c.id,
1054
                author: c.author,
1055
                body: c.body,
1056
                created_ms: c.created_ms,
1057
                path: None,
1058
                anchor: None,
1059
                resolved: false,
1060
            })
1061
            .collect(),
1062
    ))
1063
}
1064
1065
async fn issue_comment(
1066
    State(app): State<Shared>,
1067
    headers: HeaderMap,
1068
    Path((owner, name, number)): Path<(String, String, i64)>,
1069
    Json(body): Json<NewComment>,
1070
) -> Result<(StatusCode, Json<Comment>), ApiError> {
1071
    let c = caller(&app, &headers).await?;
1072
    let me = c.require("issues:write").or_else(|_| c.require("repo:write"))?.clone();
1073
    let (_, row, role) = reach(&app, &c, &owner, &name, Access::Read).await?;
1074
    let i = db::issues::get(&app.db, row.id, number).await?.ok_or_else(|| not_found("no such issue"))?;
1075
    if i.locked && role < Role::Triage {
1076
        return Err(forbidden("this issue is locked"));
1077
    }
1078
    if body.body.trim().is_empty() {
1079
        return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into()));
1080
    }
1081
    let cm = db::issues::add_comment(&app.db, i.id, Some(me.id), &me.username, body.body.trim()).await?;
1082
    crate::web::issues::reindex(&app, row.id, &i).await;
1083
    crate::notify::deliver(
1084
        &app,
1085
        crate::notify::Event {
1086
            kind: "issue.comment",
1087
            title: format!("{owner}/{name}#{number}: {}", i.title),
1088
            url: format!("/{owner}/{name}/issue/{number}"),
1089
            actor: &me,
1090
            repo_id: row.id,
1091
            thread: Some(("issue", i.id)),
1092
            mention_text: &body.body,
1093
            direct: i.author_id.into_iter().collect(),
1094
        },
1095
    )
1096
    .await;
1097
    let api = Comment { id: cm.id, author: cm.author, body: cm.body, created_ms: cm.created_ms, path: None, anchor: None, resolved: false };
1098
    crate::hooks::emit(&app, &row, "comment.issue", &me.username, serde_json::json!({ "number": number, "comment": api })).await;
1099
    Ok((StatusCode::CREATED, Json(api)))
1100
}
1101
1102
async fn labels(
1103
    State(app): State<Shared>,
1104
    headers: HeaderMap,
1105
    Path((owner, name)): Path<(String, String)>,
1106
) -> Result<Json<Vec<Label>>, ApiError> {
1107
    let c = caller(&app, &headers).await?;
1108
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1109
    Ok(Json(
1110
        db::issues::labels(&app.db, row.id)
1111
            .await?
1112
            .into_iter()
1113
            .map(|l| Label { name: l.name, colour: l.colour, description: l.description })
1114
            .collect(),
1115
    ))
1116
}
1117
1118
async fn milestones(
1119
    State(app): State<Shared>,
1120
    headers: HeaderMap,
1121
    Path((owner, name)): Path<(String, String)>,
1122
) -> Result<Json<Vec<Milestone>>, ApiError> {
1123
    let c = caller(&app, &headers).await?;
1124
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1125
    let mut out = Vec::new();
1126
    for m in db::issues::milestones(&app.db, row.id).await? {
1127
        let (open, closed) = db::issues::milestone_counts(&app.db, m.id).await?;
1128
        out.push(Milestone { title: m.title, description: m.description, due_ms: m.due_ms, state: m.state, open, closed });
1129
    }
1130
    Ok(Json(out))
1131
}
1132
1133
// --------------------------------------------------------- hooks, checks
1134
1135
fn hook_of(h: &db::hooks::Hook) -> Hook {
1136
    Hook { id: h.id, url: h.url.clone(), events: h.events.clone(), active: h.active, created_ms: h.created_ms }
1137
}
1138
1139
async fn hooks(
1140
    State(app): State<Shared>,
1141
    headers: HeaderMap,
1142
    Path((owner, name)): Path<(String, String)>,
1143
) -> Result<Json<Vec<Hook>>, ApiError> {
1144
    let c = caller(&app, &headers).await?;
1145
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1146
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1147
    Ok(Json(db::hooks::hooks_for_repo(&app.db, row.id).await?.iter().map(hook_of).collect()))
1148
}
1149
1150
async fn create_hook(
1151
    State(app): State<Shared>,
1152
    headers: HeaderMap,
1153
    Path((owner, name)): Path<(String, String)>,
1154
    Json(body): Json<NewHook>,
1155
) -> Result<(StatusCode, Json<Hook>), ApiError> {
1156
    let c = caller(&app, &headers).await?;
1157
    let me = c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?.clone();
1158
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1159
    if !(body.url.starts_with("http://") || body.url.starts_with("https://")) {
1160
        return Err(ApiError(StatusCode::BAD_REQUEST, "the URL must be http or https".into()));
1161
    }
1162
    let h = db::hooks::create_hook(&app.db, Some(row.id), None, &body.url, &body.secret, &body.events).await?;
1163
    db::accounts::audit(&app.db, Some(&me), "hook.created", &row.slug(), &body.url).await.ok();
1164
    Ok((StatusCode::CREATED, Json(hook_of(&h))))
1165
}
1166
1167
async fn delete_hook(
1168
    State(app): State<Shared>,
1169
    headers: HeaderMap,
1170
    Path((owner, name, id)): Path<(String, String, i64)>,
1171
) -> Result<StatusCode, ApiError> {
1172
    let c = caller(&app, &headers).await?;
1173
    c.require("hooks:admin").or_else(|_| c.require("repo:admin"))?;
1174
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Admin).await?;
1175
    if db::hooks::hook(&app.db, id).await?.is_some_and(|h| h.repo_id == Some(row.id)) {
1176
        db::hooks::delete_hook(&app.db, id).await?;
1177
    }
1178
    Ok(StatusCode::NO_CONTENT)
1179
}
1180
1181
pub fn check_of(c: &db::hooks::Check) -> Check {
1182
    Check {
1183
        state: c.state.clone(),
1184
        name: c.name.clone(),
1185
        status: CheckStatus::parse(&c.status).unwrap_or(CheckStatus::Queued),
1186
        summary: c.summary.clone(),
1187
        url: c.url.clone(),
1188
        reporter: c.reporter.clone(),
1189
        seal: if c.seal.is_empty() { None } else { Some(c.seal.clone()) },
1190
        started_ms: c.started_ms,
1191
        finished_ms: c.finished_ms,
1192
    }
1193
}
1194
1195
/// `POST /repos/{o}/{n}/checks`: any `checks:write` (or write) token may
1196
/// report; the state hash names what was checked.
1197
async fn post_check(
1198
    State(app): State<Shared>,
1199
    headers: HeaderMap,
1200
    Path((owner, name)): Path<(String, String)>,
1201
    Json(body): Json<NewCheck>,
1202
) -> Result<(StatusCode, Json<Check>), ApiError> {
1203
    let c = caller(&app, &headers).await?;
1204
    let me = c.require("checks:write").or_else(|_| c.require("repo:write"))?.clone();
1205
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1206
    if body.state.len() != 64 || !body.state.chars().all(|ch| ch.is_ascii_hexdigit()) {
1207
        return Err(ApiError(StatusCode::BAD_REQUEST, "state is a 64-character hex state hash".into()));
1208
    }
1209
    if body.name.trim().is_empty() || body.name.len() > 100 {
1210
        return Err(ApiError(StatusCode::BAD_REQUEST, "a check has a short name".into()));
1211
    }
1212
    let ch = db::hooks::upsert_check(
1213
        &app.db,
1214
        row.id,
1215
        &body.state,
1216
        body.name.trim(),
1217
        body.status.as_str(),
1218
        &body.summary,
1219
        &body.url,
1220
        Some((me.id, &me.username)),
1221
        body.seal.as_deref().unwrap_or(""),
1222
    )
1223
    .await?;
1224
    let api = check_of(&ch);
1225
    app.events.proposal(row.id, ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1226
    crate::hooks::emit(&app, &row, "check", &me.username, serde_json::json!({ "check": api })).await;
1227
    Ok((StatusCode::CREATED, Json(api)))
1228
}
1229
1230
async fn checks_for_state(
1231
    State(app): State<Shared>,
1232
    headers: HeaderMap,
1233
    Path((owner, name, state)): Path<(String, String, String)>,
1234
) -> Result<Json<Vec<Check>>, ApiError> {
1235
    let c = caller(&app, &headers).await?;
1236
    let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?;
1237
    Ok(Json(db::hooks::checks_for_state(&app.db, row.id, &state).await?.iter().map(check_of).collect()))
1238
}