OXYGENHUB

Live L5: the issue page, the overview and Diffusion (closes #207) a10446c68043

- The issue page's timeline listens on repo:<id>/issue:<n> and
  repo:<id>.
- The repository overview's latest change and bookmarks listen on
  repo:<id>.
- Its Diffusion card and the Diffusion tab listen on
  diffusion:<repo id>.

The repository chrome carries the id for these topics. As with L4, the
client refetches the page and swaps the marked parts. Test: each page
carries its markers. docs/live.md status.

Closes #207
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e4d074b0452e
When
· 2026-10-18 02:54 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
a10446c68043dc8ad22b68048df2a70c0cc9adb8db19a064131933c5de7c4722
Depends on
18e381ea88c2 19bb31ff1f5c 3eb491e9489b 42fa95540468 73ef9f767132 757195ad7a5d 7f283f8e8b97 83d5052bf76c 8a48dd830178 c0e39717895d c9aacc0e7dc1 d23464c0ac60 ffb67a81188c
Depended on by
33d98147511b 7c06f5b28ade e97500b70f9a
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 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
14 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/hub/src/web/issues.rs +7 −0
…
325 unchanged lines
326 326
    error: Option<String>,
327 327
}
328 328
329 +
impl IssuePage {
330 +
    /// The issue's live topic (docs/live.md L5).
331 +
    pub fn live_topic(&self) -> String {
332 +
        crate::events::topic::issue(self.i.repo_id, self.i.number)
333 +
    }
334 +
}
335 +
329 336
/// The page assembler every action ends in.
330 337
#[allow(clippy::too_many_arguments)]
331 338
async fn detail(
…
750 unchanged lines
modified crates/hub/src/web/mod.rs +13 −0
…
458 unchanged lines
459 459
/// The repository chrome: tabs and counts.
460 460
#[derive(Clone, Debug)]
461 461
pub struct RepoShell {
462 +
    /// The repository's row id: its live topics name it (docs/live.md).
463 +
    pub id: i64,
462 464
    pub owner: String,
463 465
    pub name: String,
464 466
    pub url: String,
…
32 unchanged lines
497 499
impl RepoShell {
498 500
    pub fn new(app: &Shared, row: &db::repos::RepoRow, tab: &'static str) -> RepoShell {
499 501
        RepoShell {
502 +
            id: row.id,
500 503
            owner: row.owner.clone(),
501 504
            name: row.name.clone(),
502 505
            url: format!("/{}/{}", row.owner, row.name),
…
23 unchanged lines
526 529
        self
527 530
    }
528 531
532 +
    /// The repository's live topic (docs/live.md).
533 +
    pub fn live_topic(&self) -> String {
534 +
        crate::events::topic::repo(self.id)
535 +
    }
536 +
537 +
    /// Its Diffusion topic: bumps and pills.
538 +
    pub fn diffusion_topic(&self) -> String {
539 +
        crate::events::topic::diffusion(self.id)
540 +
    }
541 +
529 542
    pub fn with_role(mut self, role: Role) -> RepoShell {
530 543
        self.role = Some(role);
531 544
        self
…
299 unchanged lines
modified crates/hub/tests/flow.rs +28 −0
…
5319 unchanged lines
5320 5320
    assert!(!text.contains("oxyhub_flow_jobs{state=\"queued\"} 0"), "the run's jobs are queued:\n{text}");
5321 5321
}
5322 5322
5323 +
/// Live L5 (docs/live.md, oxygenhub#207): the issue page's timeline
5324 +
/// listens on the issue's topic and the repository's, the overview's latest
5325 +
/// change and bookmarks on the repository's, and its Diffusion card and the
5326 +
/// Diffusion tab on the repository's Diffusion topic.
5327 +
#[tokio::test(flavor = "multi_thread")]
5328 +
async fn the_issue_overview_and_diffusion_pages_listen_on_their_topics() {
5329 +
    let h = hub().await;
5330 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
5331 +
    create(&h.app, "andy", "demo").await;
5332 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
5333 +
    let work = tempfile::tempdir().unwrap();
5334 +
    let c = client(&format!("{}/andy/demo", h.base), work.path());
5335 +
    write(work.path(), "a.txt", "a\n");
5336 +
    c.snapshot("t").unwrap();
5337 +
    c.commit("a", &[]).unwrap();
5338 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
5339 +
    let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/demo/issues", Some("site"), Some(r#"{"title":"Broken"}"#.into())).await;
5340 +
    assert!(st.is_success(), "{st} {body}");
5341 +
    let repo = format!("repo:{}", row.id);
5342 +
    let (_, issue) = call(&h.app, "GET", "/andy/demo/issue/1", None, None).await;
5343 +
    assert!(issue.contains(&format!("data-live=\"{repo}/issue:1 {repo}\" data-fragment=\"timeline\"")), "the issue's timeline");
5344 +
    let (_, overview) = call(&h.app, "GET", "/andy/demo", None, None).await;
5345 +
    assert!(overview.contains(&format!("data-live=\"{repo}\" data-fragment=\"latest\"")), "the latest change");
5346 +
    assert!(overview.contains(&format!("data-live=\"{repo}\" data-fragment=\"bookmarks\"")), "the bookmarks");
5347 +
    let (_, diffusion) = call(&h.app, "GET", "/andy/demo/diffusion", None, None).await;
5348 +
    assert!(diffusion.contains(&format!("data-live=\"diffusion:{} {repo}\" data-fragment=\"diffusion\"", row.id)), "the Diffusion tab");
5349 +
}
5350 +
5323 5351
/// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a
5324 5352
/// bookmark, the hub signs when it accepted the update and serves that
5325 5353
/// with the bookmark, so a fetch checks the certificate at the hub's time
…
181 unchanged lines
modified docs/live.md +6 −0
…
128 unchanged lines
129 129
  takes 16). Nothing new on the server: the client refetches the page and
130 130
  swaps each marked part, so every route already serves them.
131 131
132 +
- **L5 (done, #207):** the issue page's timeline listens on
133 +
  `repo:<id>/issue:<n>` and `repo:<id>`; the repository overview's latest
134 +
  change and bookmarks on `repo:<id>`, and its Diffusion card and the
135 +
  Diffusion tab on `diffusion:<repo id>` (the tab on `repo:<id>` too).
136 +
  The repository chrome (`RepoShell`) carries the id for these topics.
137 +
132 138
## 6. Order
133 139
134 140
| Step | What | Issue |
…
8 unchanged lines
modified templates/diffusion.html +2 −0
…
9 unchanged lines
10 10
<div data-backdrop-avoid>
11 11
<div class="head"><h1>Diffusion <small>how changes move between this repository and the ones that pin it</small></h1></div>
12 12
<p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p>
13 +
<div data-live="{{ repo.diffusion_topic() }} {{ repo.live_topic() }}" data-fragment="diffusion">
13 14
{% if !used_by.is_empty() %}
14 15
<h2>Diffuses to <span class="count-chip">{{ used_by.len() }}</span></h2>
15 16
{% if unverified %}<div class="strip warn">{{ repo.owner }}/{{ repo.name }} has no checks of its own, so its consumers' bumps are amber: only their own pipelines test them. A <code>.flow/pipeline.kdl</code> here that passes turns them green.</div>{% endif %}
…
15 unchanged lines
31 32
  {% endfor %}
32 33
</table></div>
33 34
{% endif %}
35 +
</div>
34 36
{% if used_by.is_empty() && follows.is_empty() %}<p class="empty">No repository on this hub pins this one, and it pins none that this hub holds.</p>{% endif %}
35 37
<p class="small mute">Diffusion opens a proposal in a consumer when its upstream moves, and never lands one itself. How each repository follows is under its Settings › Diffusion. <a href="/help#diffusion">How it works</a></p>
36 38
</div>
…
1 unchanged lines
modified templates/issue.html +2 −0
…
15 unchanged lines
16 16
  <div class="comment"><span class="who">{{ i.author }}</span> <time class="mute" title="{{ i.created_ms|when_i }}">{{ i.created_ms|ago_i }}</time>
17 17
    {% if can_edit %}<details class="inline-edit"><summary class="small">edit</summary><form method="post" action="{{ repo.url }}/issue/{{ i.number }}/edit"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="title" value="{{ i.title }}"><textarea name="body" rows="6">{{ i.body }}</textarea><button type="submit">SAVE</button></form></details>{% endif %}
18 18
    <div class="body">{% if i.body.is_empty() %}<p class="mute">No description.</p>{% else %}{{ body_html|safe }}{% endif %}</div></div>
19 +
  <div data-live="{{ self.live_topic() }} {{ repo.live_topic() }}" data-fragment="timeline">
19 20
  {% for item in timeline %}
20 21
    {% match item %}
21 22
    {% when crate::web::issues::Item::Comment with (c, html) %}
…
4 unchanged lines
26 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 28
    {% endmatch %}
28 29
  {% endfor %}
30 +
  </div>
29 31
  {% if shell.user.is_some() && (!i.locked || can_triage) %}
30 32
  <form class="card comment-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/comment">
31 33
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
76 unchanged lines
modified templates/overview.html +3 −3
…
40 unchanged lines
41 41
    {% include "quickstart.html" %}
42 42
    {% else %}
43 43
    <div class="tree-box">
44 -
    {% if let Some(c) = o.latest %}<div class="tree-head">
44 +
    {% if let Some(c) = o.latest %}<div class="tree-head" data-live="{{ repo.live_topic() }}" data-fragment="latest">
45 45
      <span class="tree-title">{% if let Some(a) = c.author.account %}<a class="who" href="/{{ a }}"><img class="avatar" src="/avatar/{{ a }}" alt="" width="20" height="20"> {{ a }}</a>{% else %}<span class="who">{{ c.author.name }}</span>{% endif %} <a class="t" href="{{ repo.url }}/change/{{ c.hash }}" title="{{ c.title }}">{{ c.title }}</a></span>
46 46
      <span class="tree-meta"><a href="{{ repo.url }}/change/{{ c.hash }}"><code>{{ c.short }}</code></a><time class="ago" title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time><a class="hist" href="{{ repo.url }}/changes/{{ at }}">{{ crate::web::icons::svg("history")|safe }} {{ o.changes|plural("change", "changes") }}</a></span>
47 47
    </div>{% endif %}
…
19 unchanged lines
67 67
    </div></section>
68 68
    {% if let Some(rel) = latest_release %}<section class="side-card"><h3>Latest release</h3><div class="body"><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p></div></section>{% endif %}
69 69
    {% if !follows.is_empty() || !used_by.is_empty() %}
70 -
    <section class="side-card"><h3>Diffusion <a class="more" href="{{ repo.url }}/diffusion">details ›</a></h3><div class="body">
70 +
    <section class="side-card" data-live="{{ repo.diffusion_topic() }}" data-fragment="diffusion"><h3>Diffusion <a class="more" href="{{ repo.url }}/diffusion">details ›</a></h3><div class="body">
71 71
    {% if !follows.is_empty() %}<h4>Takes from <span class="count-chip">{{ follows.len() }}</span></h4>
72 72
    <ul class="plain df-list">{% for (up, c) in follows %}<li>
73 73
      <div class="df-row"><a class="df-name" href="/{{ up.owner }}/{{ up.name }}" title="{{ up.owner }}/{{ up.name }}, pinned at {{ c.path }}">{{ up.owner }}/{{ up.name }}</a><span class="df-pill" role="img" aria-label="upstream {{ c.upstream.word }}, here {{ c.downstream.word }}"><span class="df-half {{ c.upstream.class }}" title="Upstream: {{ c.upstream.title }}">{{ c.upstream.word }}</span><span class="df-half {{ c.downstream.class }}" title="Here: {{ c.downstream.title }}">{{ c.downstream.word }}</span></span></div>
…
20 unchanged lines
94 94
    <ul class="plain langs">{% for l in o.languages %}<li><span class="lang-dot" style="background: {{ l.colour }}" aria-hidden="true"></span> <strong>{{ l.name }}</strong> <span class="mute">{{ "{:.1}"|format(l.percent) }}%</span></li>{% endfor %}</ul>
95 95
    </div></section>
96 96
    {% endif %}
97 -
    <section class="side-card"><h3>Bookmarks <span class="count-chip">{{ o.bookmarks.len() }}</span>{% if o.bookmarks.len() > 10 %}<a class="more" href="{{ repo.url }}/bookmarks">all {{ o.bookmarks.len() }} ›</a>{% endif %}</h3><div class="body">
97 +
    <section class="side-card" data-live="{{ repo.live_topic() }}" data-fragment="bookmarks"><h3>Bookmarks <span class="count-chip">{{ o.bookmarks.len() }}</span>{% if o.bookmarks.len() > 10 %}<a class="more" href="{{ repo.url }}/bookmarks">all {{ o.bookmarks.len() }} ›</a>{% endif %}</h3><div class="body">
98 98
    <ul class="plain bookmarks">{% for b in o.recent_bookmarks() %}<li><a href="{{ repo.url }}?at={{ b.name|urlencode }}" title="{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %}<span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %}<span class="tag bad">conflicts</span>{% endif %}<time class="small mute" title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></li>{% endfor %}</ul>
99 99
    </div></section>
100 100
    <section class="side-card"><h3>Tags</h3><div class="body">
…
13 unchanged lines