OXYGENHUB

Project cards: take owner/repo#N, as the board shows them efb51b39db1c

A board lists its cards as owner/repo#N, but adding one in that form
was refused ('Name an issue or proposal as repo#12'). The board's own
owner is now accepted, and another owner is refused saying the board
holds only its owner's issues and proposals.

closes #118
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-08 21:19 UTC
Signature
✔ signed by Andy McCall
Hash
efb51b39db1c38e7b881f05a1ce1c46d09dc3317349df6ebfd24a2c6d12279db
Depends on
93be9ce4653e b64e2537d702 e1ec9fbf538e ef20fdc14e8b
Depended on by
nothing yet
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.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
9 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/hub/src/web/projects.rs +28 −13
…
255 unchanged lines
256 256
}
257 257
258 258
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
259 -
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> {
259 +
/// `repo#12`, `#12` with `repo`, or `owner/repo#12` when `owner` is the
260 +
/// board's (the form the board itself shows, oxygenhub#118). `Err` says
261 +
/// what is wrong.
262 +
fn parse_reference(reference: &str, repo: &str, owner: &str) -> Result<(String, i64), String> {
263 +
    let bad = || "Name an issue or proposal as repo#12, or write a note.".to_string();
260 264
    let r = reference.trim();
261 -
    let (name, n) = r.split_once('#')?;
265 +
    let (name, n) = r.split_once('#').ok_or_else(bad)?;
262 266
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
263 -
    let n: i64 = n.trim().parse().ok()?;
264 -
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
267 +
    let name = match name.split_once('/') {
268 +
        Some((o, rest)) if o.eq_ignore_ascii_case(owner) => rest,
269 +
        Some((o, _)) => return Err(format!("This board holds only {owner}'s issues and proposals, not {o}'s.")),
270 +
        None => name,
271 +
    };
272 +
    let n: i64 = n.trim().parse().map_err(|_| bad())?;
273 +
    if name.is_empty() || n <= 0 || name.contains('/') {
274 +
        return Err(bad());
275 +
    }
276 +
    Ok((name.to_string(), n))
265 277
}
266 278
267 279
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
…
21 unchanged lines
289 301
        .await?;
290 302
        return Ok(Ok(newest_card(app, project_id).await?));
291 303
    }
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()));
304 +
    let (repo, n) = match parse_reference(reference, repo, owner) {
305 +
        Ok(r) => r,
306 +
        Err(why) => return Ok(Err(why)),
294 307
    };
295 308
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
296 309
    let role = roles::role_for(&app.db, account, &row).await?;
…
151 unchanged lines
448 461
449 462
    #[test]
450 463
    fn references() {
451 -
        assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12)));
452 -
        assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7)));
453 -
        assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3)));
454 -
        assert_eq!(parse_reference("#7", ""), None, "a repository is needed");
455 -
        assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories");
456 -
        assert_eq!(parse_reference("hub#0", ""), None);
457 -
        assert_eq!(parse_reference("hub", ""), None);
464 +
        let p = |r: &str, repo: &str| parse_reference(r, repo, "andy").ok();
465 +
        assert_eq!(p("oxygen#12", ""), Some(("oxygen".into(), 12)));
466 +
        assert_eq!(p("#7", "hub"), Some(("hub".into(), 7)));
467 +
        assert_eq!(p(" hub # 3 ", ""), Some(("hub".into(), 3)));
468 +
        assert_eq!(p("#7", ""), None, "a repository is needed");
469 +
        assert_eq!(p("andy/hub#4", ""), Some(("hub".into(), 4)), "the board's own owner, as it shows cards");
470 +
        assert!(parse_reference("other/repo#1", "", "andy").unwrap_err().contains("only andy's"), "another owner is refused, saying why");
471 +
        assert_eq!(p("hub#0", ""), None);
472 +
        assert_eq!(p("hub", ""), None);
458 473
    }
459 474
}
modified crates/hub/tests/api.rs +5 −0
…
620 unchanged lines
621 621
    let (st, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1"}"#)).await;
622 622
    assert_eq!(st, StatusCode::BAD_REQUEST);
623 623
    assert!(v["detail"].as_str().unwrap().contains("already on this project"));
624 +
    // The form the board shows its cards in is taken too (#118); another owner's is refused.
625 +
    let (_, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"andy/demo#1"}"#)).await;
626 +
    assert!(v["detail"].as_str().unwrap().contains("already on this project"), "{v}");
627 +
    let (_, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"other/demo#1"}"#)).await;
628 +
    assert!(v["detail"].as_str().unwrap().contains("only andy's"), "{v}");
624 629
    assert_eq!(
625 630
        api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1","column":"nope"}"#)).await.0,
626 631
        StatusCode::BAD_REQUEST
…
28 unchanged lines
modified docs/spec.md +1 −1
…
377 unchanged lines
378 378
A **project** is a board that plans work across the repositories of one owner (a person or an organisation): a number per owner, a title, a description, public or private, open or closed, and ordered **columns** (new projects start with Backlog, Doing and Done). A column holds ordered **cards**; a card is an issue or proposal of one of the owner's repositories, or a note.
379 379
380 380
- Who: anyone may see a public project; a private one only its owner (for an organisation, its members) and site administrators. The owner, an organisation's members and administrators may change it. A card for an issue or proposal whose repository the viewer cannot read is not shown at all.
381 -
- The board works without JavaScript: each card has a column picker and up and down buttons; cards are added by reference (`#12` in a named repository, or `repo#12`) or as a note; an issue or proposal is on a project at most once. Issue and proposal pages list the projects they are on, with their column.
381 +
- The board works without JavaScript: each card has a column picker and up and down buttons; cards are added by reference (`#12` in a named repository, `repo#12`, or `owner/repo#12` as the board shows them, the board's owner only) or as a note; an issue or proposal is on a project at most once. Issue and proposal pages list the projects they are on, with their column.
382 382
- Columns can be added, renamed, moved and removed (removing one moves its cards to the first column). Closing a project keeps it read-only.
383 383
384 384
## 9. Releases and tags
…
192 unchanged lines