Board cards can be archived, by hand or by age (refs #293) 439ecd8708a1
#293's second slice: an archived card is off the board but kept, on the
board's archive view (?archived=1, filtered the same way) and restorable
to the end of its column. A column archives its cards that reached it
more than N days ago at once, or on its own once set to (hourly, the
projects.archiver worker task); an issue's page says its card is
archived. API: POST cards/archive (#289's selection plus older_days,
dry_run), GET cards?archived=true, POST cards/{id}/restore; oh project
card archive/restore and oh project view --archived. Migration 0093
(SQLite and PostgreSQL): project_cards.archived_ms and moved_ms,
project_columns.archive_after_days. Spec 8.2 and the API list say so.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key eb78ea88b7f0
- When
- · 2026-10-25 08:37 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
439ecd8708a13ffd255537fab186ab2b3f49ef9f6a8684d55b0b294d434d6ab7- Depends on
- 17a3a84b00d1 18e381ea88c2 3b1bc27fe805 456f4197ade3 4f8404093d30 5c6e92fd8d71 5d90e0e045e2 657e7d5c9da9 6a306cd03fc1 6b195ed3b637 8a3334b7a364 93be9ce4653e a7e6e8e6094c b1e92a6233ee b5668108487f b64e2537d702 c79a1111dd56 cb7b3cd45dc0 d1bf2d698777 d23464c0ac60 dbcb0b813caa dd327d76062d e1ec9fbf538e e57765dee61a ecac6e8d9b41 ef20fdc14e8b
- Depended on by
- 0bc95135954d 306aec1839aa 046e3352cdfd 1000b40a2629 17dc8de4fca8 946fe7a09faa f4e138301198
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 81 atoms, 2 tree operations, 18 files
Browse the tree at this change
added crates/hub/migrations-pg/0093_card_archive.sql +10 −0
| 1 | + | -- Archived board cards (oxygenhub#293): off the board, kept, found with |
|
| 2 | + | -- the board's archive view and restorable. NULL while on the board. |
|
| 3 | + | ALTER TABLE project_cards ADD COLUMN archived_ms BIGINT; |
|
| 4 | + | -- When a card joined its column, so a column's cards can be archived some |
|
| 5 | + | -- days after they reached it (Done after 14 days). |
|
| 6 | + | ALTER TABLE project_cards ADD COLUMN moved_ms BIGINT NOT NULL DEFAULT 0; |
|
| 7 | + | UPDATE project_cards SET moved_ms = created_ms; |
|
| 8 | + | -- A column whose cards are archived this many days after they joined it; |
|
| 9 | + | -- NULL never. |
|
| 10 | + | ALTER TABLE project_columns ADD COLUMN archive_after_days BIGINT; |
added crates/hub/migrations/0093_card_archive.sql +10 −0
| 1 | + | -- Archived board cards (oxygenhub#293): off the board, kept, found with |
|
| 2 | + | -- the board's archive view and restorable. NULL while on the board. |
|
| 3 | + | ALTER TABLE project_cards ADD COLUMN archived_ms INTEGER; |
|
| 4 | + | -- When a card joined its column, so a column's cards can be archived some |
|
| 5 | + | -- days after they reached it (Done after 14 days). |
|
| 6 | + | ALTER TABLE project_cards ADD COLUMN moved_ms INTEGER NOT NULL DEFAULT 0; |
|
| 7 | + | UPDATE project_cards SET moved_ms = created_ms; |
|
| 8 | + | -- A column whose cards are archived this many days after they joined it; |
|
| 9 | + | -- NULL never. |
|
| 10 | + | ALTER TABLE project_columns ADD COLUMN archive_after_days INTEGER; |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Board cards can be archived (#293, archive):** off the board but kept, on the board's archive view (`?archived=1`, with the same filter) and restored to the end of their column. A column archives its cards that reached it more than N days ago at once ("Archive cards here more than 14 days"), or on its own once set to (checked hourly, `projects.archiver`), so a Done column stops growing. An issue's or proposal's page says its card is archived. The API has `POST …/cards/archive` (#289's selection plus `older_days`, with `dry_run`), `GET …/cards?archived=true` and `POST …/cards/{id}/restore`; `oh project card archive N --from Done --older 14d`, `oh project card restore N CARD` and `oh project view N --archived`. Migration 0093 adds `project_cards.archived_ms` and `moved_ms` (when a card joined its column) and `project_columns.archive_after_days`. |
|
| 7 | 8 | - **Board cards show their labels and assignees, and the board filters by them (#293, filters):** an issue's or proposal's card shows its labels (each a link to the board filtered by it) and an issue's assignees; the filter takes `label:NAME`, `assignee:NAME` and `state:STATE` with words, as the issue list does, every term holding. The board reads what its cards show once per repository rather than once per card (a board of 300 cards made 300 queries). The API's cards and `oh project view` carry the labels and assignees too. |
|
| 8 | 9 | ||
| 9 | 10 | ## 0.8.0 (2026-10-25) |
|
| … | 297 unchanged lines |
modified crates/hub-api/src/lib.rs +29 −0
| … | 776 unchanged lines |
||
| 777 | 777 | /// An issue's assignees' usernames. |
|
| 778 | 778 | #[serde(default)] |
|
| 779 | 779 | pub assignees: Vec<String>, |
|
| 780 | + | /// When it was archived (ms), for an archived card (oxygenhub#293). |
|
| 781 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 782 | + | pub archived_ms: Option<i64>, |
|
| 780 | 783 | } |
|
| 781 | 784 | ||
| 782 | 785 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 76 unchanged lines |
||
| 859 | 862 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 860 | 863 | pub struct CardsMoved { |
|
| 861 | 864 | pub moved: Vec<ProjectCard>, |
|
| 865 | + | pub kept: Vec<ProjectCard>, |
|
| 866 | + | pub dry_run: bool, |
|
| 867 | + | } |
|
| 868 | + | ||
| 869 | + | /// `POST /projects/{owner}/{n}/cards/archive` (oxygenhub#293): take the |
|
| 870 | + | /// cards of column `from` off the board, kept and restorable, but those |
|
| 871 | + | /// `except` names and, given `state`, only cards whose issue or proposal is |
|
| 872 | + | /// in one of them; given `older_days`, only cards that joined the column |
|
| 873 | + | /// more than that many days ago. With `dry_run`, nothing is archived. |
|
| 874 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 875 | + | pub struct CardsArchive { |
|
| 876 | + | pub from: String, |
|
| 877 | + | #[serde(default)] |
|
| 878 | + | pub older_days: Option<i64>, |
|
| 879 | + | #[serde(default)] |
|
| 880 | + | pub except: Vec<String>, |
|
| 881 | + | #[serde(default)] |
|
| 882 | + | pub state: Vec<String>, |
|
| 883 | + | #[serde(default)] |
|
| 884 | + | pub dry_run: bool, |
|
| 885 | + | } |
|
| 886 | + | ||
| 887 | + | /// What an archive took off the board, and what it left in the column. |
|
| 888 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 889 | + | pub struct CardsArchived { |
|
| 890 | + | pub archived: Vec<ProjectCard>, |
|
| 862 | 891 | pub kept: Vec<ProjectCard>, |
|
| 863 | 892 | pub dry_run: bool, |
|
| 864 | 893 | } |
|
| … | 600 unchanged lines |
modified crates/hub/src/api/mod.rs +2 −0
| … | 83 unchanged lines |
||
| 84 | 84 | .route("/projects/{owner}/{number}", get(projects::board).patch(projects::edit)) |
|
| 85 | 85 | .route("/projects/{owner}/{number}/cards", get(projects::cards).post(projects::add_card)) |
|
| 86 | 86 | .route("/projects/{owner}/{number}/cards/move", post(projects::move_cards)) |
|
| 87 | + | .route("/projects/{owner}/{number}/cards/archive", post(projects::archive_cards)) |
|
| 88 | + | .route("/projects/{owner}/{number}/cards/{id}/restore", post(projects::restore_card)) |
|
| 87 | 89 | .route("/projects/{owner}/{number}/cards/{id}", axum::routing::patch(projects::move_card).delete(projects::remove_card)) |
|
| 88 | 90 | .route("/device/code", post(device::code).delete(device::cancel)) |
|
| 89 | 91 | .route("/device/token", post(device::token)) |
|
| … | 2857 unchanged lines |
modified crates/hub/src/api/projects.rs +83 −6
| … | 4 unchanged lines |
||
| 5 | 5 | use crate::db::projects as pdb; |
|
| 6 | 6 | use crate::web::projects as wp; |
|
| 7 | 7 | use crate::Shared; |
|
| 8 | - | use axum::extract::{Path, State}; |
|
| 8 | + | use axum::extract::{Path, Query, State}; |
|
| 9 | 9 | use axum::http::{HeaderMap, StatusCode}; |
|
| 10 | 10 | use axum::Json; |
|
| 11 | - | use hub_api::{Board, CardMove, CardsMove, CardsMoved, NewCard, NewProject, Project, ProjectCard, ProjectColumn, ProjectEdit}; |
|
| 11 | + | use hub_api::{ |
|
| 12 | + | Board, CardMove, CardsArchive, CardsArchived, CardsMove, CardsMoved, NewCard, NewProject, Project, ProjectCard, ProjectColumn, |
|
| 13 | + | ProjectEdit, |
|
| 14 | + | }; |
|
| 12 | 15 | ||
| 13 | 16 | fn project_of(app: &Shared, p: &pdb::Project) -> Project { |
|
| 14 | 17 | Project { |
|
| … | 17 unchanged lines |
||
| 32 | 35 | state: v.state.clone(), |
|
| 33 | 36 | labels: v.labels.iter().map(|l| l.name.clone()).collect(), |
|
| 34 | 37 | assignees: v.assignees.clone(), |
|
| 38 | + | archived_ms: v.card.archived_ms, |
|
| 35 | 39 | } |
|
| 36 | 40 | } |
|
| 37 | 41 | ||
| … | 75 unchanged lines |
||
| 113 | 117 | ) -> Result<Json<Board>, ApiError> { |
|
| 114 | 118 | let c = caller(&app, &headers).await?; |
|
| 115 | 119 | let (_, p) = visible(&app, &c, &owner, number).await?; |
|
| 116 | - | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?; |
|
| 120 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 117 | 121 | Ok(Json(Board { |
|
| 118 | 122 | project: project_of(&app, &p), |
|
| 119 | 123 | columns: columns |
|
| … | 41 unchanged lines |
||
| 161 | 165 | ||
| 162 | 166 | /// `GET /projects/{owner}/{n}/cards`: the cards by column, as the board |
|
| 163 | 167 | /// has them. |
|
| 168 | + | #[derive(serde::Deserialize, Default)] |
|
| 169 | + | pub struct CardsQuery { |
|
| 170 | + | /// The archived cards instead (#293). |
|
| 171 | + | #[serde(default)] |
|
| 172 | + | archived: bool, |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | /// `GET /projects/{owner}/{n}/cards`, `?archived=true` for the archived ones. |
|
| 164 | 176 | pub async fn cards( |
|
| 165 | 177 | State(app): State<Shared>, |
|
| 166 | 178 | headers: HeaderMap, |
|
| 167 | 179 | Path((owner, number)): Path<(String, i64)>, |
|
| 180 | + | Query(q): Query<CardsQuery>, |
|
| 168 | 181 | ) -> Result<Json<Vec<ProjectColumn>>, ApiError> { |
|
| 169 | - | Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)) |
|
| 182 | + | if !q.archived { |
|
| 183 | + | return Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)); |
|
| 184 | + | } |
|
| 185 | + | let c = caller(&app, &headers).await?; |
|
| 186 | + | let (_, p) = visible(&app, &c, &owner, number).await?; |
|
| 187 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, true).await?; |
|
| 188 | + | Ok(Json( |
|
| 189 | + | columns |
|
| 190 | + | .iter() |
|
| 191 | + | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
|
| 192 | + | .collect(), |
|
| 193 | + | )) |
|
| 170 | 194 | } |
|
| 171 | 195 | ||
| 172 | 196 | /// `POST /projects/{owner}/{n}/cards`. |
|
| … | 11 unchanged lines |
||
| 184 | 208 | Err(why) => return Err(ApiError(StatusCode::BAD_REQUEST, why)), |
|
| 185 | 209 | }; |
|
| 186 | 210 | // Answer with the card as the board shows it. |
|
| 187 | - | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?; |
|
| 211 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 188 | 212 | let view = columns |
|
| 189 | 213 | .iter() |
|
| 190 | 214 | .flat_map(|col| col.cards.iter()) |
|
| … | 51 unchanged lines |
||
| 242 | 266 | if from.id == to.id { |
|
| 243 | 267 | return Err(ApiError(StatusCode::BAD_REQUEST, "the cards are in that column already".into())); |
|
| 244 | 268 | } |
|
| 245 | - | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p).await?; |
|
| 269 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 246 | 270 | let cards: Vec<ProjectCard> = |
|
| 247 | 271 | columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter().map(card_of)).collect(); |
|
| 248 | 272 | let states: Vec<String> = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); |
|
| … | 7 unchanged lines |
||
| 256 | 280 | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_moved", &format!("{owner}/{number}"), &detail).await.ok(); |
|
| 257 | 281 | } |
|
| 258 | 282 | Ok(Json(CardsMoved { moved, kept, dry_run: body.dry_run })) |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | /// `POST /projects/{owner}/{n}/cards/archive` (#293): a column's cards off |
|
| 286 | + | /// the board at once, kept, with #289's exceptions and state filter and an |
|
| 287 | + | /// age; one audit entry. |
|
| 288 | + | pub async fn archive_cards( |
|
| 289 | + | State(app): State<Shared>, |
|
| 290 | + | headers: HeaderMap, |
|
| 291 | + | Path((owner, number)): Path<(String, i64)>, |
|
| 292 | + | Json(body): Json<CardsArchive>, |
|
| 293 | + | ) -> Result<Json<CardsArchived>, ApiError> { |
|
| 294 | + | let (c, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 295 | + | let from = column(&app, p.id, Some(&body.from)).await?; |
|
| 296 | + | if body.older_days.is_some_and(|d| !(0..=3650).contains(&d)) { |
|
| 297 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "older_days is 0 to 3650".into())); |
|
| 298 | + | } |
|
| 299 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 300 | + | let now = crate::db::now_ms(); |
|
| 301 | + | let states: Vec<String> = body.state.iter().map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).collect(); |
|
| 302 | + | let (archived, kept): (Vec<&wp::CardView>, Vec<&wp::CardView>) = |
|
| 303 | + | columns.iter().filter(|col| col.column.id == from.id).flat_map(|col| col.cards.iter()).partition(|v| { |
|
| 304 | + | let card = card_of(v); |
|
| 305 | + | !body.except.iter().any(|e| names_card(e, &card)) |
|
| 306 | + | && (states.is_empty() || states.iter().any(|s| card.state.eq_ignore_ascii_case(s))) |
|
| 307 | + | && body.older_days.is_none_or(|d| pdb::older_than(&v.card, d, now)) |
|
| 308 | + | }); |
|
| 309 | + | if !body.dry_run && !archived.is_empty() { |
|
| 310 | + | let ids: Vec<i64> = archived.iter().map(|v| v.card.id).collect(); |
|
| 311 | + | pdb::archive_cards(&app.db, p.id, &ids).await?; |
|
| 312 | + | let detail = format!("{} card(s) of {}", ids.len(), from.name); |
|
| 313 | + | crate::db::accounts::audit(&app.db, c.account.as_ref(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); |
|
| 314 | + | } |
|
| 315 | + | let stamp = |v: &wp::CardView| ProjectCard { archived_ms: (!body.dry_run).then_some(now), ..card_of(v) }; |
|
| 316 | + | Ok(Json(CardsArchived { |
|
| 317 | + | archived: archived.into_iter().map(stamp).collect(), |
|
| 318 | + | kept: kept.into_iter().map(card_of).collect(), |
|
| 319 | + | dry_run: body.dry_run, |
|
| 320 | + | })) |
|
| 321 | + | } |
|
| 322 | + | ||
| 323 | + | /// `POST /projects/{owner}/{n}/cards/{id}/restore` (#293): an archived |
|
| 324 | + | /// card back on the board, at the end of its column. |
|
| 325 | + | pub async fn restore_card( |
|
| 326 | + | State(app): State<Shared>, |
|
| 327 | + | headers: HeaderMap, |
|
| 328 | + | Path((owner, number, id)): Path<(String, i64, i64)>, |
|
| 329 | + | ) -> Result<StatusCode, ApiError> { |
|
| 330 | + | let (_, p) = changeable(&app, &headers, &owner, number, false).await?; |
|
| 331 | + | if !pdb::cards(&app.db, p.id).await?.iter().any(|c| c.id == id && c.archived_ms.is_some()) { |
|
| 332 | + | return Err(not_found("no such archived card")); |
|
| 333 | + | } |
|
| 334 | + | pdb::restore_card(&app.db, p.id, id).await?; |
|
| 335 | + | Ok(StatusCode::NO_CONTENT) |
|
| 259 | 336 | } |
|
| 260 | 337 | ||
| 261 | 338 | /// `DELETE /projects/{owner}/{n}/cards/{id}`. |
|
| … | 12 unchanged lines |
modified crates/hub/src/db/projects.rs +96 −7
| … | 21 unchanged lines |
||
| 22 | 22 | pub project_id: i64, |
|
| 23 | 23 | pub name: String, |
|
| 24 | 24 | pub position: i64, |
|
| 25 | + | /// Its cards are archived this many days after they joined it (#293). |
|
| 26 | + | pub archive_after_days: Option<i64>, |
|
| 25 | 27 | } |
|
| 26 | 28 | ||
| 27 | 29 | #[derive(Clone, Debug, FromRow)] |
|
| … | 8 unchanged lines |
||
| 36 | 38 | pub note: String, |
|
| 37 | 39 | pub added_by: String, |
|
| 38 | 40 | pub created_ms: i64, |
|
| 41 | + | /// Off the board since then (#293): kept, and restorable. |
|
| 42 | + | pub archived_ms: Option<i64>, |
|
| 43 | + | /// When it joined its column. |
|
| 44 | + | pub moved_ms: i64, |
|
| 39 | 45 | } |
|
| 40 | 46 | ||
| 47 | + | /// A day, for the archive's ages. |
|
| 48 | + | const DAY_MS: i64 = 86_400_000; |
|
| 49 | + | ||
| 41 | 50 | pub const DEFAULT_COLUMNS: [&str; 3] = ["Backlog", "Doing", "Done"]; |
|
| 42 | 51 | ||
| 43 | 52 | pub async fn create(pool: &Pool, owner: &str, title: &str, description: &str, public: bool) -> sqlx::Result<Project> { |
|
| … | 182 unchanged lines |
||
| 226 | 235 | .fetch_one(pool) |
|
| 227 | 236 | .await?; |
|
| 228 | 237 | let r = sqlx::query( |
|
| 229 | - | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING", |
|
| 238 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING", |
|
| 230 | 239 | ) |
|
| 231 | 240 | .bind(project_id) |
|
| 232 | 241 | .bind(c.column_id) |
|
| … | 24 unchanged lines |
||
| 257 | 266 | .bind(column_id) |
|
| 258 | 267 | .fetch_one(pool) |
|
| 259 | 268 | .await?; |
|
| 260 | - | sqlx::query("UPDATE project_cards SET column_id = $1, position = $2 WHERE id = $3 AND project_id = $4") |
|
| 269 | + | sqlx::query("UPDATE project_cards SET moved_ms = CASE WHEN column_id = $1 THEN moved_ms ELSE $3 END, column_id = $1, position = $2 WHERE id = $4 AND project_id = $5") |
|
| 261 | 270 | .bind(column_id) |
|
| 262 | 271 | .bind(pos) |
|
| 272 | + | .bind(now_ms()) |
|
| 263 | 273 | .bind(card_id) |
|
| 264 | 274 | .bind(project_id) |
|
| 265 | 275 | .execute(pool) |
|
| … | 9 unchanged lines |
||
| 275 | 285 | .bind(column_id) |
|
| 276 | 286 | .fetch_one(&mut *tx) |
|
| 277 | 287 | .await?; |
|
| 288 | + | let now = now_ms(); |
|
| 278 | 289 | for (i, id) in card_ids.iter().enumerate() { |
|
| 279 | - | sqlx::query("UPDATE project_cards SET column_id = $1, position = $2 WHERE id = $3 AND project_id = $4") |
|
| 290 | + | sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5") |
|
| 280 | 291 | .bind(column_id) |
|
| 281 | 292 | .bind(pos + i as i64) |
|
| 293 | + | .bind(now) |
|
| 282 | 294 | .bind(id) |
|
| 283 | 295 | .bind(project_id) |
|
| 284 | 296 | .execute(&mut *tx) |
|
| 285 | 297 | .await?; |
|
| 286 | 298 | } |
|
| 287 | - | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now_ms()).bind(project_id).execute(&mut *tx).await?; |
|
| 299 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?; |
|
| 288 | 300 | tx.commit().await |
|
| 301 | + | } |
|
| 302 | + | ||
| 303 | + | /// Take `card_ids` off the board, kept (#293); how many were on it. |
|
| 304 | + | pub async fn archive_cards(pool: &Pool, project_id: i64, card_ids: &[i64]) -> sqlx::Result<u64> { |
|
| 305 | + | let mut tx = pool.begin().await?; |
|
| 306 | + | let now = now_ms(); |
|
| 307 | + | let mut n = 0; |
|
| 308 | + | for id in card_ids { |
|
| 309 | + | n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL") |
|
| 310 | + | .bind(now) |
|
| 311 | + | .bind(id) |
|
| 312 | + | .bind(project_id) |
|
| 313 | + | .execute(&mut *tx) |
|
| 314 | + | .await? |
|
| 315 | + | .rows_affected(); |
|
| 316 | + | } |
|
| 317 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?; |
|
| 318 | + | tx.commit().await?; |
|
| 319 | + | Ok(n) |
|
| 320 | + | } |
|
| 321 | + | ||
| 322 | + | /// Put an archived card back on the board, at the end of its column, as |
|
| 323 | + | /// if it had just joined it (so its column does not archive it again at |
|
| 324 | + | /// once); `false` when it was not archived. |
|
| 325 | + | pub async fn restore_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<bool> { |
|
| 326 | + | let mut tx = pool.begin().await?; |
|
| 327 | + | let pos: i64 = sqlx::query_scalar( |
|
| 328 | + | "SELECT COALESCE(MAX(position), -1) + 1 FROM project_cards WHERE column_id = (SELECT column_id FROM project_cards WHERE id = $1)", |
|
| 329 | + | ) |
|
| 330 | + | .bind(card_id) |
|
| 331 | + | .fetch_one(&mut *tx) |
|
| 332 | + | .await?; |
|
| 333 | + | let now = now_ms(); |
|
| 334 | + | let r = sqlx::query( |
|
| 335 | + | "UPDATE project_cards SET archived_ms = NULL, moved_ms = $1, position = $2 WHERE id = $3 AND project_id = $4 AND archived_ms IS NOT NULL", |
|
| 336 | + | ) |
|
| 337 | + | .bind(now) |
|
| 338 | + | .bind(pos) |
|
| 339 | + | .bind(card_id) |
|
| 340 | + | .bind(project_id) |
|
| 341 | + | .execute(&mut *tx) |
|
| 342 | + | .await?; |
|
| 343 | + | sqlx::query("UPDATE projects SET updated_ms = $1 WHERE id = $2").bind(now).bind(project_id).execute(&mut *tx).await?; |
|
| 344 | + | tx.commit().await?; |
|
| 345 | + | Ok(r.rows_affected() == 1) |
|
| 346 | + | } |
|
| 347 | + | ||
| 348 | + | /// Whether `card` joined its column more than `days` ago, at `now`. |
|
| 349 | + | pub fn older_than(card: &Card, days: i64, now: i64) -> bool { |
|
| 350 | + | card.moved_ms < now - days * DAY_MS |
|
| 351 | + | } |
|
| 352 | + | ||
| 353 | + | /// Archive a column's cards this many days after they join it, or never. |
|
| 354 | + | pub async fn set_archive_after(pool: &Pool, project_id: i64, column_id: i64, days: Option<i64>) -> sqlx::Result<()> { |
|
| 355 | + | sqlx::query("UPDATE project_columns SET archive_after_days = $1 WHERE id = $2 AND project_id = $3") |
|
| 356 | + | .bind(days) |
|
| 357 | + | .bind(column_id) |
|
| 358 | + | .bind(project_id) |
|
| 359 | + | .execute(pool) |
|
| 360 | + | .await?; |
|
| 361 | + | touch(pool, project_id).await |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | /// Archive, on every board, the cards of columns that archive after some |
|
| 365 | + | /// days which joined them longer ago than that (#293); how many. |
|
| 366 | + | pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> { |
|
| 367 | + | let r = sqlx::query( |
|
| 368 | + | "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \ |
|
| 369 | + | (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NOT NULL \ |
|
| 370 | + | AND project_cards.moved_ms < $1 - c.archive_after_days * $2)", |
|
| 371 | + | ) |
|
| 372 | + | .bind(now) |
|
| 373 | + | .bind(DAY_MS) |
|
| 374 | + | .execute(pool) |
|
| 375 | + | .await?; |
|
| 376 | + | Ok(r.rows_affected()) |
|
| 289 | 377 | } |
|
| 290 | 378 | ||
| 291 | 379 | /// Swap a card with its neighbour in its column (`-1` up, `1` down). |
|
| … | 30 unchanged lines |
||
| 322 | 410 | ||
| 323 | 411 | /// The projects (and column names) an issue or proposal is on. |
|
| 324 | 412 | pub async fn placements(pool: &Pool, repo_id: i64, number: i64) -> sqlx::Result<Vec<(Project, String)>> { |
|
| 325 | - | let rows: Vec<(i64, String)> = sqlx::query_as( |
|
| 326 | - | "SELECT c.project_id, col.name 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'", |
|
| 413 | + | let rows: Vec<(i64, String, Option<i64>)> = sqlx::query_as( |
|
| 414 | + | "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'", |
|
| 327 | 415 | ) |
|
| 328 | 416 | .bind(repo_id) |
|
| 329 | 417 | .bind(number) |
|
| 330 | 418 | .fetch_all(pool) |
|
| 331 | 419 | .await?; |
|
| 332 | 420 | let mut out = Vec::new(); |
|
| 333 | - | for (pid, col) in rows { |
|
| 421 | + | for (pid, col, archived) in rows { |
|
| 422 | + | let col = if archived.is_some() { format!("{col}, archived") } else { col }; |
|
| 334 | 423 | if let Some(p) = sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1").bind(pid).fetch_optional(pool).await? { |
|
| 335 | 424 | out.push((p, col)); |
|
| 336 | 425 | } |
|
| … | 3 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 199 unchanged lines |
||
| 200 | 200 | leader::run(app, "flow.scheduler", flow::scheduler); |
|
| 201 | 201 | leader::run(app, "flow.placement", flow::placement); |
|
| 202 | 202 | leader::run(app, "maintenance.nightly", maintenance::nightly); |
|
| 203 | + | leader::run(app, "projects.archiver", maintenance::archiver); |
|
| 203 | 204 | leader::run(app, "backups.scheduler", backups::scheduler); |
|
| 204 | 205 | // The reverse pin index for repositories pushed before it existed (#148): |
|
| 205 | 206 | // once, by whichever worker leads it first (idempotent if run again). |
|
| … | 221 unchanged lines |
modified crates/hub/src/maintenance.rs +13 −0
| … | 146 unchanged lines |
||
| 147 | 147 | } |
|
| 148 | 148 | } |
|
| 149 | 149 | ||
| 150 | + | /// Each hour, archive the cards of columns that archive after some days |
|
| 151 | + | /// once they have been there longer (#293). |
|
| 152 | + | pub async fn archiver(app: Shared) { |
|
| 153 | + | loop { |
|
| 154 | + | match crate::db::projects::archive_due(&app.db, crate::db::now_ms()).await { |
|
| 155 | + | Ok(n) if n > 0 => tracing::info!("projects: archived {n} card(s) their columns keep no longer"), |
|
| 156 | + | Ok(_) => {} |
|
| 157 | + | Err(e) => tracing::warn!("projects: archiving failed: {e}"), |
|
| 158 | + | } |
|
| 159 | + | tokio::time::sleep(std::time::Duration::from_secs(3600)).await; |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | ||
| 150 | 163 | #[cfg(test)] |
|
| 151 | 164 | mod tests { |
|
| 152 | 165 | #[test] |
|
| … | 5 unchanged lines |
modified crates/hub/src/web/projects.rs +90 −7
| … | 194 unchanged lines |
||
| 195 | 195 | /// those a filter does not match; `total` is how many it holds. |
|
| 196 | 196 | pub hidden: usize, |
|
| 197 | 197 | pub total: usize, |
|
| 198 | + | /// Its archived cards (#293), shown on the archive view. |
|
| 199 | + | pub archived: usize, |
|
| 198 | 200 | } |
|
| 199 | 201 | ||
| 200 | 202 | /// A column longer than this shows its newest cards only, with "show all" |
|
| … | 13 unchanged lines |
||
| 214 | 216 | /// Only cards in this state (`open`, `closed`, `landed`…). |
|
| 215 | 217 | #[serde(default)] |
|
| 216 | 218 | state: Option<String>, |
|
| 219 | + | /// The archived cards instead (`?archived=1`). |
|
| 220 | + | #[serde(default)] |
|
| 221 | + | archived: Option<String>, |
|
| 217 | 222 | } |
|
| 218 | 223 | ||
| 219 | 224 | impl BoardQuery { |
|
| … | 2 unchanged lines |
||
| 222 | 227 | } |
|
| 223 | 228 | fn state(&self) -> String { |
|
| 224 | 229 | self.state.as_deref().map(str::trim).unwrap_or("").to_ascii_lowercase() |
|
| 230 | + | } |
|
| 231 | + | fn archived(&self) -> bool { |
|
| 232 | + | self.archived.as_deref().is_some_and(|a| !a.is_empty() && a != "0") |
|
| 225 | 233 | } |
|
| 226 | 234 | } |
|
| 227 | 235 | ||
| … | 42 unchanged lines |
||
| 270 | 278 | col.total = col.cards.len(); |
|
| 271 | 279 | if !filter.is_empty() { |
|
| 272 | 280 | col.cards.retain(|c| filter.keeps(c)); |
|
| 273 | - | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) { |
|
| 281 | + | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() { |
|
| 274 | 282 | // Cards join a column at its end: the newest are the last. |
|
| 275 | 283 | let cut = col.cards.len() - COLUMN_SHOWN; |
|
| 276 | 284 | col.cards.drain(..cut); |
|
| … | 23 unchanged lines |
||
| 300 | 308 | q: String, |
|
| 301 | 309 | state: String, |
|
| 302 | 310 | filtered: bool, |
|
| 311 | + | /// The archive view: archived cards, each restorable (#293). |
|
| 312 | + | archived: bool, |
|
| 313 | + | /// Archived cards on the whole board. |
|
| 314 | + | archived_total: usize, |
|
| 303 | 315 | } |
|
| 304 | 316 | ||
| 305 | 317 | #[allow(clippy::too_many_arguments)] |
|
| … | 14 unchanged lines |
||
| 320 | 332 | .map_err(e)? |
|
| 321 | 333 | .filter(|p| p.public || r.member) |
|
| 322 | 334 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 323 | - | let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?; |
|
| 335 | + | let archived = view.archived(); |
|
| 336 | + | let (mut columns, repos) = board_columns(app, viewer.account(), owner, &project, archived).await.map_err(e)?; |
|
| 324 | 337 | shape(&mut columns, view); |
|
| 338 | + | let archived_total = columns.iter().map(|c| c.archived).sum(); |
|
| 325 | 339 | let can_edit = r.edit && !project.closed; |
|
| 326 | 340 | let mut transfer_to = Vec::new(); |
|
| 327 | 341 | if let (true, Some(a)) = (r.edit, viewer.account()) { |
|
| … | 4 unchanged lines |
||
| 332 | 346 | } |
|
| 333 | 347 | let (q, state) = (view.text(), view.state()); |
|
| 334 | 348 | let filtered = !q.is_empty() || !state.is_empty(); |
|
| 335 | - | Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to, q, state, filtered })) |
|
| 349 | + | Ok(Page(BoardPage { |
|
| 350 | + | shell, |
|
| 351 | + | project, |
|
| 352 | + | columns, |
|
| 353 | + | repos, |
|
| 354 | + | can_edit, |
|
| 355 | + | can_manage: r.edit, |
|
| 356 | + | notice, |
|
| 357 | + | error, |
|
| 358 | + | transfer_to, |
|
| 359 | + | q, |
|
| 360 | + | state, |
|
| 361 | + | filtered, |
|
| 362 | + | archived, |
|
| 363 | + | archived_total, |
|
| 364 | + | })) |
|
| 336 | 365 | } |
|
| 337 | 366 | ||
| 338 | 367 | /// A project's columns with the cards `account` may see, and the owner's |
|
| 339 | - | /// repositories it can read (for adding cards). |
|
| 368 | + | /// repositories it can read (for adding cards). `archived`: the archived |
|
| 369 | + | /// cards instead of those on the board (#293); each column counts its |
|
| 370 | + | /// archived cards either way. |
|
| 340 | 371 | pub async fn board_columns( |
|
| 341 | 372 | app: &Shared, |
|
| 342 | 373 | account: Option<&db::accounts::Account>, |
|
| 343 | 374 | owner: &str, |
|
| 344 | 375 | project: &pdb::Project, |
|
| 376 | + | archived: bool, |
|
| 345 | 377 | ) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> { |
|
| 346 | 378 | let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default(); |
|
| 347 | 379 | for row in db::repos::list_by_owner(&app.db, owner).await? { |
|
| … | 2 unchanged lines |
||
| 350 | 382 | readable.insert(row.id, row); |
|
| 351 | 383 | } |
|
| 352 | 384 | } |
|
| 353 | - | let cards = pdb::cards(&app.db, project.id).await?; |
|
| 385 | + | let all = pdb::cards(&app.db, project.id).await?; |
|
| 386 | + | let archived_in = |column: i64| all.iter().filter(|c| c.column_id == column && c.archived_ms.is_some()).count(); |
|
| 387 | + | let cards: Vec<&pdb::Card> = all.iter().filter(|c| c.archived_ms.is_some() == archived).collect(); |
|
| 354 | 388 | // What the cards show, read once per repository, not per card: a |
|
| 355 | 389 | // board of 300 cards made 300 queries. |
|
| 356 | 390 | let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default(); |
|
| … | 7 unchanged lines |
||
| 364 | 398 | let mut columns = Vec::new(); |
|
| 365 | 399 | for column in pdb::columns(&app.db, project.id).await? { |
|
| 366 | 400 | let mut views = Vec::new(); |
|
| 367 | - | for card in cards.iter().filter(|c| c.column_id == column.id) { |
|
| 401 | + | for card in cards.iter().copied().filter(|c| c.column_id == column.id) { |
|
| 368 | 402 | match (card.kind.as_str(), card.repo_id, card.number) { |
|
| 369 | 403 | ("note", _, _) => views.push(CardView { |
|
| 370 | 404 | card: card.clone(), |
|
| … | 23 unchanged lines |
||
| 394 | 428 | } |
|
| 395 | 429 | } |
|
| 396 | 430 | let total = views.len(); |
|
| 397 | - | columns.push(ColumnView { column, cards: views, hidden: 0, total }); |
|
| 431 | + | let archived = archived_in(column.id); |
|
| 432 | + | columns.push(ColumnView { column, cards: views, hidden: 0, total, archived }); |
|
| 398 | 433 | } |
|
| 399 | 434 | let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
|
| 400 | 435 | repos.sort(); |
|
| … | 86 unchanged lines |
||
| 487 | 522 | /// The owner to transfer the project to (#125). |
|
| 488 | 523 | #[serde(default)] |
|
| 489 | 524 | to: String, |
|
| 525 | + | /// Days, for archiving a column's older cards (#293); empty is never. |
|
| 526 | + | #[serde(default)] |
|
| 527 | + | days: String, |
|
| 490 | 528 | } |
|
| 491 | 529 | ||
| 492 | 530 | /// `repo#12` or `#12` (with a repository chosen) to (repository, number). |
|
| … | 121 unchanged lines |
||
| 614 | 652 | "remove" => { |
|
| 615 | 653 | if let Some(card) = f.card { |
|
| 616 | 654 | pdb::remove_card(&app.db, project.id, card).await.map_err(e)?; |
|
| 655 | + | } |
|
| 656 | + | } |
|
| 657 | + | "archive" => { |
|
| 658 | + | if let Some(card) = f.card { |
|
| 659 | + | pdb::archive_cards(&app.db, project.id, &[card]).await.map_err(e)?; |
|
| 660 | + | } |
|
| 661 | + | } |
|
| 662 | + | "restore" => { |
|
| 663 | + | if let Some(card) = f.card { |
|
| 664 | + | pdb::restore_card(&app.db, project.id, card).await.map_err(e)?; |
|
| 665 | + | } |
|
| 666 | + | return Ok(Redirect::to(&format!("/projects/{owner}/{number}?archived=1")).into_response()); |
|
| 667 | + | } |
|
| 668 | + | "archive_older" | "archive_after" => { |
|
| 669 | + | let Some(col) = f.column else { return Err(Error::bad(shell, "no column")) }; |
|
| 670 | + | let days = match f.days.trim() { |
|
| 671 | + | "" if f.action == "archive_after" => None, |
|
| 672 | + | d => match d.parse::<i64>() { |
|
| 673 | + | Ok(n) if (0..=3650).contains(&n) => Some(n), |
|
| 674 | + | _ => { |
|
| 675 | + | error = Some("Give a number of days, 0 to 3650.".into()); |
|
| 676 | + | None |
|
| 677 | + | } |
|
| 678 | + | }, |
|
| 679 | + | }; |
|
| 680 | + | let name = columns.iter().find(|c| c.id == col).map(|c| c.name.clone()).unwrap_or_default(); |
|
| 681 | + | if error.is_none() && f.action == "archive_after" { |
|
| 682 | + | pdb::set_archive_after(&app.db, project.id, col, days).await.map_err(e)?; |
|
| 683 | + | notice = Some(match days { |
|
| 684 | + | Some(n) => format!("{name} archives its cards {n} day{} after they reach it.", if n == 1 { "" } else { "s" }), |
|
| 685 | + | None => format!("{name} keeps its cards until you archive them."), |
|
| 686 | + | }); |
|
| 687 | + | } else if let (None, Some(days)) = (&error, days) { |
|
| 688 | + | let now = db::now_ms(); |
|
| 689 | + | let ids: Vec<i64> = pdb::cards(&app.db, project.id) |
|
| 690 | + | .await |
|
| 691 | + | .map_err(e)? |
|
| 692 | + | .into_iter() |
|
| 693 | + | .filter(|c| c.column_id == col && c.archived_ms.is_none() && pdb::older_than(c, days, now)) |
|
| 694 | + | .map(|c| c.id) |
|
| 695 | + | .collect(); |
|
| 696 | + | let n = pdb::archive_cards(&app.db, project.id, &ids).await.map_err(e)?; |
|
| 697 | + | let detail = format!("{n} card(s) of {name} older than {days} days"); |
|
| 698 | + | db::accounts::audit(&app.db, viewer.account(), "project.cards_archived", &format!("{owner}/{number}"), &detail).await.ok(); |
|
| 699 | + | notice = Some(format!("Archived {n} card{} from {name}.", if n == 1 { "" } else { "s" })); |
|
| 617 | 700 | } |
|
| 618 | 701 | } |
|
| 619 | 702 | "add_column" => { |
|
| … | 199 unchanged lines |
modified crates/hub/tests/api.rs +26 −0
| … | 989 unchanged lines |
||
| 990 | 990 | StatusCode::FORBIDDEN |
|
| 991 | 991 | ); |
|
| 992 | 992 | ||
| 993 | + | // Archive (#293): Done's cards there more than a week (#3, made old), |
|
| 994 | + | // listed with ?archived=true, restored once. |
|
| 995 | + | sqlx::query("UPDATE project_cards SET moved_ms = 0 WHERE number = 3").execute(&app.db).await.unwrap(); |
|
| 996 | + | let archive = |body: &'static str| api(app, "POST", "/api/v1/projects/andy/1/cards/archive", Some(&a), Some(body)); |
|
| 997 | + | let (st, v) = archive(r#"{"from":"Done","older_days":7,"dry_run":true}"#).await; |
|
| 998 | + | assert_eq!((st, refs(&v, "archived")), (StatusCode::OK, vec!["andy/demo#3".to_string()]), "{v}"); |
|
| 999 | + | assert!(v["archived"][0].get("archived_ms").is_none(), "a dry run archives nothing: {v}"); |
|
| 1000 | + | let (_, v) = archive(r#"{"from":"Done","older_days":7}"#).await; |
|
| 1001 | + | assert_eq!((refs(&v, "archived"), refs(&v, "kept").len()), (vec!["andy/demo#3".to_string()], 1), "{v}"); |
|
| 1002 | + | assert!(v["archived"][0]["archived_ms"].as_i64().is_some()); |
|
| 1003 | + | let (_, board) = api(app, "GET", "/api/v1/projects/andy/1", None, None).await; |
|
| 1004 | + | assert_eq!(board["columns"][2]["cards"].as_array().unwrap().len(), 1, "off the board: {board}"); |
|
| 1005 | + | let (st, archived) = api(app, "GET", "/api/v1/projects/andy/1/cards?archived=true", None, None).await; |
|
| 1006 | + | assert_eq!((st, archived[2]["cards"][0]["reference"].as_str()), (StatusCode::OK, Some("andy/demo#3")), "{archived}"); |
|
| 1007 | + | let card = archived[2]["cards"][0]["id"].as_i64().unwrap(); |
|
| 1008 | + | let restore = format!("/api/v1/projects/andy/1/cards/{card}/restore"); |
|
| 1009 | + | assert_eq!(api(app, "POST", &restore, Some(&b), None).await.0, StatusCode::FORBIDDEN); |
|
| 1010 | + | assert_eq!(api(app, "POST", &restore, Some(&a), None).await.0, StatusCode::NO_CONTENT); |
|
| 1011 | + | assert_eq!(api(app, "POST", &restore, Some(&a), None).await.0, StatusCode::NOT_FOUND, "not archived now"); |
|
| 1012 | + | let (_, board) = api(app, "GET", "/api/v1/projects/andy/1", None, None).await; |
|
| 1013 | + | assert_eq!(board["columns"][2]["cards"].as_array().unwrap().len(), 2); |
|
| 1014 | + | assert_eq!(archive(r#"{"from":"Done","older_days":-1}"#).await.0, StatusCode::BAD_REQUEST); |
|
| 1015 | + | let audited: i64 = |
|
| 1016 | + | sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'project.cards_archived'").fetch_one(&app.db).await.unwrap(); |
|
| 1017 | + | assert_eq!(audited, 1); |
|
| 1018 | + | ||
| 993 | 1019 | // Private: hidden from others; closed: read-only; then remove a card. |
|
| 994 | 1020 | let (_, edited) = api(app, "PATCH", "/api/v1/projects/andy/1", Some(&a), Some(r#"{"private":true}"#)).await; |
|
| 995 | 1021 | assert_eq!(edited["public"], false); |
|
| … | 747 unchanged lines |
modified crates/hub/tests/issues.rs +75 −0
| … | 1082 unchanged lines |
||
| 1083 | 1083 | assert!(closed.contains("0 of 3"), "{closed}"); |
|
| 1084 | 1084 | } |
|
| 1085 | 1085 | ||
| 1086 | + | /// #293: a card is archived off the board, kept and found on the archive |
|
| 1087 | + | /// view, and restored; a column archives its cards older than some days |
|
| 1088 | + | /// at once, or on its own once set to; an issue's page says its card is |
|
| 1089 | + | /// archived. |
|
| 1086 | 1090 | #[tokio::test(flavor = "multi_thread")] |
|
| 1091 | + | async fn board_cards_are_archived_and_restored() { |
|
| 1092 | + | let h = hub().await; |
|
| 1093 | + | let app = &h.app; |
|
| 1094 | + | let andy = register(app, "andy").await; |
|
| 1095 | + | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 1096 | + | post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await; |
|
| 1097 | + | let (project, done): (i64, i64) = |
|
| 1098 | + | sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id WHERE c.name = 'Done'") |
|
| 1099 | + | .fetch_one(&app.db) |
|
| 1100 | + | .await |
|
| 1101 | + | .unwrap(); |
|
| 1102 | + | let day = 86_400_000i64; |
|
| 1103 | + | let now = db::now_ms(); |
|
| 1104 | + | for (i, age) in [40, 20, 10, 1].iter().enumerate() { |
|
| 1105 | + | sqlx::query( |
|
| 1106 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, note, created_ms, moved_ms) VALUES ($1, $2, $3, 'note', $4, 0, $5)", |
|
| 1107 | + | ) |
|
| 1108 | + | .bind(project) |
|
| 1109 | + | .bind(done) |
|
| 1110 | + | .bind(i as i64) |
|
| 1111 | + | .bind(format!("aged-{age}")) |
|
| 1112 | + | .bind(now - age * day) |
|
| 1113 | + | .execute(&app.db) |
|
| 1114 | + | .await |
|
| 1115 | + | .unwrap(); |
|
| 1116 | + | } |
|
| 1117 | + | let id_of = |note: &'static str| async move { |
|
| 1118 | + | sqlx::query_scalar::<_, i64>("SELECT id FROM project_cards WHERE note = $1").bind(note).fetch_one(&app.db).await.unwrap() |
|
| 1119 | + | }; |
|
| 1120 | + | let board = "/projects/andy/1"; |
|
| 1121 | + | let (who, token) = (&andy, &csrf); |
|
| 1122 | + | let act = |body: String| async move { post(app, board, Some(who), &format!("_csrf={token}&{body}")).await }; |
|
| 1123 | + | ||
| 1124 | + | // One card by hand: off the board, on the archive view, restorable. |
|
| 1125 | + | let forty = id_of("aged-40").await; |
|
| 1126 | + | act(format!("action=archive&card={forty}")).await; |
|
| 1127 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1128 | + | assert!(!page.contains("aged-40") && page.contains("aged-20"), "{page}"); |
|
| 1129 | + | assert!(page.contains("?archived=1\">1 archived card</a>"), "{page}"); |
|
| 1130 | + | let archived = get(app, &format!("{board}?archived=1"), Some(&andy)).await.body; |
|
| 1131 | + | assert!(archived.contains("aged-40") && !archived.contains("aged-20"), "{archived}"); |
|
| 1132 | + | assert!(archived.contains("value=\"restore\"") && archived.contains("Back to the board")); |
|
| 1133 | + | assert!(!archived.contains("add a card"), "the archive view adds nothing"); |
|
| 1134 | + | let found = get(app, &format!("{board}?archived=1&q=aged-40"), Some(&andy)).await.body; |
|
| 1135 | + | assert!(found.contains("1 of 1") && found.contains("name=\"archived\" value=\"1\""), "{found}"); |
|
| 1136 | + | act(format!("action=restore&card={forty}")).await; |
|
| 1137 | + | assert!(get(app, board, Some(&andy)).await.body.contains("aged-40")); |
|
| 1138 | + | ||
| 1139 | + | // The column's cards there more than 15 days, at once: 40 (just |
|
| 1140 | + | // restored, so it joined again now) stays, 20 goes. |
|
| 1141 | + | let r = act(format!("action=archive_older&column={done}&days=15")).await; |
|
| 1142 | + | assert!(r.body.contains("Archived 1 card from Done."), "{}", r.body); |
|
| 1143 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1144 | + | assert!(page.contains("aged-40") && !page.contains("aged-20") && page.contains("aged-10")); |
|
| 1145 | + | let r = act(format!("action=archive_older&column={done}&days=soon")).await; |
|
| 1146 | + | assert!(r.body.contains("Give a number of days"), "{}", r.body); |
|
| 1147 | + | ||
| 1148 | + | // On its own: Done after 5 days; the hourly pass takes aged-10 only. |
|
| 1149 | + | let r = act(format!("action=archive_after&column={done}&days=5")).await; |
|
| 1150 | + | assert!(r.body.contains("Done archives its cards 5 days after they reach it."), "{}", r.body); |
|
| 1151 | + | assert!(get(app, board, Some(&andy)).await.body.contains("archives cards 5 days after they reach it")); |
|
| 1152 | + | assert_eq!(db::projects::archive_due(&app.db, db::now_ms()).await.unwrap(), 1); |
|
| 1153 | + | let page = get(app, board, Some(&andy)).await.body; |
|
| 1154 | + | assert!(page.contains("aged-40") && !page.contains("aged-10") && page.contains("aged-1<"), "{page}"); |
|
| 1155 | + | assert!(page.contains("2 archived cards")); |
|
| 1156 | + | let r = act(format!("action=archive_after&column={done}&days=")).await; |
|
| 1157 | + | assert!(r.body.contains("Done keeps its cards until you archive them.")); |
|
| 1158 | + | assert_eq!(db::projects::archive_due(&app.db, db::now_ms() + 100 * day).await.unwrap(), 0, "never, now"); |
|
| 1159 | + | } |
|
| 1160 | + | ||
| 1161 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1087 | 1162 | async fn issue_markdown_follows_the_default_bookmark() { |
|
| 1088 | 1163 | let h = hub().await; |
|
| 1089 | 1164 | let app = &h.app; |
|
| … | 242 unchanged lines |
modified crates/hub/tests/roles.rs +3 −2
| … | 11 unchanged lines |
||
| 12 | 12 | use tower::ServiceExt; |
|
| 13 | 13 | ||
| 14 | 14 | /// The worker's singleton tasks that run for as long as the process does. |
|
| 15 | - | const WORKER_SINGLETONS: [&str; 8] = [ |
|
| 15 | + | const WORKER_SINGLETONS: [&str; 9] = [ |
|
| 16 | 16 | "backups.scheduler", |
|
| 17 | 17 | "diffusion.reconciler", |
|
| 18 | 18 | "flow.placement", |
|
| … | 1 unchanged lines |
||
| 20 | 20 | "flow.scheduler", |
|
| 21 | 21 | "hooks.worker", |
|
| 22 | 22 | "maintenance.nightly", |
|
| 23 | + | "projects.archiver", |
|
| 23 | 24 | "secrets.history_scan", |
|
| 24 | 25 | ]; |
|
| 25 | 26 | ||
| … | 161 unchanged lines |
||
| 187 | 188 | hermetic(); |
|
| 188 | 189 | let dir = tempfile::tempdir().unwrap(); |
|
| 189 | 190 | let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap(); |
|
| 190 | - | assert_eq!(app.leader.tasks().len(), 12, "{:?}", app.leader.tasks()); |
|
| 191 | + | assert_eq!(app.leader.tasks().len(), 13, "{:?}", app.leader.tasks()); |
|
| 191 | 192 | assert_eq!(health(&app).await["roles"], serde_json::json!(["web", "worker"])); |
|
| 192 | 193 | assert_eq!(get(&app, "/explore", None).await.0, StatusCode::OK); |
|
| 193 | 194 | let mut bad = config(dir.path(), &["web", "cron"]); |
|
| … | 57 unchanged lines |
modified crates/oh/src/main.rs +60 −5
| … | 428 unchanged lines |
||
| 429 | 429 | /// The owner's boards |
|
| 430 | 430 | List, |
|
| 431 | 431 | /// One board: its columns and cards |
|
| 432 | - | View { number: i64 }, |
|
| 432 | + | View { |
|
| 433 | + | number: i64, |
|
| 434 | + | /// Its archived cards instead (#293) |
|
| 435 | + | #[arg(long)] |
|
| 436 | + | archived: bool, |
|
| 437 | + | }, |
|
| 433 | 438 | /// Create a board (owner, or an organisation's members) |
|
| 434 | 439 | Create { |
|
| 435 | 440 | title: String, |
|
| … | 15 unchanged lines |
||
| 451 | 456 | #[arg(long)] |
|
| 452 | 457 | to: String, |
|
| 453 | 458 | }, |
|
| 454 | - | /// Cards on a board: add, move, remove |
|
| 459 | + | /// Cards on a board: add, move, archive, restore, remove |
|
| 455 | 460 | Card { |
|
| 456 | 461 | #[command(subcommand)] |
|
| 457 | 462 | cmd: CardCmd, |
|
| … | 35 unchanged lines |
||
| 493 | 498 | state: Vec<String>, |
|
| 494 | 499 | /// With --from: say what would move, and move nothing |
|
| 495 | 500 | #[arg(long, requires = "from")] |
|
| 501 | + | dry_run: bool, |
|
| 502 | + | }, |
|
| 503 | + | /// Archive a column's cards: off the board, kept and restorable (#293) |
|
| 504 | + | Archive { |
|
| 505 | + | number: i64, |
|
| 506 | + | /// The column, by name or id |
|
| 507 | + | #[arg(long)] |
|
| 508 | + | from: String, |
|
| 509 | + | /// Only cards that joined the column more than this long ago (14d, or 14) |
|
| 510 | + | #[arg(long)] |
|
| 511 | + | older: Option<String>, |
|
| 512 | + | /// Leave these on the board (card ids, or 12, #12, name#12, owner/name#12), comma-separated |
|
| 513 | + | #[arg(long, value_delimiter = ',')] |
|
| 514 | + | except: Vec<String>, |
|
| 515 | + | /// Only cards whose issue or proposal is in one of these states (closed, landed…), comma-separated |
|
| 516 | + | #[arg(long, value_delimiter = ',')] |
|
| 517 | + | state: Vec<String>, |
|
| 518 | + | /// Say what would be archived, and archive nothing |
|
| 519 | + | #[arg(long)] |
|
| 496 | 520 | dry_run: bool, |
|
| 497 | 521 | }, |
|
| 522 | + | /// Put an archived card back at the end of its column |
|
| 523 | + | Restore { number: i64, card: i64 }, |
|
| 498 | 524 | /// Take a card off a board |
|
| 499 | 525 | Remove { number: i64, card: i64 }, |
|
| 500 | 526 | } |
|
| … | 1228 unchanged lines |
||
| 1729 | 1755 | println!("{}", project_line(p)); |
|
| 1730 | 1756 | } |
|
| 1731 | 1757 | } |
|
| 1732 | - | ProjectCmd::View { number } => { |
|
| 1758 | + | ProjectCmd::View { number, archived } => { |
|
| 1733 | 1759 | let path = format!("{base}/{number}"); |
|
| 1760 | + | let cards = format!("{path}/cards?archived=true"); |
|
| 1734 | 1761 | if json { |
|
| 1735 | - | println!("{}", c.get_text(&path)?); |
|
| 1762 | + | println!("{}", c.get_text(if archived { &cards } else { &path })?); |
|
| 1736 | 1763 | return Ok(0); |
|
| 1737 | 1764 | } |
|
| 1738 | - | let b: hub_api::Board = c.get(&path)?; |
|
| 1765 | + | let mut b: hub_api::Board = c.get(&path)?; |
|
| 1766 | + | if archived { |
|
| 1767 | + | b.columns = c.get(&cards)?; |
|
| 1768 | + | } |
|
| 1739 | 1769 | println!("{}", project_line(&b.project)); |
|
| 1740 | 1770 | if !b.project.description.is_empty() { |
|
| 1741 | 1771 | println!("{}", b.project.description); |
|
| … | 88 unchanged lines |
||
| 1830 | 1860 | } |
|
| 1831 | 1861 | _ => bail!("name a card, or --from COLUMN to move every card of a column"), |
|
| 1832 | 1862 | }, |
|
| 1863 | + | ProjectCmd::Card { cmd: CardCmd::Archive { number, from, older, except, state, dry_run } } => { |
|
| 1864 | + | let older_days = match older.as_deref().map(|o| o.trim().trim_end_matches('d')) { |
|
| 1865 | + | None => None, |
|
| 1866 | + | Some(d) => Some(d.parse::<i64>().map_err(|_| anyhow::anyhow!("--older takes days: 14d, or 14"))?), |
|
| 1867 | + | }; |
|
| 1868 | + | let r: hub_api::CardsArchived = c.send( |
|
| 1869 | + | "POST", |
|
| 1870 | + | &format!("{base}/{number}/cards/archive"), |
|
| 1871 | + | &hub_api::CardsArchive { from: from.clone(), older_days, except, state, dry_run }, |
|
| 1872 | + | )?; |
|
| 1873 | + | if json { |
|
| 1874 | + | println!("{}", serde_json::to_string(&r)?); |
|
| 1875 | + | return Ok(0); |
|
| 1876 | + | } |
|
| 1877 | + | let verb = if dry_run { "would archive" } else { "archived" }; |
|
| 1878 | + | for a in &r.archived { |
|
| 1879 | + | let name = if a.reference.is_empty() { a.title.clone() } else { a.reference.clone() }; |
|
| 1880 | + | println!(" {verb} {name} {}", ui::dim(&a.title)); |
|
| 1881 | + | } |
|
| 1882 | + | println!("{} {verb} {} card(s) from {from}; {} left on the board", ui::ok("✔"), r.archived.len(), r.kept.len()); |
|
| 1883 | + | } |
|
| 1884 | + | ProjectCmd::Card { cmd: CardCmd::Restore { number, card } } => { |
|
| 1885 | + | c.send_text("POST", &format!("{base}/{number}/cards/{card}/restore"), &serde_json::json!({}))?; |
|
| 1886 | + | println!("{} card {card} restored", ui::ok("✔")); |
|
| 1887 | + | } |
|
| 1833 | 1888 | ProjectCmd::Card { cmd: CardCmd::Remove { number, card } } => { |
|
| 1834 | 1889 | c.send_text("DELETE", &format!("{base}/{number}/cards/{card}"), &serde_json::json!({}))?; |
|
| 1835 | 1890 | println!("{} card {card} removed", ui::ok("✔")); |
|
| … | 230 unchanged lines |
modified docs/roles.md +2 −2
| … | 108 unchanged lines |
||
| 109 | 109 | ||
| 110 | 110 | The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`, |
|
| 111 | 111 | `flow.placement`, `maintenance.nightly`, `backups.scheduler`, |
|
| 112 | - | `diffusion.backfill`, `diffusion.reconciler`, `secrets.history_scan` |
|
| 113 | - | (worker role) and `tlog.checkpointer` (web role, below). |
|
| 112 | + | `diffusion.backfill`, `diffusion.reconciler`, `secrets.history_scan`, |
|
| 113 | + | `projects.archiver` (worker role) and `tlog.checkpointer` (web role, below). |
|
| 114 | 114 | `images.learn_archs` runs in every worker, without a lock. |
|
| 115 | 115 | ||
| 116 | 116 | `/health` says what the process does and which locks it holds: |
|
| … | 147 unchanged lines |
modified docs/spec.md +3 −1
| … | 451 unchanged lines |
||
| 452 | 452 | - 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. |
|
| 453 | 453 | - 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. |
|
| 454 | 454 | - Columns can be added, renamed, moved and removed (removing one moves its cards to the first column). Closing a project keeps it read-only. |
|
| 455 | + | - Long boards (oxygenhub#293): a column of more than 30 cards shows its newest 30, with "show all". A card shows its issue's or proposal's labels and an issue's assignees, and the filter above the board takes words (title or reference) with `label:NAME`, `assignee:NAME` and `state:STATE`, every term holding, as the issue list does. |
|
| 456 | + | - Archive (oxygenhub#293): a card can be archived, off the board but kept, found on the board's archive view (`?archived=1`, filtered the same way) and restored to the end of its column. A column archives its cards that joined it more than N days ago at once, or does so on its own once set to (Done after 14 days, say; checked hourly). An issue's or proposal's page says when its card is archived. |
|
| 455 | 457 | ||
| 456 | 458 | ## 9. Releases and tags |
|
| 457 | 459 | ||
| … | 32 unchanged lines |
||
| 490 | 492 | ||
| 491 | 493 | ### 12.1 API |
|
| 492 | 494 | ||
| 493 | - | 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` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/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, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `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=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/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. |
|
| 495 | + | 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` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/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, `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `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=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/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; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/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}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/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. |
|
| 494 | 496 | ||
| 495 | 497 | ### 12.2 Webhooks |
|
| 496 | 498 | ||
| … | 396 unchanged lines |
modified templates/project.html +22 −8
| … | 5 unchanged lines |
||
| 6 | 6 | {% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div> |
|
| 7 | 7 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | 8 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | - | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search"> |
|
| 9 | + | {% if archived %}<p class="note board-archive-note">Archived cards: off the board, kept and searchable{% if can_edit %}; restore one to put it back at the end of its column{% endif %}. <a href="/projects/{{ project.owner }}/{{ project.number }}">Back to the board</a></p>{% endif %} |
|
| 10 | + | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">{% if archived %}<input type="hidden" name="archived" value="1">{% endif %} |
|
| 10 | 11 | <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, label:bug, assignee:andy" title="Words in the title or reference; label:NAME, assignee:NAME and state:STATE as on the issue list"> |
|
| 11 | 12 | <label class="visually-hidden" for="board-state">State</label><select id="board-state" name="state"><option value="">any state</option>{% for s in ["open", "closed", "landed", "draft"] %}<option{% if state == *s %} selected{% endif %}>{{ s }}</option>{% endfor %}</select> |
|
| 12 | - | <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}">clear</a>{% endif %} |
|
| 13 | + | <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}{% if archived %}?archived=1{% endif %}">clear</a>{% endif %} |
|
| 14 | + | {% if !archived && archived_total > 0 %}<a class="small board-archived-link" href="/projects/{{ project.owner }}/{{ project.number }}?archived=1">{{ archived_total }} archived card{% if archived_total != 1 %}s{% endif %}</a>{% endif %} |
|
| 13 | 15 | </form> |
|
| 14 | 16 | <div class="board" role="list" aria-label="Columns"> |
|
| 15 | 17 | {% for col in columns %} |
|
| 16 | 18 | <section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}"> |
|
| 17 | 19 | <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{% if filtered %}{{ col.cards.len() }} of {{ col.total }}{% else %}{{ col.total }}{% endif %}</span></h2> |
|
| 18 | - | {% if can_edit %}<form class="tiny" 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 }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header> |
|
| 19 | - | {% if col.hidden > 0 && !filtered %}<p class="small mute col-hidden">{{ col.hidden }} older card{% if col.hidden != 1 %}s{% endif %} not shown · <a href="/projects/{{ project.owner }}/{{ project.number }}?all={{ col.column.id }}#col-{{ col.column.id }}">show all {{ col.total }}</a></p>{% endif %} |
|
| 20 | + | {% if can_edit && !archived %}<form class="tiny" 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 }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header> |
|
| 21 | + | {% if let Some(d) = col.column.archive_after_days %}{% if !archived %}<p class="small mute col-archives">archives cards {{ d }} day{% if *d != 1 %}s{% endif %} after they reach it</p>{% endif %}{% endif %} |
|
| 22 | + | {% if col.hidden > 0 && !filtered && !archived %}<p class="small mute col-hidden">{{ col.hidden }} older card{% if col.hidden != 1 %}s{% endif %} not shown · <a href="/projects/{{ project.owner }}/{{ project.number }}?all={{ col.column.id }}#col-{{ col.column.id }}">show all {{ col.total }}</a></p>{% endif %} |
|
| 20 | 23 | <ul class="cards"> |
|
| 21 | 24 | {% for c in col.cards %} |
|
| 22 | 25 | <li class="card-item{% if c.card.kind == "note" %} note{% endif %}"> |
|
| … | 1 unchanged lines |
||
| 24 | 27 | {% else %}<a href="{{ c.url }}">{{ c.title }}</a> |
|
| 25 | 28 | <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span>{% for a in c.assignees %} · <a class="card-who" href="/projects/{{ project.owner }}/{{ project.number }}?q=assignee:{{ a|urlencode }}" title="Cards assigned to {{ a }}">@{{ a }}</a>{% endfor %}</p> |
|
| 26 | 29 | {% if !c.labels.is_empty() %}<p class="card-labels">{% for l in c.labels %}<a class="label" style="--c: {{ l.colour }}" href="/projects/{{ project.owner }}/{{ project.number }}?q=label:{{ l.name|urlencode }}" title="{% if l.description.is_empty() %}Cards labelled {{ l.name }}{% else %}{{ l.description }}{% endif %}">{{ l.name }}</a>{% endfor %}</p>{% endif %}{% endif %} |
|
| 27 | - | {% if can_edit %} |
|
| 30 | + | {% if let Some(t) = c.card.archived_ms %}<p class="small mute">archived <time title="{{ t|when_i }}">{{ t|ago_i }}</time></p>{% endif %} |
|
| 31 | + | {% if can_edit && archived %} |
|
| 32 | + | <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"><button name="action" value="restore">restore</button></form> |
|
| 33 | + | {% else if can_edit %} |
|
| 28 | 34 | <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"> |
|
| 29 | 35 | <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label> |
|
| 30 | 36 | <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == col.column.id %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select> |
|
| 31 | - | <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="remove" aria-label="Remove from the project">✕</button> |
|
| 37 | + | <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="archive" title="Archive: off the board, kept and restorable">archive</button><button name="action" value="remove" aria-label="Remove from the project" title="Remove from the project">✕</button> |
|
| 32 | 38 | </form>{% endif %} |
|
| 33 | 39 | </li> |
|
| 34 | 40 | {% endfor %} |
|
| 35 | 41 | </ul> |
|
| 36 | - | {% if can_edit %} |
|
| 42 | + | {% if can_edit && !archived %} |
|
| 37 | 43 | <details class="add-card"><summary class="small">add a card</summary> |
|
| 38 | 44 | <form 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="add"> |
|
| 39 | 45 | <label for="ref-{{ col.column.id }}">Issue or proposal</label> |
|
| … | 6 unchanged lines |
||
| 46 | 52 | <form 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 }}"> |
|
| 47 | 53 | <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" data-confirm="Remove this column? Its cards move to the first column.">REMOVE</button> |
|
| 48 | 54 | </form> |
|
| 55 | + | <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 }}"> |
|
| 56 | + | <label for="older-{{ col.column.id }}">Archive cards here more than</label> |
|
| 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 | + | </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 }}"> |
|
| 60 | + | <label for="after-{{ col.column.id }}">Archive automatically after</label> |
|
| 61 | + | <div class="inline-fields"><input type="number" id="after-{{ col.column.id }}" name="days" min="0" max="3650" placeholder="never"{% if let Some(d) = col.column.archive_after_days %} value="{{ d }}"{% endif %}><span class="small mute">days</span><button name="action" value="archive_after" class="small">SAVE</button></div> |
|
| 62 | + | </form> |
|
| 49 | 63 | </details> |
|
| 50 | 64 | {% endif %} |
|
| 51 | 65 | </section> |
|
| 52 | 66 | {% endfor %} |
|
| 53 | - | {% if can_edit %} |
|
| 67 | + | {% if can_edit && !archived %} |
|
| 54 | 68 | <section class="board-col add-col"><form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="newcol">New column</label><input type="text" id="newcol" name="name" maxlength="40" required><button name="action" value="add_column" class="small">ADD COLUMN</button></form></section> |
|
| 55 | 69 | {% endif %} |
|
| 56 | 70 | </div> |
|
| … | 20 unchanged lines |
modified templates/style.css +5 −0
| … | 380 unchanged lines |
||
| 381 | 381 | .board-filter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; } |
|
| 382 | 382 | .board-filter input[type=search] { min-width: 0; flex: 1 1 14em; max-width: 24em; } |
|
| 383 | 383 | .col-hidden { margin: 0 0 6px; } |
|
| 384 | + | .col-archives { margin: 0 0 6px; } |
|
| 385 | + | .board-archived-link { margin-left: auto; } |
|
| 386 | + | .col-archive { margin-top: 8px; } |
|
| 387 | + | .col-archive .inline-fields { align-items: center; } |
|
| 388 | + | .col-archive input[type=number] { width: 6em; margin: 0; } |
|
| 384 | 389 | .refs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; max-width: 100%; } |
|
| 385 | 390 | .refs-chips .chip { overflow-wrap: anywhere; } |
|
| 386 | 391 | details.inline-edit { display: inline; margin-left: 8px; } |
|
| … | 1061 unchanged lines |