#7 m3/forge-readers

mergedpassopened
details
branches
master ← m3/forge-readers
updated
id
15e9bea3-5285-4508-b38e-03babf491c7b

Patch

src/build.rs+44 −17
diff --git a/src/build.rs b/src/build.rsindex ae9f381..9920bf8 100644--- a/src/build.rs+++ b/src/build.rs@@ -1,8 +1,10 @@1-//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5).1+//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).22 //!33 //! Reads one branch via [`crate::gitx`], emits `dist/`:44 //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),5-//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `wiki/index.html`,5+//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,6+//! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,7+//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,68 //! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.79 //!810 //! Bodies from the repo are semi-trusted but treated as untrusted:@@ -13,7 +15,7 @@ use std::collections::BTreeMap;1315 use std::path::Path;1416 1517 use crate::layout::esc;16-use crate::{commits, feed, gitx, wiki};18+use crate::{commits, feed, forge, gitx, wiki};1719 1820 /// Files at or above this size get a placeholder page + download link.1921 pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;@@ -145,9 +147,16 @@ fn render_text_pre(display: &str) -> String {145147     out146148 }147149 148-fn nav_code() -> String {149-    "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · <a href=\"/wiki/index.html\">wiki</a> <!-- TODO(m3): issues/pulls/tasks/ci/releases tabs -->"150-        .to_string()150+/// Top nav with live f0rge counts (no stubs, no TODO markers).151+fn nav_bar(issue_count: usize, pr_count: usize) -> String {152+    format!(153+        "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \154+         <a href=\"/issues/index.html\">issues ({issue_count})</a> · \155+         <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \156+         <a href=\"/tasks/index.html\">tasks</a> · \157+         <a href=\"/ci/index.html\">ci</a> · \158+         <a href=\"/wiki/index.html\">wiki</a>"159+    )151160 }152161 153162 /// Write `out/style.css` from the embedded static file.@@ -219,6 +228,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu219228     std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;220229     write_style(out)?;221230 231+    // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front232+    // so every page (including file pages below) gets a nav with live233+    // counts. Loading never fails: bad files are skipped + warned.234+    let forge_data = forge::load(repo);235+    let nav = nav_bar(forge_data.issues.len(), forge_data.prs.len());236+222237     // Raw files first: byte-identical copies.223238     let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();224239     for f in &files {@@ -273,7 +288,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu273288             }274289         }275290         body.push_str("</ul>\n");276-        let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body);291+        let html = crate::layout::page(&format!("{dir} — {name}"), &nav, &body);277292         let dest = out.join("files").join(format!("{dir}.html"));278293         if let Some(parent) = dest.parent() {279294             std::fs::create_dir_all(parent)@@ -325,7 +340,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu325340             let display = String::from_utf8_lossy(&bytes).into_owned();326341             body.push_str(&render_text_pre(&display));327342         }328-        let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body);343+        let html = crate::layout::page(&format!("{f} — {name}"), &nav, &body);329344         let dest = out.join("files").join(format!("{f}.html"));330345         if let Some(parent) = dest.parent() {331346             std::fs::create_dir_all(parent)@@ -335,10 +350,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu335350     }336351 337352     // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.338-    let nav = nav_code();339353     commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;340354     feed::render(repo, out, &branch, &name)?;341355     wiki::render(repo, out, &name, &nav)?;356+    // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.357+    forge::render_loaded(&forge_data, out, &name, &nav)?;342358 343359     // Snapshot last so the index can link sizes.344360     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;@@ -366,10 +382,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu366382         esc(&branch),367383         esc(&short_sha)368384     ));369-    body.push_str(370-        "<p>issues (0) · pulls (0) · tasks (0) <!-- TODO(m3): real f0rge counts --></p>\n",371-    );372-    body.push_str("<p>CI: unknown (stub)</p>\n");385+    body.push_str(&format!(386+        "<p><a href=\"/issues/index.html\">issues ({})</a> · \387+         <a href=\"/pulls/index.html\">pulls ({})</a> · \388+         <a href=\"/tasks/index.html\">tasks ({})</a></p>\n",389+        forge_data.issues.len(),390+        forge_data.prs.len(),391+        forge_data.tasks.len(),392+    ));393+    body.push_str(&format!(394+        "<p>CI: {}</p>\n",395+        forge::verdict_badge(forge_data.ci.latest_verdict()),396+    ));373397     body.push_str(&format!(374398         "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \375399          <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"@@ -410,7 +434,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu410434         None => body.push_str("<p class=\"muted\">No README found.</p>\n"),411435     }412436 413-    let index = crate::layout::page(&name, &nav_code(), &body);437+    let index = crate::layout::page(&name, &nav, &body);414438     std::fs::write(out.join("index.html"), index)415439         .map_err(|e| format!("hu13: write index.html: {e}"))?;416440 @@ -556,11 +580,14 @@ mod tests {556580             "raw HTML escaped"557581         );558582         assert!(index.contains("&lt;script&gt;"), "escaped HTML present");559-        // Header: branch + short sha, f0rge counts stub, CI stub.583+        // Header: branch + short sha, live f0rge counts, CI verdict badge.560584         assert!(index.contains("main"), "branch shown");561-        assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker");585+        assert!(!index.contains("TODO"), "no TODO markers left");562586         assert!(index.contains("issues (0)"), "f0rge counts as 0");563-        assert!(index.contains("CI:"), "CI verdict stub");587+        assert!(index.contains("pulls (0)"), "pull counts as 0");588+        assert!(index.contains("tasks (0)"), "task counts as 0");589+        assert!(index.contains("CI:"), "CI verdict shown");590+        assert!(index.contains("badge"), "CI verdict badge");564591         // Snapshot links with sizes.565592         assert!(index.contains("repo.bundle"), "bundle linked");566593         assert!(index.contains("repo.tar.gz"), "tarball linked");
src/forge.rs+1411 −0
diff --git a/src/forge.rs b/src/forge.rsnew file mode 100644index 0000000..9e4e9c5--- /dev/null+++ b/src/forge.rs@@ -0,0 +1,1411 @@1+//! f0rge readers (m3, f0rge issue #7): issues / pulls / tasks / ci pages.2+//!3+//! Source is the on-disk `<repo>/.forge/` tree (NOT git objects):4+//! `issues/<uuid>/issue.md` (+ `comments/<uuid>.md`),5+//! `prs/<uuid>/pr.md` (+ `patch.diff`, `comments/`, `reviews/`),6+//! `tasks/<uuid>.md`, `labels.md` + `milestones.md` (YAML lists, no fences),7+//! `ci/ci.env` + `steps/*.sh` + `runs/<ts>/summary.md`.8+//!9+//! Rules (spec/schema.md §0, DRAFT): frontmatter is a tiny `---` split +10+//! `yaml_serde`; unknown fields are ignored; a missing required field11+//! skips that file with an `eprintln!` warning naming file + field and the12+//! build still exits 0. Numbers are derived (never stored): 1-based rank13+//! of `(created, id)` at build time. Pages display the number and link by14+//! uuid.15+//!16+//! SECURITY: every byte from `.forge/` is untrusted. Markdown bodies go17+//! through [`crate::build::markdown_to_html`] (raw HTML escaped); titles,18+//! ids, states, diffs, and logs go through [`crate::layout::esc`] (diffs19+//! and logs as escaped `<pre>`, never markdown).20+21+use std::path::Path;22+23+use serde::Deserialize;24+25+use crate::build::markdown_to_html;26+use crate::layout::{esc, page};27+28+/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).29+pub const FORGE_MAX_BYTES: u64 = 256 * 1024;30+31+/// Step-log bytes kept per log file (rest truncated with a marker).32+pub const LOG_PREVIEW_MAX: usize = 64 * 1024;33+34+// ---------------------------------------------------------------------------35+// Frontmatter plumbing36+// ---------------------------------------------------------------------------37+38+/// Split `---\nyaml\n---\nbody` into `(yaml, body)`.39+///40+/// The opening fence must be the first line; the closing fence must be a41+/// full line `---`. Returns `None` when there is no frontmatter.42+fn split_frontmatter(text: &str) -> Option<(&str, &str)> {43+    let rest = text.strip_prefix("---")?;44+    let rest = rest45+        .strip_prefix('\n')46+        .or_else(|| rest.strip_prefix("\r\n"))?;47+    let mut offset = 0;48+    for line in rest.split_inclusive('\n') {49+        let trimmed = line.trim_end_matches(['\n', '\r']);50+        if trimmed == "---" {51+            let yaml = &rest[..offset];52+            let body = &rest[offset + line.len()..];53+            return Some((yaml, body));54+        }55+        offset += line.len();56+    }57+    None58+}59+60+/// Read a UTF-8 file, skipping (with a warning) files that are missing,61+/// oversized, or non-UTF-8. `label` names the file for warnings.62+fn read_text_capped(path: &Path, label: &str) -> Option<String> {63+    let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);64+    if len > FORGE_MAX_BYTES {65+        eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");66+        return None;67+    }68+    match std::fs::read(path) {69+        Ok(bytes) => match String::from_utf8(bytes) {70+            Ok(text) => Some(text),71+            Err(_) => {72+                eprintln!("hu13: forge: skipping {label}: not valid UTF-8");73+                None74+            }75+        },76+        Err(e) => {77+            eprintln!("hu13: forge: skipping {label}: cannot read: {e}");78+            None79+        }80+    }81+}82+83+/// Parse YAML frontmatter; on error warn (naming file via `label` plus the84+/// serde error, which names the field) and return `None` (skip the file).85+fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>86+where87+    T: for<'de> Deserialize<'de>,88+{89+    match yaml_serde::from_str(yaml) {90+        Ok(value) => Some(value),91+        Err(e) => {92+            eprintln!("hu13: forge: skipping {label}: {e}");93+            None94+        }95+    }96+}97+98+/// Sorted entry names (files or dirs) directly under `dir`.99+/// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).100+fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {101+    let rd = match std::fs::read_dir(dir) {102+        Ok(rd) => rd,103+        Err(_) => return Vec::new(),104+    };105+    let mut names: Vec<String> = Vec::new();106+    for ent in rd {107+        let ent = match ent {108+            Ok(ent) => ent,109+            Err(e) => {110+                eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());111+                continue;112+            }113+        };114+        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);115+        if is_dir != only_dirs {116+            continue;117+        }118+        names.push(ent.file_name().to_string_lossy().into_owned());119+    }120+    names.sort();121+    names122+}123+124+// ---------------------------------------------------------------------------125+// Typed frontmatter structs (unknown fields ignored by serde default;126+// `#[serde(default)]` marks every optional from schema §2)127+// ---------------------------------------------------------------------------128+129+#[derive(Debug, Clone, Deserialize)]130+struct IssueFm {131+    id: String,132+    title: String,133+    state: String,134+    #[serde(default)]135+    labels: Vec<String>,136+    #[serde(default)]137+    assignees: Vec<String>,138+    #[serde(default)]139+    milestone: Option<String>,140+    created: String,141+    #[serde(default)]142+    updated: Option<String>,143+}144+145+#[derive(Debug, Clone, Deserialize)]146+struct CommentFm {147+    id: String,148+    created: String,149+    #[serde(default)]150+    author: Option<String>,151+}152+153+#[derive(Debug, Clone, Deserialize)]154+struct PrFm {155+    id: String,156+    title: String,157+    state: String,158+    base: String,159+    head: String,160+    created: String,161+    #[serde(default)]162+    draft: bool,163+    #[serde(default)]164+    issues: Vec<String>,165+    #[serde(default)]166+    ci: Option<String>,167+    #[serde(default)]168+    updated: Option<String>,169+}170+171+#[derive(Debug, Clone, Deserialize)]172+struct ReviewFm {173+    id: String,174+    created: String,175+    #[serde(default)]176+    author: Option<String>,177+    #[serde(default)]178+    state: Option<String>,179+}180+181+#[derive(Debug, Clone, Deserialize)]182+struct TaskFm {183+    id: String,184+    title: String,185+    status: String,186+    created: String,187+    #[serde(default)]188+    assignee: Option<String>,189+    #[serde(default)]190+    updated: Option<String>,191+    #[serde(default)]192+    closed: Option<String>,193+}194+195+#[derive(Debug, Clone, Deserialize)]196+struct LabelFm {197+    name: String,198+    #[serde(default)]199+    color: Option<String>,200+    #[serde(default)]201+    description: Option<String>,202+}203+204+fn default_open() -> String {205+    "open".to_string()206+}207+208+#[derive(Debug, Clone, Deserialize)]209+struct MilestoneFm {210+    name: String,211+    #[serde(default = "default_open")]212+    state: String,213+    #[serde(default)]214+    due: Option<String>,215+    #[serde(default)]216+    description: Option<String>,217+}218+219+fn default_runner() -> String {220+    "local".to_string()221+}222+223+#[derive(Debug, Clone, Deserialize)]224+struct CiEnvFm {225+    #[serde(default = "default_runner")]226+    runner: String,227+}228+229+#[derive(Debug, Clone, Deserialize)]230+struct CiRunFm {231+    run: String,232+    verdict: String,233+    created: String,234+    #[serde(default)]235+    steps: Vec<CiStepFm>,236+}237+238+#[derive(Debug, Clone, Deserialize)]239+struct CiStepFm {240+    #[serde(default)]241+    name: String,242+    #[serde(default)]243+    exit: i32,244+    #[serde(default)]245+    duration_ms: u64,246+}247+248+// ---------------------------------------------------------------------------249+// Loaded entities (frontmatter + body + sidecars, numbers assigned at load)250+// ---------------------------------------------------------------------------251+252+/// One issue/PR comment.253+#[derive(Debug, Clone, PartialEq, Eq)]254+pub struct Comment {255+    /// Comment uuid (filename stem).256+    pub id: String,257+    /// Optional author handle.258+    pub author: Option<String>,259+    /// RFC3339 creation timestamp.260+    pub created: String,261+    /// Markdown body after the frontmatter.262+    pub body: String,263+}264+265+/// One issue with derived display number and loaded comments.266+#[derive(Debug, Clone)]267+pub struct Issue {268+    /// 1-based rank of `(created, id)` — display only, never stored.269+    pub number: usize,270+    /// Issue uuid (link key).271+    pub id: String,272+    pub title: String,273+    pub state: String,274+    pub labels: Vec<String>,275+    pub assignees: Vec<String>,276+    pub milestone: Option<String>,277+    pub created: String,278+    pub updated: Option<String>,279+    /// Markdown body after the frontmatter.280+    pub body: String,281+    /// Comments sorted by `(created, id)`.282+    pub comments: Vec<Comment>,283+}284+285+/// One PR review (state + body).286+#[derive(Debug, Clone, PartialEq, Eq)]287+pub struct Review {288+    /// Review uuid.289+    pub id: String,290+    /// Optional author handle.291+    pub author: Option<String>,292+    /// Optional `approved|changes-requested|commented`.293+    pub state: Option<String>,294+    /// RFC3339 creation timestamp.295+    pub created: String,296+    /// Markdown body after the frontmatter.297+    pub body: String,298+}299+300+/// One PR with derived display number, patch text, comments, and reviews.301+#[derive(Debug, Clone)]302+pub struct Pr {303+    /// 1-based rank of `(created, id)` — display only, never stored.304+    pub number: usize,305+    /// PR uuid (link key).306+    pub id: String,307+    pub title: String,308+    pub state: String,309+    pub base: String,310+    pub head: String,311+    pub draft: bool,312+    pub issues: Vec<String>,313+    pub ci: Option<String>,314+    pub created: String,315+    pub updated: Option<String>,316+    /// Markdown body after the frontmatter.317+    pub body: String,318+    /// Raw `patch.diff` text when present.319+    pub patch: Option<String>,320+    /// Comments sorted by `(created, id)`.321+    pub comments: Vec<Comment>,322+    /// Reviews sorted by `(created, id)`.323+    pub reviews: Vec<Review>,324+}325+326+/// One task file (grouped by `status` at render).327+#[derive(Debug, Clone)]328+pub struct TaskItem {329+    /// Task uuid (filename stem).330+    pub id: String,331+    pub title: String,332+    /// `todo|doing|blocked|done`.333+    pub status: String,334+    pub created: String,335+    pub assignee: Option<String>,336+    pub updated: Option<String>,337+    pub closed: Option<String>,338+    /// Markdown body after the frontmatter.339+    pub body: String,340+}341+342+/// One label entry from `labels.md`.343+#[derive(Debug, Clone, PartialEq, Eq)]344+pub struct Label {345+    pub name: String,346+    pub color: Option<String>,347+    pub description: Option<String>,348+}349+350+/// One milestone entry from `milestones.md`.351+#[derive(Debug, Clone, PartialEq, Eq)]352+pub struct Milestone {353+    pub name: String,354+    pub state: String,355+    pub due: Option<String>,356+    pub description: Option<String>,357+}358+359+/// One step result from a CI run summary.360+#[derive(Debug, Clone, PartialEq, Eq)]361+pub struct CiStepStatus {362+    pub name: String,363+    pub exit: i32,364+    pub duration_ms: u64,365+}366+367+/// One CI run (latest only is rendered).368+#[derive(Debug, Clone)]369+pub struct CiRun {370+    /// Run timestamp id (dir name).371+    pub run: String,372+    /// `pass|fail|running|cancelled`.373+    pub verdict: String,374+    pub created: String,375+    pub steps: Vec<CiStepStatus>,376+    /// Summary body after the frontmatter (the run log).377+    pub body: String,378+    /// `(log filename, content)` for `runs/<ts>/steps/*.log`, sorted.379+    pub logs: Vec<(String, String)>,380+}381+382+/// CI section: env runner, step script names, latest run.383+#[derive(Debug, Clone)]384+pub struct CiInfo {385+    /// `runner:` from `ci.env` (default `local`).386+    pub runner: String,387+    /// Sorted `steps/*.sh` filenames.388+    pub steps: Vec<String>,389+    /// Latest `runs/<ts>/summary.md` by lexicographic (== chronological) order.390+    pub latest: Option<CiRun>,391+}392+393+impl CiInfo {394+    /// Latest verdict, or `"none"` when no runs exist at build time.395+    #[must_use]396+    pub fn latest_verdict(&self) -> &str {397+        self.latest398+            .as_ref()399+            .map(|r| r.verdict.as_str())400+            .unwrap_or("none")401+    }402+}403+404+/// Everything the f0rge readers load (numbers already assigned).405+#[derive(Debug, Clone)]406+pub struct ForgeData {407+    /// Issues sorted by `(created, id)` with 1-based numbers.408+    pub issues: Vec<Issue>,409+    /// PRs sorted by `(created, id)` with 1-based numbers.410+    pub prs: Vec<Pr>,411+    /// Tasks sorted by `(created, id)`.412+    pub tasks: Vec<TaskItem>,413+    /// Labels in file order.414+    pub labels: Vec<Label>,415+    /// Milestones in file order.416+    pub milestones: Vec<Milestone>,417+    /// CI env + steps + latest run.418+    pub ci: CiInfo,419+}420+421+// ---------------------------------------------------------------------------422+// Loading423+// ---------------------------------------------------------------------------424+425+fn load_comments(dir: &Path, scope: &str) -> Vec<Comment> {426+    let mut out = Vec::new();427+    for name in sorted_entries(dir, false) {428+        if !name.ends_with(".md") {429+            continue;430+        }431+        let label = format!("{scope}/{name}");432+        let Some(text) = read_text_capped(&dir.join(&name), &label) else {433+            continue;434+        };435+        let Some((yaml, body)) = split_frontmatter(&text) else {436+            eprintln!("hu13: forge: skipping {label}: missing frontmatter");437+            continue;438+        };439+        let Some(fm): Option<CommentFm> = parse_frontmatter(yaml, &label) else {440+            continue;441+        };442+        out.push(Comment {443+            id: fm.id,444+            author: fm.author,445+            created: fm.created,446+            body: body.to_string(),447+        });448+    }449+    out.sort_by_key(|c| (c.created.clone(), c.id.clone()));450+    out451+}452+453+fn load_reviews(dir: &Path, scope: &str) -> Vec<Review> {454+    let mut out = Vec::new();455+    for name in sorted_entries(dir, false) {456+        if !name.ends_with(".md") {457+            continue;458+        }459+        let label = format!("{scope}/{name}");460+        let Some(text) = read_text_capped(&dir.join(&name), &label) else {461+            continue;462+        };463+        let Some((yaml, body)) = split_frontmatter(&text) else {464+            eprintln!("hu13: forge: skipping {label}: missing frontmatter");465+            continue;466+        };467+        let Some(fm): Option<ReviewFm> = parse_frontmatter(yaml, &label) else {468+            continue;469+        };470+        out.push(Review {471+            id: fm.id,472+            author: fm.author,473+            state: fm.state,474+            created: fm.created,475+            body: body.to_string(),476+        });477+    }478+    out.sort_by_key(|c| (c.created.clone(), c.id.clone()));479+    out480+}481+482+fn load_issues(root: &Path) -> Vec<Issue> {483+    let dir = root.join("issues");484+    let mut issues = Vec::new();485+    for uuid in sorted_entries(&dir, true) {486+        let label = format!("issues/{uuid}/issue.md");487+        let Some(text) = read_text_capped(&dir.join(&uuid).join("issue.md"), &label) else {488+            continue;489+        };490+        let Some((yaml, body)) = split_frontmatter(&text) else {491+            eprintln!("hu13: forge: skipping {label}: missing frontmatter");492+            continue;493+        };494+        let Some(fm): Option<IssueFm> = parse_frontmatter(yaml, &label) else {495+            continue;496+        };497+        let comments = load_comments(498+            &dir.join(&uuid).join("comments"),499+            &format!("issues/{uuid}/comments"),500+        );501+        issues.push(Issue {502+            number: 0,503+            id: fm.id,504+            title: fm.title,505+            state: fm.state,506+            labels: fm.labels,507+            assignees: fm.assignees,508+            milestone: fm.milestone,509+            created: fm.created,510+            updated: fm.updated,511+            body: body.to_string(),512+            comments,513+        });514+    }515+    // Numbers: 1-based rank of (created, id).516+    issues.sort_by_key(|i| (i.created.clone(), i.id.clone()));517+    for (i, issue) in issues.iter_mut().enumerate() {518+        issue.number = i + 1;519+    }520+    issues521+}522+523+fn load_prs(root: &Path) -> Vec<Pr> {524+    let dir = root.join("prs");525+    let mut prs = Vec::new();526+    for uuid in sorted_entries(&dir, true) {527+        let label = format!("prs/{uuid}/pr.md");528+        let Some(text) = read_text_capped(&dir.join(&uuid).join("pr.md"), &label) else {529+            continue;530+        };531+        let Some((yaml, body)) = split_frontmatter(&text) else {532+            eprintln!("hu13: forge: skipping {label}: missing frontmatter");533+            continue;534+        };535+        let Some(fm): Option<PrFm> = parse_frontmatter(yaml, &label) else {536+            continue;537+        };538+        let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))539+            .ok()540+            .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());541+        let comments = load_comments(542+            &dir.join(&uuid).join("comments"),543+            &format!("prs/{uuid}/comments"),544+        );545+        let reviews = load_reviews(546+            &dir.join(&uuid).join("reviews"),547+            &format!("prs/{uuid}/reviews"),548+        );549+        prs.push(Pr {550+            number: 0,551+            id: fm.id,552+            title: fm.title,553+            state: fm.state,554+            base: fm.base,555+            head: fm.head,556+            draft: fm.draft,557+            issues: fm.issues,558+            ci: fm.ci,559+            created: fm.created,560+            updated: fm.updated,561+            body: body.to_string(),562+            patch,563+            comments,564+            reviews,565+        });566+    }567+    // Numbers: 1-based rank of (created, id).568+    prs.sort_by_key(|p| (p.created.clone(), p.id.clone()));569+    for (i, pr) in prs.iter_mut().enumerate() {570+        pr.number = i + 1;571+    }572+    prs573+}574+575+fn load_tasks(root: &Path) -> Vec<TaskItem> {576+    let dir = root.join("tasks");577+    let mut tasks = Vec::new();578+    for name in sorted_entries(&dir, false) {579+        if !name.ends_with(".md") {580+            continue;581+        }582+        let label = format!("tasks/{name}");583+        let Some(text) = read_text_capped(&dir.join(&name), &label) else {584+            continue;585+        };586+        let Some((yaml, body)) = split_frontmatter(&text) else {587+            eprintln!("hu13: forge: skipping {label}: missing frontmatter");588+            continue;589+        };590+        let Some(fm): Option<TaskFm> = parse_frontmatter(yaml, &label) else {591+            continue;592+        };593+        if !matches!(fm.status.as_str(), "todo" | "doing" | "blocked" | "done") {594+            eprintln!(595+                "hu13: forge: skipping {label}: unknown status '{}'",596+                fm.status597+            );598+            continue;599+        }600+        tasks.push(TaskItem {601+            id: fm.id,602+            title: fm.title,603+            status: fm.status,604+            created: fm.created,605+            assignee: fm.assignee,606+            updated: fm.updated,607+            closed: fm.closed,608+            body: body.to_string(),609+        });610+    }611+    tasks.sort_by_key(|t| (t.created.clone(), t.id.clone()));612+    tasks613+}614+615+fn load_labels(root: &Path) -> Vec<Label> {616+    let path = root.join("labels.md");617+    if !path.exists() {618+        return Vec::new();619+    }620+    let Some(text) = read_text_capped(&path, "labels.md") else {621+        return Vec::new();622+    };623+    // YAML list, no fences: parse the whole file.624+    match yaml_serde::from_str::<Vec<LabelFm>>(&text) {625+        Ok(items) => items626+            .into_iter()627+            .map(|l| Label {628+                name: l.name,629+                color: l.color,630+                description: l.description,631+            })632+            .collect(),633+        Err(e) => {634+            eprintln!("hu13: forge: skipping labels.md: {e}");635+            Vec::new()636+        }637+    }638+}639+640+fn load_milestones(root: &Path) -> Vec<Milestone> {641+    let path = root.join("milestones.md");642+    if !path.exists() {643+        return Vec::new();644+    }645+    let Some(text) = read_text_capped(&path, "milestones.md") else {646+        return Vec::new();647+    };648+    // YAML list, no fences: parse the whole file.649+    match yaml_serde::from_str::<Vec<MilestoneFm>>(&text) {650+        Ok(items) => items651+            .into_iter()652+            .map(|m| Milestone {653+                name: m.name,654+                state: m.state,655+                due: m.due,656+                description: m.description,657+            })658+            .collect(),659+        Err(e) => {660+            eprintln!("hu13: forge: skipping milestones.md: {e}");661+            Vec::new()662+        }663+    }664+}665+666+fn truncate_log(text: &str) -> String {667+    if text.len() <= LOG_PREVIEW_MAX {668+        return text.to_string();669+    }670+    let mut end = LOG_PREVIEW_MAX;671+    while !text.is_char_boundary(end) {672+        end -= 1;673+    }674+    format!("{}…[truncated]", &text[..end])675+}676+677+fn load_ci(root: &Path) -> CiInfo {678+    let dir = root.join("ci");679+    let runner = if dir.join("ci.env").exists() {680+        match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {681+            Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {682+                Ok(env) => env.runner,683+                Err(e) => {684+                    eprintln!("hu13: forge: skipping ci/ci.env: {e}");685+                    default_runner()686+                }687+            },688+            None => default_runner(),689+        }690+    } else {691+        default_runner()692+    };693+    let steps: Vec<String> = sorted_entries(&dir.join("steps"), false)694+        .into_iter()695+        .filter(|n| n.ends_with(".sh"))696+        .collect();697+698+    let mut runs = sorted_entries(&dir.join("runs"), true);699+    runs.sort();700+    let latest = runs.last().cloned().and_then(|ts| {701+        let label = format!("ci/runs/{ts}/summary.md");702+        let text = read_text_capped(&dir.join("runs").join(&ts).join("summary.md"), &label)?;703+        let (yaml, body) = match split_frontmatter(&text) {704+            Some(pair) => pair,705+            None => {706+                eprintln!("hu13: forge: skipping {label}: missing frontmatter");707+                return None;708+            }709+        };710+        let fm: CiRunFm = parse_frontmatter(yaml, &label)?;711+        let mut logs = Vec::new();712+        for name in sorted_entries(&dir.join("runs").join(&ts).join("steps"), false) {713+            if !name.ends_with(".log") {714+                continue;715+            }716+            let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))717+                .unwrap_or_default();718+            logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));719+        }720+        Some(CiRun {721+            run: fm.run,722+            verdict: fm.verdict,723+            created: fm.created,724+            steps: fm725+                .steps726+                .into_iter()727+                .map(|s| CiStepStatus {728+                    name: s.name,729+                    exit: s.exit,730+                    duration_ms: s.duration_ms,731+                })732+                .collect(),733+            body: body.to_string(),734+            logs,735+        })736+    });737+738+    CiInfo {739+        runner,740+        steps,741+        latest,742+    }743+}744+745+/// Load every f0rge section from on-disk `<repo>/.forge/`.746+/// Never fails: bad files are skipped with warnings; a missing747+/// `.forge/` yields empty sections.748+#[must_use]749+pub fn load(repo: &Path) -> ForgeData {750+    let root = repo.join(".forge");751+    ForgeData {752+        issues: load_issues(&root),753+        prs: load_prs(&root),754+        tasks: load_tasks(&root),755+        labels: load_labels(&root),756+        milestones: load_milestones(&root),757+        ci: load_ci(&root),758+    }759+}760+761+// ---------------------------------------------------------------------------762+// Rendering763+// ---------------------------------------------------------------------------764+765+/// Verdict badge: `<span class="badge {verdict}">verdict</span>` for766+/// `pass|fail|running|cancelled`, plain `badge` otherwise (`none`, unknown).767+#[must_use]768+pub fn verdict_badge(verdict: &str) -> String {769+    let v = verdict.trim().to_lowercase();770+    if matches!(v.as_str(), "pass" | "fail" | "running" | "cancelled") {771+        format!("<span class=\"badge {v}\">{}</span>", esc(&v))772+    } else {773+        format!("<span class=\"badge\">{}</span>", esc(verdict.trim()))774+    }775+}776+777+fn meta_row(label: &str, value: &str) -> String {778+    format!(779+        "<p><span class=\"muted\">{}</span> {}</p>\n",780+        esc(label),781+        value782+    )783+}784+785+fn render_comment(c: &Comment) -> String {786+    let mut out = String::from("<li class=\"comment\">\n");787+    let author = c.author.as_deref().unwrap_or("unknown");788+    out.push_str(&format!(789+        "<p class=\"muted\">{} · <code>{}</code></p>\n",790+        esc(&c.created),791+        esc(author),792+    ));793+    out.push_str(&markdown_to_html(&c.body));794+    out.push_str("</li>\n");795+    out796+}797+798+fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {799+    let mut body = format!(800+        "<h1>#{n} {title}</h1>\n",801+        n = issue.number,802+        title = esc(&issue.title),803+    );804+    body.push_str(&meta_row("state:", &esc(&issue.state)));805+    if !issue.labels.is_empty() {806+        let labels = issue807+            .labels808+            .iter()809+            .map(|l| esc(l))810+            .collect::<Vec<_>>()811+            .join(", ");812+        body.push_str(&meta_row("labels:", &labels));813+    }814+    if !issue.assignees.is_empty() {815+        let assignees = issue816+            .assignees817+            .iter()818+            .map(|a| esc(a))819+            .collect::<Vec<_>>()820+            .join(", ");821+        body.push_str(&meta_row("assignees:", &assignees));822+    }823+    if let Some(m) = &issue.milestone {824+        body.push_str(&meta_row("milestone:", &esc(m)));825+    }826+    body.push_str(&meta_row("created:", &esc(&issue.created)));827+    if let Some(u) = &issue.updated {828+        body.push_str(&meta_row("updated:", &esc(u)));829+    }830+    body.push_str(&meta_row(831+        "id:",832+        &format!("<code>{}</code>", esc(&issue.id)),833+    ));834+    body.push_str(&markdown_to_html(&issue.body));835+    body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));836+    if issue.comments.is_empty() {837+        body.push_str("<p class=\"muted\">No comments yet.</p>\n");838+    } else {839+        body.push_str("<ul class=\"comments\">\n");840+        for c in &issue.comments {841+            body.push_str(&render_comment(c));842+        }843+        body.push_str("</ul>\n");844+    }845+    body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");846+    page(847+        &format!("#{} {} — {site_name}", issue.number, issue.title),848+        nav,849+        &body,850+    )851+}852+853+fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {854+    let mut body = format!(855+        "<h1>#{n} {title}</h1>\n",856+        n = pr.number,857+        title = esc(&pr.title),858+    );859+    body.push_str(&meta_row("state:", &esc(&pr.state)));860+    body.push_str(&meta_row(861+        "branches:",862+        &format!(863+            "<code>{}</code> ← <code>{}</code>",864+            esc(&pr.base),865+            esc(&pr.head)866+        ),867+    ));868+    if pr.draft {869+        body.push_str(&meta_row("draft:", "yes"));870+    }871+    if let Some(ci) = &pr.ci {872+        body.push_str(&meta_row("ci:", &verdict_badge(ci)));873+    }874+    if !pr.issues.is_empty() {875+        let issues = pr876+            .issues877+            .iter()878+            .map(|i| format!("<code>{}</code>", esc(i)))879+            .collect::<Vec<_>>()880+            .join(", ");881+        body.push_str(&meta_row("issues:", &issues));882+    }883+    body.push_str(&meta_row("created:", &esc(&pr.created)));884+    if let Some(u) = &pr.updated {885+        body.push_str(&meta_row("updated:", &esc(u)));886+    }887+    body.push_str(&meta_row("id:", &format!("<code>{}</code>", esc(&pr.id))));888+    body.push_str(&markdown_to_html(&pr.body));889+    body.push_str("<h2>Patch</h2>\n");890+    match &pr.patch {891+        Some(patch) if !patch.is_empty() => {892+            body.push_str(&format!("<pre class=\"diff\">{}</pre>\n", esc(patch)));893+        }894+        _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),895+    }896+    body.push_str(&format!("<h2>Comments ({})</h2>\n", pr.comments.len()));897+    if pr.comments.is_empty() {898+        body.push_str("<p class=\"muted\">No comments yet.</p>\n");899+    } else {900+        body.push_str("<ul class=\"comments\">\n");901+        for c in &pr.comments {902+            body.push_str(&render_comment(c));903+        }904+        body.push_str("</ul>\n");905+    }906+    body.push_str(&format!("<h2>Reviews ({})</h2>\n", pr.reviews.len()));907+    if pr.reviews.is_empty() {908+        body.push_str("<p class=\"muted\">No reviews yet.</p>\n");909+    } else {910+        body.push_str("<ul class=\"reviews\">\n");911+        for r in &pr.reviews {912+            let state = r.state.as_deref().unwrap_or("commented");913+            let author = r.author.as_deref().unwrap_or("unknown");914+            body.push_str(&format!(915+                "<li class=\"review\">\n<p class=\"muted\">{} · <code>{}</code> · {}</p>\n{}</li>\n",916+                esc(&r.created),917+                esc(author),918+                esc(state),919+                markdown_to_html(&r.body),920+            ));921+        }922+        body.push_str("</ul>\n");923+    }924+    body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");925+    page(926+        &format!("#{} {} — {site_name}", pr.number, pr.title),927+        nav,928+        &body,929+    )930+}931+932+fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<String, String> {933+    let mut body = String::from("<h1>Issues</h1>\n");934+    if data.issues.is_empty() {935+        body.push_str("<p class=\"muted\">No issues yet.</p>\n");936+    } else {937+        body.push_str("<table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");938+        for issue in &data.issues {939+            body.push_str(&format!(940+                "<tr><td>{n}</td><td><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",941+                n = issue.number,942+                id = esc(&issue.id),943+                title = esc(&issue.title),944+                state = esc(&issue.state),945+                comments = issue.comments.len(),946+                created = esc(&issue.created),947+            ));948+        }949+        body.push_str("</tbody>\n</table>\n");950+    }951+    if !data.labels.is_empty() {952+        body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");953+        for l in &data.labels {954+            body.push_str(&format!("<li><code>{}</code></li>\n", esc(&l.name)));955+        }956+        body.push_str("</ul>\n");957+    }958+    if !data.milestones.is_empty() {959+        body.push_str("<h2>Milestones</h2>\n<ul class=\"milestones\">\n");960+        for m in &data.milestones {961+            body.push_str(&format!(962+                "<li><code>{}</code> <span class=\"muted\">{}</span></li>\n",963+                esc(&m.name),964+                esc(&m.state),965+            ));966+        }967+        body.push_str("</ul>\n");968+    }969+    Ok(page(&format!("issues — {site_name}"), nav, &body))970+}971+972+fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {973+    let mut body = String::from("<h1>Pulls</h1>\n");974+    if prs.is_empty() {975+        body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");976+    } else {977+        body.push_str("<table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");978+        for pr in prs {979+            let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));980+            let draft = if pr.draft { " draft" } else { "" };981+            body.push_str(&format!(982+                "<tr><td>{n}</td><td><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td>{state}</td><td><code>{lanes}</code></td><td>{created}</td></tr>\n",983+                n = pr.number,984+                id = esc(&pr.id),985+                title = esc(&pr.title),986+                state = esc(&pr.state),987+                created = esc(&pr.created),988+            ));989+        }990+        body.push_str("</tbody>\n</table>\n");991+    }992+    page(&format!("pulls — {site_name}"), nav, &body)993+}994+995+fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String {996+    let mut body = String::from("<h1>Tasks</h1>\n");997+    if tasks.is_empty() {998+        body.push_str("<p class=\"muted\">No tasks yet.</p>\n");999+    } else {1000+        for status in ["todo", "doing", "blocked", "done"] {1001+            let group: Vec<&TaskItem> = tasks.iter().filter(|t| t.status == status).collect();1002+            body.push_str(&format!("<h2>{status} ({})</h2>\n", group.len()));1003+            if group.is_empty() {1004+                body.push_str("<p class=\"muted\">None.</p>\n");1005+            } else {1006+                body.push_str("<ul class=\"tasks\">\n");1007+                for t in group {1008+                    let mut meta = format!("<code>{}</code> · {}", esc(&t.id), esc(&t.created));1009+                    if let Some(u) = &t.updated {1010+                        meta.push_str(&format!(" · updated {}", esc(u)));1011+                    }1012+                    if let Some(c) = &t.closed {1013+                        meta.push_str(&format!(" · closed {}", esc(c)));1014+                    }1015+                    body.push_str(&format!(1016+                        "<li class=\"task\">\n<p><strong>{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1017+                        title = esc(&t.title),1018+                        assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(1019+                            "<p class=\"muted\">assignee: <code>{}</code></p>\n",1020+                            esc(a)1021+                        )),1022+                        body_md = markdown_to_html(&t.body),1023+                    ));1024+                }1025+                body.push_str("</ul>\n");1026+            }1027+        }1028+    }1029+    page(&format!("tasks — {site_name}"), nav, &body)1030+}1031+1032+fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {1033+    let mut body = String::from("<h1>CI</h1>\n");1034+    body.push_str(&meta_row("runner:", &esc(&ci.runner)));1035+    if ci.steps.is_empty() {1036+        body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");1037+    } else {1038+        body.push_str("<h2>Steps</h2>\n<ul class=\"ci-steps\">\n");1039+        for step in &ci.steps {1040+            body.push_str(&format!("<li><code>{}</code></li>\n", esc(step)));1041+        }1042+        body.push_str("</ul>\n");1043+    }1044+    body.push_str("<h2>Latest run</h2>\n");1045+    match &ci.latest {1046+        Some(run) => {1047+            body.push_str(&meta_row("verdict:", &verdict_badge(&run.verdict)));1048+            body.push_str(&meta_row(1049+                "run:",1050+                &format!("<code>{}</code>", esc(&run.run)),1051+            ));1052+            body.push_str(&meta_row("created:", &esc(&run.created)));1053+            if run.steps.is_empty() {1054+                body.push_str("<p class=\"muted\">No step results recorded.</p>\n");1055+            } else {1056+                body.push_str("<table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");1057+                for s in &run.steps {1058+                    body.push_str(&format!(1059+                        "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",1060+                        esc(&s.name),1061+                        s.exit,1062+                        s.duration_ms,1063+                    ));1064+                }1065+                body.push_str("</tbody>\n</table>\n");1066+            }1067+            body.push_str("<h3>Log</h3>\n");1068+            if run.body.trim().is_empty() {1069+                body.push_str("<p class=\"muted\">Empty log.</p>\n");1070+            } else {1071+                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(&run.body)));1072+            }1073+            for (name, content) in &run.logs {1074+                body.push_str(&format!("<h3>{}</h3>\n", esc(name)));1075+                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(content)));1076+            }1077+        }1078+        None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),1079+    }1080+    page(&format!("ci — {site_name}"), nav, &body)1081+}1082+1083+fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {1084+    let dest = out.join(rel);1085+    if let Some(parent) = dest.parent() {1086+        std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create {rel} parent: {e}"))?;1087+    }1088+    std::fs::write(&dest, html).map_err(|e| format!("hu13: write {rel}: {e}"))1089+}1090+1091+/// Render `issues/`, `pulls/`, `tasks/`, `ci/` pages from already-loaded data.1092+pub fn render_loaded(1093+    data: &ForgeData,1094+    out: &Path,1095+    site_name: &str,1096+    nav: &str,1097+) -> Result<(), String> {1098+    write_page(1099+        out,1100+        "issues/index.html",1101+        &render_issues_index(data, site_name, nav)?,1102+    )?;1103+    for issue in &data.issues {1104+        write_page(1105+            out,1106+            &format!("issues/{}.html", issue.id),1107+            &render_issue_page(issue, site_name, nav),1108+        )?;1109+    }1110+1111+    write_page(1112+        out,1113+        "pulls/index.html",1114+        &render_pulls_index(&data.prs, site_name, nav),1115+    )?;1116+    for pr in &data.prs {1117+        write_page(1118+            out,1119+            &format!("pulls/{}.html", pr.id),1120+            &render_pr_page(pr, site_name, nav),1121+        )?;1122+    }1123+1124+    write_page(1125+        out,1126+        "tasks/index.html",1127+        &render_tasks_index(&data.tasks, site_name, nav),1128+    )?;1129+    write_page(1130+        out,1131+        "ci/index.html",1132+        &render_ci_index(&data.ci, site_name, nav),1133+    )?;1134+1135+    Ok(())1136+}1137+1138+#[cfg(test)]1139+mod tests {1140+    use super::*;1141+    use std::path::PathBuf;1142+    use std::sync::atomic::{AtomicU64, Ordering};1143+1144+    static COUNTER: AtomicU64 = AtomicU64::new(0);1145+1146+    fn mktemp(prefix: &str) -> PathBuf {1147+        let id = COUNTER.fetch_add(1, Ordering::SeqCst);1148+        let dir =1149+            std::env::temp_dir().join(format!("hu13-forge-{prefix}-{}-{id}", std::process::id()));1150+        std::fs::create_dir_all(&dir).expect("mktemp");1151+        dir1152+    }1153+1154+    fn write_forge(repo: &Path, rel: &str, content: &str) {1155+        let dest = repo.join(".forge").join(rel);1156+        std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");1157+        std::fs::write(&dest, content).expect("write forge file");1158+    }1159+1160+    #[test]1161+    fn frontmatter_split_shapes() {1162+        let (yaml, body) = split_frontmatter("---\nid: x\n---\nhello\n").expect("split");1163+        assert_eq!(yaml, "id: x\n");1164+        assert_eq!(body, "hello\n");1165+        assert!(split_frontmatter("no fences\n").is_none());1166+        assert!(split_frontmatter("---\nunclosed\n").is_none());1167+        assert!(split_frontmatter("---\n---\nempty yaml\n")1168+            .expect("e")1169+            .01170+            .is_empty());1171+    }1172+1173+    #[test]1174+    fn unknown_fields_ignored() {1175+        let yaml =1176+            "id: a\ntitle: T\nstate: open\ncreated: 2026-01-01T00:00:00Z\nsome_future_field: 123\n";1177+        let fm: IssueFm = yaml_serde::from_str(yaml).expect("unknown fields ignored");1178+        assert_eq!(fm.title, "T");1179+        assert!(fm.labels.is_empty());1180+        assert!(fm.milestone.is_none());1181+    }1182+1183+    #[test]1184+    fn missing_required_is_error() {1185+        let yaml = "id: a\nstate: open\ncreated: 2026-01-01T00:00:00Z\n";1186+        let err = yaml_serde::from_str::<IssueFm>(yaml).expect_err("title missing");1187+        assert!(1188+            err.to_string().contains("title"),1189+            "error names field: {err}"1190+        );1191+    }1192+1193+    /// Two issues with out-of-order created dates: the earlier one ranks #1.1194+    #[test]1195+    fn issue_numbers_rank_by_created_then_id() {1196+        let repo = mktemp("numbers");1197+        write_forge(1198+            &repo,1199+            "issues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/issue.md",1200+            "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Newer\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nnewer body\n",1201+        );1202+        write_forge(1203+            &repo,1204+            "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",1205+            "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Older\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nolder body\n",1206+        );1207+        let data = load(&repo);1208+        assert_eq!(data.issues.len(), 2);1209+        assert_eq!(data.issues[0].title, "Older");1210+        assert_eq!(data.issues[0].number, 1);1211+        assert_eq!(data.issues[1].title, "Newer");1212+        assert_eq!(data.issues[1].number, 2);1213+    }1214+1215+    #[test]1216+    fn broken_issue_skipped_build_lives() {1217+        let repo = mktemp("broken");1218+        write_forge(1219+            &repo,1220+            "issues/good11111-1111-1111-1111-111111111111/issue.md",1221+            "---\nid: good11111-1111-1111-1111-111111111111\ntitle: Good\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nok\n",1222+        );1223+        write_forge(1224+            &repo,1225+            "issues/bad222222-2222-2222-2222-222222222222/issue.md",1226+            "---\nid: bad222222-2222-2222-2222-222222222222\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nno title\n",1227+        );1228+        let data = load(&repo);1229+        assert_eq!(data.issues.len(), 1, "broken file skipped");1230+        assert_eq!(data.issues[0].number, 1);1231+    }1232+1233+    #[test]1234+    fn pr_loads_patch_and_reviews() {1235+        let repo = mktemp("pr");1236+        write_forge(1237+            &repo,1238+            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1239+            "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\ndraft: true\n---\ndesc <b>bold</b>\n",1240+        );1241+        let dest = repo1242+            .join(".forge")1243+            .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1244+        std::fs::write(&dest, "diff --git a/x b/x\n+evil <script>\n").unwrap();1245+        write_forge(1246+            &repo,1247+            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/reviews/dddddddd-dddd-dddd-dddd-dddddddddddd.md",1248+            "---\nid: dddddddd-dddd-dddd-dddd-dddddddddddd\nauthor: z\nstate: approved\ncreated: 2026-09-10T05:00:00Z\n---\nlgtm\n",1249+        );1250+        let data = load(&repo);1251+        assert_eq!(data.prs.len(), 1);1252+        assert_eq!(data.prs[0].number, 1);1253+        assert!(data.prs[0].draft);1254+        assert!(data.prs[0]1255+            .patch1256+            .as_ref()1257+            .expect("patch")1258+            .contains("diff --git"));1259+        assert_eq!(data.prs[0].reviews.len(), 1);1260+        assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));1261+1262+        let out = mktemp("pr-out");1263+        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1264+        let html =1265+            std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1266+                .expect("pr page");1267+        assert!(html.contains("#1"), "number displayed");1268+        assert!(!html.contains("<script>"), "patch escaped");1269+        assert!(html.contains("&lt;script&gt;"), "escaped patch present");1270+        assert!(html.contains("approved"), "review state shown");1271+        assert!(html.contains("lgtm"), "review body shown");1272+    }1273+1274+    #[test]1275+    fn tasks_group_and_ci_fail_badge() {1276+        let repo = mktemp("taskci");1277+        for (id, status) in [1278+            ("11111111-1111-1111-1111-111111111111", "todo"),1279+            ("22222222-2222-2222-2222-222222222222", "doing"),1280+            ("33333333-3333-3333-3333-333333333333", "blocked"),1281+            ("44444444-4444-4444-4444-444444444444", "done"),1282+        ] {1283+            write_forge(1284+                &repo,1285+                &format!("tasks/{id}.md"),1286+                &format!("---\nid: {id}\ntitle: Task {status}\nstatus: {status}\ncreated: 2026-09-10T03:00:00Z\n---\nbody {status}\n"),1287+            );1288+        }1289+        write_forge(&repo, "ci/ci.env", "runner: local\n");1290+        std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();1291+        std::fs::write(1292+            repo.join(".forge").join("ci/steps/00_test.sh"),1293+            "#!/bin/sh\n",1294+        )1295+        .unwrap();1296+        write_forge(1297+            &repo,1298+            "ci/runs/2026-09-10T00-00-00Z/summary.md",1299+            "---\nrun: 2026-09-10T00-00-00Z\nverdict: fail\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 1\n    duration_ms: 42\n---\nboom log\n",1300+        );1301+        let data = load(&repo);1302+        assert_eq!(data.tasks.len(), 4);1303+        assert_eq!(data.ci.runner, "local");1304+        assert_eq!(data.ci.steps, vec!["00_test.sh".to_string()]);1305+        assert_eq!(data.ci.latest_verdict(), "fail");1306+1307+        let out = mktemp("taskci-out");1308+        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1309+        let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");1310+        for status in ["todo", "doing", "blocked", "done"] {1311+            assert!(tasks.contains(status), "group {status}");1312+        }1313+        let ci = std::fs::read_to_string(out.join("ci/index.html")).expect("ci");1314+        assert!(ci.contains("badge fail"), "fail badge");1315+        assert!(ci.contains(">fail<"), "verdict text");1316+        assert!(ci.contains("00_test.sh"), "step name");1317+        assert!(ci.contains("boom log"), "log body");1318+    }1319+1320+    #[test]1321+    fn missing_forge_is_empty_not_error() {1322+        let repo = mktemp("empty");1323+        let data = load(&repo);1324+        assert!(data.issues.is_empty());1325+        assert!(data.prs.is_empty());1326+        assert!(data.tasks.is_empty());1327+        assert_eq!(data.ci.latest_verdict(), "none");1328+        let out = mktemp("empty-out");1329+        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1330+        for page in [1331+            "issues/index.html",1332+            "pulls/index.html",1333+            "tasks/index.html",1334+            "ci/index.html",1335+        ] {1336+            let html = std::fs::read_to_string(out.join(page)).expect(page);1337+            assert!(html.contains("No "), "{page} empty state");1338+        }1339+    }1340+1341+    #[test]1342+    fn verdict_badge_shapes() {1343+        assert!(verdict_badge("pass").contains("badge pass"));1344+        assert!(verdict_badge("fail").contains("badge fail"));1345+        assert!(verdict_badge("running").contains("badge running"));1346+        assert!(verdict_badge("cancelled").contains("badge cancelled"));1347+        assert!(verdict_badge("none").contains(">none<"));1348+        assert!(verdict_badge("<evil>").contains("&lt;evil&gt;"));1349+        assert!(!verdict_badge("<evil>").contains("<evil>"));1350+    }1351+1352+    /// Big-forge: read the real f0rge checkout. Counts must equal a direct1353+    /// file-count (and `f0rge issue list` when available); zero panics.1354+    #[test]1355+    fn big_forge_real_shape_no_panics() {1356+        let real = PathBuf::from("~/src/code/gl0w-w0rld/f0rge/.forge");1357+        if !real.exists() {1358+            return;1359+        }1360+        let repo = real.parent().expect("parent").to_path_buf();1361+        let data = load(&repo);1362+        let dir_count = |sub: &str| {1363+            std::fs::read_dir(real.join(sub))1364+                .map(|rd| rd.filter_map(Result::ok).count())1365+                .unwrap_or(0)1366+        };1367+        let file_count = |sub: &str| {1368+            std::fs::read_dir(real.join(sub))1369+                .map(|rd| {1370+                    rd.filter_map(Result::ok)1371+                        .filter(|e| e.path().extension().is_some_and(|x| x == "md"))1372+                        .count()1373+                })1374+                .unwrap_or(0)1375+        };1376+        assert_eq!(1377+            data.issues.len(),1378+            dir_count("issues"),1379+            "issues == dir count"1380+        );1381+        assert_eq!(data.prs.len(), dir_count("prs"), "prs == dir count");1382+        assert_eq!(data.tasks.len(), file_count("tasks"), "tasks == file count");1383+        // Cross-check against `f0rge issue list` when the binary is around.1384+        let cli_count = std::process::Command::new("f0rge")1385+            .arg("issue")1386+            .arg("list")1387+            .current_dir(&repo)1388+            .output()1389+            .ok()1390+            .filter(|o| o.status.success())1391+            .map(|o| {1392+                String::from_utf8_lossy(&o.stdout)1393+                    .lines()1394+                    .filter(|l| !l.trim().is_empty())1395+                    .count()1396+            });1397+        if let Some(n) = cli_count {1398+            assert_eq!(data.issues.len(), n, "issues == `f0rge issue list` count");1399+        }1400+        // Numbers are a clean 1..=N rank in (created, id) order.1401+        let mut prev = (String::new(), String::new());1402+        for (i, issue) in data.issues.iter().enumerate() {1403+            assert_eq!(issue.number, i + 1);1404+            let key = (issue.created.clone(), issue.id.clone());1405+            assert!(key >= prev, "sorted order");1406+            prev = key;1407+        }1408+        assert!(!data.ci.steps.is_empty(), "real ci steps read");1409+        assert!(data.ci.latest.is_some(), "real ci latest run read");1410+    }1411+}
src/main.rs+1 −0
diff --git a/src/main.rs b/src/main.rsindex d15fb9e..6d84b9b 100644--- a/src/main.rs+++ b/src/main.rs@@ -1,6 +1,7 @@11 mod build;22 mod commits;33 mod feed;4+mod forge;45 mod gitx;56 mod layout;67 mod wiki;
tests/forge_readers.rs+317 −1
diff --git a/tests/forge_readers.rs b/tests/forge_readers.rsnew file mode 100644index 0000000..f512a39--- /dev/null+++ b/tests/forge_readers.rs@@ -0,0 +1,317 @@1+//! CLI-level test for the f0rge readers (m3, f0rge issue #7).2+//!3+//! Builds a fixture `.forge/` exercising every required shape, runs4+//! `hu13 build` on it, and asserts the rendered site:5+//! - 2 issues with out-of-order `created` dates -> numbers rank by6+//!   `(created, id)` (5-number spot check across list + detail pages)7+//! - 1 broken issue (missing `title`) -> skipped, warned on stderr8+//!   naming file + field, build still exits 09+//! - 1 PR with `patch.diff` + review (state + body)10+//! - tasks in all 4 states (todo|doing|blocked|done)11+//! - a failing CI run fixture -> `fail` badge + steps + log12+//! - rendered counts match a direct file-count of the fixture13+14+use std::path::{Path, PathBuf};15+use std::process::Command;16+17+const OLDER_UUID: &str = "11111111-1111-4111-8111-111111111111";18+const NEWER_UUID: &str = "22222222-2222-4222-8222-222222222222";19+const BROKEN_UUID: &str = "33333333-3333-4333-8333-333333333333";20+const PR_UUID: &str = "44444444-4444-4444-8444-444444444444";21+const REVIEW_UUID: &str = "55555555-5555-4555-8555-555555555555";22+const COMMENT_UUID: &str = "66666666-6666-4666-8666-666666666666";23+24+fn git(repo: &Path, args: &[&str]) {25+    let st = Command::new("git")26+        .arg("-C")27+        .arg(repo)28+        .args(args)29+        .status()30+        .expect("spawn git");31+    assert!(st.success(), "git {args:?} failed");32+}33+34+fn fresh_dir(prefix: &str) -> PathBuf {35+    let dir = std::env::temp_dir().join(format!(36+        "hu13-forge-{prefix}-{}-{}",37+        std::process::id(),38+        std::time::SystemTime::now()39+            .duration_since(std::time::UNIX_EPOCH)40+            .map(|d| d.as_nanos())41+            .unwrap_or(0)42+    ));43+    std::fs::create_dir_all(&dir).unwrap();44+    dir45+}46+47+fn forge_write(repo: &Path, rel: &str, content: &str) {48+    let dest = repo.join(".forge").join(rel);49+    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();50+    std::fs::write(&dest, content).unwrap();51+}52+53+/// Fixture `.forge/` covering every shape in f0rge issue #7.54+fn fixture_forge(repo: &Path) {55+    // Older issue (must rank #1 despite sorting after the newer uuid).56+    forge_write(57+        repo,58+        &format!("issues/{OLDER_UUID}/issue.md"),59+        "---\n\60+         id: 11111111-1111-4111-8111-111111111111\n\61+         title: Older issue\n\62+         state: open\n\63+         labels:\n\64+         - kind:test\n\65+         assignees: []\n\66+         milestone: m1\n\67+         created: 2026-09-10T03:00:00Z\n\68+         updated: null\n\69+         ---\n\70+         Older body with <script>alert(1)</script> inside.\n",71+    );72+    forge_write(73+        repo,74+        &format!("issues/{OLDER_UUID}/comments/{COMMENT_UUID}.md"),75+        "---\n\76+         id: 66666666-6666-4666-8666-666666666666\n\77+         author: z\n\78+         created: 2026-09-10T03:30:00Z\n\79+         ---\n\80+         First comment.\n",81+    );82+    // Newer issue (must rank #2).83+    forge_write(84+        repo,85+        &format!("issues/{NEWER_UUID}/issue.md"),86+        "---\n\87+         id: 22222222-2222-4222-8222-222222222222\n\88+         title: Newer issue\n\89+         state: closed\n\90+         labels: []\n\91+         assignees: []\n\92+         milestone: null\n\93+         created: 2026-09-10T04:00:00Z\n\94+         ---\n\95+         Newer body.\n",96+    );97+    // Broken issue: missing required `title` -> skipped + warned.98+    forge_write(99+        repo,100+        &format!("issues/{BROKEN_UUID}/issue.md"),101+        "---\n\102+         id: 33333333-3333-4333-8333-333333333333\n\103+         state: open\n\104+         created: 2026-09-10T05:00:00Z\n\105+         ---\n\106+         No title here.\n",107+    );108+    // PR with patch + review.109+    forge_write(110+        repo,111+        &format!("prs/{PR_UUID}/pr.md"),112+        "---\n\113+         id: 44444444-4444-4444-8444-444444444444\n\114+         title: Fix the thing\n\115+         state: open\n\116+         base: main\n\117+         head: fix-thing\n\118+         created: 2026-09-10T03:30:00Z\n\119+         draft: false\n\120+         issues:\n\121+         - 11111111-1111-4111-8111-111111111111\n\122+         ci: fail\n\123+         ---\n\124+         PR description.\n",125+    );126+    forge_write(127+        repo,128+        &format!("prs/{PR_UUID}/patch.diff"),129+        "diff --git a/x.txt b/x.txt\n+added <tag> line\n",130+    );131+    forge_write(132+        repo,133+        &format!("prs/{PR_UUID}/reviews/{REVIEW_UUID}.md"),134+        "---\n\135+         id: 55555555-5555-4555-8555-555555555555\n\136+         author: reviewer\n\137+         state: approved\n\138+         created: 2026-09-10T05:00:00Z\n\139+         ---\n\140+         Looks good to me.\n",141+    );142+    // Tasks in all four states.143+    for (n, status) in ["todo", "doing", "blocked", "done"].iter().enumerate() {144+        let id = format!("7777777{n}-7777-4777-8777-77777777777{n}");145+        forge_write(146+            repo,147+            &format!("tasks/{id}.md"),148+            &format!(149+                "---\nid: {id}\ntitle: Task {status}\nstatus: {status}\n\150+                 created: 2026-09-10T03:0{n}:00Z\n---\nTask {status} body.\n"151+            ),152+        );153+    }154+    // Labels + milestones (YAML lists, no fences).155+    forge_write(repo, "labels.md", "- name: kind:test\n");156+    forge_write(repo, "milestones.md", "- name: m1\n  state: open\n");157+    // CI: env + one step + one failing run.158+    forge_write(repo, "ci/ci.env", "runner: local\n");159+    forge_write(repo, "ci/steps/00_test.sh", "#!/bin/sh\nexit 1\n");160+    forge_write(161+        repo,162+        "ci/runs/2026-09-10T00-00-00Z/summary.md",163+        "---\n\164+         run: 2026-09-10T00-00-00Z\n\165+         verdict: fail\n\166+         created: 2026-09-10T00:00:00Z\n\167+         steps:\n\168+           - name: 00_test.sh\n\169+             exit: 1\n\170+             duration_ms: 7\n\171+         ---\n\172+         boom: the test failed\n",173+    );174+}175+176+fn direct_count(dir: &Path) -> usize {177+    std::fs::read_dir(dir)178+        .map(|rd| rd.filter_map(Result::ok).count())179+        .unwrap_or(0)180+}181+182+#[test]183+fn forge_fixture_renders_counts_numbers_and_ci() {184+    let repo = fresh_dir("repo");185+    git(&repo, &["init", "-b", "main"]);186+    git(&repo, &["config", "user.name", "hu13-test"]);187+    git(&repo, &["config", "user.email", "[email protected]"]);188+    git(&repo, &["config", "commit.gpgsign", "false"]);189+    std::fs::write(repo.join("README.md"), "# forge fixture\n").unwrap();190+    fixture_forge(&repo);191+    git(&repo, &["add", "-A"]);192+    git(&repo, &["commit", "-m", "forge fixture"]);193+194+    // Direct file-counts of the fixture (ground truth for the asserts).195+    assert_eq!(direct_count(&repo.join(".forge/issues")), 3);196+    assert_eq!(direct_count(&repo.join(".forge/prs")), 1);197+    assert_eq!(direct_count(&repo.join(".forge/tasks")), 4);198+199+    let out = fresh_dir("out");200+    let bin = env!("CARGO_BIN_EXE_hu13");201+    let produced = Command::new(bin)202+        .arg("build")203+        .arg(&repo)204+        .arg("-o")205+        .arg(&out)206+        .output()207+        .expect("spawn hu13 build");208+    let stderr = String::from_utf8_lossy(&produced.stderr).into_owned();209+    assert!(210+        produced.status.success(),211+        "broken issue must not fail the build; stderr: {stderr}"212+    );213+    // Broken file warned, naming file (uuid) + field (title).214+    assert!(215+        stderr.contains(BROKEN_UUID),216+        "stderr names the broken file; got: {stderr}"217+    );218+    assert!(219+        stderr.contains("title"),220+        "stderr names the missing field; got: {stderr}"221+    );222+223+    let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);224+225+    // --- issues index: 2 rendered rows (broken one skipped) ---226+    let issues = read("issues/index.html");227+    let rows = issues.matches("<tr><td>").count();228+    assert_eq!(rows, 2, "rendered issues match file-count minus broken");229+    assert!(!issues.contains("No title here"), "broken issue skipped");230+    // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.231+    assert!(232+        issues.contains(&format!(233+            "<td>1</td><td><a href=\"/issues/{OLDER_UUID}.html\">"234+        )),235+        "issue #1 -> older uuid"236+    );237+    assert!(238+        issues.contains(&format!(239+            "<td>2</td><td><a href=\"/issues/{NEWER_UUID}.html\">"240+        )),241+        "issue #2 -> newer uuid"242+    );243+    // (2): rank order — older sorts first despite uuid order.244+    let older_pos = issues.find("Older issue").expect("older row");245+    let newer_pos = issues.find("Newer issue").expect("newer row");246+    assert!(older_pos < newer_pos, "ranked by (created, id)");247+    assert!(issues.contains("kind:test"), "labels shown");248+    assert!(issues.contains("m1"), "milestones shown");249+250+    // (3): detail pages display their numbers.251+    let older = read(&format!("issues/{OLDER_UUID}.html"));252+    assert!(older.contains("<h1>#1 Older issue</h1>"), "detail shows #1");253+    assert!(older.contains("Comments (1)"), "comment counted");254+    assert!(older.contains("First comment."), "comment rendered");255+    assert!(256+        !older.contains("<script>alert(1)</script>"),257+        "issue body escaped"258+    );259+    assert!(older.contains("&lt;script&gt;"), "escaped form present");260+    let newer = read(&format!("issues/{NEWER_UUID}.html"));261+    assert!(newer.contains("<h1>#2 Newer issue</h1>"), "detail shows #2");262+263+    // (4): pulls — same shape + head/base/draft/ci + patch <pre>.264+    let pulls = read("pulls/index.html");265+    assert_eq!(pulls.matches("<tr><td>").count(), 1, "prs match file-count");266+    assert!(267+        pulls.contains(&format!("<td>1</td><td><a href=\"/pulls/{PR_UUID}.html\">")),268+        "pr #1 -> uuid"269+    );270+    let pr = read(&format!("pulls/{PR_UUID}.html"));271+    assert!(272+        pr.contains("<h1>#1 Fix the thing</h1>"),273+        "pr detail shows #1"274+    );275+    assert!(pr.contains("fix-thing"), "head shown");276+    assert!(pr.contains("main"), "base shown");277+    assert!(pr.contains("diff --git"), "patch.diff shown");278+    assert!(279+        pr.contains("&lt;tag&gt;"),280+        "patch escaped in <pre>, not markdown"281+    );282+    assert!(pr.contains("approved"), "review state shown");283+    assert!(pr.contains("Looks good to me."), "review body shown");284+285+    // --- tasks grouped in four columns ---286+    let tasks = read("tasks/index.html");287+    for status in ["todo", "doing", "blocked", "done"] {288+        assert!(289+            tasks.contains(&format!("<h2>{status} (1)</h2>")),290+            "group {status} with one task"291+        );292+        assert!(293+            tasks.contains(&format!("Task {status}")),294+            "{status} task shown"295+        );296+    }297+298+    // --- ci: env runner, step names, latest fail badge + steps + log ---299+    let ci = read("ci/index.html");300+    assert!(ci.contains("local"), "env runner shown");301+    assert!(ci.contains("00_test.sh"), "step script name shown");302+    assert!(ci.contains("badge fail"), "fail badge on ci page");303+    assert!(ci.contains(">fail<"), "verdict text on ci page");304+    assert!(ci.contains("boom: the test failed"), "run log in <pre>");305+306+    // --- index header: real counts + verdict, no TODO markers ---307+    let index = read("index.html");308+    assert!(index.contains("issues (2)"), "real issue count");309+    assert!(index.contains("pulls (1)"), "real pull count");310+    assert!(index.contains("tasks (4)"), "real task count");311+    assert!(index.contains("badge fail"), "index verdict badge");312+    assert!(!index.contains("TODO"), "TODO markers deleted");313+    assert!(index.contains("/issues/index.html"), "nav links issues");314+    assert!(index.contains("/pulls/index.html"), "nav links pulls");315+    assert!(index.contains("/tasks/index.html"), "nav links tasks");316+    assert!(index.contains("/ci/index.html"), "nav links ci");317+}0-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls