Finished work moves to Done 1000b40a2629
A card whose issue closes, or whose proposal lands or closes, moves to its board's column for finished work: a new board's Done, and each existing board's column named Done; a column's menu turns it on or off. Migration 0096. closes #325
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 69f65c7fc162
- When
- · 2026-10-26 03:51 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1000b40a2629d21e3160b89695eff7c7af8b8449ca21a24d49fd5024e92bbd96- Depends on
- 0b20f1ec4007 18e381ea88c2 3b1bc27fe805 439ecd8708a1 5d90e0e045e2 a7e6e8e6094c b5668108487f bff9106edcba d23464c0ac60 d7050703d2f4 de505df5cf8d e1ec9fbf538e
- Depended on by
- 0bc95135954d faf5246c900e
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 16 atoms, 2 tree operations, 9 files
Browse the tree at this change
added crates/hub/migrations-pg/0096_finished_column.sql +5 −0
| 1 | + | -- A board's column for finished work (#325): a card whose issue closes, or |
|
| 2 | + | -- whose proposal lands or closes, moves there. One per board at most; a |
|
| 3 | + | -- board's column named Done takes it to begin with. |
|
| 4 | + | ALTER TABLE project_columns ADD COLUMN takes_finished BOOLEAN NOT NULL DEFAULT FALSE; |
|
| 5 | + | UPDATE project_columns SET takes_finished = TRUE WHERE lower(name) = 'done'; |
added crates/hub/migrations/0096_finished_column.sql +5 −0
| 1 | + | -- A board's column for finished work (#325): a card whose issue closes, or |
|
| 2 | + | -- whose proposal lands or closes, moves there. One per board at most; a |
|
| 3 | + | -- board's column named Done takes it to begin with. |
|
| 4 | + | ALTER TABLE project_columns ADD COLUMN takes_finished INTEGER NOT NULL DEFAULT 0; |
|
| 5 | + | UPDATE project_columns SET takes_finished = 1 WHERE lower(name) = 'done'; |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Finished work moves to Done (#325):** a card whose issue closes (on its page, through the API, or by a change's `closes #N`), or whose proposal lands or closes, moves to its board's column for finished work. A new board's Done is that column, and so is each existing board's column named Done; a column's menu turns it on or off (one column of a board). Archived cards stay archived. |
|
| 7 | 8 | - **A WASM job's placement names no image (#323):** a job on the WASM tier was placed saying its runner "fetches the image from the hub"; it has none, so the line now starts with what the runner is doing (andymccall/windows-runner-demo run #1). |
|
| 8 | 9 | - **Runner updates: policy, test groups and the offer (#322):** Admin › Runners › Updates sets where runner builds come from (a repository whose releases carry `flow-linux-ARCH` files) and who takes them. Updates are off unless chosen; the site, an owner, a test group and a runner may each set updates on or off, a channel (`stable`, `canary` or a version), a window of hours (UTC) and dry run, and the nearest setting wins, each setting on its own. A test group is chosen runners or a percentage of the site's (or an owner's) runners, by a stable hash of each runner's id, so a share is the same machines every rollout and 10% sits inside 50%. `GET /api/v1/flow/runner/update` tells a runner its policy, where each setting came from, and the build to install (its BLAKE3, to check the download against); `POST` takes what the runner did, shown on the page and in the audit log. Pins OxygenFlow's main. |
|
| 9 | 10 | ||
| … | 336 unchanged lines |
modified crates/hub/src/db/issues.rs +13 −0
| … | 132 unchanged lines |
||
| 133 | 133 | .bind(id) |
|
| 134 | 134 | .execute(pool) |
|
| 135 | 135 | .await?; |
|
| 136 | + | if state == "closed" { |
|
| 137 | + | finished(pool, "issues", id).await?; |
|
| 138 | + | } |
|
| 136 | 139 | Ok(()) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// Move the cards of finished issue or proposal `id` of `table` (#325). |
|
| 143 | + | pub(super) async fn finished(pool: &Pool, table: &str, id: i64) -> sqlx::Result<()> { |
|
| 144 | + | let at: Option<(i64, i64)> = |
|
| 145 | + | sqlx::query_as(&format!("SELECT repo_id, number FROM {table} WHERE id = $1")).bind(id).fetch_optional(pool).await?; |
|
| 146 | + | match at { |
|
| 147 | + | Some((repo, number)) => super::projects::finished(pool, repo, number).await, |
|
| 148 | + | None => Ok(()), |
|
| 149 | + | } |
|
| 137 | 150 | } |
|
| 138 | 151 | ||
| 139 | 152 | pub async fn set_flags(pool: &Pool, id: i64, locked: Option<bool>, pinned: Option<bool>) -> sqlx::Result<()> { |
|
| … | 274 unchanged lines |
modified crates/hub/src/db/projects.rs +45 −1
| … | 23 unchanged lines |
||
| 24 | 24 | pub position: i64, |
|
| 25 | 25 | /// Its cards are archived this many days after they joined it (#293). |
|
| 26 | 26 | pub archive_after_days: Option<i64>, |
|
| 27 | + | /// Cards whose issue closes or whose proposal lands or closes move here |
|
| 28 | + | /// (#325); one column of a board at most. |
|
| 29 | + | pub takes_finished: bool, |
|
| 27 | 30 | } |
|
| 28 | 31 | ||
| 29 | 32 | #[derive(Clone, Debug, FromRow)] |
|
| … | 39 unchanged lines |
||
| 69 | 72 | // A board here now: an old address of a transferred one gives way (#125). |
|
| 70 | 73 | sqlx::query("DELETE FROM project_redirects WHERE owner = $1 AND number = $2").bind(owner).bind(number).execute(&mut *tx).await?; |
|
| 71 | 74 | for (i, name) in DEFAULT_COLUMNS.iter().enumerate() { |
|
| 72 | - | sqlx::query("INSERT INTO project_columns (project_id, name, position) VALUES ($1, $2, $3)") |
|
| 75 | + | sqlx::query("INSERT INTO project_columns (project_id, name, position, takes_finished) VALUES ($1, $2, $3, $4)") |
|
| 73 | 76 | .bind(p.id) |
|
| 74 | 77 | .bind(name) |
|
| 75 | 78 | .bind(i as i64) |
|
| 79 | + | .bind(*name == "Done") |
|
| 76 | 80 | .execute(&mut *tx) |
|
| 77 | 81 | .await?; |
|
| 78 | 82 | } |
|
| … | 354 unchanged lines |
||
| 433 | 437 | } |
|
| 434 | 438 | ||
| 435 | 439 | /// The projects (and column names) an issue or proposal is on. |
|
| 440 | + | /// Make `column_id` the board's column for finished work, or none (#325). |
|
| 441 | + | pub async fn set_takes_finished(pool: &Pool, project_id: i64, column_id: Option<i64>) -> sqlx::Result<()> { |
|
| 442 | + | let mut tx = super::begin_write(pool).await?; |
|
| 443 | + | sqlx::query("UPDATE project_columns SET takes_finished = FALSE WHERE project_id = $1").bind(project_id).execute(&mut *tx).await?; |
|
| 444 | + | if let Some(c) = column_id { |
|
| 445 | + | sqlx::query("UPDATE project_columns SET takes_finished = TRUE WHERE id = $1 AND project_id = $2") |
|
| 446 | + | .bind(c) |
|
| 447 | + | .bind(project_id) |
|
| 448 | + | .execute(&mut *tx) |
|
| 449 | + | .await?; |
|
| 450 | + | } |
|
| 451 | + | tx.commit().await?; |
|
| 452 | + | touch(pool, project_id).await |
|
| 453 | + | } |
|
| 454 | + | ||
| 455 | + | /// Issue or proposal `number` of `repo_id` is finished (closed, landed): |
|
| 456 | + | /// its cards move to their boards' column for finished work, where a board |
|
| 457 | + | /// has one (#325). Archived cards stay archived. |
|
| 458 | + | pub async fn finished(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<()> { |
|
| 459 | + | let cards: Vec<(i64, i64, i64)> = sqlx::query_as( |
|
| 460 | + | "SELECT c.id, c.project_id, c.column_id FROM project_cards c |
|
| 461 | + | WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note' AND c.archived_ms IS NULL", |
|
| 462 | + | ) |
|
| 463 | + | .bind(repo_id) |
|
| 464 | + | .bind(number) |
|
| 465 | + | .fetch_all(pool) |
|
| 466 | + | .await?; |
|
| 467 | + | for (card, project, column) in cards { |
|
| 468 | + | let to: Option<i64> = |
|
| 469 | + | sqlx::query_scalar("SELECT id FROM project_columns WHERE project_id = $1 AND takes_finished ORDER BY position LIMIT 1") |
|
| 470 | + | .bind(project) |
|
| 471 | + | .fetch_optional(pool) |
|
| 472 | + | .await?; |
|
| 473 | + | if let Some(to) = to.filter(|to| *to != column) { |
|
| 474 | + | move_card(pool, project, card, to).await?; |
|
| 475 | + | } |
|
| 476 | + | } |
|
| 477 | + | Ok(()) |
|
| 478 | + | } |
|
| 479 | + | ||
| 436 | 480 | pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> { |
|
| 437 | 481 | let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as( |
|
| 438 | 482 | "SELECT c.project_id, col.name, c.archived_ms FROM project_cards c JOIN project_columns col ON col.id = c.column_id WHERE c.repo_id = $1 AND c.number = $2 AND c.kind <> 'note'", |
|
| … | 14 unchanged lines |
modified crates/hub/src/db/proposals.rs +4 −1
| … | 193 unchanged lines |
||
| 194 | 194 | .bind(id) |
|
| 195 | 195 | .execute(pool) |
|
| 196 | 196 | .await?; |
|
| 197 | + | if state == "closed" || state == "landed" { |
|
| 198 | + | super::issues::finished(pool, "proposals", id).await?; |
|
| 199 | + | } |
|
| 197 | 200 | Ok(()) |
|
| 198 | 201 | } |
|
| 199 | 202 | ||
| … | 11 unchanged lines |
||
| 211 | 214 | .bind(id) |
|
| 212 | 215 | .execute(pool) |
|
| 213 | 216 | .await?; |
|
| 214 | - | Ok(()) |
|
| 217 | + | super::issues::finished(pool, "proposals", id).await |
|
| 215 | 218 | } |
|
| 216 | 219 | ||
| 217 | 220 | pub async fn edit(pool: &Pool, id: i64, title: &str, body: &str, target: &str) -> sqlx::Result<()> { |
|
| … | 229 unchanged lines |
modified crates/hub/src/web/projects.rs +11 −0
| … | 672 unchanged lines |
||
| 673 | 673 | } |
|
| 674 | 674 | return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response()); |
|
| 675 | 675 | } |
|
| 676 | + | "takes_finished" => { |
|
| 677 | + | let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; |
|
| 678 | + | let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default(); |
|
| 679 | + | let on = f.mode == "on"; |
|
| 680 | + | pdb::set_takes_finished(&app.db, project.id, on.then_some(col)).await.map_err(e)?; |
|
| 681 | + | notice = Some(if on { |
|
| 682 | + | format!("Cards move to {name} when their issue closes or their proposal lands or closes.") |
|
| 683 | + | } else { |
|
| 684 | + | "Cards stay where they are when their issue closes or their proposal lands.".into() |
|
| 685 | + | }); |
|
| 686 | + | } |
|
| 676 | 687 | "archive_older" | "archive_after" => { |
|
| 677 | 688 | let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; |
|
| 678 | 689 | let days = match (f.action.as_str(), f.mode.as_str(), f.days.trim()) { |
|
| … | 233 unchanged lines |
modified crates/hub/tests/issues.rs +56 −0
| … | 1463 unchanged lines |
||
| 1464 | 1464 | assert!(page.body.contains("Not an agent") && page.body.contains("Mark as agent"), "{}", page.body); |
|
| 1465 | 1465 | assert!(page.body.contains(r#"<span class="pill agent-pill""#), "the agent's token says so"); |
|
| 1466 | 1466 | } |
|
| 1467 | + | ||
| 1468 | + | /// #325: a card whose issue closes, or whose proposal lands or closes, |
|
| 1469 | + | /// moves to its board's column for finished work, Done to begin with. |
|
| 1470 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1471 | + | async fn finished_work_moves_to_its_column() { |
|
| 1472 | + | let h = hub().await; |
|
| 1473 | + | let app = &h.app; |
|
| 1474 | + | let andy = register(app, "andy").await; |
|
| 1475 | + | app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); |
|
| 1476 | + | let row = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 1477 | + | let mut issues = vec![]; |
|
| 1478 | + | for t in ["closed here", "closed by a change", "stays open", "the column is off"] { |
|
| 1479 | + | let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: row.id, title: t, body: "", author_id: None, author: "andy" }) |
|
| 1480 | + | .await |
|
| 1481 | + | .unwrap(); |
|
| 1482 | + | issues.push(i); |
|
| 1483 | + | } |
|
| 1484 | + | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 1485 | + | post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await; |
|
| 1486 | + | let cols: Vec<(i64, String, bool)> = |
|
| 1487 | + | sqlx::query_as("SELECT id, name, takes_finished FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap(); |
|
| 1488 | + | assert_eq!(cols.iter().map(|c| c.2).collect::<Vec<_>>(), [false, false, true], "a new board's Done takes finished work"); |
|
| 1489 | + | let (backlog, doing, done) = (cols[0].0, cols[1].0, cols[2].0); |
|
| 1490 | + | let act = |form: String| { |
|
| 1491 | + | let andy = andy.clone(); |
|
| 1492 | + | async move { post(app, "/projects/andy/1", Some(&andy), &form).await } |
|
| 1493 | + | }; |
|
| 1494 | + | for (i, col) in issues.iter().zip([backlog, doing, backlog, backlog]) { |
|
| 1495 | + | act(format!("_csrf={csrf}&action=add&column={col}&reference=demo%23{}", i.number)).await; |
|
| 1496 | + | } |
|
| 1497 | + | let column_of = |n: i64| async move { |
|
| 1498 | + | sqlx::query_scalar::<_, i64>("SELECT column_id FROM project_cards WHERE number = $1").bind(n).fetch_one(&app.db).await.unwrap() |
|
| 1499 | + | }; |
|
| 1500 | + | let page = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 1501 | + | assert!(page.contains("Finished work comes here") && page.contains("MOVE IT HERE"), "{page}"); |
|
| 1502 | + | ||
| 1503 | + | // Closed on its page, and by a change's `closes #N` (the same call). |
|
| 1504 | + | let issue = format!("/andy/demo/issue/{}", issues[0].number); |
|
| 1505 | + | let icsrf = csrf_of(&get(app, &issue, Some(&andy)).await.body); |
|
| 1506 | + | post(app, &format!("{issue}/state"), Some(&andy), &format!("_csrf={icsrf}&action=close")).await; |
|
| 1507 | + | assert_eq!(column_of(issues[0].number).await, done, "closed on its page"); |
|
| 1508 | + | db::issues::set_state(&app.db, issues[1].id, "closed", "completed").await.unwrap(); |
|
| 1509 | + | assert_eq!(column_of(issues[1].number).await, done, "from Doing too"); |
|
| 1510 | + | assert_eq!(column_of(issues[2].number).await, backlog, "an open issue stays"); |
|
| 1511 | + | ||
| 1512 | + | // Off: nothing moves; Backlog can take it instead. |
|
| 1513 | + | let r = act(format!("_csrf={csrf}&action=takes_finished&column={done}&mode=off")).await; |
|
| 1514 | + | assert!(r.body.contains("Cards stay where they are"), "{}", r.body); |
|
| 1515 | + | db::issues::set_state(&app.db, issues[3].id, "closed", "not_planned").await.unwrap(); |
|
| 1516 | + | assert_eq!(column_of(issues[3].number).await, backlog); |
|
| 1517 | + | let r = act(format!("_csrf={csrf}&action=takes_finished&column={doing}&mode=on")).await; |
|
| 1518 | + | assert!(r.body.contains("Cards move to Doing when their issue closes"), "{}", r.body); |
|
| 1519 | + | let flags: Vec<bool> = |
|
| 1520 | + | sqlx::query_scalar("SELECT takes_finished FROM project_columns ORDER BY position").fetch_all(&app.db).await.unwrap(); |
|
| 1521 | + | assert_eq!(flags, [false, true, false], "one column of a board"); |
|
| 1522 | + | } |
modified templates/project.html +3 −0
| … | 55 unchanged lines |
||
| 56 | 56 | <label for="older-{{ col.column.id }}">Archive cards here more than</label> |
|
| 57 | 57 | <div class="inline-fields"><input type="number" id="older-{{ col.column.id }}" name="days" min="0" max="3650" value="14" required><span class="small mute">days</span><button name="action" value="archive_older" class="small">ARCHIVE</button></div> |
|
| 58 | 58 | </form> |
|
| 59 | + | <form class="col-archive" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><input type="hidden" name="action" value="takes_finished"> |
|
| 60 | + | {% if col.column.takes_finished %}<p class="small">Finished work comes here: a card moves here when its issue closes or its proposal lands or closes.</p><button name="mode" value="off" class="small">STOP</button>{% else %}<label>Finished work</label><button name="mode" value="on" class="small" title="A card moves here when its issue closes or its proposal lands or closes (one column of the board)">MOVE IT HERE</button>{% endif %} |
|
| 61 | + | </form> |
|
| 59 | 62 | <form class="col-archive" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 60 | 63 | <label for="mode-{{ col.column.id }}">Archive automatically</label> |
|
| 61 | 64 | <div class="inline-fields"><select id="mode-{{ col.column.id }}" name="mode" aria-label="Archive automatically"><option value="finished"{% if col.column.archive_after_days.is_none() %} selected{% endif %}>finished cards after {{ crate::db::projects::FINISHED_KEPT_DAYS }} days</option><option value="days"{% if col.column.archive_after_days.unwrap_or(-1) >= 0 %} selected{% endif %}>every card after</option><option value="never"{% if col.column.archive_after_days == Some(-1) %} selected{% endif %}>never</option></select><input type="number" id="after-{{ col.column.id }}" name="days" min="0" max="3650" placeholder="days" aria-label="Days"{% if let Some(d) = col.column.archive_after_days %}{% if *d >= 0 %} value="{{ d }}"{% endif %}{% endif %}><button name="action" value="archive_after" class="small">SAVE</button></div> |
|
| … | 29 unchanged lines |