From 5d700ecb2cc50b63f5d7208a55ba0b815679cda8 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 07:49:52 +0100 Subject: [PATCH] m6/open-counts: open-only nav, drop code-tab counts line --- src/build.rs | 97 +++++++++++++++++++++++++++++++------- src/forge.rs | 103 ++++++++++++++++++++++++++++++++++++++++- tests/forge_readers.rs | 20 ++++++-- 3 files changed, 197 insertions(+), 23 deletions(-) diff --git a/src/build.rs b/src/build.rs index a48bee1..d158b5c 100644 --- a/src/build.rs +++ b/src/build.rs @@ -424,7 +424,8 @@ fn render_text_pre(display: &str) -> String { out } -/// Top nav with live f0rge counts (no stubs, no TODO markers). +/// Top nav with live open f0rge counts (m6 open-counts, f0rge #29: +/// open means `state == "open"` exactly; drafts count as open). fn nav_bar(issue_count: usize, pr_count: usize) -> String { format!( "code · commits · \ @@ -509,9 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result> = BTreeMap::new(); @@ -667,6 +669,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result

{}

\n", esc(&name)); if let Some(d) = &description { body.push_str(&format!("

{}

\n", esc(d))); @@ -676,16 +680,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultissues ({}) · \ - pulls ({}) · \ - tasks ({}) · \ - releases ({})

\n", - forge_data.issues.len(), - forge_data.prs.len(), - forge_data.tasks.len(), - release_data.len(), - )); body.push_str(&format!( "

CI: {}

\n", forge::verdict_badge(forge_data.ci.latest_verdict()), @@ -890,14 +884,21 @@ mod tests { "raw HTML escaped" ); assert!(index.contains("<script>"), "escaped HTML present"); - // Header: branch + short sha, live f0rge counts, CI verdict badge. + // Header: branch + short sha, live open f0rge counts, CI verdict badge. assert!(index.contains("main"), "branch shown"); assert!(!index.contains("TODO"), "no TODO markers left"); - assert!(index.contains("issues (0)"), "f0rge counts as 0"); - assert!(index.contains("pulls (0)"), "pull counts as 0"); - assert!(index.contains("tasks (0)"), "task counts as 0"); + assert!(index.contains("issues (0)"), "nav open issues 0"); + assert!(index.contains("pulls (0)"), "nav open pulls 0"); + assert!(index.contains("repo-meta"), "branch/sha meta kept"); assert!(index.contains("CI:"), "CI verdict shown"); assert!(index.contains("badge"), "CI verdict badge"); + // f0rge #36: code tab carries no redundant counts line. + let head_start = index.find("
").expect("repo-head"); + let head_end = index[head_start..].find("
").expect("repo-head end") + head_start; + let head = &index[head_start..head_end]; + for needle in ["issues (", "pulls (", "tasks (", "releases ("] { + assert!(!head.contains(needle), "code tab drops {needle}"); + } // Clone block (bundle-first: no origin remote is configured). assert!(index.contains("clone-block"), "clone block present"); assert!(index.contains("repo.bundle"), "bundle linked"); @@ -914,6 +915,68 @@ mod tests { ); } + #[test] + fn open_counts_nav_and_no_code_tab_counts() { + // m6/open-counts (f0rge #29 + #36): header nav is open-only, + // code tab keeps branch/sha/CI but drops the counts line, + // index pages carry the `N closed` totals. + let repo = fixture_repo(); + let forge_write = |rel: &str, content: &str| { + let dest = repo.join(".forge").join(rel); + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap(); + std::fs::write(&dest, content).unwrap(); + }; + for (uuid, state, hour) in [ + ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"), + ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"), + ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"), + ] { + forge_write( + &format!("issues/{uuid}/issue.md"), + &format!( + "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n" + ), + ); + } + for (uuid, state, draft, hour) in [ + ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"), + ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"), + ( + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "merged", + false, + "05", + ), + ] { + forge_write( + &format!("prs/{uuid}/pr.md"), + &format!( + "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n" + ), + ); + } + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "forge counts"]); + let out = mktemp("out-open-counts"); + run_build(&repo, &out, &opts()).expect("build"); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("issues (1)"), "nav open issues"); + assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)"); + assert!(index.contains("repo-meta"), "branch/sha meta kept"); + assert!(index.contains("CI:"), "CI meta kept"); + let head_start = index.find("
").expect("repo-head"); + let head_end = index[head_start..].find("
").expect("repo-head end") + head_start; + let head = &index[head_start..head_end]; + for needle in ["issues (", "pulls (", "tasks (", "releases ("] { + assert!(!head.contains(needle), "code tab drops {needle}"); + } + let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues"); + assert!(issues.contains("2 closed"), "issues closed total"); + let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls"); + assert!(pulls.contains("1 closed"), "pulls closed total"); + assert!(pulls.contains(" draft"), "draft stays marked"); + } + #[test] fn clone_url_flag_wins() { let repo = fixture_repo(); diff --git a/src/forge.rs b/src/forge.rs index 3574c8a..fe2c0e4 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -418,6 +418,37 @@ pub struct ForgeData { pub ci: CiInfo, } +impl ForgeData { + /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29). + /// `in-progress` (and `closed`/`archived`) counts as NOT open. + #[must_use] + pub fn open_issue_count(&self) -> usize { + self.issues.iter().filter(|i| i.state == "open").count() + } + + /// Non-open issues (`total - open`): shown as the `N closed` line on + /// the issues index so closed totals stay visible (f0rge #29). + #[must_use] + pub fn closed_issue_count(&self) -> usize { + self.issues.len() - self.open_issue_count() + } + + /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29). + /// Drafts (`draft: true`) are counted as open and stay marked + /// (` draft` suffix) in the pulls table. + #[must_use] + pub fn open_pr_count(&self) -> usize { + self.prs.iter().filter(|p| p.state == "open").count() + } + + /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the + /// `N closed` line on the pulls index (f0rge #29). + #[must_use] + pub fn closed_pr_count(&self) -> usize { + self.prs.len() - self.open_pr_count() + } +} + // --------------------------------------------------------------------------- // Loading // --------------------------------------------------------------------------- @@ -966,6 +997,10 @@ fn render_issues_index( } body.push_str("\n\n"); } + body.push_str(&format!( + "

{n} closed

\n", + n = data.closed_issue_count(), + )); if !data.labels.is_empty() { body.push_str("

Labels

\n
    \n"); for l in &data.labels { @@ -993,7 +1028,13 @@ fn render_issues_index( )) } -fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String { +fn render_pulls_index( + data: &ForgeData, + site_name: &str, + nav: &str, + description: Option<&str>, +) -> String { + let prs = &data.prs; let mut body = String::from("\n"); if prs.is_empty() { @@ -1014,6 +1055,10 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Optio } body.push_str("\n\n"); } + body.push_str(&format!( + "

    {n} closed

    \n", + n = data.closed_pr_count(), + )); page_site( &format!("pulls — {site_name}"), nav, @@ -1166,7 +1211,7 @@ pub fn render_loaded( write_page( out, "pulls/index.html", - &render_pulls_index(&data.prs, site_name, nav, description), + &render_pulls_index(data, site_name, nav, description), )?; for pr in &data.prs { write_page( @@ -1651,4 +1696,58 @@ mod tests { let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls"); assert!(pulls.contains(&long_sha), "long head sha kept"); } + + /// m6/open-counts (f0rge #29): nav counts are open-only + /// (`state == "open"` exactly; in-progress is NOT open; drafts count + /// as open and stay marked) with `N closed` totals on index pages. + #[test] + fn open_only_counts_with_closed_lines() { + let repo = mktemp("open-counts"); + for (uuid, state, hour) in [ + ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"), + ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"), + ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"), + ] { + write_forge( + &repo, + &format!("issues/{uuid}/issue.md"), + &format!( + "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n" + ), + ); + } + for (uuid, state, draft, hour) in [ + ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"), + ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"), + ( + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "merged", + false, + "05", + ), + ] { + write_forge( + &repo, + &format!("prs/{uuid}/pr.md"), + &format!( + "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n" + ), + ); + } + let data = load(&repo); + assert_eq!(data.open_issue_count(), 1, "only state==open"); + assert_eq!(data.closed_issue_count(), 2, "in-progress + closed"); + assert_eq!(data.open_pr_count(), 2, "open + draft-open"); + assert_eq!(data.closed_pr_count(), 1, "merged"); + + let out = mktemp("open-counts-out"); + render_loaded(&data, &out, "test", "