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