From 47eeff5e808586fdafaa389606b6b762fe76598b Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 05:26:30 +0100 Subject: [PATCH] m3/forge-readers: issues/prs/tasks/ci + numbers + header wiring (54 tests) --- src/build.rs | 61 +- src/forge.rs | 1411 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + tests/forge_readers.rs | 317 +++++++++ 4 files changed, 1773 insertions(+), 17 deletions(-) create mode 100644 src/forge.rs create mode 100644 tests/forge_readers.rs diff --git a/src/build.rs b/src/build.rs index ae9f381..9920bf8 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,8 +1,10 @@ -//! `hu13 build` — static site (m1 code tab + m2 commits/wiki, f0rge issues #4/#5). +//! `hu13 build` — static site (m1 code tab + m2 commits/wiki + m3 f0rge readers). //! //! Reads one branch via [`crate::gitx`], emits `dist/`: //! `index.html`, `files/.html`, `raw/` (byte-identical), -//! `commits.html`, `commit/.html`, `feed.atom`, `wiki/index.html`, +//! `commits.html`, `commit/.html`, `feed.atom`, `issues/index.html`, +//! `issues/.html`, `pulls/index.html`, `pulls/.html`, +//! `tasks/index.html`, `ci/index.html`, `wiki/index.html`, //! `wiki/.html`, `repo.bundle` + `repo.tar.gz`, and `style.css`. //! //! Bodies from the repo are semi-trusted but treated as untrusted: @@ -13,7 +15,7 @@ use std::collections::BTreeMap; use std::path::Path; use crate::layout::esc; -use crate::{commits, feed, gitx, wiki}; +use crate::{commits, feed, forge, gitx, wiki}; /// Files at or above this size get a placeholder page + download link. pub const PREVIEW_MAX_BYTES: usize = 256 * 1024; @@ -145,9 +147,16 @@ fn render_text_pre(display: &str) -> String { out } -fn nav_code() -> String { - "code · commits · wiki " - .to_string() +/// Top nav with live f0rge counts (no stubs, no TODO markers). +fn nav_bar(issue_count: usize, pr_count: usize) -> String { + format!( + "code · commits · \ + issues ({issue_count}) · \ + pulls ({pr_count}) · \ + tasks · \ + ci · \ + wiki" + ) } /// Write `out/style.css` from the embedded static file. @@ -219,6 +228,12 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result> = BTreeMap::new(); for f in &files { @@ -273,7 +288,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result\n"); - let html = crate::layout::page(&format!("{dir} — {name}"), &nav_code(), &body); + let html = crate::layout::page(&format!("{dir} — {name}"), &nav, &body); let dest = out.join("files").join(format!("{dir}.html")); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) @@ -325,7 +340,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Result Resultissues (0) · pulls (0) · tasks (0)

\n", - ); - body.push_str("

CI: unknown (stub)

\n"); + body.push_str(&format!( + "

issues ({}) · \ + pulls ({}) · \ + tasks ({})

\n", + forge_data.issues.len(), + forge_data.prs.len(), + forge_data.tasks.len(), + )); + body.push_str(&format!( + "

CI: {}

\n", + forge::verdict_badge(forge_data.ci.latest_verdict()), + )); body.push_str(&format!( "

repo.bundle ({bundle_size} bytes) · \ repo.tar.gz ({tarball_size} bytes)

\n" @@ -410,7 +434,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result body.push_str("

No README found.

\n"), } - let index = crate::layout::page(&name, &nav_code(), &body); + let index = crate::layout::page(&name, &nav, &body); std::fs::write(out.join("index.html"), index) .map_err(|e| format!("hu13: write index.html: {e}"))?; @@ -556,11 +580,14 @@ mod tests { "raw HTML escaped" ); assert!(index.contains("<script>"), "escaped HTML present"); - // Header: branch + short sha, f0rge counts stub, CI stub. + // Header: branch + short sha, live f0rge counts, CI verdict badge. assert!(index.contains("main"), "branch shown"); - assert!(index.contains("TODO(m3)"), "f0rge-count TODO marker"); + assert!(!index.contains("TODO"), "no TODO markers left"); assert!(index.contains("issues (0)"), "f0rge counts as 0"); - assert!(index.contains("CI:"), "CI verdict stub"); + assert!(index.contains("pulls (0)"), "pull counts as 0"); + assert!(index.contains("tasks (0)"), "task counts as 0"); + assert!(index.contains("CI:"), "CI verdict shown"); + assert!(index.contains("badge"), "CI verdict badge"); // Snapshot links with sizes. assert!(index.contains("repo.bundle"), "bundle linked"); assert!(index.contains("repo.tar.gz"), "tarball linked"); diff --git a/src/forge.rs b/src/forge.rs new file mode 100644 index 0000000..9e4e9c5 --- /dev/null +++ b/src/forge.rs @@ -0,0 +1,1411 @@ +//! f0rge readers (m3, f0rge issue #7): issues / pulls / tasks / ci pages. +//! +//! Source is the on-disk `/.forge/` tree (NOT git objects): +//! `issues//issue.md` (+ `comments/.md`), +//! `prs//pr.md` (+ `patch.diff`, `comments/`, `reviews/`), +//! `tasks/.md`, `labels.md` + `milestones.md` (YAML lists, no fences), +//! `ci/ci.env` + `steps/*.sh` + `runs//summary.md`. +//! +//! Rules (spec/schema.md §0, DRAFT): frontmatter is a tiny `---` split + +//! `yaml_serde`; unknown fields are ignored; a missing required field +//! skips that file with an `eprintln!` warning naming file + field and the +//! build still exits 0. Numbers are derived (never stored): 1-based rank +//! of `(created, id)` at build time. Pages display the number and link by +//! uuid. +//! +//! SECURITY: every byte from `.forge/` is untrusted. Markdown bodies go +//! through [`crate::build::markdown_to_html`] (raw HTML escaped); titles, +//! ids, states, diffs, and logs go through [`crate::layout::esc`] (diffs +//! and logs as escaped `
`, never markdown).
+
+use std::path::Path;
+
+use serde::Deserialize;
+
+use crate::build::markdown_to_html;
+use crate::layout::{esc, page};
+
+/// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).
+pub const FORGE_MAX_BYTES: u64 = 256 * 1024;
+
+/// Step-log bytes kept per log file (rest truncated with a marker).
+pub const LOG_PREVIEW_MAX: usize = 64 * 1024;
+
+// ---------------------------------------------------------------------------
+// Frontmatter plumbing
+// ---------------------------------------------------------------------------
+
+/// Split `---\nyaml\n---\nbody` into `(yaml, body)`.
+///
+/// The opening fence must be the first line; the closing fence must be a
+/// full line `---`. Returns `None` when there is no frontmatter.
+fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
+    let rest = text.strip_prefix("---")?;
+    let rest = rest
+        .strip_prefix('\n')
+        .or_else(|| rest.strip_prefix("\r\n"))?;
+    let mut offset = 0;
+    for line in rest.split_inclusive('\n') {
+        let trimmed = line.trim_end_matches(['\n', '\r']);
+        if trimmed == "---" {
+            let yaml = &rest[..offset];
+            let body = &rest[offset + line.len()..];
+            return Some((yaml, body));
+        }
+        offset += line.len();
+    }
+    None
+}
+
+/// Read a UTF-8 file, skipping (with a warning) files that are missing,
+/// oversized, or non-UTF-8. `label` names the file for warnings.
+fn read_text_capped(path: &Path, label: &str) -> Option {
+    let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
+    if len > FORGE_MAX_BYTES {
+        eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");
+        return None;
+    }
+    match std::fs::read(path) {
+        Ok(bytes) => match String::from_utf8(bytes) {
+            Ok(text) => Some(text),
+            Err(_) => {
+                eprintln!("hu13: forge: skipping {label}: not valid UTF-8");
+                None
+            }
+        },
+        Err(e) => {
+            eprintln!("hu13: forge: skipping {label}: cannot read: {e}");
+            None
+        }
+    }
+}
+
+/// Parse YAML frontmatter; on error warn (naming file via `label` plus the
+/// serde error, which names the field) and return `None` (skip the file).
+fn parse_frontmatter(yaml: &str, label: &str) -> Option
+where
+    T: for<'de> Deserialize<'de>,
+{
+    match yaml_serde::from_str(yaml) {
+        Ok(value) => Some(value),
+        Err(e) => {
+            eprintln!("hu13: forge: skipping {label}: {e}");
+            None
+        }
+    }
+}
+
+/// Sorted entry names (files or dirs) directly under `dir`.
+/// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).
+fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec {
+    let rd = match std::fs::read_dir(dir) {
+        Ok(rd) => rd,
+        Err(_) => return Vec::new(),
+    };
+    let mut names: Vec = Vec::new();
+    for ent in rd {
+        let ent = match ent {
+            Ok(ent) => ent,
+            Err(e) => {
+                eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());
+                continue;
+            }
+        };
+        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
+        if is_dir != only_dirs {
+            continue;
+        }
+        names.push(ent.file_name().to_string_lossy().into_owned());
+    }
+    names.sort();
+    names
+}
+
+// ---------------------------------------------------------------------------
+// Typed frontmatter structs (unknown fields ignored by serde default;
+// `#[serde(default)]` marks every optional from schema §2)
+// ---------------------------------------------------------------------------
+
+#[derive(Debug, Clone, Deserialize)]
+struct IssueFm {
+    id: String,
+    title: String,
+    state: String,
+    #[serde(default)]
+    labels: Vec,
+    #[serde(default)]
+    assignees: Vec,
+    #[serde(default)]
+    milestone: Option,
+    created: String,
+    #[serde(default)]
+    updated: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CommentFm {
+    id: String,
+    created: String,
+    #[serde(default)]
+    author: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct PrFm {
+    id: String,
+    title: String,
+    state: String,
+    base: String,
+    head: String,
+    created: String,
+    #[serde(default)]
+    draft: bool,
+    #[serde(default)]
+    issues: Vec,
+    #[serde(default)]
+    ci: Option,
+    #[serde(default)]
+    updated: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct ReviewFm {
+    id: String,
+    created: String,
+    #[serde(default)]
+    author: Option,
+    #[serde(default)]
+    state: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct TaskFm {
+    id: String,
+    title: String,
+    status: String,
+    created: String,
+    #[serde(default)]
+    assignee: Option,
+    #[serde(default)]
+    updated: Option,
+    #[serde(default)]
+    closed: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct LabelFm {
+    name: String,
+    #[serde(default)]
+    color: Option,
+    #[serde(default)]
+    description: Option,
+}
+
+fn default_open() -> String {
+    "open".to_string()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct MilestoneFm {
+    name: String,
+    #[serde(default = "default_open")]
+    state: String,
+    #[serde(default)]
+    due: Option,
+    #[serde(default)]
+    description: Option,
+}
+
+fn default_runner() -> String {
+    "local".to_string()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiEnvFm {
+    #[serde(default = "default_runner")]
+    runner: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiRunFm {
+    run: String,
+    verdict: String,
+    created: String,
+    #[serde(default)]
+    steps: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CiStepFm {
+    #[serde(default)]
+    name: String,
+    #[serde(default)]
+    exit: i32,
+    #[serde(default)]
+    duration_ms: u64,
+}
+
+// ---------------------------------------------------------------------------
+// Loaded entities (frontmatter + body + sidecars, numbers assigned at load)
+// ---------------------------------------------------------------------------
+
+/// One issue/PR comment.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Comment {
+    /// Comment uuid (filename stem).
+    pub id: String,
+    /// Optional author handle.
+    pub author: Option,
+    /// RFC3339 creation timestamp.
+    pub created: String,
+    /// Markdown body after the frontmatter.
+    pub body: String,
+}
+
+/// One issue with derived display number and loaded comments.
+#[derive(Debug, Clone)]
+pub struct Issue {
+    /// 1-based rank of `(created, id)` — display only, never stored.
+    pub number: usize,
+    /// Issue uuid (link key).
+    pub id: String,
+    pub title: String,
+    pub state: String,
+    pub labels: Vec,
+    pub assignees: Vec,
+    pub milestone: Option,
+    pub created: String,
+    pub updated: Option,
+    /// Markdown body after the frontmatter.
+    pub body: String,
+    /// Comments sorted by `(created, id)`.
+    pub comments: Vec,
+}
+
+/// One PR review (state + body).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Review {
+    /// Review uuid.
+    pub id: String,
+    /// Optional author handle.
+    pub author: Option,
+    /// Optional `approved|changes-requested|commented`.
+    pub state: Option,
+    /// RFC3339 creation timestamp.
+    pub created: String,
+    /// Markdown body after the frontmatter.
+    pub body: String,
+}
+
+/// One PR with derived display number, patch text, comments, and reviews.
+#[derive(Debug, Clone)]
+pub struct Pr {
+    /// 1-based rank of `(created, id)` — display only, never stored.
+    pub number: usize,
+    /// PR uuid (link key).
+    pub id: String,
+    pub title: String,
+    pub state: String,
+    pub base: String,
+    pub head: String,
+    pub draft: bool,
+    pub issues: Vec,
+    pub ci: Option,
+    pub created: String,
+    pub updated: Option,
+    /// Markdown body after the frontmatter.
+    pub body: String,
+    /// Raw `patch.diff` text when present.
+    pub patch: Option,
+    /// Comments sorted by `(created, id)`.
+    pub comments: Vec,
+    /// Reviews sorted by `(created, id)`.
+    pub reviews: Vec,
+}
+
+/// One task file (grouped by `status` at render).
+#[derive(Debug, Clone)]
+pub struct TaskItem {
+    /// Task uuid (filename stem).
+    pub id: String,
+    pub title: String,
+    /// `todo|doing|blocked|done`.
+    pub status: String,
+    pub created: String,
+    pub assignee: Option,
+    pub updated: Option,
+    pub closed: Option,
+    /// Markdown body after the frontmatter.
+    pub body: String,
+}
+
+/// One label entry from `labels.md`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Label {
+    pub name: String,
+    pub color: Option,
+    pub description: Option,
+}
+
+/// One milestone entry from `milestones.md`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Milestone {
+    pub name: String,
+    pub state: String,
+    pub due: Option,
+    pub description: Option,
+}
+
+/// One step result from a CI run summary.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CiStepStatus {
+    pub name: String,
+    pub exit: i32,
+    pub duration_ms: u64,
+}
+
+/// One CI run (latest only is rendered).
+#[derive(Debug, Clone)]
+pub struct CiRun {
+    /// Run timestamp id (dir name).
+    pub run: String,
+    /// `pass|fail|running|cancelled`.
+    pub verdict: String,
+    pub created: String,
+    pub steps: Vec,
+    /// Summary body after the frontmatter (the run log).
+    pub body: String,
+    /// `(log filename, content)` for `runs//steps/*.log`, sorted.
+    pub logs: Vec<(String, String)>,
+}
+
+/// CI section: env runner, step script names, latest run.
+#[derive(Debug, Clone)]
+pub struct CiInfo {
+    /// `runner:` from `ci.env` (default `local`).
+    pub runner: String,
+    /// Sorted `steps/*.sh` filenames.
+    pub steps: Vec,
+    /// Latest `runs//summary.md` by lexicographic (== chronological) order.
+    pub latest: Option,
+}
+
+impl CiInfo {
+    /// Latest verdict, or `"none"` when no runs exist at build time.
+    #[must_use]
+    pub fn latest_verdict(&self) -> &str {
+        self.latest
+            .as_ref()
+            .map(|r| r.verdict.as_str())
+            .unwrap_or("none")
+    }
+}
+
+/// Everything the f0rge readers load (numbers already assigned).
+#[derive(Debug, Clone)]
+pub struct ForgeData {
+    /// Issues sorted by `(created, id)` with 1-based numbers.
+    pub issues: Vec,
+    /// PRs sorted by `(created, id)` with 1-based numbers.
+    pub prs: Vec,
+    /// Tasks sorted by `(created, id)`.
+    pub tasks: Vec,
+    /// Labels in file order.
+    pub labels: Vec