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: ¬e, 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: ¬e, 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 |