.forge/prs/da1e8606-82e6-4b10-8145-8f7959ddfd27/patch.diff 15.4 KiB 345 lines Raw
1 From 5d700ecb2cc50b63f5d7208a55ba0b815679cda8 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:49:52 +01004 Subject: [PATCH] m6/open-counts: open-only nav, drop code-tab counts line5 6 ---7  src/build.rs           |  97 +++++++++++++++++++++++++++++++-------8  src/forge.rs           | 103 ++++++++++++++++++++++++++++++++++++++++-9  tests/forge_readers.rs |  20 ++++++--10  3 files changed, 197 insertions(+), 23 deletions(-)11 12 diff --git a/src/build.rs b/src/build.rs13 index a48bee1..d158b5c 10064414 --- a/src/build.rs15 +++ b/src/build.rs16 @@ -424,7 +424,8 @@ fn render_text_pre(display: &str) -> String {17      out18  }19  20 -/// Top nav with live f0rge counts (no stubs, no TODO markers).21 +/// Top nav with live open f0rge counts (m6 open-counts, f0rge #29:22 +/// open means `state == "open"` exactly; drafts count as open).23  fn nav_bar(issue_count: usize, pr_count: usize) -> String {24      format!(25          "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \26 @@ -509,9 +510,10 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu27  28      // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front29      // so every page (including file pages below) gets a nav with live30 -    // counts. Loading never fails: bad files are skipped + warned.31 +    // open counts (m6 open-counts, f0rge #29). Loading never fails:32 +    // bad files are skipped + warned.33      let forge_data = forge::load(repo);34 -    let nav = nav_bar(forge_data.issues.len(), forge_data.prs.len());35 +    let nav = nav_bar(forge_data.open_issue_count(), forge_data.open_pr_count());36  37      // Raw files first: byte-identical copies.38      let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();39 @@ -667,6 +669,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu40  41      // index.html header: stacked repo-head (name + clone + meta) so narrow42      // screens wrap safely; clone/sha break-all via `.clone`/`.repo-meta`.43 +    // No counts line here (m6 open-counts, f0rge #36): nav carries the44 +    // open issues/pulls counts; branch/sha/CI meta stay.45      let mut body = format!("<div class=\"repo-head\"><h1>{}</h1>\n", esc(&name));46      if let Some(d) = &description {47          body.push_str(&format!("<p class=\"repo-desc\">{}</p>\n", esc(d)));48 @@ -676,16 +680,6 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu49          esc(&branch),50          esc(&short_sha)51      ));52 -    body.push_str(&format!(53 -        "<p><a href=\"/issues/index.html\">issues ({})</a> · \54 -         <a href=\"/pulls/index.html\">pulls ({})</a> · \55 -         <a href=\"/tasks/index.html\">tasks ({})</a> · \56 -         <a href=\"/releases/index.html\">releases ({})</a></p>\n",57 -        forge_data.issues.len(),58 -        forge_data.prs.len(),59 -        forge_data.tasks.len(),60 -        release_data.len(),61 -    ));62      body.push_str(&format!(63          "<p>CI: {}</p>\n",64          forge::verdict_badge(forge_data.ci.latest_verdict()),65 @@ -890,14 +884,21 @@ mod tests {66              "raw HTML escaped"67          );68          assert!(index.contains("&lt;script&gt;"), "escaped HTML present");69 -        // Header: branch + short sha, live f0rge counts, CI verdict badge.70 +        // Header: branch + short sha, live open f0rge counts, CI verdict badge.71          assert!(index.contains("main"), "branch shown");72          assert!(!index.contains("TODO"), "no TODO markers left");73 -        assert!(index.contains("issues (0)"), "f0rge counts as 0");74 -        assert!(index.contains("pulls (0)"), "pull counts as 0");75 -        assert!(index.contains("tasks (0)"), "task counts as 0");76 +        assert!(index.contains("issues (0)"), "nav open issues 0");77 +        assert!(index.contains("pulls (0)"), "nav open pulls 0");78 +        assert!(index.contains("repo-meta"), "branch/sha meta kept");79          assert!(index.contains("CI:"), "CI verdict shown");80          assert!(index.contains("badge"), "CI verdict badge");81 +        // f0rge #36: code tab carries no redundant counts line.82 +        let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");83 +        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;84 +        let head = &index[head_start..head_end];85 +        for needle in ["issues (", "pulls (", "tasks (", "releases ("] {86 +            assert!(!head.contains(needle), "code tab drops {needle}");87 +        }88          // Clone block (bundle-first: no origin remote is configured).89          assert!(index.contains("clone-block"), "clone block present");90          assert!(index.contains("repo.bundle"), "bundle linked");91 @@ -914,6 +915,68 @@ mod tests {92          );93      }94  95 +    #[test]96 +    fn open_counts_nav_and_no_code_tab_counts() {97 +        // m6/open-counts (f0rge #29 + #36): header nav is open-only,98 +        // code tab keeps branch/sha/CI but drops the counts line,99 +        // index pages carry the `N closed` totals.100 +        let repo = fixture_repo();101 +        let forge_write = |rel: &str, content: &str| {102 +            let dest = repo.join(".forge").join(rel);103 +            std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();104 +            std::fs::write(&dest, content).unwrap();105 +        };106 +        for (uuid, state, hour) in [107 +            ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),108 +            ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),109 +            ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),110 +        ] {111 +            forge_write(112 +                &format!("issues/{uuid}/issue.md"),113 +                &format!(114 +                    "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"115 +                ),116 +            );117 +        }118 +        for (uuid, state, draft, hour) in [119 +            ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),120 +            ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),121 +            (122 +                "ffffffff-ffff-ffff-ffff-ffffffffffff",123 +                "merged",124 +                false,125 +                "05",126 +            ),127 +        ] {128 +            forge_write(129 +                &format!("prs/{uuid}/pr.md"),130 +                &format!(131 +                    "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"132 +                ),133 +            );134 +        }135 +        git(&repo, &["add", "-A"]);136 +        git(&repo, &["commit", "-m", "forge counts"]);137 +        let out = mktemp("out-open-counts");138 +        run_build(&repo, &out, &opts()).expect("build");139 +        let index = std::fs::read_to_string(out.join("index.html")).expect("index");140 +        assert!(index.contains("issues (1)"), "nav open issues");141 +        assert!(index.contains("pulls (2)"), "nav open pulls (draft incl)");142 +        assert!(index.contains("repo-meta"), "branch/sha meta kept");143 +        assert!(index.contains("CI:"), "CI meta kept");144 +        let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");145 +        let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;146 +        let head = &index[head_start..head_end];147 +        for needle in ["issues (", "pulls (", "tasks (", "releases ("] {148 +            assert!(!head.contains(needle), "code tab drops {needle}");149 +        }150 +        let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");151 +        assert!(issues.contains("2 closed"), "issues closed total");152 +        let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");153 +        assert!(pulls.contains("1 closed"), "pulls closed total");154 +        assert!(pulls.contains(" draft"), "draft stays marked");155 +    }156 +157      #[test]158      fn clone_url_flag_wins() {159          let repo = fixture_repo();160 diff --git a/src/forge.rs b/src/forge.rs161 index 3574c8a..fe2c0e4 100644162 --- a/src/forge.rs163 +++ b/src/forge.rs164 @@ -418,6 +418,37 @@ pub struct ForgeData {165      pub ci: CiInfo,166  }167  168 +impl ForgeData {169 +    /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29).170 +    /// `in-progress` (and `closed`/`archived`) counts as NOT open.171 +    #[must_use]172 +    pub fn open_issue_count(&self) -> usize {173 +        self.issues.iter().filter(|i| i.state == "open").count()174 +    }175 +176 +    /// Non-open issues (`total - open`): shown as the `N closed` line on177 +    /// the issues index so closed totals stay visible (f0rge #29).178 +    #[must_use]179 +    pub fn closed_issue_count(&self) -> usize {180 +        self.issues.len() - self.open_issue_count()181 +    }182 +183 +    /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29).184 +    /// Drafts (`draft: true`) are counted as open and stay marked185 +    /// (` draft` suffix) in the pulls table.186 +    #[must_use]187 +    pub fn open_pr_count(&self) -> usize {188 +        self.prs.iter().filter(|p| p.state == "open").count()189 +    }190 +191 +    /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the192 +    /// `N closed` line on the pulls index (f0rge #29).193 +    #[must_use]194 +    pub fn closed_pr_count(&self) -> usize {195 +        self.prs.len() - self.open_pr_count()196 +    }197 +}198 +199  // ---------------------------------------------------------------------------200  // Loading201  // ---------------------------------------------------------------------------202 @@ -966,6 +997,10 @@ fn render_issues_index(203          }204          body.push_str("</tbody>\n</table></div>\n");205      }206 +    body.push_str(&format!(207 +        "<p class=\"muted\">{n} closed</p>\n",208 +        n = data.closed_issue_count(),209 +    ));210      if !data.labels.is_empty() {211          body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");212          for l in &data.labels {213 @@ -993,7 +1028,13 @@ fn render_issues_index(214      ))215  }216  217 -fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {218 +fn render_pulls_index(219 +    data: &ForgeData,220 +    site_name: &str,221 +    nav: &str,222 +    description: Option<&str>,223 +) -> String {224 +    let prs = &data.prs;225      let mut body =226          String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");227      if prs.is_empty() {228 @@ -1014,6 +1055,10 @@ fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Optio229          }230          body.push_str("</tbody>\n</table></div>\n");231      }232 +    body.push_str(&format!(233 +        "<p class=\"muted\">{n} closed</p>\n",234 +        n = data.closed_pr_count(),235 +    ));236      page_site(237          &format!("pulls — {site_name}"),238          nav,239 @@ -1166,7 +1211,7 @@ pub fn render_loaded(240      write_page(241          out,242          "pulls/index.html",243 -        &render_pulls_index(&data.prs, site_name, nav, description),244 +        &render_pulls_index(data, site_name, nav, description),245      )?;246      for pr in &data.prs {247          write_page(248 @@ -1651,4 +1696,58 @@ mod tests {249          let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");250          assert!(pulls.contains(&long_sha), "long head sha kept");251      }252 +253 +    /// m6/open-counts (f0rge #29): nav counts are open-only254 +    /// (`state == "open"` exactly; in-progress is NOT open; drafts count255 +    /// as open and stay marked) with `N closed` totals on index pages.256 +    #[test]257 +    fn open_only_counts_with_closed_lines() {258 +        let repo = mktemp("open-counts");259 +        for (uuid, state, hour) in [260 +            ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),261 +            ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),262 +            ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),263 +        ] {264 +            write_forge(265 +                &repo,266 +                &format!("issues/{uuid}/issue.md"),267 +                &format!(268 +                    "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"269 +                ),270 +            );271 +        }272 +        for (uuid, state, draft, hour) in [273 +            ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),274 +            ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),275 +            (276 +                "ffffffff-ffff-ffff-ffff-ffffffffffff",277 +                "merged",278 +                false,279 +                "05",280 +            ),281 +        ] {282 +            write_forge(283 +                &repo,284 +                &format!("prs/{uuid}/pr.md"),285 +                &format!(286 +                    "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"287 +                ),288 +            );289 +        }290 +        let data = load(&repo);291 +        assert_eq!(data.open_issue_count(), 1, "only state==open");292 +        assert_eq!(data.closed_issue_count(), 2, "in-progress + closed");293 +        assert_eq!(data.open_pr_count(), 2, "open + draft-open");294 +        assert_eq!(data.closed_pr_count(), 1, "merged");295 +296 +        let out = mktemp("open-counts-out");297 +        render_loaded(&data, &out, "test", "<nav>", None).expect("render");298 +        let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");299 +        assert!(issues.contains("2 closed"), "issues closed total");300 +        assert!(issues.contains("Issue open"), "open row kept");301 +        assert!(issues.contains("Issue closed"), "closed row kept");302 +        let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");303 +        assert!(pulls.contains("1 closed"), "pulls closed total");304 +        assert!(pulls.contains(" draft"), "draft stays marked");305 +    }306  }307 diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs308 index fda2b8b..d695600 100644309 --- a/tests/forge_readers.rs310 +++ b/tests/forge_readers.rs311 @@ -309,15 +309,27 @@ fn forge_fixture_renders_counts_numbers_and_ci() {312      assert!(ci.contains(">fail<"), "verdict text on ci page");313      assert!(ci.contains("boom: the test failed"), "run log in <pre>");314  315 -    // --- index header: real counts + verdict, no TODO markers ---316 +    // --- index header: open counts + verdict, no TODO markers ---317 +    // m6/open-counts (#29 + #36): nav is open-only (Older open, Newer318 +    // closed -> issues (1)); code tab keeps branch/sha/CI, drops the319 +    // redundant counts line; closed totals live on index pages.320      let index = read("index.html");321 -    assert!(index.contains("issues (2)"), "real issue count");322 -    assert!(index.contains("pulls (1)"), "real pull count");323 -    assert!(index.contains("tasks (4)"), "real task count");324 +    assert!(index.contains("issues (1)"), "nav open issue count");325 +    assert!(index.contains("pulls (1)"), "nav open pull count");326 +    assert!(index.contains("repo-meta"), "branch/sha meta kept");327 +    let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");328 +    let head_end = index[head_start..].find("</div>").expect("repo-head end") + head_start;329 +    let head = &index[head_start..head_end];330 +    for needle in ["issues (", "pulls (", "tasks (", "releases ("] {331 +        assert!(!head.contains(needle), "code tab drops {needle}");332 +    }333      assert!(index.contains("badge fail"), "index verdict badge");334      assert!(!index.contains("TODO"), "TODO markers deleted");335      assert!(index.contains("/issues/index.html"), "nav links issues");336      assert!(index.contains("/pulls/index.html"), "nav links pulls");337      assert!(index.contains("/tasks/index.html"), "nav links tasks");338      assert!(index.contains("/ci/index.html"), "nav links ci");339 +    // Closed totals stay visible on their index pages.340 +    assert!(issues.contains("1 closed"), "issues closed total");341 +    assert!(pulls.contains("0 closed"), "pulls closed total");342  }343 -- 344 2.55.0345