Name the kind of every number; show the issue -> proposal -> bookmark chain 38833ed78707
Issues and proposals share one number space per repository, so page headers and the links #N becomes say which: "issue #1", "proposal #2". A proposal shows the issues it closes (closes/fixes/resolves #N in its description or its changes) in a strip under its title - each issue with its state, then the proposal, then its target, marked landed once it is - and in a Closes list in its sidebar. An issue shows the proposals that close it by the same rule. closes #52
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 16:23 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
38833ed78707292c7e2db4f3250227e7f024f1ca6e96bfd7d1a372a4e47b927d- Depends on
- 101740454617 145306044896 18e381ea88c2 2bfe5ef66b0c 93be9ce4653e b64e2537d702 b6b7e4cf6b24 d23464c0ac60 de505df5cf8d e1ec9fbf538e
- Depended on by
- f2d2776b2a14 e97500b70f9a c8684d44d549 b5a0a69b3622 2e9188ca4f98 d7d182467382 554ccacfc123 8e96499f2705 df71c7892d27 df15b74774ca e9bfea8e26db a1b9869565aa
- 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
- 27 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/src/web/chain.rs +74 −0
| 1 | + | //! The chain of work between issues and proposals (hub spec §8): the |
|
| 2 | + | //! issues a proposal closes (`closes #N` in its description or its |
|
| 3 | + | //! changes), and the proposals that close an issue. |
|
| 4 | + | ||
| 5 | + | use crate::db::{self, issues as idb, proposals as pdb}; |
|
| 6 | + | use crate::render::refs; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | ||
| 9 | + | /// An issue or proposal as a link in the chain. |
|
| 10 | + | #[derive(Clone, Debug)] |
|
| 11 | + | pub struct Link { |
|
| 12 | + | /// `issue` or `proposal`. |
|
| 13 | + | pub kind: &'static str, |
|
| 14 | + | pub number: i64, |
|
| 15 | + | pub title: String, |
|
| 16 | + | /// `open`, `closed`, `draft`, `landed`, … |
|
| 17 | + | pub state: String, |
|
| 18 | + | pub url: String, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// The issues of this repository that `texts` close. |
|
| 22 | + | pub async fn issues_closed_by(app: &Shared, row: &db::repos::RepoRow, texts: &[&str]) -> Vec<Link> { |
|
| 23 | + | let mut numbers: Vec<i64> = texts.iter().flat_map(|t| refs::closes(t)).collect(); |
|
| 24 | + | numbers.sort(); |
|
| 25 | + | numbers.dedup(); |
|
| 26 | + | let mut out = Vec::new(); |
|
| 27 | + | for n in numbers { |
|
| 28 | + | if let Ok(Some(i)) = idb::get(&app.db, row.id, n).await { |
|
| 29 | + | out.push(Link { kind: "issue", number: n, title: i.title, state: i.state, url: format!("/{}/issue/{n}", row.slug()) }); |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | out |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// The proposals of this repository that close issue `n`: in their |
|
| 36 | + | /// description, or in the message of one of their changes. |
|
| 37 | + | pub async fn proposals_closing(app: &Shared, row: &db::repos::RepoRow, n: i64) -> Vec<Link> { |
|
| 38 | + | let all = pdb::list(&app.db, row.id, &["draft", "open", "landed", "closed"]).await.unwrap_or_default(); |
|
| 39 | + | // The changes' messages, read once from the repository. |
|
| 40 | + | let hashes: Vec<Vec<String>> = all.iter().map(|p| p.projected_changes()).collect(); |
|
| 41 | + | let messages: Vec<Vec<String>> = match crate::repos::RepoName::new(&row.owner, &row.name) { |
|
| 42 | + | Some(name) => { |
|
| 43 | + | let wanted = hashes.clone(); |
|
| 44 | + | app.repos |
|
| 45 | + | .read(&name, move |r, _| { |
|
| 46 | + | Ok(wanted |
|
| 47 | + | .iter() |
|
| 48 | + | .map(|hs| { |
|
| 49 | + | hs.iter() |
|
| 50 | + | .filter_map(|h| oxy_core::hash::ChangeHash::from_hex(h)) |
|
| 51 | + | .filter_map(|h| r.store().get_change(&h).ok().map(|c| c.message)) |
|
| 52 | + | .collect() |
|
| 53 | + | }) |
|
| 54 | + | .collect()) |
|
| 55 | + | }) |
|
| 56 | + | .await |
|
| 57 | + | .unwrap_or_else(|_| vec![vec![]; hashes.len()]) |
|
| 58 | + | } |
|
| 59 | + | None => vec![vec![]; hashes.len()], |
|
| 60 | + | }; |
|
| 61 | + | all.into_iter() |
|
| 62 | + | .zip(messages) |
|
| 63 | + | .filter(|(p, msgs)| { |
|
| 64 | + | refs::closes(&p.body).contains(&n) || refs::closes(&p.title).contains(&n) || msgs.iter().any(|m| refs::closes(m).contains(&n)) |
|
| 65 | + | }) |
|
| 66 | + | .map(|(p, _)| Link { |
|
| 67 | + | kind: "proposal", |
|
| 68 | + | number: p.number, |
|
| 69 | + | url: format!("/{}/proposal/{}", row.slug(), p.number), |
|
| 70 | + | title: p.title, |
|
| 71 | + | state: p.state, |
|
| 72 | + | }) |
|
| 73 | + | .collect() |
|
| 74 | + | } |
modified crates/hub/src/render/refs.rs +8 −6
| … | 9 unchanged lines |
||
| 10 | 10 | } |
|
| 11 | 11 | ||
| 12 | 12 | impl Numbers<'_> { |
|
| 13 | - | fn url_for(&self, n: i64) -> Option<String> { |
|
| 13 | + | /// Where `#n` points, and what it is: issues and proposals share one |
|
| 14 | + | /// number space, so the link says which. |
|
| 15 | + | fn url_for(&self, n: i64) -> Option<(String, &'static str)> { |
|
| 14 | 16 | if self.proposals.contains(&n) { |
|
| 15 | - | Some(format!("{}/proposal/{n}", self.repo_url)) |
|
| 17 | + | Some((format!("{}/proposal/{n}", self.repo_url), "proposal")) |
|
| 16 | 18 | } else if self.issues.contains(&n) { |
|
| 17 | - | Some(format!("{}/issue/{n}", self.repo_url)) |
|
| 19 | + | Some((format!("{}/issue/{n}", self.repo_url), "issue")) |
|
| 18 | 20 | } else { |
|
| 19 | 21 | None |
|
| 20 | 22 | } |
|
| … | 41 unchanged lines |
||
| 62 | 64 | if let Some(n) = c.name("n") { |
|
| 63 | 65 | let num: i64 = n.as_str().parse().unwrap_or(0); |
|
| 64 | 66 | return match numbers.url_for(num) { |
|
| 65 | - | Some(url) => format!("{pre}[#{num}]({url})"), |
|
| 67 | + | Some((url, kind)) => format!("{pre}[{kind} #{num}]({url})"), |
|
| 66 | 68 | None => c[0].to_string(), |
|
| 67 | 69 | }; |
|
| 68 | 70 | } |
|
| … | 46 unchanged lines |
||
| 115 | 117 | fn links_references_outside_code() { |
|
| 116 | 118 | let n = Numbers { repo_url: "/a/b", issues: &[3], proposals: &[7] }; |
|
| 117 | 119 | let out = link("see #3 and #7 and #9, `#3` too, by @andy; a/b#3 and other/repo#5; hash deadbeefcafe1234\n```\n#3 @x\n```\n", &n); |
|
| 118 | - | assert!(out.contains("[#3](/a/b/issue/3)"), "{out}"); |
|
| 119 | - | assert!(out.contains("[#7](/a/b/proposal/7)"), "{out}"); |
|
| 120 | + | assert!(out.contains("[issue #3](/a/b/issue/3)"), "{out}"); |
|
| 121 | + | assert!(out.contains("[proposal #7](/a/b/proposal/7)"), "{out}"); |
|
| 120 | 122 | assert!(out.contains(" #9,"), "unknown numbers stay: {out}"); |
|
| 121 | 123 | assert!(out.contains("`#3`"), "code spans untouched: {out}"); |
|
| 122 | 124 | assert!(out.contains("[@andy](/andy)"), "{out}"); |
|
| … | 11 unchanged lines |
modified crates/hub/src/web/issues.rs +4 −0
| … | 314 unchanged lines |
||
| 315 | 315 | can_triage: bool, |
|
| 316 | 316 | /// Projects this issue is on that the viewer may see, with the column. |
|
| 317 | 317 | projects: Vec<(crate::db::projects::Project, String)>, |
|
| 318 | + | /// Proposals whose description closes it. |
|
| 319 | + | closed_by: Vec<super::chain::Link>, |
|
| 318 | 320 | notice: Option<String>, |
|
| 319 | 321 | error: Option<String>, |
|
| 320 | 322 | } |
|
| … | 50 unchanged lines |
||
| 371 | 373 | let can_triage = role >= Role::Triage; |
|
| 372 | 374 | let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage; |
|
| 373 | 375 | let projects = super::projects::placements_for(app, viewer, &row, i.number).await; |
|
| 376 | + | let closed_by = super::chain::proposals_closing(app, &row, i.number).await; |
|
| 374 | 377 | Ok(Page(IssuePage { |
|
| 375 | 378 | shell, |
|
| 376 | 379 | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| 377 | 380 | i, |
|
| 381 | + | closed_by, |
|
| 378 | 382 | body_html, |
|
| 379 | 383 | timeline: timeline.into_iter().map(|(_, it)| it).collect(), |
|
| 380 | 384 | comments, |
|
| … | 625 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 4 unchanged lines |
||
| 5 | 5 | ||
| 6 | 6 | mod admin; |
|
| 7 | 7 | mod avatar; |
|
| 8 | + | pub mod chain; |
|
| 8 | 9 | pub mod edit; |
|
| 9 | 10 | pub mod filters; |
|
| 10 | 11 | pub mod flow; |
|
| … | 590 unchanged lines |
modified crates/hub/src/web/proposals.rs +5 −0
| … | 545 unchanged lines |
||
| 546 | 546 | shell: Shell, |
|
| 547 | 547 | repo: RepoShell, |
|
| 548 | 548 | p: pdb::ProposalRow, |
|
| 549 | + | /// Issues it closes, from its description and its changes. |
|
| 550 | + | closes: Vec<super::chain::Link>, |
|
| 549 | 551 | body_html: String, |
|
| 550 | 552 | tab: String, |
|
| 551 | 553 | changes: Vec<ChangeView>, |
|
| … | 115 unchanged lines |
||
| 667 | 669 | _ => p.source_bookmark.clone(), |
|
| 668 | 670 | }; |
|
| 669 | 671 | let projects = super::projects::placements_for(app, viewer, &row, p.number).await; |
|
| 672 | + | let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect(); |
|
| 673 | + | let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await; |
|
| 670 | 674 | Ok(Page(ProposalPage { |
|
| 675 | + | closes, |
|
| 671 | 676 | projects, |
|
| 672 | 677 | shell, |
|
| 673 | 678 | repo: RepoShell::new(app, &row, "proposals").with_role(role), |
|
| … | 500 unchanged lines |
modified crates/hub/tests/api.rs +69 −0
| … | 1043 unchanged lines |
||
| 1044 | 1044 | // What has landed on main is not rearranged. |
|
| 1045 | 1045 | assert!(plan(&second, &base).await["error"].as_str().unwrap().contains("already on main")); |
|
| 1046 | 1046 | } |
|
| 1047 | + | ||
| 1048 | + | /// Issues and proposals share a number space, so every number says what it |
|
| 1049 | + | /// is, and the chain of work shows from both ends (hub spec §8). |
|
| 1050 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1051 | + | async fn proposals_and_issues_show_their_chain() { |
|
| 1052 | + | let h = hub().await; |
|
| 1053 | + | let app = &h.app; |
|
| 1054 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1055 | + | .await |
|
| 1056 | + | .cookie |
|
| 1057 | + | .unwrap(); |
|
| 1058 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1059 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 1060 | + | let url = format!("{}/andy/demo", h.base); |
|
| 1061 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1062 | + | let w = work.path().to_path_buf(); |
|
| 1063 | + | tokio::task::spawn_blocking(move || { |
|
| 1064 | + | let mut c = Repo::init(&w).unwrap(); |
|
| 1065 | + | c.config.set("remote.origin.url", &url).unwrap(); |
|
| 1066 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 1067 | + | c.save_config().unwrap(); |
|
| 1068 | + | write(&w, "a.txt", b"a\n"); |
|
| 1069 | + | c.commit("base", &[]).unwrap(); |
|
| 1070 | + | assert!(c.push("origin", false).unwrap()[0].ok); |
|
| 1071 | + | c.bookmark_create("feature", None).unwrap(); |
|
| 1072 | + | c.bookmark_switch("feature", false).unwrap(); |
|
| 1073 | + | write(&w, "a.txt", b"a\nfixed\n"); |
|
| 1074 | + | c.commit("Fix the crash\n\nfixes #2", &[]).unwrap(); |
|
| 1075 | + | assert!(c.push_bookmark("origin", "feature", false).unwrap()[0].ok); |
|
| 1076 | + | }) |
|
| 1077 | + | .await |
|
| 1078 | + | .unwrap(); |
|
| 1079 | + | for title in ["Slow start", "Crash on save"] { |
|
| 1080 | + | let (st, _) = api(app, "POST", "/api/v1/repos/andy/demo/issues", Some("site"), Some(&format!(r#"{{"title":"{title}"}}"#))).await; |
|
| 1081 | + | assert!(st.is_success()); |
|
| 1082 | + | } |
|
| 1083 | + | let (st, v) = api( |
|
| 1084 | + | app, |
|
| 1085 | + | "POST", |
|
| 1086 | + | "/api/v1/repos/andy/demo/proposals", |
|
| 1087 | + | Some("site"), |
|
| 1088 | + | Some(r#"{"title":"Faster, safer","body":"Closes #1. See also #2.","target":"main","source":"feature"}"#), |
|
| 1089 | + | ) |
|
| 1090 | + | .await; |
|
| 1091 | + | assert!(st.is_success(), "{st} {v}"); |
|
| 1092 | + | assert_eq!(v["number"], 3, "one number space: issues 1 and 2, then proposal 3"); |
|
| 1093 | + | ||
| 1094 | + | // The proposal names its kind, links its references by kind, and shows |
|
| 1095 | + | // the chain: the issues it closes (description and changes) → it → main. |
|
| 1096 | + | let p = call(app, "GET", "/andy/demo/proposal/3", None, None, None, None).await.body; |
|
| 1097 | + | assert!(p.contains(">proposal #3</small>"), "header"); |
|
| 1098 | + | assert!(p.contains(">issue #1</a>") && p.contains(">issue #2</a>"), "links in the description say what they point at"); |
|
| 1099 | + | let chain = &p[p.find("class=\"chain\"").expect("a chain")..]; |
|
| 1100 | + | let chain = &chain[..chain.find("</p>").unwrap()]; |
|
| 1101 | + | for want in ["issue #1</span> Slow start", "issue #2</span> Crash on save", "proposal #3", ">main<"] { |
|
| 1102 | + | assert!(chain.contains(want), "chain lacks {want:?}: {chain}"); |
|
| 1103 | + | } |
|
| 1104 | + | assert!(p.contains("<h3>Closes</h3>")); |
|
| 1105 | + | ||
| 1106 | + | // The issue shows the proposal that closes it, and names its own kind. |
|
| 1107 | + | let i = call(app, "GET", "/andy/demo/issue/1", None, None, None, None).await.body; |
|
| 1108 | + | assert!(i.contains(">issue #1</small>")); |
|
| 1109 | + | let chain = &i[i.find("class=\"chain\"").expect("a chain on the issue")..]; |
|
| 1110 | + | assert!(chain.contains("closed by") && chain.contains("proposal #3</span> Faster, safer"), "{chain}"); |
|
| 1111 | + | // Closed by the proposal's change, not its description: still shown. |
|
| 1112 | + | let two = call(app, "GET", "/andy/demo/issue/2", None, None, None, None).await.body; |
|
| 1113 | + | let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..]; |
|
| 1114 | + | assert!(chain.contains("proposal #3"), "{chain}"); |
|
| 1115 | + | } |
modified docs/spec.md +3 −1
| … | 373 unchanged lines |
||
| 374 | 374 | ||
| 375 | 375 | ## 8. Issues |
|
| 376 | 376 | ||
| 377 | - | 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. |
|
| 377 | + | Per-repository numbered issues sharing the number space with proposals (as GitHub does; so every number is shown with its kind, "issue #1" or "proposal #2", in page headers and in the links `#N` becomes), 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. |
|
| 378 | + | ||
| 379 | + | **The chain of work.** A proposal shows the issues it closes (`closes`, `fixes` or `resolves #N` in its description or in any of its changes' messages) in a strip under its title: each issue with its state, then the proposal, then its target bookmark, marked landed once it is. A Closes list sits in its sidebar. An issue shows the proposals that close it, by the same rule, with their states. So the path from a report to the change that landed it can be followed from either end. |
|
| 378 | 380 | ||
| 379 | 381 | 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. |
|
| 380 | 382 | ||
| … | 254 unchanged lines |
modified templates/issue.html +4 −2
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}#{{ i.number }} {{ i.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 3 | + | {% block title %}Issue #{{ i.number }} {{ i.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "issues") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>{{ i.title }} <small class="px">#{{ i.number }}</small> |
|
| 7 | + | <div class="head"><h1>{{ i.title }} <small class="px kind-issue">issue #{{ i.number }}</small> |
|
| 8 | 8 | <small><span class="tag{% if i.state == "closed" %} bad{% else %} head{% endif %}">{{ i.state }}{% if !i.reason.is_empty() %}: {{ i.reason.replace("_", " ") }}{% endif %}</span> {{ i.author }} opened this <time title="{{ i.created_ms|when_i }}">{{ i.created_ms|ago_i }}</time> · {{ comments.len()|plural("comment", "comments") }}{% if i.locked %} · <span class="tag">locked</span>{% endif %}</small> |
|
| 9 | 9 | </h1></div> |
|
| 10 | + | {% if !closed_by.is_empty() %}<p class="chain" aria-label="The chain of work"><span class="chain-link kind-issue st-{{ i.state }}"><span class="chain-kind">issue #{{ i.number }}</span> <span class="chain-state">{{ i.state }}</span></span> |
|
| 11 | + | <span class="chain-arrow">←</span> closed by {% for c in closed_by %}<a class="chain-link kind-proposal st-{{ c.state }}" href="{{ c.url }}" title="{{ c.title }}"><span class="chain-kind">proposal #{{ c.number }}</span> {{ c.title }} <span class="chain-state">{{ c.state }}</span></a>{% if !loop.last %} <span class="chain-and">·</span> {% endif %}{% endfor %}</p>{% endif %} |
|
| 10 | 12 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 11 | 13 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 12 | 14 | <div class="side-layout"> |
|
| … | 57 unchanged lines |
modified templates/proposal.html +6 −2
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}#{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 3 | + | {% block title %}Proposal #{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %} |
|
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | - | <div class="head"><h1>{{ p.title }} <small class="px">#{{ p.number }}</small> |
|
| 8 | + | <div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small> |
|
| 9 | 9 | <small> |
|
| 10 | 10 | <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span> |
|
| 11 | 11 | {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a> |
|
| 12 | 12 | · {{ changes.len()|plural("change", "changes") }} · opened <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time> |
|
| 13 | 13 | </small> |
|
| 14 | 14 | </h1></div> |
|
| 15 | + | {% if !closes.is_empty() %}<p class="chain" aria-label="The chain of work">{% for c in closes %}<a class="chain-link kind-issue st-{{ c.state }}" href="{{ c.url }}" title="{{ c.title }}"><span class="chain-kind">issue #{{ c.number }}</span> {{ c.title }} <span class="chain-state">{{ c.state }}</span></a>{% if !loop.last %} <span class="chain-and">+</span> {% endif %}{% endfor %} |
|
| 16 | + | <span class="chain-arrow">→</span> <span class="chain-link kind-proposal st-{{ p.state }}"><span class="chain-kind">proposal #{{ p.number }}</span> <span class="chain-state">{{ p.state }}</span></span> |
|
| 17 | + | <span class="chain-arrow">→</span> <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>{% if p.state == "landed" %} <span class="chain-state st-landed">landed</span>{% endif %}</p>{% endif %} |
|
| 15 | 18 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 16 | 19 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 17 | 20 | {% if conflicted %}<div class="strip bad">Landing would carry {{ conflicts.len()|plural("conflict", "conflicts") }}. Any collaborator can push a resolution change to the source; the proposal follows it. <a href="?tab=files#conflicts">See them</a></div>{% endif %} |
|
| … | 95 unchanged lines |
||
| 113 | 116 | {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %} |
|
| 114 | 117 | {% endif %} |
|
| 115 | 118 | {% endif %} |
|
| 119 | + | {% if !closes.is_empty() %}<h3>Closes</h3><ul class="plain">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="small mute">{{ c.state }}</span></li>{% endfor %}</ul>{% endif %} |
|
| 116 | 120 | <h3>Labels</h3> |
|
| 117 | 121 | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 118 | 122 | {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %} |
|
| … | 36 unchanged lines |
modified templates/style.css +15 −0
| … | 578 unchanged lines |
||
| 579 | 579 | .runner-status.st-offline { color: var(--mute); } |
|
| 580 | 580 | .runner-status.st-draining { color: var(--warn); border-color: var(--warn); } |
|
| 581 | 581 | .runner-status.st-disabled { color: var(--bad); border-color: var(--bad); } |
|
| 582 | + | ||
| 583 | + | /* The chain of work: issue → proposal → bookmark (hub spec §8). Issues and |
|
| 584 | + | proposals share one number space, so every number says which it is. */ |
|
| 585 | + | .kind-issue::before { content: "◇ "; color: var(--warn); } |
|
| 586 | + | .kind-proposal::before { content: "⇄ "; color: var(--acc); } |
|
| 587 | + | p.chain { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 10px 0 14px; padding: 8px 12px; border: 1px solid var(--line); border-left: 3px solid var(--acc); background: var(--panel); font-size: 13px; } |
|
| 588 | + | .chain-link { display: inline-flex; align-items: baseline; gap: 6px; padding: 2px 8px; border: 1px solid var(--line); background: var(--soft); color: var(--ink); text-decoration: none; } |
|
| 589 | + | a.chain-link:hover { border-color: var(--acc); text-decoration: none; } |
|
| 590 | + | .chain-kind { font-weight: 600; } |
|
| 591 | + | .chain-state { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--mute); } |
|
| 592 | + | .st-open .chain-state, .chain-state.st-open { color: var(--ok); } |
|
| 593 | + | .st-landed .chain-state, .chain-state.st-landed { color: var(--acc); } |
|
| 594 | + | .st-closed .chain-state { color: var(--mute); } |
|
| 595 | + | .chain-arrow { color: var(--acc); font-weight: 700; } |
|
| 596 | + | .chain-and { color: var(--mute); } |