OXYGENHUB

Issue, comment and proposal Markdown resolves relative links against the default bookmark, release notes against the release's tag c79a1111dd56

closes #6
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 15:08 UTC
Signature
✔ signed by Andy McCall
Hash
c79a1111dd56a694829779446d7f0771d1a803390ac02618e0f01f511d139e1d
Depends on
18e381ea88c2 3f4b0ea6eabf 736f6813543d 78001759604c 93be9ce4653e de505df5cf8d e1ec9fbf538e
Depended on by
046e3352cdfd 439ecd8708a1 6a306cd03fc1 657e7d5c9da9 3b1bc27fe805 ffb67a81188c acc85ac0f70b b1e92a6233ee 10708ab8f54d
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
11 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub/src/web/issues.rs +10 −8
…
13 unchanged lines
14 14
use serde::Deserialize;
15 15
use std::collections::HashMap;
16 16
17 -
/// Markdown with cross references linked, for a repository.
18 -
pub async fn markdown(app: &Shared, repo_id: i64, repo_url: &str, text: &str) -> String {
19 -
    let (issues, proposals) = idb::numbers(&app.db, repo_id).await.unwrap_or_default();
20 -
    let linked = render::refs::link(text, &Numbers { repo_url, issues: &issues, proposals: &proposals });
21 -
    render::markdown::render(&linked, &format!("{repo_url}/file/main/"), &format!("{repo_url}/raw/main/"))
17 +
/// Markdown with cross references linked, for a repository; relative
18 +
/// links and images resolve against `reference` (the default bookmark for
19 +
/// issues and proposals, the tag for release notes).
20 +
pub async fn markdown(app: &Shared, row: &db::repos::RepoRow, reference: &str, text: &str) -> String {
21 +
    let repo_url = format!("/{}", row.slug());
22 +
    let (issues, proposals) = idb::numbers(&app.db, row.id).await.unwrap_or_default();
23 +
    let linked = render::refs::link(text, &Numbers { repo_url: &repo_url, issues: &issues, proposals: &proposals });
24 +
    render::markdown::render(&linked, &format!("{repo_url}/file/{reference}/"), &format!("{repo_url}/raw/{reference}/"))
22 25
}
23 26
24 27
/// Re-index a thread for search: title, body and every comment.
…
298 unchanged lines
323 326
    let (_, row, role) = lookup(app, &shell, viewer, owner, name).await?;
324 327
    let e = |e| Error::from_db(shell.clone(), e);
325 328
    let i = idb::get(&app.db, row.id, number).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
326 -
    let repo_url = format!("/{owner}/{name}");
327 -
    let body_html = markdown(app, row.id, &repo_url, &i.body).await;
329 +
    let body_html = markdown(app, &row, &row.default_bookmark, &i.body).await;
328 330
    let comments = idb::comments(&app.db, i.id).await.map_err(e)?;
329 331
    let events = idb::events(&app.db, i.id).await.map_err(e)?;
330 332
    let mut timeline: Vec<(i64, Item)> = Vec::new();
331 333
    for c in &comments {
332 -
        timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, row.id, &repo_url, &c.body).await)));
334 +
        timeline.push((c.created_ms, Item::Comment(c.clone(), markdown(app, &row, &row.default_bookmark, &c.body).await)));
333 335
    }
334 336
    for ev in events {
335 337
        if ev.event != "opened" {
…
643 unchanged lines
modified crates/hub/src/web/proposals.rs +1 −1
…
670 unchanged lines
671 671
        projects,
672 672
        shell,
673 673
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
674 -
        body_html: super::issues::markdown(app, row.id, &format!("/{owner}/{name}"), &p.body).await,
674 +
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
675 675
        p,
676 676
        tab,
677 677
        changes,
…
495 unchanged lines
modified crates/hub/src/web/releases.rs +1 −1
…
37 unchanged lines
38 38
    for r in list {
39 39
        let assets = rdb::assets(&app.db, r.id).await?;
40 40
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
41 -
        let notes_html = super::issues::markdown(app, row.id, &format!("/{}", row.slug()), &r.notes).await;
41 +
        let notes_html = super::issues::markdown(app, row, &r.tag, &r.notes).await;
42 42
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer });
43 43
    }
44 44
    Ok(out)
…
447 unchanged lines
modified crates/hub/tests/issues.rs +26 −0
…
802 unchanged lines
803 803
    assert_eq!(get(app, "/andy/demo/issue/1", None).await.status, StatusCode::OK);
804 804
    assert_eq!(get(app, "/projects/nobody", None).await.status, StatusCode::NOT_FOUND);
805 805
}
806 +
807 +
#[tokio::test(flavor = "multi_thread")]
808 +
async fn issue_markdown_follows_the_default_bookmark() {
809 +
    let h = hub().await;
810 +
    let app = &h.app;
811 +
    register(app, "andy").await;
812 +
    app.repos.create(&RepoName::parse("andy/trunky").unwrap(), None).unwrap();
813 +
    let row = db::repos::create(&app.db, "andy", "trunky", "", hub_api::Visibility::Public).await.unwrap();
814 +
    sqlx::query("UPDATE repositories SET default_bookmark = 'trunk' WHERE id = ?").bind(row.id).execute(&app.db).await.unwrap();
815 +
    db::issues::create(
816 +
        &app.db,
817 +
        db::issues::NewIssue {
818 +
            repo_id: row.id,
819 +
            title: "Docs",
820 +
            body: "See [the guide](docs/guide.md) and ![shot](img/a.png).",
821 +
            author_id: None,
822 +
            author: "andy",
823 +
        },
824 +
    )
825 +
    .await
826 +
    .unwrap();
827 +
    let page = get(app, "/andy/trunky/issue/1", None).await.body;
828 +
    assert!(page.contains("href=\"/andy/trunky/file/trunk/docs/guide.md\""), "{page}");
829 +
    assert!(page.contains("src=\"/andy/trunky/raw/trunk/img/a.png\""));
830 +
    assert!(!page.contains("/file/main/"));
831 +
}
modified docs/spec.md +1 −1
…
369 unchanged lines
370 370
371 371
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.
372 372
373 -
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.
373 +
Relative links and images in issue, comment and proposal text resolve against the repository's default bookmark, and in release notes against the release's tag. 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.
374 374
375 375
Import from GitHub or GitLab is a 1.x item.
376 376
…
207 unchanged lines