#30 m6/open-counts

mergedpassopened
details
branches
master ← m6/open-counts
updated
id
da1e8606-82e6-4b10-8145-8f7959ddfd27

Patch

src/build.rs+80 −17
diff --git a/src/build.rs b/src/build.rsindex a48bee1..d158b5c 100644--- 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("&lt;script&gt;"), "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
diff --git a/src/forge.rs b/src/forge.rsindex 3574c8a..fe2c0e4 100644--- 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
diff --git a/tests/forge_readers.rs b/tests/forge_readers.rsindex fda2b8b..d695600 100644--- 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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls