diff --git a/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/patch.diff b/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/patch.diffnew file mode 100644index 0000000..4eb1d46--- /dev/null+++ b/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/patch.diff@@ -0,0 +1,352 @@1+From 7a3e509e7612fa30db6997639f6c502a23e35f05 Mon Sep 17 00:00:00 20012+From: gl0w <gl0w@world>3+Date: Thu, 10 Sep 2026 09:55:35 +01004+Subject: [PATCH] m6/sort: open-first + newest-first issues/pulls5+6+---7+ src/api.rs | 10 +-8+ src/forge.rs | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++-9+ 2 files changed, 260 insertions(+), 8 deletions(-)10+11+diff --git a/src/api.rs b/src/api.rs12+index cf43f6a..d54aff7 10064413+--- a/src/api.rs14++++ b/src/api.rs15+@@ -92,6 +92,8 @@ fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), Strin16+ /// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,17+ /// `api/releases.json`, and `api/wiki.json` from the same structs rendered18+ /// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.19++/// Issues/pulls rows follow the index display order (open-first,20++/// newest-first; numbers stay creation-rank).21+ pub fn render(22+ out: &Path,23+ forge: &ForgeData,24+@@ -100,8 +102,8 @@ pub fn render(25+ wiki: &[WikiPage],26+ ) -> Result<(), String> {27+ let issues: Vec<IssueJson> = forge28+- .issues29+- .iter()30++ .issues_in_display_order()31++ .into_iter()32+ .map(|i| IssueJson {33+ number: i.number,34+ id: i.id.clone(),35+@@ -116,8 +118,8 @@ pub fn render(36+ })37+ .collect();38+ let pulls: Vec<PullJson> = forge39+- .prs40+- .iter()41++ .prs_in_display_order()42++ .into_iter()43+ .map(|p| PullJson {44+ number: p.number,45+ id: p.id.clone(),46+diff --git a/src/forge.rs b/src/forge.rs47+index 40aa052..f92aabd 10064448+--- a/src/forge.rs49++++ b/src/forge.rs50+@@ -405,8 +405,10 @@ impl CiInfo {51+ #[derive(Debug, Clone)]52+ pub struct ForgeData {53+ /// Issues sorted by `(created, id)` with 1-based numbers.54++ /// Display via [`issues_display_order`] (open-first, newest-first).55+ pub issues: Vec<Issue>,56+ /// PRs sorted by `(created, id)` with 1-based numbers.57++ /// Display via [`prs_display_order`] (open-first, newest-first).58+ pub prs: Vec<Pr>,59+ /// Tasks sorted by `(created, id)`.60+ pub tasks: Vec<TaskItem>,61+@@ -447,6 +449,58 @@ impl ForgeData {62+ pub fn closed_pr_count(&self) -> usize {63+ self.prs.len() - self.open_pr_count()64+ }65++66++ /// Issues in index display order (m6/sort, f0rge #48 + #49).67++ #[must_use]68++ pub fn issues_in_display_order(&self) -> Vec<&Issue> {69++ issues_display_order(&self.issues)70++ }71++72++ /// PRs in index display order (m6/sort, f0rge #48 + #49).73++ #[must_use]74++ pub fn prs_in_display_order(&self) -> Vec<&Pr> {75++ prs_display_order(&self.prs)76++ }77++}78++79++/// `true` when an issue/PR counts as open in the index (`state == "open"`80++/// exactly; drafts stay open, `closed`/`merged` and friends stay closed).81++fn is_open_state(state: &str) -> bool {82++ state == "open"83++}84++85++/// Issues index display order (m6/sort, f0rge #48 + #49): open first86++/// (reverse-chron within), then non-open (reverse-chron within).87++/// Within each group: `created` desc, `id` desc tiebreak.88++/// Numbers stay creation-rank; input stays `(created, id)` asc.89++#[must_use]90++pub fn issues_display_order(issues: &[Issue]) -> Vec<&Issue> {91++ let mut out: Vec<&Issue> = issues.iter().collect();92++ out.sort_by(|a, b| {93++ let ao = if is_open_state(&a.state) { 0 } else { 1 };94++ let bo = if is_open_state(&b.state) { 0 } else { 1 };95++ ao.cmp(&bo)96++ .then_with(|| b.created.cmp(&a.created))97++ .then_with(|| b.id.cmp(&a.id))98++ });99++ out100++}101++102++/// Pulls index display order (m6/sort, f0rge #48 + #49): open (incl103++/// drafts) first (reverse-chron within), then `closed`/`merged`104++/// (reverse-chron within). Within each group: `created` desc, `id`105++/// desc tiebreak. Numbers stay creation-rank.106++#[must_use]107++pub fn prs_display_order(prs: &[Pr]) -> Vec<&Pr> {108++ let mut out: Vec<&Pr> = prs.iter().collect();109++ out.sort_by(|a, b| {110++ let ao = if is_open_state(&a.state) { 0 } else { 1 };111++ let bo = if is_open_state(&b.state) { 0 } else { 1 };112++ ao.cmp(&bo)113++ .then_with(|| b.created.cmp(&a.created))114++ .then_with(|| b.id.cmp(&a.id))115++ });116++ out117+ }118+ 119+ // ---------------------------------------------------------------------------120+@@ -999,7 +1053,9 @@ fn render_issues_index(121+ body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");122+ } else {123+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");124+- for issue in &data.issues {125++ // m6/sort (#48 + #49): open first (newest-first within), then126++ // non-open (newest-first within). Numbers stay creation-rank.127++ for issue in data.issues_in_display_order() {128+ body.push_str(&format!(129+ "<tr><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",130+ n = issue.number,131+@@ -1053,14 +1109,16 @@ fn render_pulls_index(132+ short_sha: &str,133+ description: Option<&str>,134+ ) -> String {135+- let prs = &data.prs;136+ let mut body =137+ String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");138+- if prs.is_empty() {139++ if data.prs.is_empty() {140+ body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");141+ } else {142+ body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");143+- for pr in prs {144++ // m6/sort (#48 + #49): open (incl drafts) first (newest-first145++ // within), then closed/merged (newest-first within).146++ // Numbers stay creation-rank.147++ for pr in data.prs_in_display_order() {148+ let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));149+ let lanes_plain = format!("{}→{}", pr.head, pr.base);150+ let draft = if pr.draft { " draft" } else { "" };151+@@ -1352,6 +1410,198 @@ mod tests {152+ assert_eq!(data.issues[1].number, 2);153+ }154+ 155++ /// m6/sort (f0rge #48 + #49): issues index shows open first156++ /// (newest-first within), then closed (newest-first within).157++ /// Numbers stay creation-rank so they appear unordered — correct.158++ #[test]159++ fn issues_open_first_then_reverse_chron_numbers_stable() {160++ let repo = mktemp("sort-issues");161++ for (id, title, state, created) in [162++ (163++ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",164++ "Open older",165++ "open",166++ "2026-09-10T01:00:00Z",167++ ),168++ (169++ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",170++ "Closed older",171++ "closed",172++ "2026-09-10T02:00:00Z",173++ ),174++ (175++ "cccccccc-cccc-cccc-cccc-cccccccccccc",176++ "Open newer",177++ "open",178++ "2026-09-10T03:00:00Z",179++ ),180++ (181++ "dddddddd-dddd-dddd-dddd-dddddddddddd",182++ "Closed newer",183++ "closed",184++ "2026-09-10T04:00:00Z",185++ ),186++ ] {187++ write_forge(188++ &repo,189++ &format!("issues/{id}/issue.md"),190++ &format!(191++ "---\nid: {id}\ntitle: {title}\nstate: {state}\ncreated: {created}\n---\nbody\n"192++ ),193++ );194++ }195++ let data = load(&repo);196++ // Numbers stay creation-rank (may appear unordered in display).197++ assert_eq!(data.issues[0].number, 1);198++ assert_eq!(data.issues[0].title, "Open older");199++ assert_eq!(data.issues[1].number, 2);200++ assert_eq!(data.issues[2].number, 3);201++ assert_eq!(data.issues[3].number, 4);202++ // Display order: open newest-first, then closed newest-first.203++ let ordered: Vec<&str> = issues_display_order(&data.issues)204++ .iter()205++ .map(|i| i.title.as_str())206++ .collect();207++ assert_eq!(208++ ordered,209++ vec!["Open newer", "Open older", "Closed newer", "Closed older"],210++ "open-first, created desc within"211++ );212++ let numbers: Vec<usize> = issues_display_order(&data.issues)213++ .iter()214++ .map(|i| i.number)215++ .collect();216++ assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");217++218++ let out = mktemp("sort-issues-out");219++ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");220++ let html = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");221++ let pos = |needle: &str| html.find(needle).expect(needle);222++ assert!(223++ pos("Open newer") < pos("Open older")224++ && pos("Open older") < pos("Closed newer")225++ && pos("Closed newer") < pos("Closed older"),226++ "exact row order: open newest-first then closed newest-first"227++ );228++ // Spot-check the stable numbers ride with their rows.229++ assert!(230++ html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"3\">3</td>"),231++ "Open newer keeps #3"232++ );233++ assert!(234++ html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"),235++ "Open older keeps #1"236++ );237++ }238++239++ /// m6/sort (f0rge #48 + #49): pulls index shows open incl drafts240++ /// first (newest-first within), then closed/merged (newest-first).241++ /// Numbers stay creation-rank. Commits stay newest-first (no change).242++ #[test]243++ fn pulls_open_first_then_reverse_chron_numbers_stable() {244++ let repo = mktemp("sort-pulls");245++ for (id, title, state, created, draft) in [246++ (247++ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",248++ "Old open",249++ "open",250++ "2026-09-10T01:00:00Z",251++ false,252++ ),253++ (254++ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",255++ "Old closed",256++ "closed",257++ "2026-09-10T02:00:00Z",258++ false,259++ ),260++ (261++ "cccccccc-cccc-cccc-cccc-cccccccccccc",262++ "New draft",263++ "open",264++ "2026-09-10T03:00:00Z",265++ true,266++ ),267++ (268++ "dddddddd-dddd-dddd-dddd-dddddddddddd",269++ "New merged",270++ "merged",271++ "2026-09-10T04:00:00Z",272++ false,273++ ),274++ ] {275++ write_forge(276++ &repo,277++ &format!("prs/{id}/pr.md"),278++ &format!(279++ "---\nid: {id}\ntitle: {title}\nstate: {state}\nbase: main\nhead: feat\ncreated: {created}\ndraft: {draft}\n---\ndesc\n"280++ ),281++ );282++ }283++ let data = load(&repo);284++ assert_eq!(data.prs[0].number, 1);285++ assert_eq!(data.prs[3].number, 4);286++ let ordered: Vec<&str> = prs_display_order(&data.prs)287++ .iter()288++ .map(|p| p.title.as_str())289++ .collect();290++ assert_eq!(291++ ordered,292++ vec!["New draft", "Old open", "New merged", "Old closed"],293++ "open incl drafts first, created desc within"294++ );295++ let numbers: Vec<usize> = prs_display_order(&data.prs)296++ .iter()297++ .map(|p| p.number)298++ .collect();299++ assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");300++301++ let out = mktemp("sort-pulls-out");302++ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");303++ let html = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");304++ let pos = |needle: &str| html.find(needle).expect(needle);305++ assert!(306++ pos("New draft") < pos("Old open")307++ && pos("Old open") < pos("New merged")308++ && pos("New merged") < pos("Old closed"),309++ "exact row order: open newest-first then closed/merged newest-first"310++ );311++ assert!(html.contains(" draft"), "draft suffix kept");312++ }313++314++ /// m6/sort (f0rge #49): `created` tiebreak is `id` desc in display.315++ #[test]316++ fn display_order_tiebreak_is_id_desc() {317++ let repo = mktemp("sort-tie");318++ for id in [319++ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",320++ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",321++ ] {322++ write_forge(323++ &repo,324++ &format!("issues/{id}/issue.md"),325++ &format!(326++ "---\nid: {id}\ntitle: T {id}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"327++ ),328++ );329++ }330++ let data = load(&repo);331++ // Creation-rank still `id` asc...332++ assert_eq!(data.issues[0].number, 1);333++ // ...but display is `id` desc on ties (newest-first mirror).334++ let ordered: Vec<&str> = issues_display_order(&data.issues)335++ .iter()336++ .map(|i| i.id.as_str())337++ .collect();338++ assert_eq!(339++ ordered,340++ vec![341++ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",342++ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"343++ ]344++ );345++ }346++347+ #[test]348+ fn broken_issue_skipped_build_lives() {349+ let repo = mktemp("broken");350+-- 351+2.55.0352+