An issue's references: no line through the chips, each change once (closes #318) 8bb7baeb4557
The issue page's events shared the class timeline, whose ::before is the proposal timeline's rule, so an opened references box drew a line down its chips: they have tl-event now. And a change was recorded again for every bookmark it was pushed on and every mention in its message: now once per change per issue or proposal, and the page shows each change once, those recorded before included.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c459b17b855a
- When
- · 2026-10-25 21:42 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8bb7baeb4557754a8602cf6a0b0ef3e4335ff5a0703e417a53c2e61aaf825036- Depends on
- 18e381ea88c2 3b65065298b7 4f8404093d30 78001759604c a7e6e8e6094c b5668108487f d23464c0ac60 de505df5cf8d
- Depended on by
- 946fe7a09faa
- 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.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 17 atoms, 0 tree operations, 7 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: no line through the chips, each change once (#318):** an opened "referenced by N changes" box drew the proposal timeline's vertical rule through its first column (the events shared its class); and a change was recorded again for every bookmark it was pushed on, and for every mention in its message, so one change filled several chips. Now one reference per change per issue or proposal, and the page shows each change once, including those recorded before. |
|
| 7 | 8 | - **Claims say how long a job is expected to take (oxygenflow#76):** `expected_ms`, the median of the job's last five passing runs in the repository (reused passes left out), so a runner leaving with notice finishes a job that fits it and hands back the rest. Pins OxygenFlow's main. |
|
| 8 | 9 | - **The hub's CI writes a test report and coverage (#307):** `test` runs under cargo-nextest and keeps a JUnit report (whether or not the tests pass); a new `coverage` job runs the tests instrumented beside it, with its own cached target, and keeps LCOV coverage. `coverage` is not a required check. |
|
| 9 | 10 | - **The new proposal form lines up (#315):** as wide as the changes above it; Draft with Claude a secondary button under the editor (it fills the fields, it does not open the proposal); the draft checkbox and Open proposal on one row. |
|
| … | 322 unchanged lines |
modified crates/hub/src/db/issues.rs +14 −0
| … | 202 unchanged lines |
||
| 203 | 203 | .await |
|
| 204 | 204 | } |
|
| 205 | 205 | ||
| 206 | + | /// Whether change `hash` (its short form) already references it (#318): |
|
| 207 | + | /// a change is recorded once, whichever bookmarks it is pushed on. |
|
| 208 | + | pub async fn referenced_by(pool: &Pool, issue_id: i64, hash: &str) -> sqlx::Result<bool> { |
|
| 209 | + | let n: i64 = sqlx::query_scalar( |
|
| 210 | + | "SELECT COUNT(*) FROM issue_events WHERE issue_id = $1 AND event = 'referenced by change' AND (detail = $2 OR detail LIKE $3)", |
|
| 211 | + | ) |
|
| 212 | + | .bind(issue_id) |
|
| 213 | + | .bind(hash) |
|
| 214 | + | .bind(format!("{hash} %")) |
|
| 215 | + | .fetch_one(pool) |
|
| 216 | + | .await?; |
|
| 217 | + | Ok(n > 0) |
|
| 218 | + | } |
|
| 219 | + | ||
| 206 | 220 | pub async fn add_event(pool: &Pool, issue_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> { |
|
| 207 | 221 | sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, $3, $4, $5)") |
|
| 208 | 222 | .bind(issue_id) |
|
| … | 191 unchanged lines |
modified crates/hub/src/db/proposals.rs +14 −0
| … | 331 unchanged lines |
||
| 332 | 332 | .await |
|
| 333 | 333 | } |
|
| 334 | 334 | ||
| 335 | + | /// Whether change `hash` (its short form) already references it (#318): |
|
| 336 | + | /// a change is recorded once, whichever bookmarks it is pushed on. |
|
| 337 | + | pub async fn referenced_by(pool: &Pool, proposal_id: i64, hash: &str) -> sqlx::Result<bool> { |
|
| 338 | + | let n: i64 = sqlx::query_scalar( |
|
| 339 | + | "SELECT COUNT(*) FROM proposal_events WHERE proposal_id = $1 AND event = 'referenced by change' AND (detail = $2 OR detail LIKE $3)", |
|
| 340 | + | ) |
|
| 341 | + | .bind(proposal_id) |
|
| 342 | + | .bind(hash) |
|
| 343 | + | .bind(format!("{hash} %")) |
|
| 344 | + | .fetch_one(pool) |
|
| 345 | + | .await?; |
|
| 346 | + | Ok(n > 0) |
|
| 347 | + | } |
|
| 348 | + | ||
| 335 | 349 | pub async fn add_event(pool: &Pool, proposal_id: i64, actor: &str, event: &str, detail: &str) -> sqlx::Result<()> { |
|
| 336 | 350 | sqlx::query("INSERT INTO proposal_events (proposal_id, at_ms, actor, event, detail) VALUES ($1, $2, $3, $4, $5)") |
|
| 337 | 351 | .bind(proposal_id) |
|
| … | 95 unchanged lines |
modified crates/hub/src/web/issues.rs +20 −4
| … | 306 unchanged lines |
||
| 307 | 307 | let day = |ms: i64| ms.div_euclid(86_400_000); |
|
| 308 | 308 | let mut out: Vec<(i64, Item)> = Vec::new(); |
|
| 309 | 309 | let mut run: Vec<idb::IssueEvent> = Vec::new(); |
|
| 310 | + | // Each change once, at its first reference (#318): before that was |
|
| 311 | + | // recorded once, a change was recorded again for every bookmark. |
|
| 312 | + | let mut seen = std::collections::HashSet::new(); |
|
| 310 | 313 | let flush = |run: &mut Vec<idb::IssueEvent>, out: &mut Vec<(i64, Item)>| match run.len() { |
|
| 311 | 314 | 0 => {} |
|
| 312 | 315 | 1 => { |
|
| … | 9 unchanged lines |
||
| 322 | 325 | for (at, item) in timeline { |
|
| 323 | 326 | match item { |
|
| 324 | 327 | Item::Event(e) if e.event == "referenced by change" => { |
|
| 328 | + | if !seen.insert(e.detail.split(' ').next().unwrap_or("").to_string()) { |
|
| 329 | + | continue; |
|
| 330 | + | } |
|
| 325 | 331 | if run.last().is_some_and(|l| day(l.at_ms) != day(e.at_ms)) { |
|
| 326 | 332 | flush(&mut run, &mut out); |
|
| 327 | 333 | } |
|
| … | 723 unchanged lines |
||
| 1051 | 1057 | pub async fn references_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)], bookmark: &str, actor: &str) { |
|
| 1052 | 1058 | let Ok((issues, proposals)) = idb::numbers(&app.db, row.id).await else { return }; |
|
| 1053 | 1059 | for (hash, message) in messages { |
|
| 1054 | - | let detail = format!("{} on {bookmark}", &hash[..12.min(hash.len())]); |
|
| 1055 | - | for n in render::refs::references(message) { |
|
| 1060 | + | let short = &hash[..12.min(hash.len())]; |
|
| 1061 | + | let detail = format!("{short} on {bookmark}"); |
|
| 1062 | + | // Once per change (#318): not again for a second mention, nor when |
|
| 1063 | + | // the change is pushed on another bookmark or landed. |
|
| 1064 | + | let mut named = render::refs::references(message); |
|
| 1065 | + | named.sort_unstable(); |
|
| 1066 | + | named.dedup(); |
|
| 1067 | + | for n in named { |
|
| 1056 | 1068 | if issues.contains(&n) { |
|
| 1057 | 1069 | if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await { |
|
| 1058 | - | idb::add_event(&app.db, i.id, actor, "referenced by change", &detail).await.ok(); |
|
| 1070 | + | if !idb::referenced_by(&app.db, i.id, short).await.unwrap_or(false) { |
|
| 1071 | + | idb::add_event(&app.db, i.id, actor, "referenced by change", &detail).await.ok(); |
|
| 1072 | + | } |
|
| 1059 | 1073 | } |
|
| 1060 | 1074 | } else if proposals.contains(&n) { |
|
| 1061 | 1075 | if let Ok(Some(p)) = db::proposals::get(&app.db, row.id, n).await { |
|
| 1062 | - | db::proposals::add_event(&app.db, p.id, actor, "referenced by change", &detail).await.ok(); |
|
| 1076 | + | if !db::proposals::referenced_by(&app.db, p.id, short).await.unwrap_or(false) { |
|
| 1077 | + | db::proposals::add_event(&app.db, p.id, actor, "referenced by change", &detail).await.ok(); |
|
| 1078 | + | } |
|
| 1063 | 1079 | } |
|
| 1064 | 1080 | } |
|
| 1065 | 1081 | } |
|
| … | 67 unchanged lines |
modified crates/hub/tests/issues.rs +20 −0
| … | 285 unchanged lines |
||
| 286 | 286 | let three = get(app, "/andy/demo/proposal/3", Some(&andy)).await.body; |
|
| 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 | + | // #318: the same change pushed on another bookmark is no new reference. |
|
| 290 | + | client.bookmark_create("again", None).unwrap(); |
|
| 291 | + | assert!(client.push_bookmark("origin", "again", false).unwrap().iter().all(|r| r.ok)); |
|
| 292 | + | tokio::time::sleep(std::time::Duration::from_millis(1500)).await; |
|
| 293 | + | let recorded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM issue_events WHERE event = 'referenced by change' AND detail LIKE $1") |
|
| 294 | + | .bind(format!("{short} %")) |
|
| 295 | + | .fetch_one(&app.db) |
|
| 296 | + | .await |
|
| 297 | + | .unwrap(); |
|
| 298 | + | assert_eq!(recorded, 1, "once per change, whichever bookmarks it is on"); |
|
| 289 | 299 | ||
| 290 | 300 | // #292: many references on one day are one box, wrapping, the newest |
|
| 291 | 301 | // day's open; a lone one stays a line. |
|
| … | 8 unchanged lines |
||
| 300 | 310 | .await |
|
| 301 | 311 | .unwrap(); |
|
| 302 | 312 | } |
|
| 313 | + | // One recorded again for another bookmark before #318: shown once. |
|
| 314 | + | sqlx::query("INSERT INTO issue_events (issue_id, at_ms, actor, event, detail) VALUES ($1, $2, 'andy', 'referenced by change', $3)") |
|
| 315 | + | .bind(issue1.id) |
|
| 316 | + | .bind(day + 4_000) |
|
| 317 | + | .bind(format!("{:012x} on develop", 0xabc000)) |
|
| 318 | + | .execute(&app.db) |
|
| 319 | + | .await |
|
| 320 | + | .unwrap(); |
|
| 303 | 321 | let one = get(app, "/andy/demo/issue/1", None).await.body; |
|
| 304 | 322 | assert!(one.contains("referenced by 3 changes on 21 Oct 2026"), "{one}"); |
|
| 323 | + | assert_eq!(one.matches(&format!("change/{:012x}\"", 0xabc000)).count(), 1, "each change once"); |
|
| 324 | + | assert!(!one.contains("mute timeline"), "the events have no rule of their own (#318)"); |
|
| 305 | 325 | assert!(one.contains("refs-day\" open><summary>referenced by 3 changes"), "the newest box is open: {one}"); |
|
| 306 | 326 | assert!(!one.contains("changes on 20 Oct 2026"), "a lone reference is a line"); |
|
| 307 | 327 | assert!(one.contains(&format!("href=\"/andy/demo/change/{short}\"")), "today's own stays"); |
|
| … | 1099 unchanged lines |
modified templates/issue.html +2 −2
| … | 23 unchanged lines |
||
| 24 | 24 | {% if let Some(u) = shell.user %}{% if *u == c.author || can_triage %}<form class="tiny" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment/{{ c.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form>{% endif %}{% endif %} |
|
| 25 | 25 | <div class="body">{{ html|safe }}</div></div> |
|
| 26 | 26 | {% when crate::web::issues::Item::Event with (e) %} |
|
| 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> |
|
| 27 | + | <p class="small mute tl-event">{{ 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 | 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> |
|
| 29 | + | <details class="small mute tl-event refs-day"{% if *open %} open{% endif %}><summary>referenced by {{ events.len() }} changes on {{ day }}</summary> |
|
| 30 | 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 | 31 | </details> |
|
| 32 | 32 | {% endmatch %} |
|
| … | 81 unchanged lines |
modified templates/style.css +3 −1
| … | 378 unchanged lines |
||
| 379 | 379 | ||
| 380 | 380 | /* issues and notifications */ |
|
| 381 | 381 | .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; } |
|
| 382 | - | .timeline { margin: 0 0 8px 14px; } |
|
| 382 | + | /* An issue's events and its references by day: indented lines, without the |
|
| 383 | + | proposal timeline's rule (.timeline::before drew one through the chips). */ |
|
| 384 | + | .tl-event { margin: 0 0 8px 14px; } |
|
| 383 | 385 | .refs-day summary { cursor: pointer; } |
|
| 384 | 386 | .board-filter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; } |
|
| 385 | 387 | .board-filter input[type=search] { min-width: 0; flex: 1 1 14em; max-width: 24em; } |
|
| … | 1075 unchanged lines |