An issue's references come in a box per day (closes #292) 4f8404093d30
Two or more 'referenced by change' events on one UTC day are one box, 'referenced by N changes on 21 Oct 2026', wrapping chips, collapsible, the newest day's open.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ec2047498f24
- When
- · 2026-10-24 23:14 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
4f8404093d3094c9bd208f41a3c7674b32ff945814b7cb9b2679332b23027d74- Depends on
- 18e381ea88c2 456f4197ade3 78001759604c a7e6e8e6094c b5668108487f d23464c0ac60 eb0c44a9ad37
- Depended on by
- 8bb7baeb4557 439ecd8708a1 657e7d5c9da9
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **An issue's references come in a box per day (#292):** an issue many changes refer to listed a line per change and ran on down the page. Two or more on one day are now one box, "referenced by 12 changes on 21 Oct 2026", their changes as wrapping chips, collapsible, the newest day's open. |
|
| 7 | 8 | - **Move a board's column at once (#289):** `oh project card move N --from Doing -c Done` moves every card of a column, `--except 317,#320` leaves some, `--state closed,landed` takes only cards whose issue or proposal is done, and `--dry-run` lists what would move. One call (`POST …/projects/{owner}/{n}/cards/move`), all or none, one audit entry: tidying a board of 115 cards took 115 commands. |
|
| 8 | 9 | - **Expediting a run moves the queue at once (#291):** a run's jobs kept the place they had ("9 of 9 jobs come before") until the next placement pass and some other change on the page. Expediting now places every queued job again straight away, every run whose jobs' notes changed updates live, and a job an expedited run moved down says so ("2 ready jobs come first (after expedited run andy/demo #12)"). |
|
| 9 | 10 | - **Create and withdraw tags on the website (#295, #301):** a writer creates a tag on the Tags page (or `oh tag create`, `POST …/tags`), signed by the hub as their delegate, under the policy, and its tag jobs run; a maintainer withdraws one there (or `oh tag delete`, `DELETE …/tags/{tag}`, or `oxy push --delete tags/NAME`) with a signed withdrawal, never while a release uses it. The withdrawal is logged, audited and listed on the page with why, and clones drop the tag on their next fetch. Tags never move, and a withdrawn name is not used again. Pins oxygen with tag withdrawal and with keyless grants that survive their owner's key rotation (oxygen#135). |
|
| … | 289 unchanged lines |
modified crates/hub/src/render/time.rs +16 −0
| … | 41 unchanged lines |
||
| 42 | 42 | } |
|
| 43 | 43 | } |
|
| 44 | 44 | ||
| 45 | + | /// `21 Oct 2026`, the UTC day. |
|
| 46 | + | pub fn day(when_ms: u64) -> String { |
|
| 47 | + | match OffsetDateTime::from_unix_timestamp((when_ms / 1000) as i64) { |
|
| 48 | + | Ok(t) => { |
|
| 49 | + | let f = time::macros::format_description!("[day padding:none] [month repr:short] [year]"); |
|
| 50 | + | t.format(&f).unwrap_or_default() |
|
| 51 | + | } |
|
| 52 | + | Err(_) => String::new(), |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | ||
| 45 | 56 | pub fn rfc3339(when_ms: u64) -> String { |
|
| 46 | 57 | OffsetDateTime::from_unix_timestamp((when_ms / 1000) as i64).ok().and_then(|t| t.format(&Rfc3339).ok()).unwrap_or_default() |
|
| 47 | 58 | } |
|
| … | 1 unchanged lines |
||
| 49 | 60 | #[cfg(test)] |
|
| 50 | 61 | mod tests { |
|
| 51 | 62 | use super::*; |
|
| 63 | + | ||
| 64 | + | #[test] |
|
| 65 | + | fn days() { |
|
| 66 | + | assert_eq!(day(1_792_540_800_000), "21 Oct 2026"); |
|
| 67 | + | } |
|
| 52 | 68 | ||
| 53 | 69 | #[test] |
|
| 54 | 70 | fn relative() { |
|
| … | 11 unchanged lines |
modified crates/hub/src/web/issues.rs +44 −0
| … | 294 unchanged lines |
||
| 295 | 295 | pub enum Item { |
|
| 296 | 296 | Comment(idb::IssueComment, String), |
|
| 297 | 297 | Event(idb::IssueEvent), |
|
| 298 | + | /// Changes that referenced the issue on one day, together (#292): the |
|
| 299 | + | /// day, the events, and whether the box starts open (the newest). |
|
| 300 | + | References(String, Vec<idb::IssueEvent>, bool), |
|
| 298 | 301 | } |
|
| 299 | 302 | ||
| 303 | + | /// Runs of two or more "referenced by change" events on one UTC day |
|
| 304 | + | /// become one item (#292), so a much-referenced issue's timeline does not |
|
| 305 | + | /// run on for a line per change; the newest such box starts open. |
|
| 306 | + | fn group_references(timeline: Vec<(i64, Item)>) -> Vec<(i64, Item)> { |
|
| 307 | + | let day = |ms: i64| ms.div_euclid(86_400_000); |
|
| 308 | + | let mut out: Vec<(i64, Item)> = Vec::new(); |
|
| 309 | + | let mut run: Vec<idb::IssueEvent> = Vec::new(); |
|
| 310 | + | let flush = |run: &mut Vec<idb::IssueEvent>, out: &mut Vec<(i64, Item)>| match run.len() { |
|
| 311 | + | 0 => {} |
|
| 312 | + | 1 => { |
|
| 313 | + | let e = run.remove(0); |
|
| 314 | + | out.push((e.at_ms, Item::Event(e))); |
|
| 315 | + | } |
|
| 316 | + | _ => { |
|
| 317 | + | let events = std::mem::take(run); |
|
| 318 | + | let at = events[0].at_ms; |
|
| 319 | + | out.push((at, Item::References(crate::render::time::day(at as u64), events, false))); |
|
| 320 | + | } |
|
| 321 | + | }; |
|
| 322 | + | for (at, item) in timeline { |
|
| 323 | + | match item { |
|
| 324 | + | Item::Event(e) if e.event == "referenced by change" => { |
|
| 325 | + | if run.last().is_some_and(|l| day(l.at_ms) != day(e.at_ms)) { |
|
| 326 | + | flush(&mut run, &mut out); |
|
| 327 | + | } |
|
| 328 | + | run.push(e); |
|
| 329 | + | } |
|
| 330 | + | other => { |
|
| 331 | + | flush(&mut run, &mut out); |
|
| 332 | + | out.push((at, other)); |
|
| 333 | + | } |
|
| 334 | + | } |
|
| 335 | + | } |
|
| 336 | + | flush(&mut run, &mut out); |
|
| 337 | + | if let Some((_, Item::References(_, _, open))) = out.iter_mut().rev().find(|(_, i)| matches!(i, Item::References(..))) { |
|
| 338 | + | *open = true; |
|
| 339 | + | } |
|
| 340 | + | out |
|
| 341 | + | } |
|
| 342 | + | ||
| 300 | 343 | #[derive(Template)] |
|
| 301 | 344 | #[template(path = "issue.html")] |
|
| 302 | 345 | pub struct IssuePage { |
|
| … | 58 unchanged lines |
||
| 361 | 404 | } |
|
| 362 | 405 | } |
|
| 363 | 406 | timeline.sort_by_key(|(t, _)| *t); |
|
| 407 | + | let timeline = group_references(timeline); |
|
| 364 | 408 | let labels = idb::issue_labels(&app.db, i.id).await.map_err(e)?; |
|
| 365 | 409 | let all_labels = idb::labels(&app.db, row.id).await.map_err(e)?; |
|
| 366 | 410 | let assignees = idb::assignees(&app.db, i.id).await.map_err(e)?; |
|
| … | 722 unchanged lines |
modified crates/hub/tests/issues.rs +19 −0
| … | 286 unchanged lines |
||
| 287 | 287 | assert!(three.contains("referenced by change") && three.contains(short), "proposals too"); |
|
| 288 | 288 | assert!(!get(app, "/andy/demo/issue/2", None).await.body.contains("referenced by change"), "a closing change records only that"); |
|
| 289 | 289 | ||
| 290 | + | // #292: many references on one day are one box, wrapping, the newest |
|
| 291 | + | // day's open; a lone one stays a line. |
|
| 292 | + | let issue1 = db::issues::get(&app.db, repo_id, 1).await.unwrap().unwrap(); |
|
| 293 | + | let day = 1_792_540_800_000i64; // 21 Oct 2026, 00:00 UTC |
|
| 294 | + | for (i, at) in [day + 1_000, day + 2_000, day + 3_000, day - 86_400_000].into_iter().enumerate() { |
|
| 295 | + | sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, 'andy', 'referenced by change', $3)") |
|
| 296 | + | .bind(issue1.id) |
|
| 297 | + | .bind(at) |
|
| 298 | + | .bind(format!("{:012x} on main", 0xabc000 + i)) |
|
| 299 | + | .execute(&app.db) |
|
| 300 | + | .await |
|
| 301 | + | .unwrap(); |
|
| 302 | + | } |
|
| 303 | + | let one = get(app, "/andy/demo/issue/1", None).await.body; |
|
| 304 | + | assert!(one.contains("referenced by 3 changes on 21 Oct 2026"), "{one}"); |
|
| 305 | + | assert!(one.contains("refs-day\" open><summary>referenced by 3 changes"), "the newest box is open: {one}"); |
|
| 306 | + | assert!(!one.contains("changes on 20 Oct 2026"), "a lone reference is a line"); |
|
| 307 | + | assert!(one.contains(&format!("href=\"/andy/demo/change/{short}\"")), "today's own stays"); |
|
| 308 | + | ||
| 290 | 309 | // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3. |
|
| 291 | 310 | let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body; |
|
| 292 | 311 | assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}"); |
|
| … | 920 unchanged lines |
modified templates/issue.html +4 −0
| … | 24 unchanged lines |
||
| 25 | 25 | <div class="body">{{ html|safe }}</div></div> |
|
| 26 | 26 | {% when crate::web::issues::Item::Event with (e) %} |
|
| 27 | 27 | <p class="small mute timeline">{{ e.actor }} {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %} {{ e.detail }}{% endif %} · <time title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p> |
|
| 28 | + | {% when crate::web::issues::Item::References with (day, events, open) %} |
|
| 29 | + | <details class="small mute timeline refs-day"{% if *open %} open{% endif %}><summary>referenced by {{ events.len() }} changes on {{ day }}</summary> |
|
| 30 | + | <p class="refs-chips">{% for e in events %}{% let hash = e.detail.split(' ').next().unwrap_or("") %}<a class="chip" href="{{ repo.url }}/change/{{ hash }}" title="{{ e.actor }}: {{ e.detail }} · {{ e.at_ms|when_i }}">{{ hash }}</a> {% endfor %}</p> |
|
| 31 | + | </details> |
|
| 28 | 32 | {% endmatch %} |
|
| 29 | 33 | {% endfor %} |
|
| 30 | 34 | </div> |
|
| … | 79 unchanged lines |
modified templates/style.css +3 −0
| … | 376 unchanged lines |
||
| 377 | 377 | /* issues and notifications */ |
|
| 378 | 378 | .label { display: inline-block; font-size: 11px; padding: 1px 7px; margin-right: 4px; border: 1px solid var(--c, var(--acc)); color: var(--c, var(--acc)); vertical-align: middle; } |
|
| 379 | 379 | .timeline { margin: 0 0 8px 14px; } |
|
| 380 | + | .refs-day summary { cursor: pointer; } |
|
| 381 | + | .refs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; max-width: 100%; } |
|
| 382 | + | .refs-chips .chip { overflow-wrap: anywhere; } |
|
| 380 | 383 | details.inline-edit { display: inline; margin-left: 8px; } |
|
| 381 | 384 | details.inline-edit summary { cursor: pointer; color: var(--acc); } |
|
| 382 | 385 | details.inline-edit input, details.inline-edit textarea { width: 100%; font: inherit; background: var(--bg); color: var(--ink); border: 1px solid var(--line); margin: 4px 0; } |
|
| … | 1055 unchanged lines |