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 |