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("<script>"), "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("<script>"), "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("<evil>"));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("<script>"), "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("<tag>"),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