#7 m3/forge-readers
Patch
src/build.rs+44 −17--- 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("<script>"), "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--- /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("<script>"), "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("<evil>"));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--- 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--- /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("<script>"), "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("<tag>"),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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.