Changes that mention #N put 'referenced by change' on that issue's or proposal's timeline, on any bookmark, with the change linked 78001759604c
closes #4
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 14:43 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
78001759604c0562572336e3c43ee8983937c1a806c588add07e660bc402a00f- Depends on
- 1553bfc9ae53 18e381ea88c2 3f4b0ea6eabf 62d6e969f180 93be9ce4653e d23464c0ac60 de505df5cf8d eb0c44a9ad37
- Depended on by
- 8bb7baeb4557 4f8404093d30 0ee5c3fb32eb 1966661b1880 acc85ac0f70b 92cea629fdf6 8933faff2043 a1b9869565aa c79a1111dd56
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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
- 15 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/hub/src/protocol.rs +18 −4
| … | 178 unchanged lines |
||
| 179 | 179 | } |
|
| 180 | 180 | }) |
|
| 181 | 181 | .collect(); |
|
| 182 | - | // Messages of the changes stored, for `closes #N` (hub spec §8), when |
|
| 183 | - | // the default bookmark moved. |
|
| 184 | - | let messages: Vec<(String, String)> = if report.updated.iter().any(|u| u.name == row.default_bookmark) { |
|
| 182 | + | // Messages of the changes stored: `closes #N` when the default |
|
| 183 | + | // bookmark moved, and `#N` references on any bookmark (hub spec §8). |
|
| 184 | + | let bookmarks: Vec<String> = report |
|
| 185 | + | .updated |
|
| 186 | + | .iter() |
|
| 187 | + | .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name)) |
|
| 188 | + | .map(|u| u.name.clone()) |
|
| 189 | + | .collect(); |
|
| 190 | + | let imported: Vec<(String, String)> = if bookmarks.is_empty() { |
|
| 191 | + | vec![] |
|
| 192 | + | } else { |
|
| 185 | 193 | self.repos |
|
| 186 | 194 | .open(&parsed) |
|
| 187 | 195 | .map(|r| report.imported.iter().filter_map(|h| r.store().get_change(h).ok().map(|c| (h.to_hex(), c.message))).collect()) |
|
| 188 | 196 | .unwrap_or_default() |
|
| 197 | + | }; |
|
| 198 | + | let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] }; |
|
| 199 | + | let on = if bookmarks.contains(&row.default_bookmark) { |
|
| 200 | + | row.default_bookmark.clone() |
|
| 189 | 201 | } else { |
|
| 190 | - | vec![] |
|
| 202 | + | bookmarks.first().cloned().unwrap_or_default() |
|
| 191 | 203 | }; |
|
| 192 | 204 | // Bookmarks that moved, with their new heads, for OxygenFlow runs. |
|
| 193 | 205 | let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report |
|
| … | 11 unchanged lines |
||
| 205 | 217 | let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()); |
|
| 206 | 218 | if let Some(app) = app { |
|
| 207 | 219 | crate::web::issues::close_from_messages(&app, &row, &messages).await; |
|
| 220 | + | let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default(); |
|
| 221 | + | crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await; |
|
| 208 | 222 | if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) { |
|
| 209 | 223 | crate::mirror::refresh(&app, &row).await; |
|
| 210 | 224 | } |
|
| … | 43 unchanged lines |
modified crates/hub/src/render/refs.rs +18 −0
| … | 80 unchanged lines |
||
| 81 | 81 | ||
| 82 | 82 | /// Issue numbers a change message closes: `closes #12`, `fixes #3`, |
|
| 83 | 83 | /// `resolved #7`. |
|
| 84 | + | /// Every bare `#N` a change message names, except those it closes |
|
| 85 | + | /// (`closes #N` records its own event) and other repositories' `a/b#N`. |
|
| 86 | + | pub fn references(message: &str) -> Vec<i64> { |
|
| 87 | + | let re = regex::Regex::new(r"(?:^|[^\w/\[&])#(\d+)\b").unwrap(); |
|
| 88 | + | let closing = closes(message); |
|
| 89 | + | let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).filter(|n| !closing.contains(n)).collect(); |
|
| 90 | + | out.sort(); |
|
| 91 | + | out.dedup(); |
|
| 92 | + | out |
|
| 93 | + | } |
|
| 94 | + | ||
| 84 | 95 | pub fn closes(message: &str) -> Vec<i64> { |
|
| 85 | 96 | let re = regex::Regex::new(r"(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)").unwrap(); |
|
| 86 | 97 | let mut out: Vec<i64> = re.captures_iter(message).filter_map(|c| c[1].parse().ok()).collect(); |
|
| … | 5 unchanged lines |
||
| 92 | 103 | #[cfg(test)] |
|
| 93 | 104 | mod tests { |
|
| 94 | 105 | use super::*; |
|
| 106 | + | ||
| 107 | + | #[test] |
|
| 108 | + | fn finds_references_but_not_closings_or_other_repositories() { |
|
| 109 | + | assert_eq!(references("Faster blame (refs #14, #3)\n\nSee #3 again; closes #9; other/repo#5; a#2"), vec![3, 14]); |
|
| 110 | + | assert!(references("Nothing to see; colour #").is_empty()); |
|
| 111 | + | assert_eq!(references("#7 at the start"), vec![7]); |
|
| 112 | + | } |
|
| 95 | 113 | ||
| 96 | 114 | #[test] |
|
| 97 | 115 | fn links_references_outside_code() { |
|
| … | 18 unchanged lines |
modified crates/hub/src/web/issues.rs +20 −0
| … | 918 unchanged lines |
||
| 919 | 919 | Ok(Redirect::to(&format!("/{owner}/{name}")).into_response()) |
|
| 920 | 920 | } |
|
| 921 | 921 | ||
| 922 | + | /// Put `#N` references of pushed changes on the timelines of those issues |
|
| 923 | + | /// and proposals: "referenced by change <hash> on <bookmark>". |
|
| 924 | + | pub async fn references_from_messages(app: &Shared, row: &db::repos::RepoRow, messages: &[(String, String)], bookmark: &str, actor: &str) { |
|
| 925 | + | let Ok((issues, proposals)) = idb::numbers(&app.db, row.id).await else { return }; |
|
| 926 | + | for (hash, message) in messages { |
|
| 927 | + | let detail = format!("{} on {bookmark}", &hash[..12.min(hash.len())]); |
|
| 928 | + | for n in render::refs::references(message) { |
|
| 929 | + | if issues.contains(&n) { |
|
| 930 | + | if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await { |
|
| 931 | + | idb::add_event(&app.db, i.id, actor, "referenced by change", &detail).await.ok(); |
|
| 932 | + | } |
|
| 933 | + | } else if proposals.contains(&n) { |
|
| 934 | + | if let Ok(Some(p)) = db::proposals::get(&app.db, row.id, n).await { |
|
| 935 | + | db::proposals::add_event(&app.db, p.id, actor, "referenced by change", &detail).await.ok(); |
|
| 936 | + | } |
|
| 937 | + | } |
|
| 938 | + | } |
|
| 939 | + | } |
|
| 940 | + | } |
|
| 941 | + | ||
| 922 | 942 | /// Close issues a landed change's message names (`closes #12`), when the |
|
| 923 | 943 | /// landing reached the default bookmark. Called from the push hook and |
|
| 924 | 944 | /// the hub's own landing. |
|
| … | 30 unchanged lines |
modified crates/hub/tests/issues.rs +17 −0
| … | 232 unchanged lines |
||
| 233 | 233 | // A comment on the issue referencing #3 links to the proposal. |
|
| 234 | 234 | let r = post(app, "/andy/demo/issue/2/comment", Some(&andy), &format!("_csrf={csrf_a}&body=see+%233")).await; |
|
| 235 | 235 | assert!(r.body.contains("href=\"/andy/demo/proposal/3\""), "{}", r.body); |
|
| 236 | + | // `#N` in a pushed change's message lands on those timelines (hub spec §8). |
|
| 237 | + | client.bookmark_switch("main", false).unwrap(); |
|
| 238 | + | write(work.path(), "c.txt", b"c\n"); |
|
| 239 | + | client.snapshot("t").unwrap(); |
|
| 240 | + | let r = client.commit("Tidy up (refs #1, see #3)", &[]).unwrap(); |
|
| 241 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 242 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 243 | + | let short = &r.to_hex()[..12]; |
|
| 244 | + | let one = get(app, "/andy/demo/issue/1", None).await.body; |
|
| 245 | + | assert!( |
|
| 246 | + | one.contains("referenced by change") && one.contains(&format!("href=\"/andy/demo/change/{short}\"")) && one.contains(" on main"), |
|
| 247 | + | "{one}" |
|
| 248 | + | ); |
|
| 249 | + | let three = get(app, "/andy/demo/proposal/3", Some(&andy)).await.body; |
|
| 250 | + | assert!(three.contains("referenced by change") && three.contains(short), "proposals too"); |
|
| 251 | + | assert!(!get(app, "/andy/demo/issue/2", None).await.body.contains("referenced by change"), "a closing change records only that"); |
|
| 252 | + | ||
| 236 | 253 | // Bob takes part in #1 (mentioned, assigned, commented) and only watches #3. |
|
| 237 | 254 | let part = get(app, "/notifications?reason=participating", Some(&bob)).await.body; |
|
| 238 | 255 | assert!(part.contains("andy/demo#1") && !part.contains("andy/demo#3: Feat"), "{part}"); |
|
| … | 470 unchanged lines |
modified docs/spec.md +1 −1
| … | 367 unchanged lines |
||
| 368 | 368 | ||
| 369 | 369 | Per-repository numbered issues sharing the number space with proposals (as GitHub does), with title, Markdown body, author, assignees, labels (name, colour, description), a milestone (title, due date, description, open/closed), state (`open`, `closed` with reason `completed` or `not planned`), comments, lock, and pin. Issue templates come from `.oxyhub/ISSUE_TEMPLATE/*.md` in the default state. |
|
| 370 | 370 | ||
| 371 | - | Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark. Every reference creates a timeline entry on the target. |
|
| 371 | + | Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark ("closed by change"). Any other `#12` in a pushed change's message, on any bookmark, puts "referenced by change <hash> on <bookmark>" on that issue's or proposal's timeline, the hash linking to the change; other repositories' `owner/name#12` are left alone. |
|
| 372 | 372 | ||
| 373 | 373 | Import from GitHub or GitLab is a 1.x item. |
|
| 374 | 374 | ||
| … | 193 unchanged lines |
modified templates/issue.html +1 −1
| … | 20 unchanged lines |
||
| 21 | 21 | {% 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 %} |
|
| 22 | 22 | <div class="body">{{ html|safe }}</div></div> |
|
| 23 | 23 | {% when crate::web::issues::Item::Event with (e) %} |
|
| 24 | - | <p class="small mute timeline">{{ e.actor }} {{ e.event }}{% if !e.detail.is_empty() %} {{ e.detail }}{% endif %} · <time title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p> |
|
| 24 | + | <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> |
|
| 25 | 25 | {% endmatch %} |
|
| 26 | 26 | {% endfor %} |
|
| 27 | 27 | {% if shell.user.is_some() && (!i.locked || can_triage) %} |
|
| … | 40 unchanged lines |
modified templates/proposal.html +1 −1
| … | 78 unchanged lines |
||
| 79 | 79 | {% for r in reviews %} |
|
| 80 | 80 | <div class="comment review-{{ r.row.verdict }}"><span class="who">{{ r.row.author }}</span> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}commented{% endif %} <time class="mute" title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time>{% if !r.row.body.is_empty() %}<div class="body">{{ r.body_html|safe }}</div>{% endif %}</div> |
|
| 81 | 81 | {% endfor %} |
|
| 82 | - | {% for e in events %}<p class="small mute">{{ e.actor }} {{ e.event }}{% if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} · {{ e.at_ms|ago_i }}</p>{% endfor %} |
|
| 82 | + | {% for e in events %}<p class="small mute">{{ 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 %} · {{ e.at_ms|ago_i }}</p>{% endfor %} |
|
| 83 | 83 | {% if shell.user.is_some() %} |
|
| 84 | 84 | <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"> |
|
| 85 | 85 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 67 unchanged lines |