OXYGENHUB

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&note=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 %}