OXYGENHUB

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