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 |