.forge/prs/15e9bea3-5285-4508-b38e-03babf491c7b/patch.diff 67.5 KiB 1910 lines Raw
1 From 47eeff5e808586fdafaa389606b6b762fe76598b Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:26:30 +01004 Subject: [PATCH] m3/forge-readers: issues/prs/tasks/ci + numbers + header5  wiring (54 tests)6 7 ---8  src/build.rs           |   61 +-9  src/forge.rs           | 1411 ++++++++++++++++++++++++++++++++++++++++10  src/main.rs            |    1 +11  tests/forge_readers.rs |  317 +++++++++12  4 files changed, 1773 insertions(+), 17 deletions(-)13  create mode 100644 src/forge.rs14  create mode 100644 tests/forge_readers.rs15 16 diff --git a/src/build.rs b/src/build.rs17 index ae9f381..9920bf8 10064418 --- a/src/build.rs19 +++ b/src/build.rs20 @@ -1,8 +1,10 @@21 -//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5).22 +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers).23  //!24  //! Reads one branch via [`crate::gitx`], emits `dist/`:25  //! `index.html`, `files/<path>.html`, `raw/<path>` (byte-identical),26 -//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `wiki/index.html`,27 +//! `commits.html`, `commit/<sha>.html`, `feed.atom`, `issues/index.html`,28 +//! `issues/<uuid>.html`, `pulls/index.html`, `pulls/<uuid>.html`,29 +//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`,30  //! `wiki/<path>.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`.31  //!32  //! Bodies from the repo are semi-trusted but treated as untrusted:33 @@ -13,7 +15,7 @@ use std::collections::BTreeMap;34  use std::path::Path;35  36  use crate::layout::esc;37 -use crate::{commits, feed, gitx, wiki};38 +use crate::{commits, feed, forge, gitx, wiki};39  40  /// Files at or above this size get a placeholder page + download link.41  pub const PREVIEW_MAX_BYTES: usize = 256 * 1024;42 @@ -145,9 +147,16 @@ fn render_text_pre(display: &str) -> String {43      out44  }45  46 -fn nav_code() -> String {47 -    "<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 -->"48 -        .to_string()49 +/// Top nav with live f0rge counts (no stubs, no TODO markers).50 +fn nav_bar(issue_count: usize, pr_count: usize) -> String {51 +    format!(52 +        "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \53 +         <a href=\"/issues/index.html\">issues ({issue_count})</a> · \54 +         <a href=\"/pulls/index.html\">pulls ({pr_count})</a> · \55 +         <a href=\"/tasks/index.html\">tasks</a> · \56 +         <a href=\"/ci/index.html\">ci</a> · \57 +         <a href=\"/wiki/index.html\">wiki</a>"58 +    )59  }60  61  /// Write `out/style.css` from the embedded static file.62 @@ -219,6 +228,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu63      std::fs::create_dir_all(out.join("raw")).map_err(|e| format!("hu13: create raw dir: {e}"))?;64      write_style(out)?;65  66 +    // f0rge readers (m3, issue #7): on-disk `.forge/`. Load once up front67 +    // so every page (including file pages below) gets a nav with live68 +    // counts. Loading never fails: bad files are skipped + warned.69 +    let forge_data = forge::load(repo);70 +    let nav = nav_bar(forge_data.issues.len(), forge_data.prs.len());71 +72      // Raw files first: byte-identical copies.73      let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();74      for f in &files {75 @@ -273,7 +288,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu76              }77          }78          body.push_str("</ul>\n");79 -        let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body);80 +        let html = crate::layout::page(&format!("{dir} — {name}"), &nav, &body);81          let dest = out.join("files").join(format!("{dir}.html"));82          if let Some(parent) = dest.parent() {83              std::fs::create_dir_all(parent)84 @@ -325,7 +340,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu85              let display = String::from_utf8_lossy(&bytes).into_owned();86              body.push_str(&render_text_pre(&display));87          }88 -        let html = crate::layout::page(&format!("{f} — {name}"), &nav_code(), &body);89 +        let html = crate::layout::page(&format!("{f} — {name}"), &nav, &body);90          let dest = out.join("files").join(format!("{f}.html"));91          if let Some(parent) = dest.parent() {92              std::fs::create_dir_all(parent)93 @@ -335,10 +350,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu94      }95  96      // Commits + wiki (m2): untrusted messages/diffs/bodies escaped inside.97 -    let nav = nav_code();98      commits::render(repo, out, &branch, opts.max_log, &name, &nav)?;99      feed::render(repo, out, &branch, &name)?;100      wiki::render(repo, out, &name, &nav)?;101 +    // f0rge pages (m3, issue #7): issues/pulls/tasks/ci from loaded data.102 +    forge::render_loaded(&forge_data, out, &name, &nav)?;103  104      // Snapshot last so the index can link sizes.105      let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;106 @@ -366,10 +382,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu107          esc(&branch),108          esc(&short_sha)109      ));110 -    body.push_str(111 -        "<p>issues (0) · pulls (0) · tasks (0) <!-- TODO(m3): real f0rge counts --></p>\n",112 -    );113 -    body.push_str("<p>CI: unknown (stub)</p>\n");114 +    body.push_str(&format!(115 +        "<p><a href=\"/issues/index.html\">issues ({})</a> · \116 +         <a href=\"/pulls/index.html\">pulls ({})</a> · \117 +         <a href=\"/tasks/index.html\">tasks ({})</a></p>\n",118 +        forge_data.issues.len(),119 +        forge_data.prs.len(),120 +        forge_data.tasks.len(),121 +    ));122 +    body.push_str(&format!(123 +        "<p>CI: {}</p>\n",124 +        forge::verdict_badge(forge_data.ci.latest_verdict()),125 +    ));126      body.push_str(&format!(127          "<p><a href=\"/repo.bundle\">repo.bundle</a> ({bundle_size} bytes) · \128           <a href=\"/repo.tar.gz\">repo.tar.gz</a> ({tarball_size} bytes)</p>\n"129 @@ -410,7 +434,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu130          None => body.push_str("<p class=\"muted\">No README found.</p>\n"),131      }132  133 -    let index = crate::layout::page(&name, &nav_code(), &body);134 +    let index = crate::layout::page(&name, &nav, &body);135      std::fs::write(out.join("index.html"), index)136          .map_err(|e| format!("hu13: write index.html: {e}"))?;137  138 @@ -556,11 +580,14 @@ mod tests {139              "raw HTML escaped"140          );141          assert!(index.contains("&lt;script&gt;"), "escaped HTML present");142 -        // Header: branch + short sha, f0rge counts stub, CI stub.143 +        // Header: branch + short sha, live f0rge counts, CI verdict badge.144          assert!(index.contains("main"), "branch shown");145 -        assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker");146 +        assert!(!index.contains("TODO"), "no TODO markers left");147          assert!(index.contains("issues (0)"), "f0rge counts as 0");148 -        assert!(index.contains("CI:"), "CI verdict stub");149 +        assert!(index.contains("pulls (0)"), "pull counts as 0");150 +        assert!(index.contains("tasks (0)"), "task counts as 0");151 +        assert!(index.contains("CI:"), "CI verdict shown");152 +        assert!(index.contains("badge"), "CI verdict badge");153          // Snapshot links with sizes.154          assert!(index.contains("repo.bundle"), "bundle linked");155          assert!(index.contains("repo.tar.gz"), "tarball linked");156 diff --git a/src/forge.rs b/src/forge.rs157 new file mode 100644158 index 0000000..9e4e9c5159 --- /dev/null160 +++ b/src/forge.rs161 @@ -0,0 +1,1411 @@162 +//! f0rge readers (m3, f0rge issue #7): issues / pulls / tasks / ci pages.163 +//!164 +//! Source is the on-disk `<repo>/.forge/` tree (NOT git objects):165 +//! `issues/<uuid>/issue.md` (+ `comments/<uuid>.md`),166 +//! `prs/<uuid>/pr.md` (+ `patch.diff`, `comments/`, `reviews/`),167 +//! `tasks/<uuid>.md`, `labels.md` + `milestones.md` (YAML lists, no fences),168 +//! `ci/ci.env` + `steps/*.sh` + `runs/<ts>/summary.md`.169 +//!170 +//! Rules (spec/schema.md §0, DRAFT): frontmatter is a tiny `---` split +171 +//! `yaml_serde`; unknown fields are ignored; a missing required field172 +//! skips that file with an `eprintln!` warning naming file + field and the173 +//! build still exits 0. Numbers are derived (never stored): 1-based rank174 +//! of `(created, id)` at build time. Pages display the number and link by175 +//! uuid.176 +//!177 +//! SECURITY: every byte from `.forge/` is untrusted. Markdown bodies go178 +//! through [`crate::build::markdown_to_html`] (raw HTML escaped); titles,179 +//! ids, states, diffs, and logs go through [`crate::layout::esc`] (diffs180 +//! and logs as escaped `<pre>`, never markdown).181 +182 +use std::path::Path;183 +184 +use serde::Deserialize;185 +186 +use crate::build::markdown_to_html;187 +use crate::layout::{esc, page};188 +189 +/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).190 +pub const FORGE_MAX_BYTES: u64 = 256 * 1024;191 +192 +/// Step-log bytes kept per log file (rest truncated with a marker).193 +pub const LOG_PREVIEW_MAX: usize = 64 * 1024;194 +195 +// ---------------------------------------------------------------------------196 +// Frontmatter plumbing197 +// ---------------------------------------------------------------------------198 +199 +/// Split `---\nyaml\n---\nbody` into `(yaml, body)`.200 +///201 +/// The opening fence must be the first line; the closing fence must be a202 +/// full line `---`. Returns `None` when there is no frontmatter.203 +fn split_frontmatter(text: &str) -> Option<(&str, &str)> {204 +    let rest = text.strip_prefix("---")?;205 +    let rest = rest206 +        .strip_prefix('\n')207 +        .or_else(|| rest.strip_prefix("\r\n"))?;208 +    let mut offset = 0;209 +    for line in rest.split_inclusive('\n') {210 +        let trimmed = line.trim_end_matches(['\n', '\r']);211 +        if trimmed == "---" {212 +            let yaml = &rest[..offset];213 +            let body = &rest[offset + line.len()..];214 +            return Some((yaml, body));215 +        }216 +        offset += line.len();217 +    }218 +    None219 +}220 +221 +/// Read a UTF-8 file, skipping (with a warning) files that are missing,222 +/// oversized, or non-UTF-8. `label` names the file for warnings.223 +fn read_text_capped(path: &Path, label: &str) -> Option<String> {224 +    let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);225 +    if len > FORGE_MAX_BYTES {226 +        eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");227 +        return None;228 +    }229 +    match std::fs::read(path) {230 +        Ok(bytes) => match String::from_utf8(bytes) {231 +            Ok(text) => Some(text),232 +            Err(_) => {233 +                eprintln!("hu13: forge: skipping {label}: not valid UTF-8");234 +                None235 +            }236 +        },237 +        Err(e) => {238 +            eprintln!("hu13: forge: skipping {label}: cannot read: {e}");239 +            None240 +        }241 +    }242 +}243 +244 +/// Parse YAML frontmatter; on error warn (naming file via `label` plus the245 +/// serde error, which names the field) and return `None` (skip the file).246 +fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>247 +where248 +    T: for<'de> Deserialize<'de>,249 +{250 +    match yaml_serde::from_str(yaml) {251 +        Ok(value) => Some(value),252 +        Err(e) => {253 +            eprintln!("hu13: forge: skipping {label}: {e}");254 +            None255 +        }256 +    }257 +}258 +259 +/// Sorted entry names (files or dirs) directly under `dir`.260 +/// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).261 +fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {262 +    let rd = match std::fs::read_dir(dir) {263 +        Ok(rd) => rd,264 +        Err(_) => return Vec::new(),265 +    };266 +    let mut names: Vec<String> = Vec::new();267 +    for ent in rd {268 +        let ent = match ent {269 +            Ok(ent) => ent,270 +            Err(e) => {271 +                eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());272 +                continue;273 +            }274 +        };275 +        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);276 +        if is_dir != only_dirs {277 +            continue;278 +        }279 +        names.push(ent.file_name().to_string_lossy().into_owned());280 +    }281 +    names.sort();282 +    names283 +}284 +285 +// ---------------------------------------------------------------------------286 +// Typed frontmatter structs (unknown fields ignored by serde default;287 +// `#[serde(default)]` marks every optional from schema §2)288 +// ---------------------------------------------------------------------------289 +290 +#[derive(Debug, Clone, Deserialize)]291 +struct IssueFm {292 +    id: String,293 +    title: String,294 +    state: String,295 +    #[serde(default)]296 +    labels: Vec<String>,297 +    #[serde(default)]298 +    assignees: Vec<String>,299 +    #[serde(default)]300 +    milestone: Option<String>,301 +    created: String,302 +    #[serde(default)]303 +    updated: Option<String>,304 +}305 +306 +#[derive(Debug, Clone, Deserialize)]307 +struct CommentFm {308 +    id: String,309 +    created: String,310 +    #[serde(default)]311 +    author: Option<String>,312 +}313 +314 +#[derive(Debug, Clone, Deserialize)]315 +struct PrFm {316 +    id: String,317 +    title: String,318 +    state: String,319 +    base: String,320 +    head: String,321 +    created: String,322 +    #[serde(default)]323 +    draft: bool,324 +    #[serde(default)]325 +    issues: Vec<String>,326 +    #[serde(default)]327 +    ci: Option<String>,328 +    #[serde(default)]329 +    updated: Option<String>,330 +}331 +332 +#[derive(Debug, Clone, Deserialize)]333 +struct ReviewFm {334 +    id: String,335 +    created: String,336 +    #[serde(default)]337 +    author: Option<String>,338 +    #[serde(default)]339 +    state: Option<String>,340 +}341 +342 +#[derive(Debug, Clone, Deserialize)]343 +struct TaskFm {344 +    id: String,345 +    title: String,346 +    status: String,347 +    created: String,348 +    #[serde(default)]349 +    assignee: Option<String>,350 +    #[serde(default)]351 +    updated: Option<String>,352 +    #[serde(default)]353 +    closed: Option<String>,354 +}355 +356 +#[derive(Debug, Clone, Deserialize)]357 +struct LabelFm {358 +    name: String,359 +    #[serde(default)]360 +    color: Option<String>,361 +    #[serde(default)]362 +    description: Option<String>,363 +}364 +365 +fn default_open() -> String {366 +    "open".to_string()367 +}368 +369 +#[derive(Debug, Clone, Deserialize)]370 +struct MilestoneFm {371 +    name: String,372 +    #[serde(default = "default_open")]373 +    state: String,374 +    #[serde(default)]375 +    due: Option<String>,376 +    #[serde(default)]377 +    description: Option<String>,378 +}379 +380 +fn default_runner() -> String {381 +    "local".to_string()382 +}383 +384 +#[derive(Debug, Clone, Deserialize)]385 +struct CiEnvFm {386 +    #[serde(default = "default_runner")]387 +    runner: String,388 +}389 +390 +#[derive(Debug, Clone, Deserialize)]391 +struct CiRunFm {392 +    run: String,393 +    verdict: String,394 +    created: String,395 +    #[serde(default)]396 +    steps: Vec<CiStepFm>,397 +}398 +399 +#[derive(Debug, Clone, Deserialize)]400 +struct CiStepFm {401 +    #[serde(default)]402 +    name: String,403 +    #[serde(default)]404 +    exit: i32,405 +    #[serde(default)]406 +    duration_ms: u64,407 +}408 +409 +// ---------------------------------------------------------------------------410 +// Loaded entities (frontmatter + body + sidecars, numbers assigned at load)411 +// ---------------------------------------------------------------------------412 +413 +/// One issue/PR comment.414 +#[derive(Debug, Clone, PartialEq, Eq)]415 +pub struct Comment {416 +    /// Comment uuid (filename stem).417 +    pub id: String,418 +    /// Optional author handle.419 +    pub author: Option<String>,420 +    /// RFC3339 creation timestamp.421 +    pub created: String,422 +    /// Markdown body after the frontmatter.423 +    pub body: String,424 +}425 +426 +/// One issue with derived display number and loaded comments.427 +#[derive(Debug, Clone)]428 +pub struct Issue {429 +    /// 1-based rank of `(created, id)` — display only, never stored.430 +    pub number: usize,431 +    /// Issue uuid (link key).432 +    pub id: String,433 +    pub title: String,434 +    pub state: String,435 +    pub labels: Vec<String>,436 +    pub assignees: Vec<String>,437 +    pub milestone: Option<String>,438 +    pub created: String,439 +    pub updated: Option<String>,440 +    /// Markdown body after the frontmatter.441 +    pub body: String,442 +    /// Comments sorted by `(created, id)`.443 +    pub comments: Vec<Comment>,444 +}445 +446 +/// One PR review (state + body).447 +#[derive(Debug, Clone, PartialEq, Eq)]448 +pub struct Review {449 +    /// Review uuid.450 +    pub id: String,451 +    /// Optional author handle.452 +    pub author: Option<String>,453 +    /// Optional `approved|changes-requested|commented`.454 +    pub state: Option<String>,455 +    /// RFC3339 creation timestamp.456 +    pub created: String,457 +    /// Markdown body after the frontmatter.458 +    pub body: String,459 +}460 +461 +/// One PR with derived display number, patch text, comments, and reviews.462 +#[derive(Debug, Clone)]463 +pub struct Pr {464 +    /// 1-based rank of `(created, id)` — display only, never stored.465 +    pub number: usize,466 +    /// PR uuid (link key).467 +    pub id: String,468 +    pub title: String,469 +    pub state: String,470 +    pub base: String,471 +    pub head: String,472 +    pub draft: bool,473 +    pub issues: Vec<String>,474 +    pub ci: Option<String>,475 +    pub created: String,476 +    pub updated: Option<String>,477 +    /// Markdown body after the frontmatter.478 +    pub body: String,479 +    /// Raw `patch.diff` text when present.480 +    pub patch: Option<String>,481 +    /// Comments sorted by `(created, id)`.482 +    pub comments: Vec<Comment>,483 +    /// Reviews sorted by `(created, id)`.484 +    pub reviews: Vec<Review>,485 +}486 +487 +/// One task file (grouped by `status` at render).488 +#[derive(Debug, Clone)]489 +pub struct TaskItem {490 +    /// Task uuid (filename stem).491 +    pub id: String,492 +    pub title: String,493 +    /// `todo|doing|blocked|done`.494 +    pub status: String,495 +    pub created: String,496 +    pub assignee: Option<String>,497 +    pub updated: Option<String>,498 +    pub closed: Option<String>,499 +    /// Markdown body after the frontmatter.500 +    pub body: String,501 +}502 +503 +/// One label entry from `labels.md`.504 +#[derive(Debug, Clone, PartialEq, Eq)]505 +pub struct Label {506 +    pub name: String,507 +    pub color: Option<String>,508 +    pub description: Option<String>,509 +}510 +511 +/// One milestone entry from `milestones.md`.512 +#[derive(Debug, Clone, PartialEq, Eq)]513 +pub struct Milestone {514 +    pub name: String,515 +    pub state: String,516 +    pub due: Option<String>,517 +    pub description: Option<String>,518 +}519 +520 +/// One step result from a CI run summary.521 +#[derive(Debug, Clone, PartialEq, Eq)]522 +pub struct CiStepStatus {523 +    pub name: String,524 +    pub exit: i32,525 +    pub duration_ms: u64,526 +}527 +528 +/// One CI run (latest only is rendered).529 +#[derive(Debug, Clone)]530 +pub struct CiRun {531 +    /// Run timestamp id (dir name).532 +    pub run: String,533 +    /// `pass|fail|running|cancelled`.534 +    pub verdict: String,535 +    pub created: String,536 +    pub steps: Vec<CiStepStatus>,537 +    /// Summary body after the frontmatter (the run log).538 +    pub body: String,539 +    /// `(log filename, content)` for `runs/<ts>/steps/*.log`, sorted.540 +    pub logs: Vec<(String, String)>,541 +}542 +543 +/// CI section: env runner, step script names, latest run.544 +#[derive(Debug, Clone)]545 +pub struct CiInfo {546 +    /// `runner:` from `ci.env` (default `local`).547 +    pub runner: String,548 +    /// Sorted `steps/*.sh` filenames.549 +    pub steps: Vec<String>,550 +    /// Latest `runs/<ts>/summary.md` by lexicographic (== chronological) order.551 +    pub latest: Option<CiRun>,552 +}553 +554 +impl CiInfo {555 +    /// Latest verdict, or `"none"` when no runs exist at build time.556 +    #[must_use]557 +    pub fn latest_verdict(&self) -> &str {558 +        self.latest559 +            .as_ref()560 +            .map(|r| r.verdict.as_str())561 +            .unwrap_or("none")562 +    }563 +}564 +565 +/// Everything the f0rge readers load (numbers already assigned).566 +#[derive(Debug, Clone)]567 +pub struct ForgeData {568 +    /// Issues sorted by `(created, id)` with 1-based numbers.569 +    pub issues: Vec<Issue>,570 +    /// PRs sorted by `(created, id)` with 1-based numbers.571 +    pub prs: Vec<Pr>,572 +    /// Tasks sorted by `(created, id)`.573 +    pub tasks: Vec<TaskItem>,574 +    /// Labels in file order.575 +    pub labels: Vec<Label>,576 +    /// Milestones in file order.577 +    pub milestones: Vec<Milestone>,578 +    /// CI env + steps + latest run.579 +    pub ci: CiInfo,580 +}581 +582 +// ---------------------------------------------------------------------------583 +// Loading584 +// ---------------------------------------------------------------------------585 +586 +fn load_comments(dir: &Path, scope: &str) -> Vec<Comment> {587 +    let mut out = Vec::new();588 +    for name in sorted_entries(dir, false) {589 +        if !name.ends_with(".md") {590 +            continue;591 +        }592 +        let label = format!("{scope}/{name}");593 +        let Some(text) = read_text_capped(&dir.join(&name), &label) else {594 +            continue;595 +        };596 +        let Some((yaml, body)) = split_frontmatter(&text) else {597 +            eprintln!("hu13: forge: skipping {label}: missing frontmatter");598 +            continue;599 +        };600 +        let Some(fm): Option<CommentFm> = parse_frontmatter(yaml, &label) else {601 +            continue;602 +        };603 +        out.push(Comment {604 +            id: fm.id,605 +            author: fm.author,606 +            created: fm.created,607 +            body: body.to_string(),608 +        });609 +    }610 +    out.sort_by_key(|c| (c.created.clone(), c.id.clone()));611 +    out612 +}613 +614 +fn load_reviews(dir: &Path, scope: &str) -> Vec<Review> {615 +    let mut out = Vec::new();616 +    for name in sorted_entries(dir, false) {617 +        if !name.ends_with(".md") {618 +            continue;619 +        }620 +        let label = format!("{scope}/{name}");621 +        let Some(text) = read_text_capped(&dir.join(&name), &label) else {622 +            continue;623 +        };624 +        let Some((yaml, body)) = split_frontmatter(&text) else {625 +            eprintln!("hu13: forge: skipping {label}: missing frontmatter");626 +            continue;627 +        };628 +        let Some(fm): Option<ReviewFm> = parse_frontmatter(yaml, &label) else {629 +            continue;630 +        };631 +        out.push(Review {632 +            id: fm.id,633 +            author: fm.author,634 +            state: fm.state,635 +            created: fm.created,636 +            body: body.to_string(),637 +        });638 +    }639 +    out.sort_by_key(|c| (c.created.clone(), c.id.clone()));640 +    out641 +}642 +643 +fn load_issues(root: &Path) -> Vec<Issue> {644 +    let dir = root.join("issues");645 +    let mut issues = Vec::new();646 +    for uuid in sorted_entries(&dir, true) {647 +        let label = format!("issues/{uuid}/issue.md");648 +        let Some(text) = read_text_capped(&dir.join(&uuid).join("issue.md"), &label) else {649 +            continue;650 +        };651 +        let Some((yaml, body)) = split_frontmatter(&text) else {652 +            eprintln!("hu13: forge: skipping {label}: missing frontmatter");653 +            continue;654 +        };655 +        let Some(fm): Option<IssueFm> = parse_frontmatter(yaml, &label) else {656 +            continue;657 +        };658 +        let comments = load_comments(659 +            &dir.join(&uuid).join("comments"),660 +            &format!("issues/{uuid}/comments"),661 +        );662 +        issues.push(Issue {663 +            number: 0,664 +            id: fm.id,665 +            title: fm.title,666 +            state: fm.state,667 +            labels: fm.labels,668 +            assignees: fm.assignees,669 +            milestone: fm.milestone,670 +            created: fm.created,671 +            updated: fm.updated,672 +            body: body.to_string(),673 +            comments,674 +        });675 +    }676 +    // Numbers: 1-based rank of (created, id).677 +    issues.sort_by_key(|i| (i.created.clone(), i.id.clone()));678 +    for (i, issue) in issues.iter_mut().enumerate() {679 +        issue.number = i + 1;680 +    }681 +    issues682 +}683 +684 +fn load_prs(root: &Path) -> Vec<Pr> {685 +    let dir = root.join("prs");686 +    let mut prs = Vec::new();687 +    for uuid in sorted_entries(&dir, true) {688 +        let label = format!("prs/{uuid}/pr.md");689 +        let Some(text) = read_text_capped(&dir.join(&uuid).join("pr.md"), &label) else {690 +            continue;691 +        };692 +        let Some((yaml, body)) = split_frontmatter(&text) else {693 +            eprintln!("hu13: forge: skipping {label}: missing frontmatter");694 +            continue;695 +        };696 +        let Some(fm): Option<PrFm> = parse_frontmatter(yaml, &label) else {697 +            continue;698 +        };699 +        let patch = std::fs::read(dir.join(&uuid).join("patch.diff"))700 +            .ok()701 +            .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());702 +        let comments = load_comments(703 +            &dir.join(&uuid).join("comments"),704 +            &format!("prs/{uuid}/comments"),705 +        );706 +        let reviews = load_reviews(707 +            &dir.join(&uuid).join("reviews"),708 +            &format!("prs/{uuid}/reviews"),709 +        );710 +        prs.push(Pr {711 +            number: 0,712 +            id: fm.id,713 +            title: fm.title,714 +            state: fm.state,715 +            base: fm.base,716 +            head: fm.head,717 +            draft: fm.draft,718 +            issues: fm.issues,719 +            ci: fm.ci,720 +            created: fm.created,721 +            updated: fm.updated,722 +            body: body.to_string(),723 +            patch,724 +            comments,725 +            reviews,726 +        });727 +    }728 +    // Numbers: 1-based rank of (created, id).729 +    prs.sort_by_key(|p| (p.created.clone(), p.id.clone()));730 +    for (i, pr) in prs.iter_mut().enumerate() {731 +        pr.number = i + 1;732 +    }733 +    prs734 +}735 +736 +fn load_tasks(root: &Path) -> Vec<TaskItem> {737 +    let dir = root.join("tasks");738 +    let mut tasks = Vec::new();739 +    for name in sorted_entries(&dir, false) {740 +        if !name.ends_with(".md") {741 +            continue;742 +        }743 +        let label = format!("tasks/{name}");744 +        let Some(text) = read_text_capped(&dir.join(&name), &label) else {745 +            continue;746 +        };747 +        let Some((yaml, body)) = split_frontmatter(&text) else {748 +            eprintln!("hu13: forge: skipping {label}: missing frontmatter");749 +            continue;750 +        };751 +        let Some(fm): Option<TaskFm> = parse_frontmatter(yaml, &label) else {752 +            continue;753 +        };754 +        if !matches!(fm.status.as_str(), "todo" | "doing" | "blocked" | "done") {755 +            eprintln!(756 +                "hu13: forge: skipping {label}: unknown status '{}'",757 +                fm.status758 +            );759 +            continue;760 +        }761 +        tasks.push(TaskItem {762 +            id: fm.id,763 +            title: fm.title,764 +            status: fm.status,765 +            created: fm.created,766 +            assignee: fm.assignee,767 +            updated: fm.updated,768 +            closed: fm.closed,769 +            body: body.to_string(),770 +        });771 +    }772 +    tasks.sort_by_key(|t| (t.created.clone(), t.id.clone()));773 +    tasks774 +}775 +776 +fn load_labels(root: &Path) -> Vec<Label> {777 +    let path = root.join("labels.md");778 +    if !path.exists() {779 +        return Vec::new();780 +    }781 +    let Some(text) = read_text_capped(&path, "labels.md") else {782 +        return Vec::new();783 +    };784 +    // YAML list, no fences: parse the whole file.785 +    match yaml_serde::from_str::<Vec<LabelFm>>(&text) {786 +        Ok(items) => items787 +            .into_iter()788 +            .map(|l| Label {789 +                name: l.name,790 +                color: l.color,791 +                description: l.description,792 +            })793 +            .collect(),794 +        Err(e) => {795 +            eprintln!("hu13: forge: skipping labels.md: {e}");796 +            Vec::new()797 +        }798 +    }799 +}800 +801 +fn load_milestones(root: &Path) -> Vec<Milestone> {802 +    let path = root.join("milestones.md");803 +    if !path.exists() {804 +        return Vec::new();805 +    }806 +    let Some(text) = read_text_capped(&path, "milestones.md") else {807 +        return Vec::new();808 +    };809 +    // YAML list, no fences: parse the whole file.810 +    match yaml_serde::from_str::<Vec<MilestoneFm>>(&text) {811 +        Ok(items) => items812 +            .into_iter()813 +            .map(|m| Milestone {814 +                name: m.name,815 +                state: m.state,816 +                due: m.due,817 +                description: m.description,818 +            })819 +            .collect(),820 +        Err(e) => {821 +            eprintln!("hu13: forge: skipping milestones.md: {e}");822 +            Vec::new()823 +        }824 +    }825 +}826 +827 +fn truncate_log(text: &str) -> String {828 +    if text.len() <= LOG_PREVIEW_MAX {829 +        return text.to_string();830 +    }831 +    let mut end = LOG_PREVIEW_MAX;832 +    while !text.is_char_boundary(end) {833 +        end -= 1;834 +    }835 +    format!("{}…[truncated]", &text[..end])836 +}837 +838 +fn load_ci(root: &Path) -> CiInfo {839 +    let dir = root.join("ci");840 +    let runner = if dir.join("ci.env").exists() {841 +        match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {842 +            Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {843 +                Ok(env) => env.runner,844 +                Err(e) => {845 +                    eprintln!("hu13: forge: skipping ci/ci.env: {e}");846 +                    default_runner()847 +                }848 +            },849 +            None => default_runner(),850 +        }851 +    } else {852 +        default_runner()853 +    };854 +    let steps: Vec<String> = sorted_entries(&dir.join("steps"), false)855 +        .into_iter()856 +        .filter(|n| n.ends_with(".sh"))857 +        .collect();858 +859 +    let mut runs = sorted_entries(&dir.join("runs"), true);860 +    runs.sort();861 +    let latest = runs.last().cloned().and_then(|ts| {862 +        let label = format!("ci/runs/{ts}/summary.md");863 +        let text = read_text_capped(&dir.join("runs").join(&ts).join("summary.md"), &label)?;864 +        let (yaml, body) = match split_frontmatter(&text) {865 +            Some(pair) => pair,866 +            None => {867 +                eprintln!("hu13: forge: skipping {label}: missing frontmatter");868 +                return None;869 +            }870 +        };871 +        let fm: CiRunFm = parse_frontmatter(yaml, &label)?;872 +        let mut logs = Vec::new();873 +        for name in sorted_entries(&dir.join("runs").join(&ts).join("steps"), false) {874 +            if !name.ends_with(".log") {875 +                continue;876 +            }877 +            let bytes = std::fs::read(dir.join("runs").join(&ts).join("steps").join(&name))878 +                .unwrap_or_default();879 +            logs.push((name, truncate_log(&String::from_utf8_lossy(&bytes))));880 +        }881 +        Some(CiRun {882 +            run: fm.run,883 +            verdict: fm.verdict,884 +            created: fm.created,885 +            steps: fm886 +                .steps887 +                .into_iter()888 +                .map(|s| CiStepStatus {889 +                    name: s.name,890 +                    exit: s.exit,891 +                    duration_ms: s.duration_ms,892 +                })893 +                .collect(),894 +            body: body.to_string(),895 +            logs,896 +        })897 +    });898 +899 +    CiInfo {900 +        runner,901 +        steps,902 +        latest,903 +    }904 +}905 +906 +/// Load every f0rge section from on-disk `<repo>/.forge/`.907 +/// Never fails: bad files are skipped with warnings; a missing908 +/// `.forge/` yields empty sections.909 +#[must_use]910 +pub fn load(repo: &Path) -> ForgeData {911 +    let root = repo.join(".forge");912 +    ForgeData {913 +        issues: load_issues(&root),914 +        prs: load_prs(&root),915 +        tasks: load_tasks(&root),916 +        labels: load_labels(&root),917 +        milestones: load_milestones(&root),918 +        ci: load_ci(&root),919 +    }920 +}921 +922 +// ---------------------------------------------------------------------------923 +// Rendering924 +// ---------------------------------------------------------------------------925 +926 +/// Verdict badge: `<span class="badge {verdict}">verdict</span>` for927 +/// `pass|fail|running|cancelled`, plain `badge` otherwise (`none`, unknown).928 +#[must_use]929 +pub fn verdict_badge(verdict: &str) -> String {930 +    let v = verdict.trim().to_lowercase();931 +    if matches!(v.as_str(), "pass" | "fail" | "running" | "cancelled") {932 +        format!("<span class=\"badge {v}\">{}</span>", esc(&v))933 +    } else {934 +        format!("<span class=\"badge\">{}</span>", esc(verdict.trim()))935 +    }936 +}937 +938 +fn meta_row(label: &str, value: &str) -> String {939 +    format!(940 +        "<p><span class=\"muted\">{}</span> {}</p>\n",941 +        esc(label),942 +        value943 +    )944 +}945 +946 +fn render_comment(c: &Comment) -> String {947 +    let mut out = String::from("<li class=\"comment\">\n");948 +    let author = c.author.as_deref().unwrap_or("unknown");949 +    out.push_str(&format!(950 +        "<p class=\"muted\">{} · <code>{}</code></p>\n",951 +        esc(&c.created),952 +        esc(author),953 +    ));954 +    out.push_str(&markdown_to_html(&c.body));955 +    out.push_str("</li>\n");956 +    out957 +}958 +959 +fn render_issue_page(issue: &Issue, site_name: &str, nav: &str) -> String {960 +    let mut body = format!(961 +        "<h1>#{n} {title}</h1>\n",962 +        n = issue.number,963 +        title = esc(&issue.title),964 +    );965 +    body.push_str(&meta_row("state:", &esc(&issue.state)));966 +    if !issue.labels.is_empty() {967 +        let labels = issue968 +            .labels969 +            .iter()970 +            .map(|l| esc(l))971 +            .collect::<Vec<_>>()972 +            .join(", ");973 +        body.push_str(&meta_row("labels:", &labels));974 +    }975 +    if !issue.assignees.is_empty() {976 +        let assignees = issue977 +            .assignees978 +            .iter()979 +            .map(|a| esc(a))980 +            .collect::<Vec<_>>()981 +            .join(", ");982 +        body.push_str(&meta_row("assignees:", &assignees));983 +    }984 +    if let Some(m) = &issue.milestone {985 +        body.push_str(&meta_row("milestone:", &esc(m)));986 +    }987 +    body.push_str(&meta_row("created:", &esc(&issue.created)));988 +    if let Some(u) = &issue.updated {989 +        body.push_str(&meta_row("updated:", &esc(u)));990 +    }991 +    body.push_str(&meta_row(992 +        "id:",993 +        &format!("<code>{}</code>", esc(&issue.id)),994 +    ));995 +    body.push_str(&markdown_to_html(&issue.body));996 +    body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));997 +    if issue.comments.is_empty() {998 +        body.push_str("<p class=\"muted\">No comments yet.</p>\n");999 +    } else {1000 +        body.push_str("<ul class=\"comments\">\n");1001 +        for c in &issue.comments {1002 +            body.push_str(&render_comment(c));1003 +        }1004 +        body.push_str("</ul>\n");1005 +    }1006 +    body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");1007 +    page(1008 +        &format!("#{} {} — {site_name}", issue.number, issue.title),1009 +        nav,1010 +        &body,1011 +    )1012 +}1013 +1014 +fn render_pr_page(pr: &Pr, site_name: &str, nav: &str) -> String {1015 +    let mut body = format!(1016 +        "<h1>#{n} {title}</h1>\n",1017 +        n = pr.number,1018 +        title = esc(&pr.title),1019 +    );1020 +    body.push_str(&meta_row("state:", &esc(&pr.state)));1021 +    body.push_str(&meta_row(1022 +        "branches:",1023 +        &format!(1024 +            "<code>{}</code> ← <code>{}</code>",1025 +            esc(&pr.base),1026 +            esc(&pr.head)1027 +        ),1028 +    ));1029 +    if pr.draft {1030 +        body.push_str(&meta_row("draft:", "yes"));1031 +    }1032 +    if let Some(ci) = &pr.ci {1033 +        body.push_str(&meta_row("ci:", &verdict_badge(ci)));1034 +    }1035 +    if !pr.issues.is_empty() {1036 +        let issues = pr1037 +            .issues1038 +            .iter()1039 +            .map(|i| format!("<code>{}</code>", esc(i)))1040 +            .collect::<Vec<_>>()1041 +            .join(", ");1042 +        body.push_str(&meta_row("issues:", &issues));1043 +    }1044 +    body.push_str(&meta_row("created:", &esc(&pr.created)));1045 +    if let Some(u) = &pr.updated {1046 +        body.push_str(&meta_row("updated:", &esc(u)));1047 +    }1048 +    body.push_str(&meta_row("id:", &format!("<code>{}</code>", esc(&pr.id))));1049 +    body.push_str(&markdown_to_html(&pr.body));1050 +    body.push_str("<h2>Patch</h2>\n");1051 +    match &pr.patch {1052 +        Some(patch) if !patch.is_empty() => {1053 +            body.push_str(&format!("<pre class=\"diff\">{}</pre>\n", esc(patch)));1054 +        }1055 +        _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),1056 +    }1057 +    body.push_str(&format!("<h2>Comments ({})</h2>\n", pr.comments.len()));1058 +    if pr.comments.is_empty() {1059 +        body.push_str("<p class=\"muted\">No comments yet.</p>\n");1060 +    } else {1061 +        body.push_str("<ul class=\"comments\">\n");1062 +        for c in &pr.comments {1063 +            body.push_str(&render_comment(c));1064 +        }1065 +        body.push_str("</ul>\n");1066 +    }1067 +    body.push_str(&format!("<h2>Reviews ({})</h2>\n", pr.reviews.len()));1068 +    if pr.reviews.is_empty() {1069 +        body.push_str("<p class=\"muted\">No reviews yet.</p>\n");1070 +    } else {1071 +        body.push_str("<ul class=\"reviews\">\n");1072 +        for r in &pr.reviews {1073 +            let state = r.state.as_deref().unwrap_or("commented");1074 +            let author = r.author.as_deref().unwrap_or("unknown");1075 +            body.push_str(&format!(1076 +                "<li class=\"review\">\n<p class=\"muted\">{} · <code>{}</code> · {}</p>\n{}</li>\n",1077 +                esc(&r.created),1078 +                esc(author),1079 +                esc(state),1080 +                markdown_to_html(&r.body),1081 +            ));1082 +        }1083 +        body.push_str("</ul>\n");1084 +    }1085 +    body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");1086 +    page(1087 +        &format!("#{} {} — {site_name}", pr.number, pr.title),1088 +        nav,1089 +        &body,1090 +    )1091 +}1092 +1093 +fn render_issues_index(data: &ForgeData, site_name: &str, nav: &str) -> Result<String, String> {1094 +    let mut body = String::from("<h1>Issues</h1>\n");1095 +    if data.issues.is_empty() {1096 +        body.push_str("<p class=\"muted\">No issues yet.</p>\n");1097 +    } else {1098 +        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");1099 +        for issue in &data.issues {1100 +            body.push_str(&format!(1101 +                "<tr><td>{n}</td><td><a href=\"/issues/{id}.html\">{title}</a></td><td>{state}</td><td>{comments}</td><td>{created}</td></tr>\n",1102 +                n = issue.number,1103 +                id = esc(&issue.id),1104 +                title = esc(&issue.title),1105 +                state = esc(&issue.state),1106 +                comments = issue.comments.len(),1107 +                created = esc(&issue.created),1108 +            ));1109 +        }1110 +        body.push_str("</tbody>\n</table>\n");1111 +    }1112 +    if !data.labels.is_empty() {1113 +        body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");1114 +        for l in &data.labels {1115 +            body.push_str(&format!("<li><code>{}</code></li>\n", esc(&l.name)));1116 +        }1117 +        body.push_str("</ul>\n");1118 +    }1119 +    if !data.milestones.is_empty() {1120 +        body.push_str("<h2>Milestones</h2>\n<ul class=\"milestones\">\n");1121 +        for m in &data.milestones {1122 +            body.push_str(&format!(1123 +                "<li><code>{}</code> <span class=\"muted\">{}</span></li>\n",1124 +                esc(&m.name),1125 +                esc(&m.state),1126 +            ));1127 +        }1128 +        body.push_str("</ul>\n");1129 +    }1130 +    Ok(page(&format!("issues — {site_name}"), nav, &body))1131 +}1132 +1133 +fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str) -> String {1134 +    let mut body = String::from("<h1>Pulls</h1>\n");1135 +    if prs.is_empty() {1136 +        body.push_str("<p class=\"muted\">No pull requests yet.</p>\n");1137 +    } else {1138 +        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");1139 +        for pr in prs {1140 +            let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1141 +            let draft = if pr.draft { " draft" } else { "" };1142 +            body.push_str(&format!(1143 +                "<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",1144 +                n = pr.number,1145 +                id = esc(&pr.id),1146 +                title = esc(&pr.title),1147 +                state = esc(&pr.state),1148 +                created = esc(&pr.created),1149 +            ));1150 +        }1151 +        body.push_str("</tbody>\n</table>\n");1152 +    }1153 +    page(&format!("pulls — {site_name}"), nav, &body)1154 +}1155 +1156 +fn render_tasks_index(tasks: &[TaskItem], site_name: &str, nav: &str) -> String {1157 +    let mut body = String::from("<h1>Tasks</h1>\n");1158 +    if tasks.is_empty() {1159 +        body.push_str("<p class=\"muted\">No tasks yet.</p>\n");1160 +    } else {1161 +        for status in ["todo", "doing", "blocked", "done"] {1162 +            let group: Vec<&TaskItem> = tasks.iter().filter(|t| t.status == status).collect();1163 +            body.push_str(&format!("<h2>{status} ({})</h2>\n", group.len()));1164 +            if group.is_empty() {1165 +                body.push_str("<p class=\"muted\">None.</p>\n");1166 +            } else {1167 +                body.push_str("<ul class=\"tasks\">\n");1168 +                for t in group {1169 +                    let mut meta = format!("<code>{}</code> · {}", esc(&t.id), esc(&t.created));1170 +                    if let Some(u) = &t.updated {1171 +                        meta.push_str(&format!(" · updated {}", esc(u)));1172 +                    }1173 +                    if let Some(c) = &t.closed {1174 +                        meta.push_str(&format!(" · closed {}", esc(c)));1175 +                    }1176 +                    body.push_str(&format!(1177 +                        "<li class=\"task\">\n<p><strong>{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1178 +                        title = esc(&t.title),1179 +                        assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(1180 +                            "<p class=\"muted\">assignee: <code>{}</code></p>\n",1181 +                            esc(a)1182 +                        )),1183 +                        body_md = markdown_to_html(&t.body),1184 +                    ));1185 +                }1186 +                body.push_str("</ul>\n");1187 +            }1188 +        }1189 +    }1190 +    page(&format!("tasks — {site_name}"), nav, &body)1191 +}1192 +1193 +fn render_ci_index(ci: &CiInfo, site_name: &str, nav: &str) -> String {1194 +    let mut body = String::from("<h1>CI</h1>\n");1195 +    body.push_str(&meta_row("runner:", &esc(&ci.runner)));1196 +    if ci.steps.is_empty() {1197 +        body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");1198 +    } else {1199 +        body.push_str("<h2>Steps</h2>\n<ul class=\"ci-steps\">\n");1200 +        for step in &ci.steps {1201 +            body.push_str(&format!("<li><code>{}</code></li>\n", esc(step)));1202 +        }1203 +        body.push_str("</ul>\n");1204 +    }1205 +    body.push_str("<h2>Latest run</h2>\n");1206 +    match &ci.latest {1207 +        Some(run) => {1208 +            body.push_str(&meta_row("verdict:", &verdict_badge(&run.verdict)));1209 +            body.push_str(&meta_row(1210 +                "run:",1211 +                &format!("<code>{}</code>", esc(&run.run)),1212 +            ));1213 +            body.push_str(&meta_row("created:", &esc(&run.created)));1214 +            if run.steps.is_empty() {1215 +                body.push_str("<p class=\"muted\">No step results recorded.</p>\n");1216 +            } else {1217 +                body.push_str("<table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");1218 +                for s in &run.steps {1219 +                    body.push_str(&format!(1220 +                        "<tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",1221 +                        esc(&s.name),1222 +                        s.exit,1223 +                        s.duration_ms,1224 +                    ));1225 +                }1226 +                body.push_str("</tbody>\n</table>\n");1227 +            }1228 +            body.push_str("<h3>Log</h3>\n");1229 +            if run.body.trim().is_empty() {1230 +                body.push_str("<p class=\"muted\">Empty log.</p>\n");1231 +            } else {1232 +                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(&run.body)));1233 +            }1234 +            for (name, content) in &run.logs {1235 +                body.push_str(&format!("<h3>{}</h3>\n", esc(name)));1236 +                body.push_str(&format!("<pre class=\"log\">{}</pre>\n", esc(content)));1237 +            }1238 +        }1239 +        None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),1240 +    }1241 +    page(&format!("ci — {site_name}"), nav, &body)1242 +}1243 +1244 +fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {1245 +    let dest = out.join(rel);1246 +    if let Some(parent) = dest.parent() {1247 +        std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create {rel} parent: {e}"))?;1248 +    }1249 +    std::fs::write(&dest, html).map_err(|e| format!("hu13: write {rel}: {e}"))1250 +}1251 +1252 +/// Render `issues/`, `pulls/`, `tasks/`, `ci/` pages from already-loaded data.1253 +pub fn render_loaded(1254 +    data: &ForgeData,1255 +    out: &Path,1256 +    site_name: &str,1257 +    nav: &str,1258 +) -> Result<(), String> {1259 +    write_page(1260 +        out,1261 +        "issues/index.html",1262 +        &render_issues_index(data, site_name, nav)?,1263 +    )?;1264 +    for issue in &data.issues {1265 +        write_page(1266 +            out,1267 +            &format!("issues/{}.html", issue.id),1268 +            &render_issue_page(issue, site_name, nav),1269 +        )?;1270 +    }1271 +1272 +    write_page(1273 +        out,1274 +        "pulls/index.html",1275 +        &render_pulls_index(&data.prs, site_name, nav),1276 +    )?;1277 +    for pr in &data.prs {1278 +        write_page(1279 +            out,1280 +            &format!("pulls/{}.html", pr.id),1281 +            &render_pr_page(pr, site_name, nav),1282 +        )?;1283 +    }1284 +1285 +    write_page(1286 +        out,1287 +        "tasks/index.html",1288 +        &render_tasks_index(&data.tasks, site_name, nav),1289 +    )?;1290 +    write_page(1291 +        out,1292 +        "ci/index.html",1293 +        &render_ci_index(&data.ci, site_name, nav),1294 +    )?;1295 +1296 +    Ok(())1297 +}1298 +1299 +#[cfg(test)]1300 +mod tests {1301 +    use super::*;1302 +    use std::path::PathBuf;1303 +    use std::sync::atomic::{AtomicU64, Ordering};1304 +1305 +    static COUNTER: AtomicU64 = AtomicU64::new(0);1306 +1307 +    fn mktemp(prefix: &str) -> PathBuf {1308 +        let id = COUNTER.fetch_add(1, Ordering::SeqCst);1309 +        let dir =1310 +            std::env::temp_dir().join(format!("hu13-forge-{prefix}-{}-{id}", std::process::id()));1311 +        std::fs::create_dir_all(&dir).expect("mktemp");1312 +        dir1313 +    }1314 +1315 +    fn write_forge(repo: &Path, rel: &str, content: &str) {1316 +        let dest = repo.join(".forge").join(rel);1317 +        std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");1318 +        std::fs::write(&dest, content).expect("write forge file");1319 +    }1320 +1321 +    #[test]1322 +    fn frontmatter_split_shapes() {1323 +        let (yaml, body) = split_frontmatter("---\nid: x\n---\nhello\n").expect("split");1324 +        assert_eq!(yaml, "id: x\n");1325 +        assert_eq!(body, "hello\n");1326 +        assert!(split_frontmatter("no fences\n").is_none());1327 +        assert!(split_frontmatter("---\nunclosed\n").is_none());1328 +        assert!(split_frontmatter("---\n---\nempty yaml\n")1329 +            .expect("e")1330 +            .01331 +            .is_empty());1332 +    }1333 +1334 +    #[test]1335 +    fn unknown_fields_ignored() {1336 +        let yaml =1337 +            "id: a\ntitle: T\nstate: open\ncreated: 2026-01-01T00:00:00Z\nsome_future_field: 123\n";1338 +        let fm: IssueFm = yaml_serde::from_str(yaml).expect("unknown fields ignored");1339 +        assert_eq!(fm.title, "T");1340 +        assert!(fm.labels.is_empty());1341 +        assert!(fm.milestone.is_none());1342 +    }1343 +1344 +    #[test]1345 +    fn missing_required_is_error() {1346 +        let yaml = "id: a\nstate: open\ncreated: 2026-01-01T00:00:00Z\n";1347 +        let err = yaml_serde::from_str::<IssueFm>(yaml).expect_err("title missing");1348 +        assert!(1349 +            err.to_string().contains("title"),1350 +            "error names field: {err}"1351 +        );1352 +    }1353 +1354 +    /// Two issues with out-of-order created dates: the earlier one ranks #1.1355 +    #[test]1356 +    fn issue_numbers_rank_by_created_then_id() {1357 +        let repo = mktemp("numbers");1358 +        write_forge(1359 +            &repo,1360 +            "issues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/issue.md",1361 +            "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Newer\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nnewer body\n",1362 +        );1363 +        write_forge(1364 +            &repo,1365 +            "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",1366 +            "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Older\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nolder body\n",1367 +        );1368 +        let data = load(&repo);1369 +        assert_eq!(data.issues.len(), 2);1370 +        assert_eq!(data.issues[0].title, "Older");1371 +        assert_eq!(data.issues[0].number, 1);1372 +        assert_eq!(data.issues[1].title, "Newer");1373 +        assert_eq!(data.issues[1].number, 2);1374 +    }1375 +1376 +    #[test]1377 +    fn broken_issue_skipped_build_lives() {1378 +        let repo = mktemp("broken");1379 +        write_forge(1380 +            &repo,1381 +            "issues/good11111-1111-1111-1111-111111111111/issue.md",1382 +            "---\nid: good11111-1111-1111-1111-111111111111\ntitle: Good\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nok\n",1383 +        );1384 +        write_forge(1385 +            &repo,1386 +            "issues/bad222222-2222-2222-2222-222222222222/issue.md",1387 +            "---\nid: bad222222-2222-2222-2222-222222222222\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nno title\n",1388 +        );1389 +        let data = load(&repo);1390 +        assert_eq!(data.issues.len(), 1, "broken file skipped");1391 +        assert_eq!(data.issues[0].number, 1);1392 +    }1393 +1394 +    #[test]1395 +    fn pr_loads_patch_and_reviews() {1396 +        let repo = mktemp("pr");1397 +        write_forge(1398 +            &repo,1399 +            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1400 +            "---\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",1401 +        );1402 +        let dest = repo1403 +            .join(".forge")1404 +            .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1405 +        std::fs::write(&dest, "diff --git a/x b/x\n+evil <script>\n").unwrap();1406 +        write_forge(1407 +            &repo,1408 +            "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/reviews/dddddddd-dddd-dddd-dddd-dddddddddddd.md",1409 +            "---\nid: dddddddd-dddd-dddd-dddd-dddddddddddd\nauthor: z\nstate: approved\ncreated: 2026-09-10T05:00:00Z\n---\nlgtm\n",1410 +        );1411 +        let data = load(&repo);1412 +        assert_eq!(data.prs.len(), 1);1413 +        assert_eq!(data.prs[0].number, 1);1414 +        assert!(data.prs[0].draft);1415 +        assert!(data.prs[0]1416 +            .patch1417 +            .as_ref()1418 +            .expect("patch")1419 +            .contains("diff --git"));1420 +        assert_eq!(data.prs[0].reviews.len(), 1);1421 +        assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));1422 +1423 +        let out = mktemp("pr-out");1424 +        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1425 +        let html =1426 +            std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1427 +                .expect("pr page");1428 +        assert!(html.contains("#1"), "number displayed");1429 +        assert!(!html.contains("<script>"), "patch escaped");1430 +        assert!(html.contains("&lt;script&gt;"), "escaped patch present");1431 +        assert!(html.contains("approved"), "review state shown");1432 +        assert!(html.contains("lgtm"), "review body shown");1433 +    }1434 +1435 +    #[test]1436 +    fn tasks_group_and_ci_fail_badge() {1437 +        let repo = mktemp("taskci");1438 +        for (id, status) in [1439 +            ("11111111-1111-1111-1111-111111111111", "todo"),1440 +            ("22222222-2222-2222-2222-222222222222", "doing"),1441 +            ("33333333-3333-3333-3333-333333333333", "blocked"),1442 +            ("44444444-4444-4444-4444-444444444444", "done"),1443 +        ] {1444 +            write_forge(1445 +                &repo,1446 +                &format!("tasks/{id}.md"),1447 +                &format!("---\nid: {id}\ntitle: Task {status}\nstatus: {status}\ncreated: 2026-09-10T03:00:00Z\n---\nbody {status}\n"),1448 +            );1449 +        }1450 +        write_forge(&repo, "ci/ci.env", "runner: local\n");1451 +        std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();1452 +        std::fs::write(1453 +            repo.join(".forge").join("ci/steps/00_test.sh"),1454 +            "#!/bin/sh\n",1455 +        )1456 +        .unwrap();1457 +        write_forge(1458 +            &repo,1459 +            "ci/runs/2026-09-10T00-00-00Z/summary.md",1460 +            "---\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",1461 +        );1462 +        let data = load(&repo);1463 +        assert_eq!(data.tasks.len(), 4);1464 +        assert_eq!(data.ci.runner, "local");1465 +        assert_eq!(data.ci.steps, vec!["00_test.sh".to_string()]);1466 +        assert_eq!(data.ci.latest_verdict(), "fail");1467 +1468 +        let out = mktemp("taskci-out");1469 +        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1470 +        let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");1471 +        for status in ["todo", "doing", "blocked", "done"] {1472 +            assert!(tasks.contains(status), "group {status}");1473 +        }1474 +        let ci = std::fs::read_to_string(out.join("ci/index.html")).expect("ci");1475 +        assert!(ci.contains("badge fail"), "fail badge");1476 +        assert!(ci.contains(">fail<"), "verdict text");1477 +        assert!(ci.contains("00_test.sh"), "step name");1478 +        assert!(ci.contains("boom log"), "log body");1479 +    }1480 +1481 +    #[test]1482 +    fn missing_forge_is_empty_not_error() {1483 +        let repo = mktemp("empty");1484 +        let data = load(&repo);1485 +        assert!(data.issues.is_empty());1486 +        assert!(data.prs.is_empty());1487 +        assert!(data.tasks.is_empty());1488 +        assert_eq!(data.ci.latest_verdict(), "none");1489 +        let out = mktemp("empty-out");1490 +        render_loaded(&load(&repo), &out, "test", "<nav>").expect("render");1491 +        for page in [1492 +            "issues/index.html",1493 +            "pulls/index.html",1494 +            "tasks/index.html",1495 +            "ci/index.html",1496 +        ] {1497 +            let html = std::fs::read_to_string(out.join(page)).expect(page);1498 +            assert!(html.contains("No "), "{page} empty state");1499 +        }1500 +    }1501 +1502 +    #[test]1503 +    fn verdict_badge_shapes() {1504 +        assert!(verdict_badge("pass").contains("badge pass"));1505 +        assert!(verdict_badge("fail").contains("badge fail"));1506 +        assert!(verdict_badge("running").contains("badge running"));1507 +        assert!(verdict_badge("cancelled").contains("badge cancelled"));1508 +        assert!(verdict_badge("none").contains(">none<"));1509 +        assert!(verdict_badge("<evil>").contains("&lt;evil&gt;"));1510 +        assert!(!verdict_badge("<evil>").contains("<evil>"));1511 +    }1512 +1513 +    /// Big-forge: read the real f0rge checkout. Counts must equal a direct1514 +    /// file-count (and `f0rge issue list` when available); zero panics.1515 +    #[test]1516 +    fn big_forge_real_shape_no_panics() {1517 +        let real = PathBuf::from("~/src/code/gl0w-w0rld/f0rge/.forge");1518 +        if !real.exists() {1519 +            return;1520 +        }1521 +        let repo = real.parent().expect("parent").to_path_buf();1522 +        let data = load(&repo);1523 +        let dir_count = |sub: &str| {1524 +            std::fs::read_dir(real.join(sub))1525 +                .map(|rd| rd.filter_map(Result::ok).count())1526 +                .unwrap_or(0)1527 +        };1528 +        let file_count = |sub: &str| {1529 +            std::fs::read_dir(real.join(sub))1530 +                .map(|rd| {1531 +                    rd.filter_map(Result::ok)1532 +                        .filter(|e| e.path().extension().is_some_and(|x| x == "md"))1533 +                        .count()1534 +                })1535 +                .unwrap_or(0)1536 +        };1537 +        assert_eq!(1538 +            data.issues.len(),1539 +            dir_count("issues"),1540 +            "issues == dir count"1541 +        );1542 +        assert_eq!(data.prs.len(), dir_count("prs"), "prs == dir count");1543 +        assert_eq!(data.tasks.len(), file_count("tasks"), "tasks == file count");1544 +        // Cross-check against `f0rge issue list` when the binary is around.1545 +        let cli_count = std::process::Command::new("f0rge")1546 +            .arg("issue")1547 +            .arg("list")1548 +            .current_dir(&repo)1549 +            .output()1550 +            .ok()1551 +            .filter(|o| o.status.success())1552 +            .map(|o| {1553 +                String::from_utf8_lossy(&o.stdout)1554 +                    .lines()1555 +                    .filter(|l| !l.trim().is_empty())1556 +                    .count()1557 +            });1558 +        if let Some(n) = cli_count {1559 +            assert_eq!(data.issues.len(), n, "issues == `f0rge issue list` count");1560 +        }1561 +        // Numbers are a clean 1..=N rank in (created, id) order.1562 +        let mut prev = (String::new(), String::new());1563 +        for (i, issue) in data.issues.iter().enumerate() {1564 +            assert_eq!(issue.number, i + 1);1565 +            let key = (issue.created.clone(), issue.id.clone());1566 +            assert!(key >= prev, "sorted order");1567 +            prev = key;1568 +        }1569 +        assert!(!data.ci.steps.is_empty(), "real ci steps read");1570 +        assert!(data.ci.latest.is_some(), "real ci latest run read");1571 +    }1572 +}1573 diff --git a/src/main.rs b/src/main.rs1574 index d15fb9e..6d84b9b 1006441575 --- a/src/main.rs1576 +++ b/src/main.rs1577 @@ -1,6 +1,7 @@1578  mod build;1579  mod commits;1580  mod feed;1581 +mod forge;1582  mod gitx;1583  mod layout;1584  mod wiki;1585 diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs1586 new file mode 1006441587 index 0000000..f512a391588 --- /dev/null1589 +++ b/tests/forge_readers.rs1590 @@ -0,0 +1,317 @@1591 +//! CLI-level test for the f0rge readers (m3, f0rge issue #7).1592 +//!1593 +//! Builds a fixture `.forge/` exercising every required shape, runs1594 +//! `hu13 build` on it, and asserts the rendered site:1595 +//! - 2 issues with out-of-order `created` dates -> numbers rank by1596 +//!   `(created, id)` (5-number spot check across list + detail pages)1597 +//! - 1 broken issue (missing `title`) -> skipped, warned on stderr1598 +//!   naming file + field, build still exits 01599 +//! - 1 PR with `patch.diff` + review (state + body)1600 +//! - tasks in all 4 states (todo|doing|blocked|done)1601 +//! - a failing CI run fixture -> `fail` badge + steps + log1602 +//! - rendered counts match a direct file-count of the fixture1603 +1604 +use std::path::{Path, PathBuf};1605 +use std::process::Command;1606 +1607 +const OLDER_UUID: &str = "11111111-1111-4111-8111-111111111111";1608 +const NEWER_UUID: &str = "22222222-2222-4222-8222-222222222222";1609 +const BROKEN_UUID: &str = "33333333-3333-4333-8333-333333333333";1610 +const PR_UUID: &str = "44444444-4444-4444-8444-444444444444";1611 +const REVIEW_UUID: &str = "55555555-5555-4555-8555-555555555555";1612 +const COMMENT_UUID: &str = "66666666-6666-4666-8666-666666666666";1613 +1614 +fn git(repo: &Path, args: &[&str]) {1615 +    let st = Command::new("git")1616 +        .arg("-C")1617 +        .arg(repo)1618 +        .args(args)1619 +        .status()1620 +        .expect("spawn git");1621 +    assert!(st.success(), "git {args:?} failed");1622 +}1623 +1624 +fn fresh_dir(prefix: &str) -> PathBuf {1625 +    let dir = std::env::temp_dir().join(format!(1626 +        "hu13-forge-{prefix}-{}-{}",1627 +        std::process::id(),1628 +        std::time::SystemTime::now()1629 +            .duration_since(std::time::UNIX_EPOCH)1630 +            .map(|d| d.as_nanos())1631 +            .unwrap_or(0)1632 +    ));1633 +    std::fs::create_dir_all(&dir).unwrap();1634 +    dir1635 +}1636 +1637 +fn forge_write(repo: &Path, rel: &str, content: &str) {1638 +    let dest = repo.join(".forge").join(rel);1639 +    std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();1640 +    std::fs::write(&dest, content).unwrap();1641 +}1642 +1643 +/// Fixture `.forge/` covering every shape in f0rge issue #7.1644 +fn fixture_forge(repo: &Path) {1645 +    // Older issue (must rank #1 despite sorting after the newer uuid).1646 +    forge_write(1647 +        repo,1648 +        &format!("issues/{OLDER_UUID}/issue.md"),1649 +        "---\n\1650 +         id: 11111111-1111-4111-8111-111111111111\n\1651 +         title: Older issue\n\1652 +         state: open\n\1653 +         labels:\n\1654 +         - kind:test\n\1655 +         assignees: []\n\1656 +         milestone: m1\n\1657 +         created: 2026-09-10T03:00:00Z\n\1658 +         updated: null\n\1659 +         ---\n\1660 +         Older body with <script>alert(1)</script> inside.\n",1661 +    );1662 +    forge_write(1663 +        repo,1664 +        &format!("issues/{OLDER_UUID}/comments/{COMMENT_UUID}.md"),1665 +        "---\n\1666 +         id: 66666666-6666-4666-8666-666666666666\n\1667 +         author: z\n\1668 +         created: 2026-09-10T03:30:00Z\n\1669 +         ---\n\1670 +         First comment.\n",1671 +    );1672 +    // Newer issue (must rank #2).1673 +    forge_write(1674 +        repo,1675 +        &format!("issues/{NEWER_UUID}/issue.md"),1676 +        "---\n\1677 +         id: 22222222-2222-4222-8222-222222222222\n\1678 +         title: Newer issue\n\1679 +         state: closed\n\1680 +         labels: []\n\1681 +         assignees: []\n\1682 +         milestone: null\n\1683 +         created: 2026-09-10T04:00:00Z\n\1684 +         ---\n\1685 +         Newer body.\n",1686 +    );1687 +    // Broken issue: missing required `title` -> skipped + warned.1688 +    forge_write(1689 +        repo,1690 +        &format!("issues/{BROKEN_UUID}/issue.md"),1691 +        "---\n\1692 +         id: 33333333-3333-4333-8333-333333333333\n\1693 +         state: open\n\1694 +         created: 2026-09-10T05:00:00Z\n\1695 +         ---\n\1696 +         No title here.\n",1697 +    );1698 +    // PR with patch + review.1699 +    forge_write(1700 +        repo,1701 +        &format!("prs/{PR_UUID}/pr.md"),1702 +        "---\n\1703 +         id: 44444444-4444-4444-8444-444444444444\n\1704 +         title: Fix the thing\n\1705 +         state: open\n\1706 +         base: main\n\1707 +         head: fix-thing\n\1708 +         created: 2026-09-10T03:30:00Z\n\1709 +         draft: false\n\1710 +         issues:\n\1711 +         - 11111111-1111-4111-8111-111111111111\n\1712 +         ci: fail\n\1713 +         ---\n\1714 +         PR description.\n",1715 +    );1716 +    forge_write(1717 +        repo,1718 +        &format!("prs/{PR_UUID}/patch.diff"),1719 +        "diff --git a/x.txt b/x.txt\n+added <tag> line\n",1720 +    );1721 +    forge_write(1722 +        repo,1723 +        &format!("prs/{PR_UUID}/reviews/{REVIEW_UUID}.md"),1724 +        "---\n\1725 +         id: 55555555-5555-4555-8555-555555555555\n\1726 +         author: reviewer\n\1727 +         state: approved\n\1728 +         created: 2026-09-10T05:00:00Z\n\1729 +         ---\n\1730 +         Looks good to me.\n",1731 +    );1732 +    // Tasks in all four states.1733 +    for (n, status) in ["todo", "doing", "blocked", "done"].iter().enumerate() {1734 +        let id = format!("7777777{n}-7777-4777-8777-77777777777{n}");1735 +        forge_write(1736 +            repo,1737 +            &format!("tasks/{id}.md"),1738 +            &format!(1739 +                "---\nid: {id}\ntitle: Task {status}\nstatus: {status}\n\1740 +                 created: 2026-09-10T03:0{n}:00Z\n---\nTask {status} body.\n"1741 +            ),1742 +        );1743 +    }1744 +    // Labels + milestones (YAML lists, no fences).1745 +    forge_write(repo, "labels.md", "- name: kind:test\n");1746 +    forge_write(repo, "milestones.md", "- name: m1\n  state: open\n");1747 +    // CI: env + one step + one failing run.1748 +    forge_write(repo, "ci/ci.env", "runner: local\n");1749 +    forge_write(repo, "ci/steps/00_test.sh", "#!/bin/sh\nexit 1\n");1750 +    forge_write(1751 +        repo,1752 +        "ci/runs/2026-09-10T00-00-00Z/summary.md",1753 +        "---\n\1754 +         run: 2026-09-10T00-00-00Z\n\1755 +         verdict: fail\n\1756 +         created: 2026-09-10T00:00:00Z\n\1757 +         steps:\n\1758 +           - name: 00_test.sh\n\1759 +             exit: 1\n\1760 +             duration_ms: 7\n\1761 +         ---\n\1762 +         boom: the test failed\n",1763 +    );1764 +}1765 +1766 +fn direct_count(dir: &Path) -> usize {1767 +    std::fs::read_dir(dir)1768 +        .map(|rd| rd.filter_map(Result::ok).count())1769 +        .unwrap_or(0)1770 +}1771 +1772 +#[test]1773 +fn forge_fixture_renders_counts_numbers_and_ci() {1774 +    let repo = fresh_dir("repo");1775 +    git(&repo, &["init", "-b", "main"]);1776 +    git(&repo, &["config", "user.name", "hu13-test"]);1777 +    git(&repo, &["config", "user.email", "[email protected]"]);1778 +    git(&repo, &["config", "commit.gpgsign", "false"]);1779 +    std::fs::write(repo.join("README.md"), "# forge fixture\n").unwrap();1780 +    fixture_forge(&repo);1781 +    git(&repo, &["add", "-A"]);1782 +    git(&repo, &["commit", "-m", "forge fixture"]);1783 +1784 +    // Direct file-counts of the fixture (ground truth for the asserts).1785 +    assert_eq!(direct_count(&repo.join(".forge/issues")), 3);1786 +    assert_eq!(direct_count(&repo.join(".forge/prs")), 1);1787 +    assert_eq!(direct_count(&repo.join(".forge/tasks")), 4);1788 +1789 +    let out = fresh_dir("out");1790 +    let bin = env!("CARGO_BIN_EXE_hu13");1791 +    let produced = Command::new(bin)1792 +        .arg("build")1793 +        .arg(&repo)1794 +        .arg("-o")1795 +        .arg(&out)1796 +        .output()1797 +        .expect("spawn hu13 build");1798 +    let stderr = String::from_utf8_lossy(&produced.stderr).into_owned();1799 +    assert!(1800 +        produced.status.success(),1801 +        "broken issue must not fail the build; stderr: {stderr}"1802 +    );1803 +    // Broken file warned, naming file (uuid) + field (title).1804 +    assert!(1805 +        stderr.contains(BROKEN_UUID),1806 +        "stderr names the broken file; got: {stderr}"1807 +    );1808 +    assert!(1809 +        stderr.contains("title"),1810 +        "stderr names the missing field; got: {stderr}"1811 +    );1812 +1813 +    let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);1814 +1815 +    // --- issues index: 2 rendered rows (broken one skipped) ---1816 +    let issues = read("issues/index.html");1817 +    let rows = issues.matches("<tr><td>").count();1818 +    assert_eq!(rows, 2, "rendered issues match file-count minus broken");1819 +    assert!(!issues.contains("No title here"), "broken issue skipped");1820 +    // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.1821 +    assert!(1822 +        issues.contains(&format!(1823 +            "<td>1</td><td><a href=\"/issues/{OLDER_UUID}.html\">"1824 +        )),1825 +        "issue #1 -> older uuid"1826 +    );1827 +    assert!(1828 +        issues.contains(&format!(1829 +            "<td>2</td><td><a href=\"/issues/{NEWER_UUID}.html\">"1830 +        )),1831 +        "issue #2 -> newer uuid"1832 +    );1833 +    // (2): rank order — older sorts first despite uuid order.1834 +    let older_pos = issues.find("Older issue").expect("older row");1835 +    let newer_pos = issues.find("Newer issue").expect("newer row");1836 +    assert!(older_pos < newer_pos, "ranked by (created, id)");1837 +    assert!(issues.contains("kind:test"), "labels shown");1838 +    assert!(issues.contains("m1"), "milestones shown");1839 +1840 +    // (3): detail pages display their numbers.1841 +    let older = read(&format!("issues/{OLDER_UUID}.html"));1842 +    assert!(older.contains("<h1>#1 Older issue</h1>"), "detail shows #1");1843 +    assert!(older.contains("Comments (1)"), "comment counted");1844 +    assert!(older.contains("First comment."), "comment rendered");1845 +    assert!(1846 +        !older.contains("<script>alert(1)</script>"),1847 +        "issue body escaped"1848 +    );1849 +    assert!(older.contains("&lt;script&gt;"), "escaped form present");1850 +    let newer = read(&format!("issues/{NEWER_UUID}.html"));1851 +    assert!(newer.contains("<h1>#2 Newer issue</h1>"), "detail shows #2");1852 +1853 +    // (4): pulls — same shape + head/base/draft/ci + patch <pre>.1854 +    let pulls = read("pulls/index.html");1855 +    assert_eq!(pulls.matches("<tr><td>").count(), 1, "prs match file-count");1856 +    assert!(1857 +        pulls.contains(&format!("<td>1</td><td><a href=\"/pulls/{PR_UUID}.html\">")),1858 +        "pr #1 -> uuid"1859 +    );1860 +    let pr = read(&format!("pulls/{PR_UUID}.html"));1861 +    assert!(1862 +        pr.contains("<h1>#1 Fix the thing</h1>"),1863 +        "pr detail shows #1"1864 +    );1865 +    assert!(pr.contains("fix-thing"), "head shown");1866 +    assert!(pr.contains("main"), "base shown");1867 +    assert!(pr.contains("diff --git"), "patch.diff shown");1868 +    assert!(1869 +        pr.contains("&lt;tag&gt;"),1870 +        "patch escaped in <pre>, not markdown"1871 +    );1872 +    assert!(pr.contains("approved"), "review state shown");1873 +    assert!(pr.contains("Looks good to me."), "review body shown");1874 +1875 +    // --- tasks grouped in four columns ---1876 +    let tasks = read("tasks/index.html");1877 +    for status in ["todo", "doing", "blocked", "done"] {1878 +        assert!(1879 +            tasks.contains(&format!("<h2>{status} (1)</h2>")),1880 +            "group {status} with one task"1881 +        );1882 +        assert!(1883 +            tasks.contains(&format!("Task {status}")),1884 +            "{status} task shown"1885 +        );1886 +    }1887 +1888 +    // --- ci: env runner, step names, latest fail badge + steps + log ---1889 +    let ci = read("ci/index.html");1890 +    assert!(ci.contains("local"), "env runner shown");1891 +    assert!(ci.contains("00_test.sh"), "step script name shown");1892 +    assert!(ci.contains("badge fail"), "fail badge on ci page");1893 +    assert!(ci.contains(">fail<"), "verdict text on ci page");1894 +    assert!(ci.contains("boom: the test failed"), "run log in <pre>");1895 +1896 +    // --- index header: real counts + verdict, no TODO markers ---1897 +    let index = read("index.html");1898 +    assert!(index.contains("issues (2)"), "real issue count");1899 +    assert!(index.contains("pulls (1)"), "real pull count");1900 +    assert!(index.contains("tasks (4)"), "real task count");1901 +    assert!(index.contains("badge fail"), "index verdict badge");1902 +    assert!(!index.contains("TODO"), "TODO markers deleted");1903 +    assert!(index.contains("/issues/index.html"), "nav links issues");1904 +    assert!(index.contains("/pulls/index.html"), "nav links pulls");1905 +    assert!(index.contains("/tasks/index.html"), "nav links tasks");1906 +    assert!(index.contains("/ci/index.html"), "nav links ci");1907 +}1908 -- 1909 2.55.01910