Transfer a project to another owner b1e92a6233ee
A board stayed with the owner it was made under. Its Settings now move
it to an owner the user may make boards under (themselves, or an
organisation they belong to; an administrator anywhere), and so do the
API (owner on PATCH /projects/{owner}/{n}) and oh project transfer N
--to OWNER. It takes the next number there with its columns, cards and
privacy; its old address redirects there permanently (project_redirects,
migration 0049) until a new board takes that number; audited as
project.transferred.
closes #125
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-10 22:23 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
b1e92a6233eea163459042345e77f8b0c082c4a31e01070450ff247bee4b7541- Depends on
- 17a3a84b00d1 18e381ea88c2 5c6e92fd8d71 617c2840446c 8aafd2023dec 93be9ce4653e b5668108487f c79a1111dd56 d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
- Depended on by
- 439ecd8708a1 657e7d5c9da9 a7aa76ac52d7 5d90e0e045e2 d7d182467382 59338826539c 40dd3f86379a 92a797b20912 c8df33a6829f 0650d94ede82 4597750166e7
- 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.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
- 22 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0049_project_redirects.sql +10 −0
| 1 | + | -- A project transferred to another owner (#125) takes the next number |
|
| 2 | + | -- there; its old address redirects to it, as a transferred repository's |
|
| 3 | + | -- does. |
|
| 4 | + | ||
| 5 | + | CREATE TABLE project_redirects ( |
|
| 6 | + | owner TEXT NOT NULL, |
|
| 7 | + | number INTEGER NOT NULL, |
|
| 8 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 9 | + | PRIMARY KEY (owner, number) |
|
| 10 | + | ); |
modified CHANGELOG.md +4 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **Transfer a project (#125).** A board's Settings move it to another owner you may make boards under, such as one of your organisations; so do the API (`owner` on `PATCH /projects/{owner}/{n}`) and `oh project transfer N --to OWNER`. It takes the next number there, with its columns, cards and privacy, and its old address redirects to it. |
|
| 8 | + | ||
| 5 | 9 | ## 0.4.1 (2026-10-10) |
|
| 6 | 10 | ||
| 7 | 11 | - **The store as its provider's icon (#123).** A repository kept in its owner's bucket shows the icon of Google Cloud Storage, Amazon S3 or Azure Blob Storage in its header; hovered, focused or tapped, a card gives the provider and, for its admins, the bucket, prefix, region and URL, with a link to Settings › Storage. |
|
| … | 147 unchanged lines |
modified crates/hub-api/src/lib.rs +4 −0
| … | 766 unchanged lines |
||
| 767 | 767 | /// `PATCH /projects/{owner}/{n}`: only the fields given change. |
|
| 768 | 768 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 769 | 769 | pub struct ProjectEdit { |
|
| 770 | + | /// Transfer the board to this owner (#125): one the caller may make |
|
| 771 | + | /// boards under. It takes the next number there. |
|
| 772 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 773 | + | pub owner: Option<String>, |
|
| 770 | 774 | #[serde(default)] |
|
| 771 | 775 | pub title: Option<String>, |
|
| 772 | 776 | #[serde(default)] |
|
| … | 461 unchanged lines |
modified crates/hub/src/api/projects.rs +15 −1
| … | 121 unchanged lines |
||
| 122 | 122 | Path((owner, number)): Path<(String, i64)>, |
|
| 123 | 123 | Json(body): Json<ProjectEdit>, |
|
| 124 | 124 | ) -> Result<Json<Project>, ApiError> { |
|
| 125 | - | let (_, p) = changeable(&app, &headers, &owner, number, true).await?; |
|
| 125 | + | let (c, p) = changeable(&app, &headers, &owner, number, true).await?; |
|
| 126 | + | // A transfer (#125) first: the other fields then apply where it went. |
|
| 127 | + | let (owner, number) = match body.owner.as_deref().map(str::trim).filter(|to| !to.is_empty() && *to != owner) { |
|
| 128 | + | Some(to) => match wp::rights_of(&app, c.account.as_ref(), to).await? { |
|
| 129 | + | Some(r) if r.edit => { |
|
| 130 | + | let moved = pdb::transfer(&app.db, p.id, to).await?; |
|
| 131 | + | let detail = format!("{owner}/{number} to {to}/{}", moved.number); |
|
| 132 | + | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.transferred", &detail, &p.title).await.ok(); |
|
| 133 | + | (moved.owner, moved.number) |
|
| 134 | + | } |
|
| 135 | + | _ => return Err(ApiError(StatusCode::FORBIDDEN, format!("you cannot make projects under {to}"))), |
|
| 136 | + | }, |
|
| 137 | + | None => (owner, number), |
|
| 138 | + | }; |
|
| 139 | + | let p = pdb::get(&app.db, &owner, number).await?.unwrap_or(p); |
|
| 126 | 140 | let title = body.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).unwrap_or(&p.title).chars().take(100).collect::<String>(); |
|
| 127 | 141 | pdb::set( |
|
| 128 | 142 | &app.db, |
|
| … | 75 unchanged lines |
modified crates/hub/src/db/projects.rs +37 −0
| … | 56 unchanged lines |
||
| 57 | 57 | .bind(now) |
|
| 58 | 58 | .fetch_one(&mut *tx) |
|
| 59 | 59 | .await?; |
|
| 60 | + | // A board here now: an old address of a transferred one gives way (#125). |
|
| 61 | + | sqlx::query("DELETE FROM project_redirects WHERE owner = ? AND number = ?").bind(owner).bind(number).execute(&mut *tx).await?; |
|
| 60 | 62 | for (i, name) in DEFAULT_COLUMNS.iter().enumerate() { |
|
| 61 | 63 | sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES (?, ?, ?)") |
|
| 62 | 64 | .bind(p.id) |
|
| … | 10 unchanged lines |
||
| 73 | 75 | sqlx::query_as("SELECT * FROM projects WHERE owner = ? ORDER BY closed, updated_ms DESC").bind(owner).fetch_all(pool).await |
|
| 74 | 76 | } |
|
| 75 | 77 | ||
| 78 | + | /// Move project `id` to owner `to` (#125): it takes the next number there, |
|
| 79 | + | /// with its columns, cards and privacy, and its old address redirects to it. |
|
| 80 | + | pub async fn transfer(pool: &Pool, id: i64, to: &str) -> sqlx::Result<Project> { |
|
| 81 | + | let mut tx = super::begin_write(pool).await?; |
|
| 82 | + | let old: Project = sqlx::query_as("SELECT * FROM projects WHERE id = ?").bind(id).fetch_one(&mut *tx).await?; |
|
| 83 | + | let number: i64 = |
|
| 84 | + | sqlx::query_scalar("SELECT COALESCE(MAX(number), 0) + 1 FROM projects WHERE owner = ?").bind(to).fetch_one(&mut *tx).await?; |
|
| 85 | + | sqlx::query("INSERT OR REPLACE INTO project_redirects (owner, number, project_id) VALUES (?, ?, ?)") |
|
| 86 | + | .bind(&old.owner) |
|
| 87 | + | .bind(old.number) |
|
| 88 | + | .bind(id) |
|
| 89 | + | .execute(&mut *tx) |
|
| 90 | + | .await?; |
|
| 91 | + | sqlx::query("DELETE FROM project_redirects WHERE owner = ? AND number = ?").bind(to).bind(number).execute(&mut *tx).await?; |
|
| 92 | + | let moved: Project = sqlx::query_as("UPDATE projects SET owner = ?, number = ?, updated_ms = ? WHERE id = ? RETURNING *") |
|
| 93 | + | .bind(to) |
|
| 94 | + | .bind(number) |
|
| 95 | + | .bind(now_ms()) |
|
| 96 | + | .bind(id) |
|
| 97 | + | .fetch_one(&mut *tx) |
|
| 98 | + | .await?; |
|
| 99 | + | tx.commit().await?; |
|
| 100 | + | Ok(moved) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// The project an old address (`owner`, `number`) now redirects to, after |
|
| 104 | + | /// a transfer (#125). |
|
| 105 | + | pub async fn redirected(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> { |
|
| 106 | + | sqlx::query_as("SELECT p.* FROM project_redirects r JOIN projects p ON p.id = r.project_id WHERE r.owner = ? AND r.number = ?") |
|
| 107 | + | .bind(owner) |
|
| 108 | + | .bind(number) |
|
| 109 | + | .fetch_optional(pool) |
|
| 110 | + | .await |
|
| 111 | + | } |
|
| 112 | + | ||
| 76 | 113 | pub async fn get(pool: &Pool, owner: &str, number: i64) -> sqlx::Result<Option<Project>> { |
|
| 77 | 114 | sqlx::query_as("SELECT * FROM projects WHERE owner = ? AND number = ?").bind(owner).bind(number).fetch_optional(pool).await |
|
| 78 | 115 | } |
|
| … | 203 unchanged lines |
modified crates/hub/src/web/projects.rs +37 −4
| … | 137 unchanged lines |
||
| 138 | 138 | can_manage: bool, |
|
| 139 | 139 | notice: Option<String>, |
|
| 140 | 140 | error: Option<String>, |
|
| 141 | + | /// Owners the viewer may move the project to (#125): themselves and the |
|
| 142 | + | /// organisations they belong to, but not its owner now. |
|
| 143 | + | transfer_to: Vec<String>, |
|
| 141 | 144 | } |
|
| 142 | 145 | ||
| 143 | 146 | async fn board( |
|
| … | 14 unchanged lines |
||
| 158 | 161 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 159 | 162 | let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?; |
|
| 160 | 163 | let can_edit = r.edit && !project.closed; |
|
| 161 | - | Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error })) |
|
| 164 | + | let mut transfer_to = Vec::new(); |
|
| 165 | + | if let (true, Some(a)) = (r.edit, viewer.account()) { |
|
| 166 | + | transfer_to.push(a.username.clone()); |
|
| 167 | + | let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?; |
|
| 168 | + | transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name)); |
|
| 169 | + | transfer_to.retain(|o| o != owner); |
|
| 170 | + | } |
|
| 171 | + | Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to })) |
|
| 162 | 172 | } |
|
| 163 | 173 | ||
| 164 | 174 | /// A project's columns with the cards `account` may see, and the owner's |
|
| … | 57 unchanged lines |
||
| 222 | 232 | Ok((columns, repos)) |
|
| 223 | 233 | } |
|
| 224 | 234 | ||
| 225 | - | pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> { |
|
| 235 | + | pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Response, Error> { |
|
| 226 | 236 | let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); |
|
| 227 | - | board(&app, shell, &viewer, &owner, number, None, None).await |
|
| 237 | + | // A board transferred away (#125): its old address goes to its new one. |
|
| 238 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 239 | + | if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() { |
|
| 240 | + | if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? { |
|
| 241 | + | return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response()); |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | Ok(board(&app, shell, &viewer, &owner, number, None, None).await?.into_response()) |
|
| 228 | 245 | } |
|
| 229 | 246 | ||
| 230 | 247 | #[derive(Deserialize)] |
|
| … | 22 unchanged lines |
||
| 253 | 270 | private: Option<String>, |
|
| 254 | 271 | #[serde(default)] |
|
| 255 | 272 | closed: Option<String>, |
|
| 273 | + | /// The owner to transfer the project to (#125). |
|
| 274 | + | #[serde(default)] |
|
| 275 | + | to: String, |
|
| 256 | 276 | } |
|
| 257 | 277 | ||
| 258 | 278 | /// `repo#12` or `#12` (with a repository chosen) to (repository, number). |
|
| … | 80 unchanged lines |
||
| 339 | 359 | if !r.edit { |
|
| 340 | 360 | return Err(Error::not_found(shell)); |
|
| 341 | 361 | } |
|
| 342 | - | if project.closed && !matches!(f.action.as_str(), "settings" | "delete") { |
|
| 362 | + | if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") { |
|
| 343 | 363 | return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into())) |
|
| 344 | 364 | .await? |
|
| 345 | 365 | .into_response()); |
|
| … | 58 unchanged lines |
||
| 404 | 424 | let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() }; |
|
| 405 | 425 | pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?; |
|
| 406 | 426 | notice = Some("Saved.".into()); |
|
| 427 | + | } |
|
| 428 | + | "transfer" => { |
|
| 429 | + | let to = f.to.trim(); |
|
| 430 | + | match rights_of(&app, viewer.account(), to).await.map_err(e)? { |
|
| 431 | + | _ if to == owner => error = Some(format!("It is {owner}'s already.")), |
|
| 432 | + | Some(t) if t.edit => { |
|
| 433 | + | let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?; |
|
| 434 | + | let detail = format!("{owner}/{number} to {to}/{}", moved.number); |
|
| 435 | + | db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok(); |
|
| 436 | + | return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response()); |
|
| 437 | + | } |
|
| 438 | + | _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")), |
|
| 439 | + | } |
|
| 407 | 440 | } |
|
| 408 | 441 | "delete" => { |
|
| 409 | 442 | pdb::delete(&app.db, project.id).await.map_err(e)?; |
|
| … | 153 unchanged lines |
modified crates/hub/tests/issues.rs +44 −0
| … | 840 unchanged lines |
||
| 841 | 841 | assert!(page.contains("src=\"/andy/trunky/raw/trunk/img/a.png\"")); |
|
| 842 | 842 | assert!(!page.contains("/file/main/")); |
|
| 843 | 843 | } |
|
| 844 | + | ||
| 845 | + | /// #125: a board moves to an owner its user may make boards under, takes the |
|
| 846 | + | /// next number there with its cards, and its old address redirects to it; a |
|
| 847 | + | /// new board at the old address takes it over. |
|
| 848 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 849 | + | async fn a_project_transfers_to_an_organisation() { |
|
| 850 | + | let h = hub().await; |
|
| 851 | + | let app = &h.app; |
|
| 852 | + | // The first account is the site's administrator, who may make boards |
|
| 853 | + | // anywhere: Andy is an ordinary user here. |
|
| 854 | + | let _first = register(app, "ada").await; |
|
| 855 | + | let andy = register(app, "andy").await; |
|
| 856 | + | let _bob = register(app, "bob").await; |
|
| 857 | + | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 858 | + | let bob_id = db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id; |
|
| 859 | + | db::accounts::create_org(&app.db, "retro", "Retro Club", andy_id).await.unwrap(); |
|
| 860 | + | db::accounts::create_org(&app.db, "other", "Someone else's", bob_id).await.unwrap(); |
|
| 861 | + | let list = get(app, "/projects/andy", Some(&andy)).await.body; |
|
| 862 | + | let csrf = csrf_of(&list); |
|
| 863 | + | let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await; |
|
| 864 | + | assert_eq!(r.location.as_deref(), Some("/projects/andy/1")); |
|
| 865 | + | post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=add¬e=Keep+me")).await; |
|
| 866 | + | ||
| 867 | + | // Settings offer the organisations Andy belongs to, not other people's. |
|
| 868 | + | let board = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 869 | + | assert!(board.contains("<option value=\"retro\">retro</option>") && !board.contains("value=\"other\""), "{board}"); |
|
| 870 | + | let refused = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=other")).await; |
|
| 871 | + | assert!(refused.body.contains("You cannot make projects under other"), "{}", refused.body); |
|
| 872 | + | ||
| 873 | + | let r = post(app, "/projects/andy/1", Some(&andy), &format!("_csrf={csrf}&action=transfer&to=retro")).await; |
|
| 874 | + | assert_eq!(r.location.as_deref(), Some("/projects/retro/1"), "{}", r.body); |
|
| 875 | + | let moved = get(app, "/projects/retro/1", Some(&andy)).await.body; |
|
| 876 | + | assert!(moved.contains("Roadmap") && moved.contains("Keep me"), "the cards come too"); |
|
| 877 | + | let old = get(app, "/projects/andy/1", Some(&andy)).await; |
|
| 878 | + | assert_eq!(old.status, StatusCode::PERMANENT_REDIRECT); |
|
| 879 | + | assert_eq!(old.location.as_deref(), Some("/projects/retro/1")); |
|
| 880 | + | ||
| 881 | + | // A new board of Andy's takes number 1 again, and its own address back. |
|
| 882 | + | let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Fresh")).await; |
|
| 883 | + | assert_eq!(r.location.as_deref(), Some("/projects/andy/1")); |
|
| 884 | + | let fresh = get(app, "/projects/andy/1", Some(&andy)).await; |
|
| 885 | + | assert_eq!(fresh.status, StatusCode::OK); |
|
| 886 | + | assert!(fresh.body.contains("Fresh")); |
|
| 887 | + | } |
modified crates/oh/src/main.rs +17 −0
| … | 339 unchanged lines |
||
| 340 | 340 | Close { number: i64 }, |
|
| 341 | 341 | /// Reopen a board |
|
| 342 | 342 | Reopen { number: i64 }, |
|
| 343 | + | /// Move a board to another owner you may make boards under (an |
|
| 344 | + | /// organisation, say); it takes the next number there |
|
| 345 | + | Transfer { |
|
| 346 | + | number: i64, |
|
| 347 | + | /// The new owner |
|
| 348 | + | #[arg(long)] |
|
| 349 | + | to: String, |
|
| 350 | + | }, |
|
| 343 | 351 | /// Cards on a board: add, move, remove |
|
| 344 | 352 | Card { |
|
| 345 | 353 | #[command(subcommand)] |
|
| … | 921 unchanged lines |
||
| 1267 | 1275 | } |
|
| 1268 | 1276 | ProjectCmd::Close { number } => state(number, true, "closed")?, |
|
| 1269 | 1277 | ProjectCmd::Reopen { number } => state(number, false, "reopened")?, |
|
| 1278 | + | ProjectCmd::Transfer { number, to } => { |
|
| 1279 | + | let p: hub_api::Project = |
|
| 1280 | + | c.send("PATCH", &format!("{base}/{number}"), &hub_api::ProjectEdit { owner: Some(to.clone()), ..Default::default() })?; |
|
| 1281 | + | if json { |
|
| 1282 | + | println!("{}", serde_json::to_string(&p)?); |
|
| 1283 | + | } else { |
|
| 1284 | + | println!("{} transferred project {} to {to}: now {} {}", ui::ok("✔"), number, p.number, p.url); |
|
| 1285 | + | } |
|
| 1286 | + | } |
|
| 1270 | 1287 | ProjectCmd::Card { cmd: CardCmd::Add { number, reference, note, column } } => { |
|
| 1271 | 1288 | // The hub names a card's issue as `repo#N` among the owner's |
|
| 1272 | 1289 | // repositories. |
|
| … | 188 unchanged lines |
modified docs/spec.md +1 −1
| … | 459 unchanged lines |
||
| 460 | 460 | ||
| 461 | 461 | ### 12.1 API |
|
| 462 | 462 | ||
| 463 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/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}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/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 list them by column or 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), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 463 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), 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/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/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` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/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}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or 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), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 464 | 464 | ||
| 465 | 465 | ### 12.2 Webhooks |
|
| 466 | 466 | ||
| … | 285 unchanged lines |
modified templates/project.html +11 −1
| … | 53 unchanged lines |
||
| 54 | 54 | <label for="pdesc">Description</label><input type="text" id="pdesc" name="description" value="{{ project.description }}"> |
|
| 55 | 55 | <div class="choice"><label><input type="checkbox" name="private" value="1"{% if !project.public %} checked{% endif %}> private</label> <label><input type="checkbox" name="closed" value="1"{% if project.closed %} checked{% endif %}> closed (read-only)</label></div> |
|
| 56 | 56 | <button name="action" value="settings">SAVE</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this project? Issues and proposals are not touched.')">DELETE PROJECT</button> |
|
| 57 | - | </form></details> |
|
| 57 | + | </form> |
|
| 58 | + | {% if !transfer_to.is_empty() %} |
|
| 59 | + | <form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 60 | + | <h3>Transfer</h3> |
|
| 61 | + | <p class="small mute">Move this project to another owner. It takes the next number there, with its columns and cards; this address goes on redirecting to it.</p> |
|
| 62 | + | <label for="pto">New owner</label> |
|
| 63 | + | <select id="pto" name="to">{% for o in transfer_to %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select> |
|
| 64 | + | <button name="action" value="transfer" onclick="return confirm('Transfer this project?')">TRANSFER</button> |
|
| 65 | + | </form> |
|
| 66 | + | {% endif %} |
|
| 67 | + | </details> |
|
| 58 | 68 | {% endif %} |
|
| 59 | 69 | {% endblock %} |