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", "