OXYGENHUB

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