OXYGENHUB

Projects API: list, create, board, edit, and cards by reference or note, moved and removed, with the pages' rules ef20fdc14e8b

closes #15
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 15:33 UTC
Signature
✔ signed by Andy McCall
Hash
ef20fdc14e8b65290f49367c71f7ee730c287e5ae61513eaf82616dd5329b1ed
Depends on
19bb31ff1f5c 42fa95540468 76f262e1a666 93be9ce4653e 9756c67d3aec b64e2537d702 d23464c0ac60 de505df5cf8d e1ec9fbf538e
Depended on by
1062a0f8e985 40a43921ef42 f2d2776b2a14 0bc95135954d 306aec1839aa 041a945f55f8 046e3352cdfd 5a9a6e9a15bc 439ecd8708a1 6a306cd03fc1 ecac6e8d9b41 657e7d5c9da9 456f4197ade3 c8684d44d549 5d90e0e045e2 71e65fcf95ab 92a797b20912 a36bab6b9ffc b1e92a6233ee efb51b39db1c 617c2840446c 17a3a84b00d1 df71c7892d27 a3c8288b0141 90326c9601f9 961c10bee174
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
31 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/hub/src/api/projects.rs +193 −0
1 +
//! Projects over the API (hub spec §8.2, §12.1): the same rules as the
2 +
//! pages, through the functions they use.
3 +
4 +
use super::{caller, forbidden, not_found, ApiError, Caller};
5 +
use crate::db::projects as pdb;
6 +
use crate::web::projects as wp;
7 +
use crate::Shared;
8 +
use axum::extract::{Path, State};
9 +
use axum::http::{HeaderMap, StatusCode};
10 +
use axum::Json;
11 +
use hub_api::{Board, CardMove, NewCard, NewProject, Project, ProjectCard, ProjectColumn, ProjectEdit};
12 +
13 +
fn project_of(app: &Shared, p: &pdb::Project) -> Project {
14 +
    Project {
15 +
        owner: p.owner.clone(),
16 +
        number: p.number,
17 +
        title: p.title.clone(),
18 +
        description: p.description.clone(),
19 +
        public: p.public,
20 +
        closed: p.closed,
21 +
        updated_ms: p.updated_ms,
22 +
        url: format!("{}/projects/{}/{}", app.config.base_url().trim_end_matches('/'), p.owner, p.number),
23 +
    }
24 +
}
25 +
26 +
fn card_of(v: &wp::CardView) -> ProjectCard {
27 +
    ProjectCard { id: v.card.id, kind: v.card.kind.clone(), reference: v.reference.clone(), title: v.title.clone(), state: v.state.clone() }
28 +
}
29 +
30 +
async fn rights(app: &Shared, c: &Caller, owner: &str) -> Result<wp::Rights, ApiError> {
31 +
    wp::rights_of(app, c.account.as_ref(), owner).await?.ok_or_else(|| not_found("no such owner"))
32 +
}
33 +
34 +
/// A project the caller may see.
35 +
async fn visible(app: &Shared, c: &Caller, owner: &str, number: i64) -> Result<(wp::Rights, pdb::Project), ApiError> {
36 +
    let r = rights(app, c, owner).await?;
37 +
    let p = pdb::get(&app.db, owner, number).await?.filter(|p| p.public || r.member).ok_or_else(|| not_found("no such project"))?;
38 +
    Ok((r, p))
39 +
}
40 +
41 +
/// A project the caller may change: a write token, edit rights, and open unless `closed_ok`.
42 +
async fn changeable(
43 +
    app: &Shared,
44 +
    headers: &HeaderMap,
45 +
    owner: &str,
46 +
    number: i64,
47 +
    closed_ok: bool,
48 +
) -> Result<(Caller, pdb::Project), ApiError> {
49 +
    let c = caller(app, headers).await?;
50 +
    c.require("repo:write")?;
51 +
    let (r, p) = visible(app, &c, owner, number).await?;
52 +
    if !r.edit {
53 +
        return Err(forbidden("only the owner (or the organisation's members) may change this project"));
54 +
    }
55 +
    if p.closed && !closed_ok {
56 +
        return Err(ApiError(StatusCode::CONFLICT, "this project is closed; reopen it to change it".into()));
57 +
    }
58 +
    Ok((c, p))
59 +
}
60 +
61 +
/// A column by id or by name (case-insensitive); the first when absent.
62 +
async fn column(app: &Shared, project_id: i64, spec: Option<&str>) -> Result<pdb::Column, ApiError> {
63 +
    let cols = pdb::columns(&app.db, project_id).await?;
64 +
    let found = match spec.map(str::trim).filter(|s| !s.is_empty()) {
65 +
        None => cols.into_iter().next(),
66 +
        Some(s) => cols.into_iter().find(|c| c.id.to_string() == s || c.name.eq_ignore_ascii_case(s)),
67 +
    };
68 +
    found.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no such column".into()))
69 +
}
70 +
71 +
/// `GET /projects/{owner}`.
72 +
pub async fn list(State(app): State<Shared>, headers: HeaderMap, Path(owner): Path<String>) -> Result<Json<Vec<Project>>, ApiError> {
73 +
    let c = caller(&app, &headers).await?;
74 +
    let r = rights(&app, &c, &owner).await?;
75 +
    let list = pdb::of_owner(&app.db, &owner).await?;
76 +
    Ok(Json(list.iter().filter(|p| p.public || r.member).map(|p| project_of(&app, p)).collect()))
77 +
}
78 +
79 +
/// `POST /projects/{owner}`.
80 +
pub async fn create(
81 +
    State(app): State<Shared>,
82 +
    headers: HeaderMap,
83 +
    Path(owner): Path<String>,
84 +
    Json(body): Json<NewProject>,
85 +
) -> Result<(StatusCode, Json<Project>), ApiError> {
86 +
    let c = caller(&app, &headers).await?;
87 +
    c.require("repo:write")?;
88 +
    if !rights(&app, &c, &owner).await?.edit {
89 +
        return Err(forbidden("only the owner (or the organisation's members) may create projects"));
90 +
    }
91 +
    let title = body.title.trim();
92 +
    if title.is_empty() || title.chars().count() > 100 {
93 +
        return Err(ApiError(StatusCode::BAD_REQUEST, "a project needs a title of up to 100 characters".into()));
94 +
    }
95 +
    let p = pdb::create(&app.db, &owner, title, body.description.trim(), !body.private).await?;
96 +
    crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
97 +
    Ok((StatusCode::CREATED, Json(project_of(&app, &p))))
98 +
}
99 +
100 +
/// `GET /projects/{owner}/{n}`.
101 +
pub async fn board(
102 +
    State(app): State<Shared>,
103 +
    headers: HeaderMap,
104 +
    Path((owner, number)): Path<(String, i64)>,
105 +
) -> Result<Json<Board>, ApiError> {
106 +
    let c = caller(&app, &headers).await?;
107 +
    let (_, p) = visible(&app, &c, &owner, number).await?;
108 +
    let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?;
109 +
    Ok(Json(Board {
110 +
        project: project_of(&app, &p),
111 +
        columns: columns
112 +
            .iter()
113 +
            .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() })
114 +
            .collect(),
115 +
    }))
116 +
}
117 +
118 +
/// `PATCH /projects/{owner}/{n}`.
119 +
pub async fn edit(
120 +
    State(app): State<Shared>,
121 +
    headers: HeaderMap,
122 +
    Path((owner, number)): Path<(String, i64)>,
123 +
    Json(body): Json<ProjectEdit>,
124 +
) -> Result<Json<Project>, ApiError> {
125 +
    let (_, p) = changeable(&app, &headers, &owner, number, true).await?;
126 +
    let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&p.title).chars().take(100).collect::<String>();
127 +
    pdb::set(
128 +
        &app.db,
129 +
        p.id,
130 +
        &title,
131 +
        body.description.as_deref().map(str::trim).unwrap_or(&p.description),
132 +
        body.private.map(|x| !x).unwrap_or(p.public),
133 +
        body.closed.unwrap_or(p.closed),
134 +
    )
135 +
    .await?;
136 +
    let fresh = pdb::get(&app.db, &owner, number).await?.unwrap_or(p);
137 +
    Ok(Json(project_of(&app, &fresh)))
138 +
}
139 +
140 +
/// `POST /projects/{owner}/{n}/cards`.
141 +
pub async fn add_card(
142 +
    State(app): State<Shared>,
143 +
    headers: HeaderMap,
144 +
    Path((owner, number)): Path<(String, i64)>,
145 +
    Json(body): Json<NewCard>,
146 +
) -> Result<(StatusCode, Json<ProjectCard>), ApiError> {
147 +
    let (c, p) = changeable(&app, &headers, &owner, number, false).await?;
148 +
    let col = column(&app, p.id, body.column.as_deref()).await?;
149 +
    let actor = c.account.as_ref().map(|a| a.username.clone()).unwrap_or_default();
150 +
    let card = match wp::add_card(&app, c.account.as_ref(), &owner, p.id, col.id, &body.reference, "", &body.note, &actor).await? {
151 +
        Ok(card) => card,
152 +
        Err(why) => return Err(ApiError(StatusCode::BAD_REQUEST, why)),
153 +
    };
154 +
    // Answer with the card as the board shows it.
155 +
    let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?;
156 +
    let view = columns
157 +
        .iter()
158 +
        .flat_map(|col| col.cards.iter())
159 +
        .find(|v| v.card.id == card.id)
160 +
        .map(card_of)
161 +
        .ok_or_else(|| not_found("no such card"))?;
162 +
    Ok((StatusCode::CREATED, Json(view)))
163 +
}
164 +
165 +
/// `PATCH /projects/{owner}/{n}/cards/{id}`: move to another column.
166 +
pub async fn move_card(
167 +
    State(app): State<Shared>,
168 +
    headers: HeaderMap,
169 +
    Path((owner, number, id)): Path<(String, i64, i64)>,
170 +
    Json(body): Json<CardMove>,
171 +
) -> Result<StatusCode, ApiError> {
172 +
    let (_, p) = changeable(&app, &headers, &owner, number, false).await?;
173 +
    let col = column(&app, p.id, Some(&body.column)).await?;
174 +
    if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) {
175 +
        return Err(not_found("no such card"));
176 +
    }
177 +
    pdb::move_card(&app.db, p.id, id, col.id).await?;
178 +
    Ok(StatusCode::NO_CONTENT)
179 +
}
180 +
181 +
/// `DELETE /projects/{owner}/{n}/cards/{id}`.
182 +
pub async fn remove_card(
183 +
    State(app): State<Shared>,
184 +
    headers: HeaderMap,
185 +
    Path((owner, number, id)): Path<(String, i64, i64)>,
186 +
) -> Result<StatusCode, ApiError> {
187 +
    let (_, p) = changeable(&app, &headers, &owner, number, false).await?;
188 +
    if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id) {
189 +
        return Err(not_found("no such card"));
190 +
    }
191 +
    pdb::remove_card(&app.db, p.id, id).await?;
192 +
    Ok(StatusCode::NO_CONTENT)
193 +
}
modified crates/hub-api/src/lib.rs +91 −0
…
562 unchanged lines
563 563
    pub milestone: Option<String>,
564 564
}
565 565
566 +
/// A project (hub spec §8.2).
567 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568 +
pub struct Project {
569 +
    pub owner: String,
570 +
    pub number: i64,
571 +
    pub title: String,
572 +
    pub description: String,
573 +
    pub public: bool,
574 +
    pub closed: bool,
575 +
    pub updated_ms: i64,
576 +
    /// The board on the hub, absolute.
577 +
    pub url: String,
578 +
}
579 +
580 +
/// A card as the caller may see it.
581 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
582 +
pub struct ProjectCard {
583 +
    pub id: i64,
584 +
    /// `issue`, `proposal` or `note`.
585 +
    pub kind: String,
586 +
    /// `owner/name#N`; empty for a note.
587 +
    pub reference: String,
588 +
    /// The issue's or proposal's title, or the note.
589 +
    pub title: String,
590 +
    /// The issue's or proposal's state; empty for a note.
591 +
    pub state: String,
592 +
}
593 +
594 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
595 +
pub struct ProjectColumn {
596 +
    pub id: i64,
597 +
    pub name: String,
598 +
    pub cards: Vec<ProjectCard>,
599 +
}
600 +
601 +
/// `GET /projects/{owner}/{n}`.
602 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
603 +
pub struct Board {
604 +
    pub project: Project,
605 +
    pub columns: Vec<ProjectColumn>,
606 +
}
607 +
608 +
/// `POST /projects/{owner}`.
609 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610 +
pub struct NewProject {
611 +
    pub title: String,
612 +
    #[serde(default)]
613 +
    pub description: String,
614 +
    #[serde(default)]
615 +
    pub private: bool,
616 +
}
617 +
618 +
/// `PATCH /projects/{owner}/{n}`: only the fields given change.
619 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
620 +
pub struct ProjectEdit {
621 +
    #[serde(default)]
622 +
    pub title: Option<String>,
623 +
    #[serde(default)]
624 +
    pub description: Option<String>,
625 +
    #[serde(default)]
626 +
    pub private: Option<bool>,
627 +
    #[serde(default)]
628 +
    pub closed: Option<bool>,
629 +
}
630 +
631 +
/// `POST /projects/{owner}/{n}/cards`: a reference (`repo#12`) or a note,
632 +
/// into the column named (by id or name; the first column when absent).
633 +
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
634 +
pub struct NewCard {
635 +
    #[serde(default)]
636 +
    pub column: Option<String>,
637 +
    #[serde(default)]
638 +
    pub reference: String,
639 +
    #[serde(default)]
640 +
    pub note: String,
641 +
}
642 +
643 +
/// `PATCH /projects/{owner}/{n}/cards/{id}`: move to a column (id or name).
644 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
645 +
pub struct CardMove {
646 +
    pub column: String,
647 +
}
648 +
566 649
/// `POST /repos/{o}/{n}/labels`: create a label, or change one of that name.
567 650
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568 651
pub struct NewLabel {
…
146 unchanged lines
715 798
        ("NewIssue", schemars::schema_for!(NewIssue)),
716 799
        ("IssueEdit", schemars::schema_for!(IssueEdit)),
717 800
        ("NewLabel", schemars::schema_for!(NewLabel)),
801 +
        ("Project", schemars::schema_for!(Project)),
802 +
        ("ProjectCard", schemars::schema_for!(ProjectCard)),
803 +
        ("ProjectColumn", schemars::schema_for!(ProjectColumn)),
804 +
        ("Board", schemars::schema_for!(Board)),
805 +
        ("NewProject", schemars::schema_for!(NewProject)),
806 +
        ("ProjectEdit", schemars::schema_for!(ProjectEdit)),
807 +
        ("NewCard", schemars::schema_for!(NewCard)),
808 +
        ("CardMove", schemars::schema_for!(CardMove)),
718 809
        ("NewComment", schemars::schema_for!(NewComment)),
719 810
        ("NewProposal", schemars::schema_for!(NewProposal)),
720 811
        ("NewReview", schemars::schema_for!(NewReview)),
…
8 unchanged lines
modified crates/hub-api/ts/index.d.ts +53 −0
…
354 unchanged lines
355 355
  name: string;
356 356
};
357 357
358 +
export type Project = {
359 +
  closed: boolean;
360 +
  description: string;
361 +
  number: number;
362 +
  owner: string;
363 +
  public: boolean;
364 +
  title: string;
365 +
  updated_ms: number;
366 +
  url: string;
367 +
};
368 +
369 +
export type ProjectCard = {
370 +
  id: number;
371 +
  kind: string;
372 +
  reference: string;
373 +
  state: string;
374 +
  title: string;
375 +
};
376 +
377 +
export type ProjectColumn = {
378 +
  cards: ProjectCard[];
379 +
  id: number;
380 +
  name: string;
381 +
};
382 +
383 +
export type Board = {
384 +
  columns: ProjectColumn[];
385 +
  project: Project;
386 +
};
387 +
388 +
export type NewProject = {
389 +
  description?: string;
390 +
  private?: boolean;
391 +
  title: string;
392 +
};
393 +
394 +
export type ProjectEdit = {
395 +
  closed?: boolean | null;
396 +
  description?: string | null;
397 +
  private?: boolean | null;
398 +
  title?: string | null;
399 +
};
400 +
401 +
export type NewCard = {
402 +
  column?: string | null;
403 +
  note?: string;
404 +
  reference?: string;
405 +
};
406 +
407 +
export type CardMove = {
408 +
  column: string;
409 +
};
410 +
358 411
export type NewComment = {
359 412
  body: string;
360 413
  line?: number | null;
…
67 unchanged lines
modified crates/hub/src/api/mod.rs +8 −2
…
14 unchanged lines
15 15
use hub_api::*;
16 16
use serde::Deserialize;
17 17
18 +
mod projects;
19 +
18 20
pub fn router() -> Router<Shared> {
19 21
    Router::new()
20 22
        .route("/flow/claim", post(crate::flow::claim))
21 23
        .route("/flow/jobs/{id}/log", post(crate::flow::log))
22 24
        .route("/flow/jobs/{id}/finish", post(crate::flow::finish))
25 +
        .route("/projects/{owner}", get(projects::list).post(projects::create))
26 +
        .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit))
27 +
        .route("/projects/{owner}/{number}/cards", post(projects::add_card))
28 +
        .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card))
23 29
        .route("/user", get(me))
24 30
        .route("/notifications", get(notifications))
25 31
        .route("/notifications/read", post(notifications_read_all))
…
117 unchanged lines
143 149
    }
144 150
}
145 151
146 -
fn not_found(what: &str) -> ApiError {
152 +
pub(crate) fn not_found(what: &str) -> ApiError {
147 153
    ApiError(StatusCode::NOT_FOUND, what.into())
148 154
}
149 155
150 -
fn forbidden(what: &str) -> ApiError {
156 +
pub(crate) fn forbidden(what: &str) -> ApiError {
151 157
    ApiError(StatusCode::FORBIDDEN, what.into())
152 158
}
153 159
…
1279 unchanged lines
modified crates/hub/src/web/mod.rs +1 −1
…
14 unchanged lines
15 15
mod inbox;
16 16
pub mod issues;
17 17
mod orgs;
18 -
mod projects;
18 +
pub mod projects;
19 19
pub mod proposals;
20 20
mod recovery;
21 21
mod refs;
…
555 unchanged lines
modified crates/hub/src/web/projects.rs +104 −72
…
10 unchanged lines
11 11
use axum::response::{IntoResponse, Redirect, Response};
12 12
use serde::Deserialize;
13 13
14 -
/// What the viewer may do with an owner's projects.
15 -
struct Rights {
14 +
/// What a caller may do with an owner's projects.
15 +
pub struct Rights {
16 16
    /// Sees private projects.
17 -
    member: bool,
17 +
    pub member: bool,
18 18
    /// Creates and changes projects.
19 -
    edit: bool,
19 +
    pub edit: bool,
20 20
}
21 21
22 -
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
23 -
    let e = |e| Error::from_db(shell.clone(), e);
24 -
    let admin = viewer.is_admin();
25 -
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await.map_err(e)? {
26 -
        let me = viewer.account().is_some_and(|v| v.id == a.id);
27 -
        return Ok(Rights { member: me || admin, edit: me || admin });
22 +
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23 +
/// `None` when there is no such person or organisation.
24 +
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25 +
    let admin = account.is_some_and(|a| a.is_admin);
26 +
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27 +
        let me = account.is_some_and(|v| v.id == a.id);
28 +
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
28 29
    }
29 -
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await.map_err(e)? {
30 -
        let member = match viewer.account() {
31 -
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await.map_err(e)?.is_some(),
30 +
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
31 +
        let member = match account {
32 +
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
32 33
            None => false,
33 34
        };
34 -
        return Ok(Rights { member: member || admin, edit: member || admin });
35 +
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
35 36
    }
36 -
    Err(Error::not_found(shell.clone()))
37 +
    Ok(None)
38 +
}
39 +
40 +
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41 +
    rights_of(app, viewer.account(), owner)
42 +
        .await
43 +
        .map_err(|e| Error::from_db(shell.clone(), e))?
44 +
        .ok_or_else(|| Error::not_found(shell.clone()))
37 45
}
38 46
39 47
#[derive(Template)]
…
108 unchanged lines
148 156
        .map_err(e)?
149 157
        .filter(|p| p.public || r.member)
150 158
        .ok_or_else(|| Error::not_found(shell.clone()))?;
159 +
    let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
160 +
    let can_edit = r.edit && !project.closed;
161 +
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
162 +
}
163 +
164 +
/// A project's columns with the cards `account` may see, and the owner's
165 +
/// repositories it can read (for adding cards).
166 +
pub async fn board_columns(
167 +
    app: &Shared,
168 +
    account: Option<&db::accounts::Account>,
169 +
    owner: &str,
170 +
    project: &pdb::Project,
171 +
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
151 172
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
152 -
    for row in db::repos::list_by_owner(&app.db, owner).await.map_err(e)? {
153 -
        let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
173 +
    for row in db::repos::list_by_owner(&app.db, owner).await? {
174 +
        let role = roles::role_for(&app.db, account, &row).await?;
154 175
        if roles::allows(role, Access::Read) {
155 176
            readable.insert(row.id, row);
156 177
        }
157 178
    }
158 -
    let cards = pdb::cards(&app.db, project.id).await.map_err(e)?;
179 +
    let cards = pdb::cards(&app.db, project.id).await?;
159 180
    let mut columns = Vec::new();
160 -
    for column in pdb::columns(&app.db, project.id).await.map_err(e)? {
181 +
    for column in pdb::columns(&app.db, project.id).await? {
161 182
        let mut views = Vec::new();
162 183
        for card in cards.iter().filter(|c| c.column_id == column.id) {
163 184
            match (card.kind.as_str(), card.repo_id, card.number) {
…
8 unchanged lines
172 193
                    // Cards of repositories the viewer cannot read are not shown at all.
173 194
                    let Some(row) = readable.get(&rid) else { continue };
174 195
                    let (title, state) = if kind == "issue" {
175 -
                        match db::issues::get(&app.db, rid, n).await.map_err(e)? {
196 +
                        match db::issues::get(&app.db, rid, n).await? {
176 197
                            Some(i) => (i.title, i.state),
177 198
                            None => continue,
178 199
                        }
179 200
                    } else {
180 -
                        match db::proposals::get(&app.db, rid, n).await.map_err(e)? {
201 +
                        match db::proposals::get(&app.db, rid, n).await? {
181 202
                            Some(p) => (p.title, p.state),
182 203
                            None => continue,
183 204
                        }
…
14 unchanged lines
198 219
    }
199 220
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
200 221
    repos.sort();
201 -
    let can_edit = r.edit && !project.closed;
202 -
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
222 +
    Ok((columns, repos))
203 223
}
204 224
205 225
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> {
…
36 unchanged lines
242 262
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
243 263
    let n: i64 = n.trim().parse().ok()?;
244 264
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
265 +
}
266 +
267 +
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
268 +
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
269 +
/// should be told: an unknown repository or number, or a card already there.
270 +
#[allow(clippy::too_many_arguments)]
271 +
pub async fn add_card(
272 +
    app: &Shared,
273 +
    account: Option<&db::accounts::Account>,
274 +
    owner: &str,
275 +
    project_id: i64,
276 +
    column_id: i64,
277 +
    reference: &str,
278 +
    repo: &str,
279 +
    note: &str,
280 +
    actor: &str,
281 +
) -> sqlx::Result<Result<pdb::Card, String>> {
282 +
    if !note.trim().is_empty() {
283 +
        let note: String = note.trim().chars().take(500).collect();
284 +
        pdb::add_card(
285 +
            &app.db,
286 +
            project_id,
287 +
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
288 +
        )
289 +
        .await?;
290 +
        return Ok(Ok(newest_card(app, project_id).await?));
291 +
    }
292 +
    let Some((repo, n)) = parse_reference(reference, repo) else {
293 +
        return Ok(Err("Name an issue or proposal as repo#12, or write a note.".into()));
294 +
    };
295 +
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
296 +
    let role = roles::role_for(&app.db, account, &row).await?;
297 +
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
298 +
    let kind = if issues.contains(&n) {
299 +
        "issue"
300 +
    } else if proposals.contains(&n) {
301 +
        "proposal"
302 +
    } else {
303 +
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
304 +
    };
305 +
    if !roles::allows(role, Access::Read) {
306 +
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
307 +
    }
308 +
    if !pdb::add_card(
309 +
        &app.db,
310 +
        project_id,
311 +
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
312 +
    )
313 +
    .await?
314 +
    {
315 +
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
316 +
    }
317 +
    Ok(Ok(newest_card(app, project_id).await?))
318 +
}
319 +
320 +
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
321 +
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
245 322
}
246 323
247 324
pub async fn act(
…
27 unchanged lines
275 352
    match f.action.as_str() {
276 353
        "add" => {
277 354
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
278 -
            if !f.note.trim().is_empty() {
279 -
                let note: String = f.note.trim().chars().take(500).collect();
280 -
                pdb::add_card(
281 -
                    &app.db,
282 -
                    project.id,
283 -
                    pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: &actor },
284 -
                )
285 -
                .await
286 -
                .map_err(e)?;
287 -
            } else {
288 -
                match parse_reference(&f.reference, &f.repo) {
289 -
                    None => error = Some("Name an issue or proposal as repo#12, or write a note.".to_string()),
290 -
                    Some((repo, n)) => match db::repos::get(&app.db, &owner, &repo).await.map_err(e)? {
291 -
                        None => error = Some(format!("{owner} has no repository {repo}.")),
292 -
                        Some(row) => {
293 -
                            let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
294 -
                            let (issues, proposals) = db::issues::numbers(&app.db, row.id).await.map_err(e)?;
295 -
                            let kind = if issues.contains(&n) {
296 -
                                Some("issue")
297 -
                            } else if proposals.contains(&n) {
298 -
                                Some("proposal")
299 -
                            } else {
300 -
                                None
301 -
                            };
302 -
                            match (roles::allows(role, Access::Read), kind) {
303 -
                                (true, Some(kind)) => {
304 -
                                    let added = pdb::add_card(
305 -
                                        &app.db,
306 -
                                        project.id,
307 -
                                        pdb::NewCard {
308 -
                                            column_id,
309 -
                                            kind,
310 -
                                            repo_id: Some(row.id),
311 -
                                            number: Some(n),
312 -
                                            note: "",
313 -
                                            added_by: &actor,
314 -
                                        },
315 -
                                    )
316 -
                                    .await
317 -
                                    .map_err(e)?;
318 -
                                    if !added {
319 -
                                        error = Some(format!("{repo}#{n} is already on this project."));
320 -
                                    }
321 -
                                }
322 -
                                _ => error = Some(format!("{repo} has no issue or proposal #{n}.")),
323 -
                            }
324 -
                        }
325 -
                    },
326 -
                }
355 +
            if let Err(msg) =
356 +
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
357 +
            {
358 +
                error = Some(msg);
327 359
            }
328 360
        }
329 361
        "move" => {
…
98 unchanged lines
modified crates/hub/tests/api.rs +74 −0
…
577 unchanged lines
578 578
    form(format!("_csrf={csrf}&id={}&action=update&url=&events=*&format=oxygenhub", hook.id)).await;
579 579
    assert!(!oxygenhub::db::hooks::hook(&app.db, hook.id).await.unwrap().unwrap().active);
580 580
}
581 +
582 +
#[tokio::test(flavor = "multi_thread")]
583 +
async fn api_projects() {
584 +
    let h = hub().await;
585 +
    let app = &h.app;
586 +
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
587 +
        .await
588 +
        .cookie
589 +
        .unwrap();
590 +
    let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery"))
591 +
        .await
592 +
        .cookie
593 +
        .unwrap();
594 +
    let mint = |cookie: String, scopes: &'static str| {
595 +
        let app = app.clone();
596 +
        async move {
597 +
            let page = call(&app, "GET", "/settings/tokens", None, Some(&cookie), None, None).await;
598 +
            let csrf = csrf_of(&page.body);
599 +
            let r = call(&app, "POST", "/settings/tokens", None, Some(&cookie), None, Some(&format!("_csrf={csrf}&name=t&{scopes}"))).await;
600 +
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
601 +
        }
602 +
    };
603 +
    let a = mint(andy, "scopes=repo%3Aadmin").await;
604 +
    let b = mint(bob, "scopes=repo%3Aread&scopes=repo%3Awrite").await;
605 +
    api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await;
606 +
    api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(r#"{"title":"Crash"}"#)).await;
607 +
608 +
    let (st, p) = api(app, "POST", "/api/v1/projects/andy", Some(&a), Some(r#"{"title":"Roadmap","description":"Next"}"#)).await;
609 +
    assert_eq!((st, p["number"].as_i64()), (StatusCode::CREATED, Some(1)), "{p}");
610 +
    assert!(p["url"].as_str().unwrap().ends_with("/projects/andy/1"));
611 +
    assert_eq!(api(app, "POST", "/api/v1/projects/andy", Some(&b), Some(r#"{"title":"Mine"}"#)).await.0, StatusCode::FORBIDDEN);
612 +
    assert_eq!(api(app, "POST", "/api/v1/projects/nobody", Some(&a), Some(r#"{"title":"X"}"#)).await.0, StatusCode::NOT_FOUND);
613 +
614 +
    // Cards by reference, into a column by name, and a note into the first column.
615 +
    let (st, card) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1","column":"doing"}"#)).await;
616 +
    assert_eq!(st, StatusCode::CREATED, "{card}");
617 +
    assert_eq!(
618 +
        (card["kind"].as_str(), card["reference"].as_str(), card["title"].as_str(), card["state"].as_str()),
619 +
        (Some("issue"), Some("andy/demo#1"), Some("Crash"), Some("open"))
620 +
    );
621 +
    let (st, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1"}"#)).await;
622 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
623 +
    assert!(v["detail"].as_str().unwrap().contains("already on this project"));
624 +
    assert_eq!(
625 +
        api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1","column":"nope"}"#)).await.0,
626 +
        StatusCode::BAD_REQUEST
627 +
    );
628 +
    let (st, note) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"note":"Write docs"}"#)).await;
629 +
    assert_eq!((st, note["kind"].as_str()), (StatusCode::CREATED, Some("note")));
630 +
    assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&b), Some(r#"{"note":"hi"}"#)).await.0, StatusCode::FORBIDDEN);
631 +
632 +
    // Move and read the board.
633 +
    let id = card["id"].as_i64().unwrap();
634 +
    assert_eq!(
635 +
        api(app, "PATCH", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), Some(r#"{"column":"Done"}"#)).await.0,
636 +
        StatusCode::NO_CONTENT
637 +
    );
638 +
    let (_, board) = api(app, "GET", "/api/v1/projects/andy/1", None, None).await;
639 +
    let names: Vec<(&str, usize)> =
640 +
        board["columns"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap(), c["cards"].as_array().unwrap().len())).collect();
641 +
    assert_eq!(names, [("Backlog", 1), ("Doing", 0), ("Done", 1)]);
642 +
    assert_eq!(board["columns"][2]["cards"][0]["reference"], "andy/demo#1");
643 +
644 +
    // Private: hidden from others; closed: read-only; then remove a card.
645 +
    let (_, edited) = api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"private":true}"#)).await;
646 +
    assert_eq!(edited["public"], false);
647 +
    assert_eq!(api(app, "GET", "/api/v1/projects/andy/1", Some(&b), None).await.0, StatusCode::NOT_FOUND);
648 +
    assert_eq!(api(app, "GET", "/api/v1/projects/andy", None, None).await.1, serde_json::json!([]));
649 +
    api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"closed":true}"#)).await;
650 +
    assert_eq!(api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"note":"late"}"#)).await.0, StatusCode::CONFLICT);
651 +
    api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"closed":false}"#)).await;
652 +
    assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NO_CONTENT);
653 +
    assert_eq!(api(app, "DELETE", &format!("/api/v1/projects/andy/1/cards/{id}"), Some(&a), None).await.0, StatusCode::NOT_FOUND);
654 +
}
modified docs/spec.md +1 −1
…
398 unchanged lines
399 399
400 400
### 12.1 API
401 401
402 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
402 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
403 403
404 404
### 12.2 Webhooks
405 405
…
171 unchanged lines