OXYGENHUB

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 &#35;").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