OXYGENHUB

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