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_in;26 use crate::layout::{diff_html, esc, page_site_with_provenance};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 // Never follow symlinks: wiki and release artifacts already skip64 // them, and `.forge/` is repo-controlled (f0rge #68).65 match std::fs::symlink_metadata(path) {66 Ok(m) if m.file_type().is_symlink() => {67 eprintln!("hu13: forge: skipping {label}: symlink");68 return None;69 }70 _ => {}71 }72 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);73 if len > FORGE_MAX_BYTES {74 eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");75 return None;76 }77 match std::fs::read(path) {78 Ok(bytes) => match String::from_utf8(bytes) {79 Ok(text) => Some(text),80 Err(_) => {81 eprintln!("hu13: forge: skipping {label}: not valid UTF-8");82 None83 }84 },85 Err(e) => {86 eprintln!("hu13: forge: skipping {label}: cannot read: {e}");87 None88 }89 }90 }91 92 /// Like [`read_text_capped`], but keeps arbitrary bytes (CI logs may be93 /// lossy UTF-8). Symlinks and oversized files are skipped, so `.forge`94 /// cannot make the build read an unbounded or outside file (f0rge #107).95 fn read_bytes_capped(path: &Path, label: &str) -> Option<Vec<u8>> {96 match std::fs::symlink_metadata(path) {97 Ok(m) if m.file_type().is_symlink() => {98 eprintln!("hu13: forge: skipping {label}: symlink");99 return None;100 }101 _ => {}102 }103 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);104 if len > FORGE_MAX_BYTES {105 eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");106 return None;107 }108 match std::fs::read(path) {109 Ok(bytes) if bytes.len() as u64 <= FORGE_MAX_BYTES => Some(bytes),110 Ok(_) => {111 eprintln!("hu13: forge: skipping {label}: over 256 KiB");112 None113 }114 Err(e) => {115 eprintln!("hu13: forge: skipping {label}: cannot read: {e}");116 None117 }118 }119 }120 121 /// Parse YAML frontmatter; on error warn (naming file via `label` plus the122 /// serde error, which names the field) and return `None` (skip the file).123 fn parse_frontmatter<T>(yaml: &str, label: &str) -> Option<T>124 where125 T: for<'de> Deserialize<'de>,126 {127 match yaml_serde::from_str(yaml) {128 Ok(value) => Some(value),129 Err(e) => {130 eprintln!("hu13: forge: skipping {label}: {e}");131 None132 }133 }134 }135 136 /// Sorted entry names (files or dirs) directly under `dir`.137 /// Missing dir yields an empty vec (no `.forge/` section yet — not a warning).138 fn sorted_entries(dir: &Path, only_dirs: bool) -> Vec<String> {139 let rd = match std::fs::read_dir(dir) {140 Ok(rd) => rd,141 Err(_) => return Vec::new(),142 };143 let mut names: Vec<String> = Vec::new();144 for ent in rd {145 let ent = match ent {146 Ok(ent) => ent,147 Err(e) => {148 eprintln!("hu13: forge: bad dir entry in {}: {e}", dir.display());149 continue;150 }151 };152 let meta = match std::fs::symlink_metadata(ent.path()) {153 Ok(m) => m,154 Err(_) => continue,155 };156 // Never descend or read through a symlink: `.forge` entries are157 // repo-controlled and must not escape the checkout (f0rge #107).158 if meta.file_type().is_symlink() || meta.is_dir() != only_dirs {159 continue;160 }161 names.push(ent.file_name().to_string_lossy().into_owned());162 }163 names.sort();164 names165 }166 167 // ---------------------------------------------------------------------------168 // Typed frontmatter structs (unknown fields ignored by serde default;169 // `#[serde(default)]` marks every optional from schema §2)170 // ---------------------------------------------------------------------------171 172 #[derive(Debug, Clone, Deserialize)]173 struct IssueFm {174 id: String,175 title: String,176 state: String,177 #[serde(default)]178 labels: Vec<String>,179 #[serde(default)]180 assignees: Vec<String>,181 #[serde(default)]182 milestone: Option<String>,183 created: String,184 #[serde(default)]185 updated: Option<String>,186 }187 188 #[derive(Debug, Clone, Deserialize)]189 struct CommentFm {190 id: String,191 created: String,192 #[serde(default)]193 author: Option<String>,194 }195 196 #[derive(Debug, Clone, Deserialize)]197 struct PrFm {198 id: String,199 title: String,200 state: String,201 base: String,202 head: String,203 created: String,204 #[serde(default)]205 draft: bool,206 #[serde(default)]207 issues: Vec<String>,208 #[serde(default)]209 ci: Option<String>,210 #[serde(default)]211 updated: Option<String>,212 }213 214 #[derive(Debug, Clone, Deserialize)]215 struct ReviewFm {216 id: String,217 created: String,218 #[serde(default)]219 author: Option<String>,220 #[serde(default)]221 state: Option<String>,222 }223 224 #[derive(Debug, Clone, Deserialize)]225 struct TaskFm {226 id: String,227 title: String,228 status: String,229 created: String,230 #[serde(default)]231 assignee: Option<String>,232 #[serde(default)]233 updated: Option<String>,234 #[serde(default)]235 closed: Option<String>,236 }237 238 #[derive(Debug, Clone, Deserialize)]239 struct LabelFm {240 name: String,241 #[serde(default)]242 color: Option<String>,243 #[serde(default)]244 description: Option<String>,245 }246 247 fn default_open() -> String {248 "open".to_string()249 }250 251 #[derive(Debug, Clone, Deserialize)]252 struct MilestoneFm {253 name: String,254 #[serde(default = "default_open")]255 state: String,256 #[serde(default)]257 due: Option<String>,258 #[serde(default)]259 description: Option<String>,260 }261 262 fn default_runner() -> String {263 "local".to_string()264 }265 266 #[derive(Debug, Clone, Deserialize)]267 struct CiEnvFm {268 #[serde(default = "default_runner")]269 runner: String,270 }271 272 #[derive(Debug, Clone, Deserialize)]273 struct CiRunFm {274 run: String,275 verdict: String,276 created: String,277 #[serde(default)]278 steps: Vec<CiStepFm>,279 }280 281 #[derive(Debug, Clone, Deserialize)]282 struct CiStepFm {283 #[serde(default)]284 name: String,285 #[serde(default)]286 exit: i32,287 #[serde(default)]288 duration_ms: u64,289 }290 291 // ---------------------------------------------------------------------------292 // Loaded entities (frontmatter + body + sidecars, numbers assigned at load)293 // ---------------------------------------------------------------------------294 295 /// One issue/PR comment.296 #[derive(Debug, Clone, PartialEq, Eq)]297 pub struct Comment {298 /// Comment uuid (filename stem).299 pub id: String,300 /// Optional author handle.301 pub author: Option<String>,302 /// RFC3339 creation timestamp.303 pub created: String,304 /// Markdown body after the frontmatter.305 pub body: String,306 }307 308 /// One issue with derived display number and loaded comments.309 #[derive(Debug, Clone)]310 pub struct Issue {311 /// 1-based rank of `(created, id)` — display only, never stored.312 pub number: usize,313 /// Issue uuid (link key).314 pub id: String,315 pub title: String,316 pub state: String,317 pub labels: Vec<String>,318 pub assignees: Vec<String>,319 pub milestone: Option<String>,320 pub created: String,321 pub updated: Option<String>,322 /// Markdown body after the frontmatter.323 pub body: String,324 /// Comments sorted by `(created, id)`.325 pub comments: Vec<Comment>,326 }327 328 /// One PR review (state + body).329 #[derive(Debug, Clone, PartialEq, Eq)]330 pub struct Review {331 /// Review uuid.332 pub id: String,333 /// Optional author handle.334 pub author: Option<String>,335 /// Optional `approved|changes-requested|commented`.336 pub state: Option<String>,337 /// RFC3339 creation timestamp.338 pub created: String,339 /// Markdown body after the frontmatter.340 pub body: String,341 }342 343 /// One PR with derived display number, patch text, comments, and reviews.344 #[derive(Debug, Clone)]345 pub struct Pr {346 /// 1-based rank of `(created, id)` — display only, never stored.347 pub number: usize,348 /// PR uuid (link key).349 pub id: String,350 pub title: String,351 pub state: String,352 pub base: String,353 pub head: String,354 pub draft: bool,355 pub issues: Vec<String>,356 pub ci: Option<String>,357 pub created: String,358 pub updated: Option<String>,359 /// Markdown body after the frontmatter.360 pub body: String,361 /// Raw `patch.diff` text when present.362 pub patch: Option<String>,363 /// Comments sorted by `(created, id)`.364 pub comments: Vec<Comment>,365 /// Reviews sorted by `(created, id)`.366 pub reviews: Vec<Review>,367 }368 369 /// One task file (grouped by `status` at render).370 #[derive(Debug, Clone)]371 pub struct TaskItem {372 /// Task uuid (filename stem).373 pub id: String,374 pub title: String,375 /// `todo|doing|blocked|done`.376 pub status: String,377 pub created: String,378 pub assignee: Option<String>,379 pub updated: Option<String>,380 pub closed: Option<String>,381 /// Markdown body after the frontmatter.382 pub body: String,383 }384 385 /// One label entry from `labels.md`.386 #[derive(Debug, Clone, PartialEq, Eq)]387 pub struct Label {388 pub name: String,389 pub color: Option<String>,390 pub description: Option<String>,391 }392 393 /// One milestone entry from `milestones.md`.394 #[derive(Debug, Clone, PartialEq, Eq)]395 pub struct Milestone {396 pub name: String,397 pub state: String,398 pub due: Option<String>,399 pub description: Option<String>,400 }401 402 /// One step result from a CI run summary.403 #[derive(Debug, Clone, PartialEq, Eq)]404 pub struct CiStepStatus {405 pub name: String,406 pub exit: i32,407 pub duration_ms: u64,408 }409 410 /// One CI run (latest only is rendered).411 #[derive(Debug, Clone)]412 pub struct CiRun {413 /// Run timestamp id (dir name).414 pub run: String,415 /// `pass|fail|running|cancelled`.416 pub verdict: String,417 pub created: String,418 pub steps: Vec<CiStepStatus>,419 /// Summary body after the frontmatter (the run log).420 pub body: String,421 /// `(log filename, content)` for `runs/<ts>/steps/*.log`, sorted.422 pub logs: Vec<(String, String)>,423 }424 425 /// CI section: env runner, step script names, latest run.426 #[derive(Debug, Clone)]427 pub struct CiInfo {428 /// `runner:` from `ci.env` (default `local`).429 pub runner: String,430 /// Sorted `steps/*.sh` filenames.431 pub steps: Vec<String>,432 /// Latest `runs/<ts>/summary.md` by lexicographic (== chronological) order.433 pub latest: Option<CiRun>,434 }435 436 impl CiInfo {437 /// Latest verdict, or `"none"` when no runs exist at build time.438 #[must_use]439 pub fn latest_verdict(&self) -> &str {440 self.latest441 .as_ref()442 .map(|r| r.verdict.as_str())443 .unwrap_or("none")444 }445 }446 447 /// Everything the f0rge readers load (numbers already assigned).448 #[derive(Debug, Clone)]449 pub struct ForgeData {450 /// Issues sorted by `(created, id)` with 1-based numbers.451 /// Display via [`issues_display_order`] (open-first, newest-first).452 pub issues: Vec<Issue>,453 /// PRs sorted by `(created, id)` with 1-based numbers.454 /// Display via [`prs_display_order`] (open-first, newest-first).455 pub prs: Vec<Pr>,456 /// Tasks sorted by `(created, id)`.457 pub tasks: Vec<TaskItem>,458 /// Labels in file order.459 pub labels: Vec<Label>,460 /// Milestones in file order.461 pub milestones: Vec<Milestone>,462 /// CI env + steps + latest run.463 pub ci: CiInfo,464 }465 466 impl ForgeData {467 /// Open issues: `state == "open"` exactly (m6 open-counts, f0rge #29).468 /// `in-progress` (and `closed`/`archived`) counts as NOT open.469 #[must_use]470 pub fn open_issue_count(&self) -> usize {471 self.issues.iter().filter(|i| i.state == "open").count()472 }473 474 /// Non-open issues (`total - open`): shown as the `N closed` line on475 /// the issues index so closed totals stay visible (f0rge #29).476 #[must_use]477 pub fn closed_issue_count(&self) -> usize {478 self.issues.len() - self.open_issue_count()479 }480 481 /// Open PRs: `state == "open"` exactly (m6 open-counts, f0rge #29).482 /// Drafts (`draft: true`) are counted as open and stay marked483 /// (` draft` suffix) in the pulls table.484 #[must_use]485 pub fn open_pr_count(&self) -> usize {486 self.prs.iter().filter(|p| p.state == "open").count()487 }488 489 /// Non-open PRs (`closed`/`merged`; `total - open`): shown as the490 /// `N closed` line on the pulls index (f0rge #29).491 #[must_use]492 pub fn closed_pr_count(&self) -> usize {493 self.prs.len() - self.open_pr_count()494 }495 496 /// Issues in index display order (m6/sort, f0rge #48 + #49).497 #[must_use]498 pub fn issues_in_display_order(&self) -> Vec<&Issue> {499 issues_display_order(&self.issues)500 }501 502 /// PRs in index display order (m6/sort, f0rge #48 + #49).503 #[must_use]504 pub fn prs_in_display_order(&self) -> Vec<&Pr> {505 prs_display_order(&self.prs)506 }507 }508 509 /// `true` when an issue/PR counts as open in the index (`state == "open"`510 /// exactly; drafts stay open, `closed`/`merged` and friends stay closed).511 fn is_open_state(state: &str) -> bool {512 state == "open"513 }514 515 /// Issues index display order (m6/sort, f0rge #48 + #49): open first516 /// (reverse-chron within), then non-open (reverse-chron within).517 /// Within each group: `created` desc, `id` desc tiebreak.518 /// Numbers stay creation-rank; input stays `(created, id)` asc.519 #[must_use]520 pub fn issues_display_order(issues: &[Issue]) -> Vec<&Issue> {521 let mut out: Vec<&Issue> = issues.iter().collect();522 out.sort_by(|a, b| {523 let ao = if is_open_state(&a.state) { 0 } else { 1 };524 let bo = if is_open_state(&b.state) { 0 } else { 1 };525 ao.cmp(&bo)526 .then_with(|| b.created.cmp(&a.created))527 .then_with(|| b.id.cmp(&a.id))528 });529 out530 }531 532 /// Pulls index display order (m6/sort, f0rge #48 + #49): open (incl533 /// drafts) first (reverse-chron within), then `closed`/`merged`534 /// (reverse-chron within). Within each group: `created` desc, `id`535 /// desc tiebreak. Numbers stay creation-rank.536 #[must_use]537 pub fn prs_display_order(prs: &[Pr]) -> Vec<&Pr> {538 let mut out: Vec<&Pr> = prs.iter().collect();539 out.sort_by(|a, b| {540 let ao = if is_open_state(&a.state) { 0 } else { 1 };541 let bo = if is_open_state(&b.state) { 0 } else { 1 };542 ao.cmp(&bo)543 .then_with(|| b.created.cmp(&a.created))544 .then_with(|| b.id.cmp(&a.id))545 });546 out547 }548 549 // ---------------------------------------------------------------------------550 // Loading551 // ---------------------------------------------------------------------------552 553 fn load_comments(dir: &Path, scope: &str) -> Vec<Comment> {554 let mut out = Vec::new();555 for name in sorted_entries(dir, false) {556 if !name.ends_with(".md") {557 continue;558 }559 let label = format!("{scope}/{name}");560 let Some(text) = read_text_capped(&dir.join(&name), &label) else {561 continue;562 };563 let Some((yaml, body)) = split_frontmatter(&text) else {564 eprintln!("hu13: forge: skipping {label}: missing frontmatter");565 continue;566 };567 let Some(fm): Option<CommentFm> = parse_frontmatter(yaml, &label) else {568 continue;569 };570 out.push(Comment {571 id: fm.id,572 author: fm.author,573 created: fm.created,574 body: body.to_string(),575 });576 }577 out.sort_by_key(|c| (c.created.clone(), c.id.clone()));578 out579 }580 581 fn load_reviews(dir: &Path, scope: &str) -> Vec<Review> {582 let mut out = Vec::new();583 for name in sorted_entries(dir, false) {584 if !name.ends_with(".md") {585 continue;586 }587 let label = format!("{scope}/{name}");588 let Some(text) = read_text_capped(&dir.join(&name), &label) else {589 continue;590 };591 let Some((yaml, body)) = split_frontmatter(&text) else {592 eprintln!("hu13: forge: skipping {label}: missing frontmatter");593 continue;594 };595 let Some(fm): Option<ReviewFm> = parse_frontmatter(yaml, &label) else {596 continue;597 };598 out.push(Review {599 id: fm.id,600 author: fm.author,601 state: fm.state,602 created: fm.created,603 body: body.to_string(),604 });605 }606 out.sort_by_key(|c| (c.created.clone(), c.id.clone()));607 out608 }609 610 fn load_issues(root: &Path) -> Vec<Issue> {611 let dir = root.join("issues");612 let mut issues = Vec::new();613 for uuid in sorted_entries(&dir, true) {614 let label = format!("issues/{uuid}/issue.md");615 let Some(text) = read_text_capped(&dir.join(&uuid).join("issue.md"), &label) else {616 continue;617 };618 let Some((yaml, body)) = split_frontmatter(&text) else {619 eprintln!("hu13: forge: skipping {label}: missing frontmatter");620 continue;621 };622 let Some(fm): Option<IssueFm> = parse_frontmatter(yaml, &label) else {623 continue;624 };625 if !crate::paths::is_safe_id(&fm.id) {626 eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);627 continue;628 }629 if crate::paths::is_reserved_index(&fm.id) {630 eprintln!("hu13: forge: skipping {label}: reserved id index");631 continue;632 }633 let comments = load_comments(634 &dir.join(&uuid).join("comments"),635 &format!("issues/{uuid}/comments"),636 );637 issues.push(Issue {638 number: 0,639 id: fm.id,640 title: fm.title,641 state: fm.state,642 labels: fm.labels,643 assignees: fm.assignees,644 milestone: fm.milestone,645 created: fm.created,646 updated: fm.updated,647 body: body.to_string(),648 comments,649 });650 }651 // Numbers: 1-based rank of (created, id).652 issues.sort_by_key(|i| (i.created.clone(), i.id.clone()));653 for (i, issue) in issues.iter_mut().enumerate() {654 issue.number = i + 1;655 }656 issues657 }658 659 fn load_prs(root: &Path) -> Vec<Pr> {660 let dir = root.join("prs");661 let mut prs = Vec::new();662 for uuid in sorted_entries(&dir, true) {663 let label = format!("prs/{uuid}/pr.md");664 let Some(text) = read_text_capped(&dir.join(&uuid).join("pr.md"), &label) else {665 continue;666 };667 let Some((yaml, body)) = split_frontmatter(&text) else {668 eprintln!("hu13: forge: skipping {label}: missing frontmatter");669 continue;670 };671 let Some(fm): Option<PrFm> = parse_frontmatter(yaml, &label) else {672 continue;673 };674 if !crate::paths::is_safe_id(&fm.id) {675 eprintln!("hu13: forge: skipping {label}: unsafe id {:?}", fm.id);676 continue;677 }678 if crate::paths::is_reserved_index(&fm.id) {679 eprintln!("hu13: forge: skipping {label}: reserved id index");680 continue;681 }682 // Symlink-safe and bounded: a hostile patch.diff used to be read683 // whole, even through a symlink (f0rge #107).684 let patch = read_text_capped(&dir.join(&uuid).join("patch.diff"), "patch.diff");685 let comments = load_comments(686 &dir.join(&uuid).join("comments"),687 &format!("prs/{uuid}/comments"),688 );689 let reviews = load_reviews(690 &dir.join(&uuid).join("reviews"),691 &format!("prs/{uuid}/reviews"),692 );693 prs.push(Pr {694 number: 0,695 id: fm.id,696 title: fm.title,697 state: fm.state,698 base: fm.base,699 head: fm.head,700 draft: fm.draft,701 issues: fm.issues,702 ci: fm.ci,703 created: fm.created,704 updated: fm.updated,705 body: body.to_string(),706 patch,707 comments,708 reviews,709 });710 }711 // Numbers: 1-based rank of (created, id).712 prs.sort_by_key(|p| (p.created.clone(), p.id.clone()));713 for (i, pr) in prs.iter_mut().enumerate() {714 pr.number = i + 1;715 }716 prs717 }718 719 fn load_tasks(root: &Path) -> Vec<TaskItem> {720 let dir = root.join("tasks");721 let mut tasks = Vec::new();722 for name in sorted_entries(&dir, false) {723 if !name.ends_with(".md") {724 continue;725 }726 let label = format!("tasks/{name}");727 let Some(text) = read_text_capped(&dir.join(&name), &label) else {728 continue;729 };730 let Some((yaml, body)) = split_frontmatter(&text) else {731 eprintln!("hu13: forge: skipping {label}: missing frontmatter");732 continue;733 };734 let Some(fm): Option<TaskFm> = parse_frontmatter(yaml, &label) else {735 continue;736 };737 if !matches!(fm.status.as_str(), "todo" | "doing" | "blocked" | "done") {738 eprintln!(739 "hu13: forge: skipping {label}: unknown status '{}'",740 fm.status741 );742 continue;743 }744 tasks.push(TaskItem {745 id: fm.id,746 title: fm.title,747 status: fm.status,748 created: fm.created,749 assignee: fm.assignee,750 updated: fm.updated,751 closed: fm.closed,752 body: body.to_string(),753 });754 }755 tasks.sort_by_key(|t| (t.created.clone(), t.id.clone()));756 tasks757 }758 759 fn load_labels(root: &Path) -> Vec<Label> {760 let path = root.join("labels.md");761 if !path.exists() {762 return Vec::new();763 }764 let Some(text) = read_text_capped(&path, "labels.md") else {765 return Vec::new();766 };767 // YAML list, no fences: parse the whole file.768 match yaml_serde::from_str::<Vec<LabelFm>>(&text) {769 Ok(items) => items770 .into_iter()771 .map(|l| Label {772 name: l.name,773 color: l.color,774 description: l.description,775 })776 .collect(),777 Err(e) => {778 eprintln!("hu13: forge: skipping labels.md: {e}");779 Vec::new()780 }781 }782 }783 784 fn load_milestones(root: &Path) -> Vec<Milestone> {785 let path = root.join("milestones.md");786 if !path.exists() {787 return Vec::new();788 }789 let Some(text) = read_text_capped(&path, "milestones.md") else {790 return Vec::new();791 };792 // YAML list, no fences: parse the whole file.793 match yaml_serde::from_str::<Vec<MilestoneFm>>(&text) {794 Ok(items) => items795 .into_iter()796 .map(|m| Milestone {797 name: m.name,798 state: m.state,799 due: m.due,800 description: m.description,801 })802 .collect(),803 Err(e) => {804 eprintln!("hu13: forge: skipping milestones.md: {e}");805 Vec::new()806 }807 }808 }809 810 /// Remove builder-local absolute paths from published CI logs (f0rge811 /// #94): cargo writes `Checking hu13 (/home/alice/.../hu13)`. Replace812 /// the canonical repo root with `<repo>`, then `$HOME` with `~`.813 fn redact_paths(text: &str, root: &Path) -> String {814 let mut out = text.to_string();815 // Only replace a canonical absolute path. `root` may be `.forge` under a816 // relative repo argument (`.`); using the raw relative form as a needle817 // would replace every dot in `v0.1.0`/`00.log` (f0rge #103).818 if let Ok(root_abs) = root.canonicalize() {819 let needle = root_abs.to_string_lossy();820 if !needle.is_empty() && needle != "/" && needle != "." && needle != ".." {821 out = out.replace(needle.as_ref(), "<repo>");822 }823 }824 if let Ok(home) = std::env::var("HOME") {825 if !home.is_empty() && home != "/" {826 out = out.replace(&home, "~");827 }828 }829 out830 }831 832 fn truncate_log(text: &str) -> String {833 if text.len() <= LOG_PREVIEW_MAX {834 return text.to_string();835 }836 let mut end = LOG_PREVIEW_MAX;837 while !text.is_char_boundary(end) {838 end -= 1;839 }840 format!("{}…[truncated]", &text[..end])841 }842 843 fn load_ci(root: &Path) -> CiInfo {844 let dir = root.join("ci");845 // `root` is `<repo>/.forge`; logs name the repo checkout itself.846 let repo_root = root.parent().unwrap_or(root);847 let runner = if dir.join("ci.env").exists() {848 match read_text_capped(&dir.join("ci.env"), "ci/ci.env") {849 Some(text) => match yaml_serde::from_str::<CiEnvFm>(&text) {850 Ok(env) => env.runner,851 Err(e) => {852 eprintln!("hu13: forge: skipping ci/ci.env: {e}");853 default_runner()854 }855 },856 None => default_runner(),857 }858 } else {859 default_runner()860 };861 let steps: Vec<String> = sorted_entries(&dir.join("steps"), false)862 .into_iter()863 .filter(|n| n.ends_with(".sh"))864 .collect();865 866 let mut runs = sorted_entries(&dir.join("runs"), true);867 runs.sort();868 let latest = runs.last().cloned().and_then(|ts| {869 let label = format!("ci/runs/{ts}/summary.md");870 let text = read_text_capped(&dir.join("runs").join(&ts).join("summary.md"), &label)?;871 let (yaml, body) = match split_frontmatter(&text) {872 Some(pair) => pair,873 None => {874 eprintln!("hu13: forge: skipping {label}: missing frontmatter");875 return None;876 }877 };878 let fm: CiRunFm = parse_frontmatter(yaml, &label)?;879 let mut logs = Vec::new();880 for name in sorted_entries(&dir.join("runs").join(&ts).join("steps"), false) {881 if !name.ends_with(".log") {882 continue;883 }884 let label = format!("ci/runs/{ts}/steps/{name}");885 let bytes = read_bytes_capped(886 &dir.join("runs").join(&ts).join("steps").join(&name),887 &label,888 )889 .unwrap_or_default();890 let text = String::from_utf8_lossy(&bytes);891 logs.push((name, truncate_log(&redact_paths(&text, repo_root))));892 }893 Some(CiRun {894 run: fm.run,895 verdict: fm.verdict,896 created: fm.created,897 steps: fm898 .steps899 .into_iter()900 .map(|s| CiStepStatus {901 name: s.name,902 exit: s.exit,903 duration_ms: s.duration_ms,904 })905 .collect(),906 body: redact_paths(body, repo_root),907 logs,908 })909 });910 911 CiInfo {912 runner,913 steps,914 latest,915 }916 }917 918 /// Load every f0rge section from on-disk `<repo>/.forge/`.919 /// Never fails: bad files are skipped with warnings; a missing920 /// `.forge/` yields empty sections.921 #[must_use]922 pub fn load(repo: &Path) -> ForgeData {923 // Resolve `.forge` and require it to stay inside the repo. A symlinked924 // `.forge` used to publish host issues/wiki/releases (f0rge #107).925 let root = crate::paths::repo_subdir(repo, ".forge")926 .unwrap_or_else(|| repo.join(".hu13-missing-forge"));927 ForgeData {928 issues: load_issues(&root),929 prs: load_prs(&root),930 tasks: load_tasks(&root),931 labels: load_labels(&root),932 milestones: load_milestones(&root),933 ci: load_ci(&root),934 }935 }936 937 // ---------------------------------------------------------------------------938 // Rendering939 // ---------------------------------------------------------------------------940 941 /// Verdict badge: `<span class="badge {verdict}">verdict</span>` for942 /// `pass|fail|running|cancelled`, plain `badge` otherwise (`none`, unknown).943 #[must_use]944 pub fn verdict_badge(verdict: &str) -> String {945 let v = verdict.trim().to_lowercase();946 if matches!(v.as_str(), "pass" | "fail" | "running" | "cancelled") {947 format!("<span class=\"badge {v}\">{}</span>", esc(&v))948 } else {949 format!("<span class=\"badge\">{}</span>", esc(verdict.trim()))950 }951 }952 953 /// CSS class for a state pill (open/closed/merged/other).954 fn state_class(state: &str) -> &'static str {955 match state.trim().to_ascii_lowercase().as_str() {956 "open" => "open",957 "closed" => "closed",958 "merged" => "merged",959 _ => "other",960 }961 }962 963 /// Colored state pill (f0rge #72).964 fn state_pill(state: &str) -> String {965 format!(966 "<span class=\"pill state-{}\">{}</span>",967 state_class(state),968 esc(state.trim())969 )970 }971 972 /// 1-letter author monogram with a deterministic hue class.973 fn monogram(handle: &str) -> String {974 let h = handle.trim();975 let initial: String = h976 .chars()977 .next()978 .map(|c| c.to_uppercase().to_string())979 .unwrap_or_default();980 let mut hash: u32 = 0;981 for b in h.bytes() {982 hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));983 }984 format!(985 "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",986 hash % 6,987 esc(&initial)988 )989 }990 991 /// `<time>` element with a relative label and the exact UTC value in992 /// `title`/`datetime` (never a raw nanosecond ISO string in visible text).993 fn time_ago(s: &str, now: i64) -> String {994 match crate::build::parse_ts_secs(s) {995 Some(ts) => format!(996 "<time datetime=\"{}\" title=\"{}\">{}</time>",997 esc(s),998 esc(&crate::build::ts_title(s)),999 esc(&crate::build::relative_age(now, ts))1000 ),1001 None => format!("<time>{}</time>", esc(s)),1002 }1003 }1004 1005 /// Human duration for CI steps: `7 ms` under a second, else `1.2 s`.1006 fn fmt_duration(ms: u64) -> String {1007 if ms < 1000 {1008 format!("{ms} ms")1009 } else {1010 format!("{:.1} s", ms as f64 / 1000.0)1011 }1012 }1013 1014 fn render_comment(c: &Comment, now: i64) -> String {1015 let mut out = String::from("<li class=\"comment\">\n");1016 let author = c.author.as_deref().unwrap_or("unknown");1017 out.push_str(&format!(1018 "<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {}</p>\n",1019 monogram(author),1020 esc(author),1021 time_ago(&c.created, now),1022 ));1023 out.push_str(&markdown_to_html_in(&c.body, "", false));1024 out.push_str("</li>\n");1025 out1026 }1027 1028 fn render_issue_page(1029 issue: &Issue,1030 site_name: &str,1031 nav: &str,1032 branch: &str,1033 short_sha: &str,1034 description: Option<&str>,1035 ) -> String {1036 let mut body = format!(1037 "<h1>#{n} {title}</h1>\n",1038 n = issue.number,1039 title = esc(&issue.title),1040 );1041 let now = crate::build::now_unix();1042 body.push_str("<div class=\"meta\">");1043 body.push_str(&state_pill(&issue.state));1044 for l in &issue.labels {1045 body.push_str(&format!("<span class=\"chip\">{}</span>", esc(l)));1046 }1047 if let Some(m) = &issue.milestone {1048 body.push_str(&format!("<span class=\"chip milestone\">{}</span>", esc(m)));1049 }1050 body.push_str(&format!(1051 "<span class=\"meta-when\">opened {}</span>",1052 time_ago(&issue.created, now)1053 ));1054 body.push_str("</div>\n");1055 let mut details =1056 String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");1057 if !issue.assignees.is_empty() {1058 let who = issue1059 .assignees1060 .iter()1061 .map(|a| format!("{}{}", monogram(a), esc(a)))1062 .collect::<Vec<_>>()1063 .join(", ");1064 details.push_str(&format!("<dt>assignees</dt><dd>{who}</dd>\n"));1065 }1066 if let Some(u) = &issue.updated {1067 details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));1068 }1069 details.push_str(&format!(1070 "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",1071 esc(&issue.id)1072 ));1073 body.push_str(&details);1074 body.push_str(&markdown_to_html_in(&issue.body, "", false));1075 body.push_str(&format!("<h2>Comments ({})</h2>\n", issue.comments.len()));1076 if issue.comments.is_empty() {1077 body.push_str("<p class=\"muted\">No comments yet.</p>\n");1078 } else {1079 body.push_str("<ul class=\"comments\">\n");1080 for c in &issue.comments {1081 body.push_str(&render_comment(c, now));1082 }1083 body.push_str("</ul>\n");1084 }1085 body.push_str("<p><a href=\"/issues/index.html\">all issues</a></p>\n");1086 page_site_with_provenance(1087 &format!("#{} {} — {site_name}", issue.number, issue.title),1088 nav,1089 &body,1090 site_name,1091 description,1092 branch,1093 short_sha,1094 )1095 }1096 1097 fn render_pr_page(1098 pr: &Pr,1099 site_name: &str,1100 nav: &str,1101 branch: &str,1102 short_sha: &str,1103 description: Option<&str>,1104 ) -> String {1105 let mut body = format!(1106 "<h1>#{n} {title}</h1>\n",1107 n = pr.number,1108 title = esc(&pr.title),1109 );1110 let now = crate::build::now_unix();1111 body.push_str("<div class=\"meta\">");1112 body.push_str(&state_pill(&pr.state));1113 if pr.draft {1114 body.push_str("<span class=\"chip\">draft</span>");1115 }1116 if let Some(ci) = &pr.ci {1117 body.push_str(&verdict_badge(ci));1118 }1119 body.push_str(&format!(1120 "<span class=\"meta-when\">opened {}</span>",1121 time_ago(&pr.created, now)1122 ));1123 body.push_str("</div>\n");1124 let mut details =1125 String::from("<details class=\"meta-more\"><summary>details</summary>\n<dl>\n");1126 details.push_str(&format!(1127 "<dt>branches</dt><dd><code>{}</code> ← <code>{}</code></dd>\n",1128 esc(&pr.base),1129 esc(&pr.head)1130 ));1131 if !pr.issues.is_empty() {1132 let issues = pr1133 .issues1134 .iter()1135 .map(|i| format!("<code>{}</code>", esc(i)))1136 .collect::<Vec<_>>()1137 .join(", ");1138 details.push_str(&format!("<dt>issues</dt><dd>{issues}</dd>\n"));1139 }1140 if let Some(u) = &pr.updated {1141 details.push_str(&format!("<dt>updated</dt><dd>{}</dd>\n", time_ago(u, now)));1142 }1143 details.push_str(&format!(1144 "<dt>id</dt><dd><code>{}</code></dd>\n</dl></details>\n",1145 esc(&pr.id)1146 ));1147 body.push_str(&details);1148 body.push_str(&markdown_to_html_in(&pr.body, "", false));1149 body.push_str("<h2>Patch</h2>\n");1150 match &pr.patch {1151 Some(patch) if !patch.is_empty() => {1152 body.push_str(&format!(1153 "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",1154 diff_html(patch)1155 ));1156 }1157 _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),1158 }1159 body.push_str(&format!("<h2>Comments ({})</h2>\n", pr.comments.len()));1160 if pr.comments.is_empty() {1161 body.push_str("<p class=\"muted\">No comments yet.</p>\n");1162 } else {1163 body.push_str("<ul class=\"comments\">\n");1164 for c in &pr.comments {1165 body.push_str(&render_comment(c, now));1166 }1167 body.push_str("</ul>\n");1168 }1169 body.push_str(&format!("<h2>Reviews ({})</h2>\n", pr.reviews.len()));1170 if pr.reviews.is_empty() {1171 body.push_str("<p class=\"muted\">No reviews yet.</p>\n");1172 } else {1173 body.push_str("<ul class=\"reviews\">\n");1174 for r in &pr.reviews {1175 let state = r.state.as_deref().unwrap_or("commented");1176 let author = r.author.as_deref().unwrap_or("unknown");1177 body.push_str(&format!(1178 "<li class=\"review\">\n<p class=\"comment-head\">{}<span class=\"who\">{}</span> · {} · {}</p>\n{}</li>\n",1179 monogram(author),1180 esc(author),1181 time_ago(&r.created, now),1182 esc(state),1183 markdown_to_html_in(&r.body, "", false),1184 ));1185 }1186 body.push_str("</ul>\n");1187 }1188 body.push_str("<p><a href=\"/pulls/index.html\">all pulls</a></p>\n");1189 page_site_with_provenance(1190 &format!("#{} {} — {site_name}", pr.number, pr.title),1191 nav,1192 &body,1193 site_name,1194 description,1195 branch,1196 short_sha,1197 )1198 }1199 1200 fn render_issues_index(1201 data: &ForgeData,1202 site_name: &str,1203 nav: &str,1204 branch: &str,1205 short_sha: &str,1206 description: Option<&str>,1207 ) -> Result<String, String> {1208 let mut body =1209 String::from("<div class=\"page-head\"><h1>Issues</h1>\n<a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a></div>\n");1210 if data.issues.is_empty() {1211 body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");1212 } else {1213 let now = crate::build::now_unix();1214 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");1215 // m6/sort (#48 + #49): open first (newest-first within), then1216 // non-open (newest-first within). Numbers stay creation-rank.1217 // m6/cards2 (#46 + #47): title-first column order.1218 for issue in data.issues_in_display_order() {1219 let state_plain = esc(&issue.state);1220 let state_html = state_pill(&issue.state);1221 let created_plain = time_ago(&issue.created, now);1222 body.push_str(&format!(1223 "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1224 n = issue.number,1225 id = esc(&issue.id),1226 title = esc(&issue.title),1227 state_plain = state_plain,1228 state_html = state_html,1229 comments = issue.comments.len(),1230 created = esc(&created_plain),1231 created_html = created_plain,1232 ));1233 }1234 body.push_str("</tbody>\n</table></div>\n");1235 }1236 body.push_str(&format!(1237 "<p class=\"muted\">{n} closed</p>\n",1238 n = data.closed_issue_count(),1239 ));1240 if !data.labels.is_empty() {1241 body.push_str("<h2>Labels</h2>\n<ul class=\"labels\">\n");1242 for l in &data.labels {1243 body.push_str(&format!("<li><code>{}</code></li>\n", esc(&l.name)));1244 }1245 body.push_str("</ul>\n");1246 }1247 if !data.milestones.is_empty() {1248 body.push_str("<h2>Milestones</h2>\n<ul class=\"milestones\">\n");1249 for m in &data.milestones {1250 body.push_str(&format!(1251 "<li><code>{}</code> <span class=\"muted\">{}</span></li>\n",1252 esc(&m.name),1253 esc(&m.state),1254 ));1255 }1256 body.push_str("</ul>\n");1257 }1258 Ok(page_site_with_provenance(1259 &format!("issues — {site_name}"),1260 nav,1261 &body,1262 site_name,1263 description,1264 branch,1265 short_sha,1266 ))1267 }1268 1269 fn render_pulls_index(1270 data: &ForgeData,1271 site_name: &str,1272 nav: &str,1273 branch: &str,1274 short_sha: &str,1275 description: Option<&str>,1276 ) -> String {1277 let mut body =1278 String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");1279 if data.prs.is_empty() {1280 body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");1281 } else {1282 let now = crate::build::now_unix();1283 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>#</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");1284 // m6/sort (#48 + #49): open (incl drafts) first (newest-first1285 // within), then closed/merged (newest-first within).1286 // Numbers stay creation-rank.1287 // m6/cards2 (#46 + #47): title-first column order.1288 for pr in data.prs_in_display_order() {1289 let lanes = format!(1290 "<span class=\"lane\">{}</span><span class=\"lane-arrow\" aria-hidden=\"true\">→</span><span class=\"lane\">{}</span>",1291 esc(&pr.head),1292 esc(&pr.base)1293 );1294 let lanes_plain = format!("{}→{}", pr.head, pr.base);1295 let draft = if pr.draft { " draft" } else { "" };1296 let full_title = format!("{}{}", pr.title, draft);1297 let state_plain = esc(&pr.state);1298 let state_html = state_pill(&pr.state);1299 let created_plain = time_ago(&pr.created, now);1300 body.push_str(&format!(1301 "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"trunc\" data-label=\"state\" title=\"{state_plain}\">{state_html}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code class=\"lanes\">{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created_html}</td></tr>\n",1302 n = pr.number,1303 id = esc(&pr.id),1304 title = esc(&pr.title),1305 full = esc(&full_title),1306 state_plain = state_plain,1307 state_html = state_html,1308 lanes_title = esc(&lanes_plain),1309 created = esc(&created_plain),1310 created_html = created_plain,1311 ));1312 }1313 body.push_str("</tbody>\n</table></div>\n");1314 }1315 body.push_str(&format!(1316 "<p class=\"muted\">{n} closed</p>\n",1317 n = data.closed_pr_count(),1318 ));1319 page_site_with_provenance(1320 &format!("pulls — {site_name}"),1321 nav,1322 &body,1323 site_name,1324 description,1325 branch,1326 short_sha,1327 )1328 }1329 1330 fn render_tasks_index(1331 tasks: &[TaskItem],1332 site_name: &str,1333 nav: &str,1334 branch: &str,1335 short_sha: &str,1336 description: Option<&str>,1337 ) -> String {1338 let mut body = String::from("<div class=\"page-head\"><h1>Tasks</h1></div>\n");1339 if tasks.is_empty() {1340 body.push_str("<p class=\"muted\">No tasks yet.</p>\n");1341 } else {1342 for status in ["todo", "doing", "blocked", "done"] {1343 let group: Vec<&TaskItem> = tasks.iter().filter(|t| t.status == status).collect();1344 body.push_str(&format!("<h2>{status} ({})</h2>\n", group.len()));1345 if group.is_empty() {1346 body.push_str("<p class=\"muted\">None.</p>\n");1347 } else {1348 body.push_str("<ul class=\"tasks\">\n");1349 for t in group {1350 let mut meta = format!("<code>{}</code> · {}", esc(&t.id), esc(&t.created));1351 if let Some(u) = &t.updated {1352 meta.push_str(&format!(" · updated {}", esc(u)));1353 }1354 if let Some(c) = &t.closed {1355 meta.push_str(&format!(" · closed {}", esc(c)));1356 }1357 body.push_str(&format!(1358 "<li class=\"task\">\n<p><strong class=\"trunc trunc-title\" title=\"{title}\">{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1359 title = esc(&t.title),1360 assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(1361 "<p class=\"muted\">assignee: <code>{}</code></p>\n",1362 esc(a)1363 )),1364 body_md = markdown_to_html_in(&t.body, "", false),1365 ));1366 }1367 body.push_str("</ul>\n");1368 }1369 }1370 }1371 page_site_with_provenance(1372 &format!("tasks — {site_name}"),1373 nav,1374 &body,1375 site_name,1376 description,1377 branch,1378 short_sha,1379 )1380 }1381 1382 fn render_ci_index(1383 ci: &CiInfo,1384 site_name: &str,1385 nav: &str,1386 branch: &str,1387 short_sha: &str,1388 description: Option<&str>,1389 ) -> String {1390 let mut body = String::from("<div class=\"page-head\"><h1>CI</h1></div>\n");1391 let now = crate::build::now_unix();1392 body.push_str(&format!(1393 "<div class=\"meta\"><span class=\"chip\">runner: {}</span></div>\n",1394 esc(&ci.runner)1395 ));1396 if ci.steps.is_empty() {1397 body.push_str("<p class=\"muted\">No CI steps configured.</p>\n");1398 } else {1399 body.push_str("<h2>Steps</h2>\n<ul class=\"ci-steps\">\n");1400 for step in &ci.steps {1401 body.push_str(&format!("<li><code>{}</code></li>\n", esc(step)));1402 }1403 body.push_str("</ul>\n");1404 }1405 body.push_str("<h2>Latest run</h2>\n");1406 match &ci.latest {1407 Some(run) => {1408 let steps = run.steps.len();1409 body.push_str("<div class=\"meta\">");1410 body.push_str(&verdict_badge(&run.verdict));1411 body.push_str(&format!(1412 "<span class=\"meta-when\">ran {} \u{b7} {steps} step{}</span>",1413 time_ago(&run.created, now),1414 if steps == 1 { "" } else { "s" }1415 ));1416 body.push_str("</div>\n");1417 body.push_str(&format!(1418 "<details class=\"meta-more\"><summary>details</summary>\n<dl><dt>run</dt><dd><code>{}</code></dd>\n<dt>created</dt><dd>{}</dd></dl></details>\n",1419 esc(&run.run),1420 time_ago(&run.created, now)1421 ));1422 if run.steps.is_empty() {1423 body.push_str("<p class=\"muted\">No step results recorded.</p>\n");1424 } else {1425 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration</th></tr></thead>\n<tbody>\n");1426 for s in &run.steps {1427 body.push_str(&format!(1428 "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration\" title=\"{dur}\">{dur_h}</td></tr>\n",1429 name = esc(&s.name),1430 exit = s.exit,1431 dur = s.duration_ms,1432 dur_h = fmt_duration(s.duration_ms),1433 ));1434 }1435 body.push_str("</tbody>\n</table></div>\n");1436 }1437 body.push_str("<h3>Log</h3>\n");1438 if run.body.trim().is_empty() {1439 body.push_str("<p class=\"muted\">Empty log.</p>\n");1440 } else {1441 body.push_str(&format!(1442 "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1443 esc(&run.body)1444 ));1445 }1446 for (name, content) in &run.logs {1447 body.push_str(&format!("<h3>{}</h3>\n", esc(name)));1448 body.push_str(&format!(1449 "<div class=\"pre-scroll\"><pre class=\"log\">{}</pre></div>\n",1450 esc(content)1451 ));1452 }1453 }1454 None => body.push_str("<p class=\"muted\">No CI runs yet.</p>\n"),1455 }1456 page_site_with_provenance(1457 &format!("ci — {site_name}"),1458 nav,1459 &body,1460 site_name,1461 description,1462 branch,1463 short_sha,1464 )1465 }1466 1467 fn write_page(out: &Path, rel: &str, html: &str) -> Result<(), String> {1468 if !crate::paths::is_safe_relative(rel) {1469 return Err(format!(1470 "hu13: refusing to write unsafe output path `{rel}`"1471 ));1472 }1473 let dest = out.join(rel);1474 if let Some(parent) = dest.parent() {1475 std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create {rel} parent: {e}"))?;1476 }1477 std::fs::write(&dest, html).map_err(|e| format!("hu13: write {rel}: {e}"))1478 }1479 1480 /// Render `issues/`, `pulls/`, `tasks/`, `ci/` pages from already-loaded data.1481 pub fn render_loaded(1482 data: &ForgeData,1483 out: &Path,1484 site_name: &str,1485 nav: &str,1486 branch: &str,1487 short_sha: &str,1488 description: Option<&str>,1489 ) -> Result<(), String> {1490 write_page(1491 out,1492 "issues/index.html",1493 &render_issues_index(data, site_name, nav, branch, short_sha, description)?,1494 )?;1495 for issue in &data.issues {1496 write_page(1497 out,1498 &format!("issues/{}.html", issue.id),1499 &render_issue_page(issue, site_name, nav, branch, short_sha, description),1500 )?;1501 }1502 1503 write_page(1504 out,1505 "pulls/index.html",1506 &render_pulls_index(data, site_name, nav, branch, short_sha, description),1507 )?;1508 for pr in &data.prs {1509 write_page(1510 out,1511 &format!("pulls/{}.html", pr.id),1512 &render_pr_page(pr, site_name, nav, branch, short_sha, description),1513 )?;1514 }1515 1516 write_page(1517 out,1518 "tasks/index.html",1519 &render_tasks_index(&data.tasks, site_name, nav, branch, short_sha, description),1520 )?;1521 write_page(1522 out,1523 "ci/index.html",1524 &render_ci_index(&data.ci, site_name, nav, branch, short_sha, description),1525 )?;1526 1527 Ok(())1528 }1529 1530 #[cfg(test)]1531 mod tests {1532 use super::*;1533 use std::path::PathBuf;1534 use std::sync::atomic::{AtomicU64, Ordering};1535 1536 static COUNTER: AtomicU64 = AtomicU64::new(0);1537 1538 fn mktemp(prefix: &str) -> PathBuf {1539 let id = COUNTER.fetch_add(1, Ordering::SeqCst);1540 let dir =1541 std::env::temp_dir().join(format!("hu13-forge-{prefix}-{}-{id}", std::process::id()));1542 std::fs::create_dir_all(&dir).expect("mktemp");1543 dir1544 }1545 1546 fn write_forge(repo: &Path, rel: &str, content: &str) {1547 let dest = repo.join(".forge").join(rel);1548 std::fs::create_dir_all(dest.parent().expect("parent")).expect("mkdir");1549 std::fs::write(&dest, content).expect("write forge file");1550 }1551 1552 #[test]1553 fn frontmatter_split_shapes() {1554 let (yaml, body) = split_frontmatter("---\nid: x\n---\nhello\n").expect("split");1555 assert_eq!(yaml, "id: x\n");1556 assert_eq!(body, "hello\n");1557 assert!(split_frontmatter("no fences\n").is_none());1558 assert!(split_frontmatter("---\nunclosed\n").is_none());1559 assert!(split_frontmatter("---\n---\nempty yaml\n")1560 .expect("e")1561 .01562 .is_empty());1563 }1564 1565 #[test]1566 fn unknown_fields_ignored() {1567 let yaml =1568 "id: a\ntitle: T\nstate: open\ncreated: 2026-01-01T00:00:00Z\nsome_future_field: 123\n";1569 let fm: IssueFm = yaml_serde::from_str(yaml).expect("unknown fields ignored");1570 assert_eq!(fm.title, "T");1571 assert!(fm.labels.is_empty());1572 assert!(fm.milestone.is_none());1573 }1574 1575 #[test]1576 fn missing_required_is_error() {1577 let yaml = "id: a\nstate: open\ncreated: 2026-01-01T00:00:00Z\n";1578 let err = yaml_serde::from_str::<IssueFm>(yaml).expect_err("title missing");1579 assert!(1580 err.to_string().contains("title"),1581 "error names field: {err}"1582 );1583 }1584 1585 /// Two issues with out-of-order created dates: the earlier one ranks #1.1586 #[test]1587 fn issue_numbers_rank_by_created_then_id() {1588 let repo = mktemp("numbers");1589 write_forge(1590 &repo,1591 "issues/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/issue.md",1592 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Newer\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nnewer body\n",1593 );1594 write_forge(1595 &repo,1596 "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",1597 "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Older\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nolder body\n",1598 );1599 let data = load(&repo);1600 assert_eq!(data.issues.len(), 2);1601 assert_eq!(data.issues[0].title, "Older");1602 assert_eq!(data.issues[0].number, 1);1603 assert_eq!(data.issues[1].title, "Newer");1604 assert_eq!(data.issues[1].number, 2);1605 }1606 1607 /// m6/sort (f0rge #48 + #49): issues index shows open first1608 /// (newest-first within), then closed (newest-first within).1609 /// Numbers stay creation-rank so they appear unordered — correct.1610 #[test]1611 fn issues_open_first_then_reverse_chron_numbers_stable() {1612 let repo = mktemp("sort-issues");1613 for (id, title, state, created) in [1614 (1615 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1616 "Open older",1617 "open",1618 "2026-09-10T01:00:00Z",1619 ),1620 (1621 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1622 "Closed older",1623 "closed",1624 "2026-09-10T02:00:00Z",1625 ),1626 (1627 "cccccccc-cccc-cccc-cccc-cccccccccccc",1628 "Open newer",1629 "open",1630 "2026-09-10T03:00:00Z",1631 ),1632 (1633 "dddddddd-dddd-dddd-dddd-dddddddddddd",1634 "Closed newer",1635 "closed",1636 "2026-09-10T04:00:00Z",1637 ),1638 ] {1639 write_forge(1640 &repo,1641 &format!("issues/{id}/issue.md"),1642 &format!(1643 "---\nid: {id}\ntitle: {title}\nstate: {state}\ncreated: {created}\n---\nbody\n"1644 ),1645 );1646 }1647 let data = load(&repo);1648 // Numbers stay creation-rank (may appear unordered in display).1649 assert_eq!(data.issues[0].number, 1);1650 assert_eq!(data.issues[0].title, "Open older");1651 assert_eq!(data.issues[1].number, 2);1652 assert_eq!(data.issues[2].number, 3);1653 assert_eq!(data.issues[3].number, 4);1654 // Display order: open newest-first, then closed newest-first.1655 let ordered: Vec<&str> = issues_display_order(&data.issues)1656 .iter()1657 .map(|i| i.title.as_str())1658 .collect();1659 assert_eq!(1660 ordered,1661 vec!["Open newer", "Open older", "Closed newer", "Closed older"],1662 "open-first, created desc within"1663 );1664 let numbers: Vec<usize> = issues_display_order(&data.issues)1665 .iter()1666 .map(|i| i.number)1667 .collect();1668 assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1669 1670 let out = mktemp("sort-issues-out");1671 render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1672 let html = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1673 let pos = |needle: &str| html.find(needle).expect(needle);1674 assert!(1675 pos("Open newer") < pos("Open older")1676 && pos("Open older") < pos("Closed newer")1677 && pos("Closed newer") < pos("Closed older"),1678 "exact row order: open newest-first then closed newest-first"1679 );1680 // Spot-check the stable numbers ride with their rows.1681 assert!(1682 html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"3\">3</td>"),1683 "Open newer keeps #3"1684 );1685 assert!(1686 html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"),1687 "Open older keeps #1"1688 );1689 }1690 1691 /// m6/sort (f0rge #48 + #49): pulls index shows open incl drafts1692 /// first (newest-first within), then closed/merged (newest-first).1693 /// Numbers stay creation-rank. Commits stay newest-first (no change).1694 #[test]1695 fn pulls_open_first_then_reverse_chron_numbers_stable() {1696 let repo = mktemp("sort-pulls");1697 for (id, title, state, created, draft) in [1698 (1699 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1700 "Old open",1701 "open",1702 "2026-09-10T01:00:00Z",1703 false,1704 ),1705 (1706 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1707 "Old closed",1708 "closed",1709 "2026-09-10T02:00:00Z",1710 false,1711 ),1712 (1713 "cccccccc-cccc-cccc-cccc-cccccccccccc",1714 "New draft",1715 "open",1716 "2026-09-10T03:00:00Z",1717 true,1718 ),1719 (1720 "dddddddd-dddd-dddd-dddd-dddddddddddd",1721 "New merged",1722 "merged",1723 "2026-09-10T04:00:00Z",1724 false,1725 ),1726 ] {1727 write_forge(1728 &repo,1729 &format!("prs/{id}/pr.md"),1730 &format!(1731 "---\nid: {id}\ntitle: {title}\nstate: {state}\nbase: main\nhead: feat\ncreated: {created}\ndraft: {draft}\n---\ndesc\n"1732 ),1733 );1734 }1735 let data = load(&repo);1736 assert_eq!(data.prs[0].number, 1);1737 assert_eq!(data.prs[3].number, 4);1738 let ordered: Vec<&str> = prs_display_order(&data.prs)1739 .iter()1740 .map(|p| p.title.as_str())1741 .collect();1742 assert_eq!(1743 ordered,1744 vec!["New draft", "Old open", "New merged", "Old closed"],1745 "open incl drafts first, created desc within"1746 );1747 let numbers: Vec<usize> = prs_display_order(&data.prs)1748 .iter()1749 .map(|p| p.number)1750 .collect();1751 assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1752 1753 let out = mktemp("sort-pulls-out");1754 render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1755 let html = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1756 let pos = |needle: &str| html.find(needle).expect(needle);1757 assert!(1758 pos("New draft") < pos("Old open")1759 && pos("Old open") < pos("New merged")1760 && pos("New merged") < pos("Old closed"),1761 "exact row order: open newest-first then closed/merged newest-first"1762 );1763 assert!(html.contains(" draft"), "draft suffix kept");1764 }1765 1766 /// m6/sort (f0rge #49): `created` tiebreak is `id` desc in display.1767 #[test]1768 fn display_order_tiebreak_is_id_desc() {1769 let repo = mktemp("sort-tie");1770 for id in [1771 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1772 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1773 ] {1774 write_forge(1775 &repo,1776 &format!("issues/{id}/issue.md"),1777 &format!(1778 "---\nid: {id}\ntitle: T {id}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"1779 ),1780 );1781 }1782 let data = load(&repo);1783 // Creation-rank still `id` asc...1784 assert_eq!(data.issues[0].number, 1);1785 // ...but display is `id` desc on ties (newest-first mirror).1786 let ordered: Vec<&str> = issues_display_order(&data.issues)1787 .iter()1788 .map(|i| i.id.as_str())1789 .collect();1790 assert_eq!(1791 ordered,1792 vec![1793 "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1794 "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"1795 ]1796 );1797 }1798 1799 #[test]1800 fn broken_issue_skipped_build_lives() {1801 let repo = mktemp("broken");1802 write_forge(1803 &repo,1804 "issues/good11111-1111-1111-1111-111111111111/issue.md",1805 "---\nid: good11111-1111-1111-1111-111111111111\ntitle: Good\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nok\n",1806 );1807 write_forge(1808 &repo,1809 "issues/bad222222-2222-2222-2222-222222222222/issue.md",1810 "---\nid: bad222222-2222-2222-2222-222222222222\nstate: open\ncreated: 2026-09-10T04:00:00Z\n---\nno title\n",1811 );1812 let data = load(&repo);1813 assert_eq!(data.issues.len(), 1, "broken file skipped");1814 assert_eq!(data.issues[0].number, 1);1815 }1816 1817 #[test]1818 fn pr_loads_patch_and_reviews() {1819 let repo = mktemp("pr");1820 write_forge(1821 &repo,1822 "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1823 "---\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",1824 );1825 let dest = repo1826 .join(".forge")1827 .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1828 std::fs::write(&dest, "diff --git a/x b/x\n+evil <script>\n").unwrap();1829 write_forge(1830 &repo,1831 "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/reviews/dddddddd-dddd-dddd-dddd-dddddddddddd.md",1832 "---\nid: dddddddd-dddd-dddd-dddd-dddddddddddd\nauthor: z\nstate: approved\ncreated: 2026-09-10T05:00:00Z\n---\nlgtm\n",1833 );1834 let data = load(&repo);1835 assert_eq!(data.prs.len(), 1);1836 assert_eq!(data.prs[0].number, 1);1837 assert!(data.prs[0].draft);1838 assert!(data.prs[0]1839 .patch1840 .as_ref()1841 .expect("patch")1842 .contains("diff --git"));1843 assert_eq!(data.prs[0].reviews.len(), 1);1844 assert_eq!(data.prs[0].reviews[0].state.as_deref(), Some("approved"));1845 1846 let out = mktemp("pr-out");1847 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1848 .expect("render");1849 let html =1850 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1851 .expect("pr page");1852 assert!(html.contains("#1"), "number displayed");1853 assert!(!html.contains("<script>"), "patch escaped");1854 assert!(html.contains("<script>"), "escaped patch present");1855 assert!(html.contains("approved"), "review state shown");1856 assert!(html.contains("lgtm"), "review body shown");1857 }1858 1859 #[test]1860 fn pr_patch_lines_carry_diff_spans() {1861 // m6 diff-colors (f0rge #22): PR patch.diff shares the commit renderer.1862 let repo = mktemp("pr-diff");1863 write_forge(1864 &repo,1865 "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",1866 "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",1867 );1868 let dest = repo1869 .join(".forge")1870 .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");1871 std::fs::write(1872 &dest,1873 "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+evil <script>\n",1874 )1875 .unwrap();1876 let out = mktemp("pr-diff-out");1877 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1878 .expect("render");1879 let html =1880 std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))1881 .expect("pr page");1882 assert!(1883 html.contains("<span class=\"diff-hunk\">@@ -1 +1 @@</span>"),1884 "hunk cyan: {html}"1885 );1886 assert!(1887 html.contains("<span class=\"diff-file\">--- a/x</span>"),1888 "--- file bold"1889 );1890 assert!(1891 html.contains("<span class=\"diff-file\">+++ b/x</span>"),1892 "+++ file bold"1893 );1894 assert!(1895 html.contains("<span class=\"diff-del\">-old</span>"),1896 "removed red"1897 );1898 assert!(!html.contains("<script>"), "patch escaped");1899 assert!(1900 html.contains("<span class=\"diff-add\">+evil <script></span>"),1901 "added green, escaped in span"1902 );1903 }1904 1905 #[test]1906 fn tasks_group_and_ci_fail_badge() {1907 let repo = mktemp("taskci");1908 for (id, status) in [1909 ("11111111-1111-1111-1111-111111111111", "todo"),1910 ("22222222-2222-2222-2222-222222222222", "doing"),1911 ("33333333-3333-3333-3333-333333333333", "blocked"),1912 ("44444444-4444-4444-4444-444444444444", "done"),1913 ] {1914 write_forge(1915 &repo,1916 &format!("tasks/{id}.md"),1917 &format!("---\nid: {id}\ntitle: Task {status}\nstatus: {status}\ncreated: 2026-09-10T03:00:00Z\n---\nbody {status}\n"),1918 );1919 }1920 write_forge(&repo, "ci/ci.env", "runner: local\n");1921 std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();1922 std::fs::write(1923 repo.join(".forge").join("ci/steps/00_test.sh"),1924 "#!/bin/sh\n",1925 )1926 .unwrap();1927 write_forge(1928 &repo,1929 "ci/runs/2026-09-10T00-00-00Z/summary.md",1930 "---\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",1931 );1932 let data = load(&repo);1933 assert_eq!(data.tasks.len(), 4);1934 assert_eq!(data.ci.runner, "local");1935 assert_eq!(data.ci.steps, vec!["00_test.sh".to_string()]);1936 assert_eq!(data.ci.latest_verdict(), "fail");1937 1938 let out = mktemp("taskci-out");1939 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1940 .expect("render");1941 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");1942 for status in ["todo", "doing", "blocked", "done"] {1943 assert!(tasks.contains(status), "group {status}");1944 }1945 let ci = std::fs::read_to_string(out.join("ci/index.html")).expect("ci");1946 assert!(ci.contains("badge fail"), "fail badge");1947 assert!(ci.contains(">fail<"), "verdict text");1948 assert!(ci.contains("00_test.sh"), "step name");1949 assert!(ci.contains("boom log"), "log body");1950 }1951 1952 #[test]1953 fn missing_forge_is_empty_not_error() {1954 let repo = mktemp("empty");1955 let data = load(&repo);1956 assert!(data.issues.is_empty());1957 assert!(data.prs.is_empty());1958 assert!(data.tasks.is_empty());1959 assert_eq!(data.ci.latest_verdict(), "none");1960 let out = mktemp("empty-out");1961 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1962 .expect("render");1963 for page in [1964 "issues/index.html",1965 "pulls/index.html",1966 "tasks/index.html",1967 "ci/index.html",1968 ] {1969 let html = std::fs::read_to_string(out.join(page)).expect(page);1970 assert!(html.contains("No "), "{page} empty state");1971 }1972 }1973 1974 /// f0rge #25: index pages carry prominent `+` submit buttons (correct1975 /// hrefs, icon + words) and the empty states link to the forms.1976 #[test]1977 fn submit_buttons_on_issues_pulls_indexes() {1978 // Empty forge: buttons + linked empty states.1979 let repo = mktemp("submit-empty");1980 let out = mktemp("submit-empty-out");1981 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)1982 .expect("render");1983 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1984 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1985 assert!(1986 issues.contains("<a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a>"),1987 "issues + button"1988 );1989 assert!(1990 pulls.contains("<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a>"),1991 "pulls + button"1992 );1993 assert!(1994 issues.contains("No issues yet.")1995 && issues.contains("href=\"/new-issue.html\">Suggest one"),1996 "issues empty state links to form"1997 );1998 assert!(1999 pulls.contains("No pull requests yet.")2000 && pulls.contains("href=\"/new-pr.html\">Suggest one"),2001 "pulls empty state links to form"2002 );2003 // Non-empty forge: buttons stay above the tables.2004 write_forge(2005 &repo,2006 "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2007 "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: T\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",2008 );2009 write_forge(2010 &repo,2011 "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2012 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: P\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",2013 );2014 let out2 = mktemp("submit-full-out");2015 render_loaded(2016 &load(&repo),2017 &out2,2018 "test",2019 "<nav>",2020 "main",2021 "abc1234",2022 None,2023 )2024 .expect("render");2025 let issues2 = std::fs::read_to_string(out2.join("issues/index.html")).expect("issues");2026 let pulls2 = std::fs::read_to_string(out2.join("pulls/index.html")).expect("pulls");2027 assert!(2028 issues2.contains("href=\"/new-issue.html\">+ New issue"),2029 "issues + button with rows"2030 );2031 assert!(2032 pulls2.contains("href=\"/new-pr.html\">+ New PR prompt"),2033 "pulls + button with rows"2034 );2035 assert!(2036 issues2.contains("table-scroll"),2037 "issues table still renders"2038 );2039 assert!(pulls2.contains("table-scroll"), "pulls table still renders");2040 }2041 2042 #[test]2043 fn verdict_badge_shapes() {2044 assert!(verdict_badge("pass").contains("badge pass"));2045 assert!(verdict_badge("fail").contains("badge fail"));2046 assert!(verdict_badge("running").contains("badge running"));2047 assert!(verdict_badge("cancelled").contains("badge cancelled"));2048 assert!(verdict_badge("none").contains(">none<"));2049 assert!(verdict_badge("<evil>").contains("<evil>"));2050 assert!(!verdict_badge("<evil>").contains("<evil>"));2051 }2052 2053 /// Big-forge: read the real f0rge checkout. Counts must equal a direct2054 /// file-count (and `f0rge issue list` when available); zero panics.2055 #[test]2056 fn big_forge_real_shape_no_panics() {2057 let real = PathBuf::from("~/src/code/gl0w-w0rld/f0rge/.forge");2058 if !real.exists() {2059 return;2060 }2061 let repo = real.parent().expect("parent").to_path_buf();2062 let data = load(&repo);2063 let dir_count = |sub: &str| {2064 std::fs::read_dir(real.join(sub))2065 .map(|rd| rd.filter_map(Result::ok).count())2066 .unwrap_or(0)2067 };2068 let file_count = |sub: &str| {2069 std::fs::read_dir(real.join(sub))2070 .map(|rd| {2071 rd.filter_map(Result::ok)2072 .filter(|e| e.path().extension().is_some_and(|x| x == "md"))2073 .count()2074 })2075 .unwrap_or(0)2076 };2077 assert_eq!(2078 data.issues.len(),2079 dir_count("issues"),2080 "issues == dir count"2081 );2082 assert_eq!(data.prs.len(), dir_count("prs"), "prs == dir count");2083 assert_eq!(data.tasks.len(), file_count("tasks"), "tasks == file count");2084 // Cross-check against `f0rge issue list` when the binary is around.2085 let cli_count = std::process::Command::new("f0rge")2086 .arg("issue")2087 .arg("list")2088 .current_dir(&repo)2089 .output()2090 .ok()2091 .filter(|o| o.status.success())2092 .map(|o| {2093 String::from_utf8_lossy(&o.stdout)2094 .lines()2095 .filter(|l| !l.trim().is_empty())2096 .count()2097 });2098 if let Some(n) = cli_count {2099 assert_eq!(data.issues.len(), n, "issues == `f0rge issue list` count");2100 }2101 // Numbers are a clean 1..=N rank in (created, id) order.2102 let mut prev = (String::new(), String::new());2103 for (i, issue) in data.issues.iter().enumerate() {2104 assert_eq!(issue.number, i + 1);2105 let key = (issue.created.clone(), issue.id.clone());2106 assert!(key >= prev, "sorted order");2107 prev = key;2108 }2109 assert!(!data.ci.steps.is_empty(), "real ci steps read");2110 assert!(data.ci.latest.is_some(), "real ci latest run read");2111 }2112 2113 /// m6/table-scroll (f0rge #30): CSS keeps natural width + nowrap cells2114 /// with a wrapping title floor; hover tint via var.2115 #[test]2116 fn table_scroll_css_rules() {2117 let css = include_str!("../static/style.css");2118 assert!(css.contains("width:max-content"), "natural width");2119 assert!(css.contains("min-width:100%"), "full-bleed floor");2120 assert!(2121 css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),2122 "cells nowrap"2123 );2124 assert!(2125 css.contains(".table-scroll td.title") && css.contains("min-width:12rem"),2126 "title floor"2127 );2128 assert!(2129 css.contains("tr:hover td") && css.contains("var(--bg-th)"),2130 "hover tint"2131 );2132 }2133 2134 /// m6/table-scroll (f0rge #31): h1 + button share one .page-head row.2135 #[test]2136 fn page_head_css_and_markup() {2137 let css = include_str!("../static/style.css");2138 assert!(css.contains(".page-head"), "page-head rule");2139 assert!(css.contains("align-items:center"), "inline row");2140 let repo = mktemp("page-head");2141 let out = mktemp("page-head-out");2142 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2143 .expect("render");2144 for (page, title, btn) in [2145 ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),2146 ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),2147 ] {2148 let html = std::fs::read_to_string(out.join(page)).expect(page);2149 let head = html.find("<div class=\"page-head\">").expect("page-head");2150 let h1 = html.find(title).expect("h1");2151 let b = html.find(btn).expect("button");2152 let close = html[head..].find("</div>").expect("close") + head;2153 assert!(2154 h1 > head && b > head && h1 < close && b < close,2155 "{page}: h1+btn in page-head"2156 );2157 }2158 // Tasks has no submit button (no page-head needed).2159 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");2160 assert!(!tasks.contains("btn-new"), "tasks: no submit button");2161 }2162 2163 /// m6/table-scroll (f0rge #30): 200-char title + long sha stay in a2164 /// .table-scroll wrapper with a td.title cell (scroll, never squash).2165 #[test]2166 fn long_title_and_sha_scroll() {2167 let long_title = "t".repeat(200);2168 let long_sha = "a".repeat(64);2169 let repo = mktemp("long-title");2170 write_forge(2171 &repo,2172 "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2173 &format!(2174 "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: {long_title}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"2175 ),2176 );2177 write_forge(2178 &repo,2179 "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2180 &format!(2181 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: {long_title}\nstate: open\nbase: main\nhead: {long_sha}\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n"2182 ),2183 );2184 let out = mktemp("long-title-out");2185 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2186 .expect("render");2187 for page in ["issues/index.html", "pulls/index.html"] {2188 let html = std::fs::read_to_string(out.join(page)).expect(page);2189 assert!(2190 html.contains("<div class=\"table-scroll\"><table>"),2191 "{page}: wrapper"2192 );2193 assert!(2194 html.contains("<td class=\"title trunc trunc-title\""),2195 "{page}: title cell trunc"2196 );2197 assert!(html.contains("title=\""), "{page}: title attrs");2198 assert!(html.contains(&long_title), "{page}: long title kept");2199 }2200 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2201 assert!(pulls.contains(&long_sha), "long head sha kept");2202 }2203 2204 /// m6/open-counts (f0rge #29): nav counts are open-only2205 /// (`state == "open"` exactly; in-progress is NOT open; drafts count2206 /// as open and stay marked) with `N closed` totals on index pages.2207 #[test]2208 fn open_only_counts_with_closed_lines() {2209 let repo = mktemp("open-counts");2210 for (uuid, state, hour) in [2211 ("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "open", "03"),2212 ("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "in-progress", "04"),2213 ("cccccccc-cccc-cccc-cccc-cccccccccccc", "closed", "05"),2214 ] {2215 write_forge(2216 &repo,2217 &format!("issues/{uuid}/issue.md"),2218 &format!(2219 "---\nid: {uuid}\ntitle: Issue {state}\nstate: {state}\ncreated: 2026-09-10T{hour}:00:00Z\n---\nbody\n"2220 ),2221 );2222 }2223 for (uuid, state, draft, hour) in [2224 ("dddddddd-dddd-dddd-dddd-dddddddddddd", "open", false, "03"),2225 ("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", "open", true, "04"),2226 (2227 "ffffffff-ffff-ffff-ffff-ffffffffffff",2228 "merged",2229 false,2230 "05",2231 ),2232 ] {2233 write_forge(2234 &repo,2235 &format!("prs/{uuid}/pr.md"),2236 &format!(2237 "---\nid: {uuid}\ntitle: PR {uuid}\nstate: {state}\nbase: main\nhead: feat\ncreated: 2026-09-10T{hour}:00:00Z\ndraft: {draft}\n---\ndesc\n"2238 ),2239 );2240 }2241 let data = load(&repo);2242 assert_eq!(data.open_issue_count(), 1, "only state==open");2243 assert_eq!(data.closed_issue_count(), 2, "in-progress + closed");2244 assert_eq!(data.open_pr_count(), 2, "open + draft-open");2245 assert_eq!(data.closed_pr_count(), 1, "merged");2246 2247 let out = mktemp("open-counts-out");2248 render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");2249 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2250 assert!(issues.contains("2 closed"), "issues closed total");2251 assert!(issues.contains("Issue open"), "open row kept");2252 assert!(issues.contains("Issue closed"), "closed row kept");2253 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");2254 assert!(pulls.contains("1 closed"), "pulls closed total");2255 assert!(pulls.contains(" draft"), "draft stays marked");2256 }2257 2258 /// m6/no-scroll (f0rge #39): stacked-card reflow on narrow, desktop untouched.2259 /// Research: from knowledge of Gitea mobile (issue/PR lists collapse to2260 /// title-led cards; file table stacks name + meta) — no live fetch here.2261 #[test]2262 fn stacked_cards_no_scroll() {2263 let css = include_str!("../static/style.css");2264 assert!(css.contains("@media (max-width:767px)"), "narrow query");2265 assert!(css.contains("overflow-x:visible"), "no scroll on narrow");2266 assert!(2267 css.contains("content:attr(data-label)"),2268 "label via ::before"2269 );2270 assert!(2271 css.contains(".table-scroll:not(.file-scroll) td::before"),2272 "label rule"2273 );2274 assert!(2275 css.contains(".table-scroll:not(.file-scroll) td.title::before"),2276 "title leads card"2277 );2278 // m6/cards2 (f0rge #46): file table exempt from the card reflow.2279 assert!(2280 css.contains(".file-scroll{overflow-x:auto}"),2281 "file table keeps h-scroll on narrow"2282 );2283 assert!(2284 css.contains("display:block") && css.contains("display:flex"),2285 "card layout"2286 );2287 assert!(css.contains("width:max-content"), "desktop width kept");2288 assert!(2289 css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),2290 "desktop nowrap kept"2291 );2292 let repo = mktemp("cards");2293 write_forge(2294 &repo,2295 "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",2296 "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: Card issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n",2297 );2298 write_forge(2299 &repo,2300 "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",2301 "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: Card pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",2302 );2303 write_forge(2304 &repo,2305 "tasks/cccccccc-cccc-cccc-cccc-cccccccccccc.md",2306 "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Card task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nwork\n",2307 );2308 write_forge(&repo, "ci/ci.env", "runner: local\n");2309 std::fs::create_dir_all(repo.join(".forge").join("ci/steps")).unwrap();2310 std::fs::write(2311 repo.join(".forge").join("ci/steps/00_test.sh"),2312 "#!/bin/sh\n",2313 )2314 .unwrap();2315 write_forge(2316 &repo,2317 "ci/runs/2026-09-10T00-00-00Z/summary.md",2318 "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_test.sh\n exit: 0\n duration_ms: 7\n---\nok\n",2319 );2320 let out = mktemp("cards-out");2321 render_loaded(&load(&repo), &out, "test", "<nav>", "main", "abc1234", None)2322 .expect("render");2323 for (page, labels) in [2324 (2325 "issues/index.html",2326 vec!["#", "title", "state", "comments", "created"],2327 ),2328 (2329 "pulls/index.html",2330 vec!["#", "title", "state", "head→base", "created"],2331 ),2332 ("ci/index.html", vec!["step", "exit", "duration"]),2333 ] {2334 let html = std::fs::read_to_string(out.join(page)).expect(page);2335 let tds = html.matches("<td").count();2336 assert!(tds > 0, "{page} has cells");2337 assert_eq!(2338 html.matches("data-label=").count(),2339 tds,2340 "{page}: every td labeled"2341 );2342 for label in labels {2343 assert!(2344 html.contains(&format!("data-label=\"{label}\"")),2345 "{page}: label {label}"2346 );2347 }2348 }2349 // m6/cards2 (f0rge #47): card DOM order is title-first via template2350 // cell order (no CSS order hacks) — desktop column headers match.2351 for (page, first, second) in [2352 (2353 "issues/index.html",2354 "data-label=\"title\"",2355 "data-label=\"#\"",2356 ),2357 (2358 "pulls/index.html",2359 "data-label=\"title\"",2360 "data-label=\"#\"",2361 ),2362 (2363 "ci/index.html",2364 "data-label=\"step\"",2365 "data-label=\"exit\"",2366 ),2367 ] {2368 let html = std::fs::read_to_string(out.join(page)).expect(page);2369 let a = html.find(first).expect("first cell");2370 let b = html.find(second).expect("second cell");2371 assert!(a < b, "{page}: card DOM title precedes meta");2372 }2373 let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");2374 assert!(!tasks.contains("table-scroll"), "tasks: no scroll wrapper");2375 let title_pos = tasks.find("Card task").expect("task title");2376 let meta_pos = tasks.find("<code>cccccccc").expect("task meta");2377 assert!(title_pos < meta_pos, "tasks: title precedes meta");2378 assert!(tasks.contains("todo (1)"), "tasks group kept");2379 let issues = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");2380 assert!(2381 issues.contains("open") && issues.contains("2026-09-10"),2382 "issues data kept in DOM"2383 );2384 }2385 }