#30 m6/open-counts
Patch
src/build.rs+80 −17--- a/src/build.rs+++ b/src/build.rs@@ -424,7 +424,8 @@ fn render_text_pre(display: &str) -> String {424424 out425425 }426426 427-/// Top nav with live f0rge counts (no stubs, no TODO markers).427+/// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:428+/// open means `state == "open"` exactly; drafts count as open).428429 fn nav_bar(issue_count: usize, pr_count: usize) -> String {429430 format!(430431 "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \@@ -509,9 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu509510 510511 // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front511512 // so every page (including file pages below) gets a nav with live512- // counts. Loading never fails: bad files are skipped + warned.513+ // open counts (m6 open-counts, f0rge #29). Loading never fails:514+ // bad files are skipped + warned.513515 let forge_data = forge::load(repo);514- let nav = nav_bar(forge_data.issues.len(), forge_data.prs.len());516+ let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());515517 516518 // Raw files first: byte-identical copies.517519 let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();@@ -667,6 +669,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu667669 668670 // index.html header: stacked repo-head (name + clone + meta) so narrow669671 // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.672+ // No counts line here (m6 open-counts, f0rge #36): nav carries the673+ // open issues/pulls counts; branch/sha/CI meta stay.670674 let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));671675 if let Some(d) = &description {672676 body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));@@ -676,16 +680,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu676680 esc(&branch),677681 esc(&short_sha)678682 ));679- body.push_str(&format!(680- "<p><a href=\"/issues/index.html\">issues ({})</a> · \681- <a href=\"/pulls/index.html\">pulls ({})</a> · \682- <a href=\"/tasks/index.html\">tasks ({})</a> · \683- <a href=\"/releases/index.html\">releases ({})</a></p>\n",684- forge_data.issues.len(),685- forge_data.prs.len(),686- forge_data.tasks.len(),687- release_data.len(),688- ));689683 body.push_str(&format!(690684 "<p>CI: {}</p>\n",691685 forge::verdict_badge(forge_data.ci.latest_verdict()),@@ -890,14 +884,21 @@ mod tests {890884 "raw HTML escaped"891885 );892886 assert!(index.contains("<script>"), "escaped HTML present");893- // Header: branch + short sha, live f0rge counts, CI verdict badge.887+ // Header: branch + short sha, live open f0rge counts, CI verdict badge.894888 assert!(index.contains("main"), "branch shown");895889 assert!(!index.contains("TODO"), "no TODO markers left");896- assert!(index.contains("issues (0)"), "f0rge counts as 0");897- assert!(index.contains("pulls (0)"), "pull counts as 0");898- assert!(index.contains("tasks (0)"), "task counts as 0");890+ assert!(index.contains("issues (0)"), "nav open issues 0");891+ assert!(index.contains("pulls (0)"), "nav open pulls 0");892+ assert!(index.contains("repo-meta"), "branch/sha meta kept");899893 assert!(index.contains("CI:"), "CI verdict shown");900894 assert!(index.contains("badge"), "CI verdict badge");895+ // f0rge #36: code tab carries no redundant counts line.896+ let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");897+ let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;898+ let head = &index[head_start..head_end];899+ for needle in ["issues (", "pulls (", "tasks (", "releases ("] {900+ assert!(!head.contains(needle), "code tab drops {needle}");901+ }901902 // Clone block (bundle-first: no origin remote is configured).902903 assert!(index.contains("clone-block"), "clone block present");903904 assert!(index.contains("repo.bundle"), "bundle linked");@@ -914,6 +915,68 @@ mod tests {914915 );915916 }916917 918+ #[test]919+ fn open_counts_nav_and_no_code_tab_counts() {920+ // m6/open-counts (f0rge #29 + #36): header nav is open-only,921+ // code tab keeps branch/sha/CI but drops the counts line,922+ // index pages carry the `N closed` totals.923+ let repo = fixture_repo();924+ let forge_write = |rel: &str, content: &str| {925+ let dest = repo.join(".forge").join(rel);926+ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();927+ std::fs::write(&dest, content).unwrap();928+ };929+ for (uuid, state, hour) in [930+ ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),931+ ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),932+ ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),933+ ] {934+ forge_write(935+ &format!("issues/{uuid}/issue.md"),936+ &format!(937+ "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"938+ ),939+ );940+ }941+ for (uuid, state, draft, hour) in [942+ ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),943+ ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),944+ (945+ "ffffffff-ffff-ffff-ffff-ffffffffffff",946+ "merged",947+ false,948+ "05",949+ ),950+ ] {951+ forge_write(952+ &format!("prs/{uuid}/pr.md"),953+ &format!(954+ "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"955+ ),956+ );957+ }958+ git(&repo, &["add", "-A"]);959+ git(&repo, &["commit", "-m", "forge counts"]);960+ let out = mktemp("out-open-counts");961+ run_build(&repo, &out, &opts()).expect("build");962+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");963+ assert!(index.contains("issues (1)"), "nav open issues");964+ assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");965+ assert!(index.contains("repo-meta"), "branch/sha meta kept");966+ assert!(index.contains("CI:"), "CI meta kept");967+ let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");968+ let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;969+ let head = &index[head_start..head_end];970+ for needle in ["issues (", "pulls (", "tasks (", "releases ("] {971+ assert!(!head.contains(needle), "code tab drops {needle}");972+ }973+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");974+ assert!(issues.contains("2 closed"), "issues closed total");975+ let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");976+ assert!(pulls.contains("1 closed"), "pulls closed total");977+ assert!(pulls.contains(" draft"), "draft stays marked");978+ }979+917980 #[test]918981 fn clone_url_flag_wins() {919982 let repo = fixture_repo();
src/forge.rs+101 −2--- a/src/forge.rs+++ b/src/forge.rs@@ -418,6 +418,37 @@ pub struct ForgeData {418418 pub ci: CiInfo,419419 }420420 421+impl ForgeData {422+ /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29).423+ /// `in-progress` (and `closed`/`archived`) counts as NOT open.424+ #[must_use]425+ pub fn open_issue_count(&self) -> usize {426+ self.issues.iter().filter(|i| i.state == "open").count()427+ }428+429+ /// Non-open issues (`total - open`): shown as the `N closed` line on430+ /// the issues index so closed totals stay visible (f0rge #29).431+ #[must_use]432+ pub fn closed_issue_count(&self) -> usize {433+ self.issues.len() - self.open_issue_count()434+ }435+436+ /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29).437+ /// Drafts (`draft: true`) are counted as open and stay marked438+ /// (` draft` suffix) in the pulls table.439+ #[must_use]440+ pub fn open_pr_count(&self) -> usize {441+ self.prs.iter().filter(|p| p.state == "open").count()442+ }443+444+ /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the445+ /// `N closed` line on the pulls index (f0rge #29).446+ #[must_use]447+ pub fn closed_pr_count(&self) -> usize {448+ self.prs.len() - self.open_pr_count()449+ }450+}451+421452 // ---------------------------------------------------------------------------422453 // Loading423454 // ---------------------------------------------------------------------------@@ -966,6 +997,10 @@ fn render_issues_index(966997 }967998 body.push_str("</tbody>\n</table></div>\n");968999 }1000+ body.push_str(&format!(1001+ "<p class=\"muted\">{n} closed</p>\n",1002+ n = data.closed_issue_count(),1003+ ));9691004 if !data.labels.is_empty() {9701005 body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");9711006 for l in &data.labels {@@ -993,7 +1028,13 @@ fn render_issues_index(9931028 ))9941029 }9951030 996-fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {1031+fn render_pulls_index(1032+ data: &ForgeData,1033+ site_name: &str,1034+ nav: &str,1035+ description: Option<&str>,1036+) -> String {1037+ let prs = &data.prs;9971038 let mut body =9981039 String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");9991040 if prs.is_empty() {@@ -1014,6 +1055,10 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Optio10141055 }10151056 body.push_str("</tbody>\n</table></div>\n");10161057 }1058+ body.push_str(&format!(1059+ "<p class=\"muted\">{n} closed</p>\n",1060+ n = data.closed_pr_count(),1061+ ));10171062 page_site(10181063 &format!("pulls — {site_name}"),10191064 nav,@@ -1166,7 +1211,7 @@ pub fn render_loaded(11661211 write_page(11671212 out,11681213 "pulls/index.html",1169- &render_pulls_index(&data.prs, site_name, nav, description),1214+ &render_pulls_index(data, site_name, nav, description),11701215 )?;11711216 for pr in &data.prs {11721217 write_page(@@ -1651,4 +1696,58 @@ mod tests {16511696 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");16521697 assert!(pulls.contains(&long_sha), "long head sha kept");16531698 }1699+1700+ /// m6/open-counts (f0rge #29): nav counts are open-only1701+ /// (`state == "open"` exactly; in-progress is NOT open; drafts count1702+ /// as open and stay marked) with `N closed` totals on index pages.1703+ #[test]1704+ fn open_only_counts_with_closed_lines() {1705+ let repo = mktemp("open-counts");1706+ for (uuid, state, hour) in [1707+ ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),1708+ ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),1709+ ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),1710+ ] {1711+ write_forge(1712+ &repo,1713+ &format!("issues/{uuid}/issue.md"),1714+ &format!(1715+ "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"1716+ ),1717+ );1718+ }1719+ for (uuid, state, draft, hour) in [1720+ ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),1721+ ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),1722+ (1723+ "ffffffff-ffff-ffff-ffff-ffffffffffff",1724+ "merged",1725+ false,1726+ "05",1727+ ),1728+ ] {1729+ write_forge(1730+ &repo,1731+ &format!("prs/{uuid}/pr.md"),1732+ &format!(1733+ "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"1734+ ),1735+ );1736+ }1737+ let data = load(&repo);1738+ assert_eq!(data.open_issue_count(), 1, "only state==open");1739+ assert_eq!(data.closed_issue_count(), 2, "in-progress + closed");1740+ assert_eq!(data.open_pr_count(), 2, "open + draft-open");1741+ assert_eq!(data.closed_pr_count(), 1, "merged");1742+1743+ let out = mktemp("open-counts-out");1744+ render_loaded(&data, &out, "test", "<nav>", None).expect("render");1745+ let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1746+ assert!(issues.contains("2 closed"), "issues closed total");1747+ assert!(issues.contains("Issue open"), "open row kept");1748+ assert!(issues.contains("Issue closed"), "closed row kept");1749+ let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1750+ assert!(pulls.contains("1 closed"), "pulls closed total");1751+ assert!(pulls.contains(" draft"), "draft stays marked");1752+ }16541753 }
tests/forge_readers.rs+16 −5--- a/tests/forge_readers.rs+++ b/tests/forge_readers.rs@@ -309,15 +309,27 @@ fn forge_fixture_renders_counts_numbers_and_ci() {309309 assert!(ci.contains(">fail<"), "verdict text on ci page");310310 assert!(ci.contains("boom: the test failed"), "run log in <pre>");311311 312- // --- index header: real counts + verdict, no TODO markers ---312+ // --- index header: open counts + verdict, no TODO markers ---313+ // m6/open-counts (#29 + #36): nav is open-only (Older open, Newer314+ // closed -> issues (1)); code tab keeps branch/sha/CI, drops the315+ // redundant counts line; closed totals live on index pages.313316 let index = read("index.html");314- assert!(index.contains("issues (2)"), "real issue count");315- assert!(index.contains("pulls (1)"), "real pull count");316- assert!(index.contains("tasks (4)"), "real task count");317+ assert!(index.contains("issues (1)"), "nav open issue count");318+ assert!(index.contains("pulls (1)"), "nav open pull count");319+ assert!(index.contains("repo-meta"), "branch/sha meta kept");320+ let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");321+ let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;322+ let head = &index[head_start..head_end];323+ for needle in ["issues (", "pulls (", "tasks (", "releases ("] {324+ assert!(!head.contains(needle), "code tab drops {needle}");325+ }317326 assert!(index.contains("badge fail"), "index verdict badge");318327 assert!(!index.contains("TODO"), "TODO markers deleted");319328 assert!(index.contains("/issues/index.html"), "nav links issues");320329 assert!(index.contains("/pulls/index.html"), "nav links pulls");321330 assert!(index.contains("/tasks/index.html"), "nav links tasks");322331 assert!(index.contains("/ci/index.html"), "nav links ci");332+ // Closed totals stay visible on their index pages.333+ assert!(issues.contains("1 closed"), "issues closed total");334+ assert!(pulls.contains("0 closed"), "pulls closed total");323335 }324--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.