diff --git a/src/forge.rs b/src/forge.rsindex ea146b8..7b5cfd2 100644--- a/src/forge.rs+++ b/src/forge.rs@@ -405,8 +405,10 @@ impl CiInfo {405405 #[derive(Debug, Clone)]406406 pub struct ForgeData {407407 /// Issues sorted by `(created, id)` with 1-based numbers.408+ /// Display via [`issues_display_order`] (open-first, newest-first).408409 pub issues: Vec<Issue>,409410 /// PRs sorted by `(created, id)` with 1-based numbers.411+ /// Display via [`prs_display_order`] (open-first, newest-first).410412 pub prs: Vec<Pr>,411413 /// Tasks sorted by `(created, id)`.412414 pub tasks: Vec<TaskItem>,@@ -447,6 +449,58 @@ impl ForgeData {447449 pub fn closed_pr_count(&self) -> usize {448450 self.prs.len() - self.open_pr_count()449451 }452+453+ /// Issues in index display order (m6/sort, f0rge #48 + #49).454+ #[must_use]455+ pub fn issues_in_display_order(&self) -> Vec<&Issue> {456+ issues_display_order(&self.issues)457+ }458+459+ /// PRs in index display order (m6/sort, f0rge #48 + #49).460+ #[must_use]461+ pub fn prs_in_display_order(&self) -> Vec<&Pr> {462+ prs_display_order(&self.prs)463+ }464+}465+466+/// `true` when an issue/PR counts as open in the index (`state == "open"`467+/// exactly; drafts stay open, `closed`/`merged` and friends stay closed).468+fn is_open_state(state: &str) -> bool {469+ state == "open"470+}471+472+/// Issues index display order (m6/sort, f0rge #48 + #49): open first473+/// (reverse-chron within), then non-open (reverse-chron within).474+/// Within each group: `created` desc, `id` desc tiebreak.475+/// Numbers stay creation-rank; input stays `(created, id)` asc.476+#[must_use]477+pub fn issues_display_order(issues: &[Issue]) -> Vec<&Issue> {478+ let mut out: Vec<&Issue> = issues.iter().collect();479+ out.sort_by(|a, b| {480+ let ao = if is_open_state(&a.state) { 0 } else { 1 };481+ let bo = if is_open_state(&b.state) { 0 } else { 1 };482+ ao.cmp(&bo)483+ .then_with(|| b.created.cmp(&a.created))484+ .then_with(|| b.id.cmp(&a.id))485+ });486+ out487+}488+489+/// Pulls index display order (m6/sort, f0rge #48 + #49): open (incl490+/// drafts) first (reverse-chron within), then `closed`/`merged`491+/// (reverse-chron within). Within each group: `created` desc, `id`492+/// desc tiebreak. Numbers stay creation-rank.493+#[must_use]494+pub fn prs_display_order(prs: &[Pr]) -> Vec<&Pr> {495+ let mut out: Vec<&Pr> = prs.iter().collect();496+ out.sort_by(|a, b| {497+ let ao = if is_open_state(&a.state) { 0 } else { 1 };498+ let bo = if is_open_state(&b.state) { 0 } else { 1 };499+ ao.cmp(&bo)500+ .then_with(|| b.created.cmp(&a.created))501+ .then_with(|| b.id.cmp(&a.id))502+ });503+ out450504 }451505 452506 // ---------------------------------------------------------------------------@@ -999,7 +1053,9 @@ fn render_issues_index(9991053 body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");10001054 } else {10011055 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");1002- for issue in &data.issues {1056+ // m6/sort (#48 + #49): open first (newest-first within), then1057+ // non-open (newest-first within). Numbers stay creation-rank.1058+ for issue in data.issues_in_display_order() {10031059 body.push_str(&format!(10041060 "<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",10051061 n = issue.number,@@ -1053,14 +1109,16 @@ fn render_pulls_index(10531109 short_sha: &str,10541110 description: Option<&str>,10551111 ) -> String {1056- let prs = &data.prs;10571112 let mut body =10581113 String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");1059- if prs.is_empty() {1114+ if data.prs.is_empty() {10601115 body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");10611116 } else {10621117 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");1063- for pr in prs {1118+ // m6/sort (#48 + #49): open (incl drafts) first (newest-first1119+ // within), then closed/merged (newest-first within).1120+ // Numbers stay creation-rank.1121+ for pr in data.prs_in_display_order() {10641122 let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));10651123 let lanes_plain = format!("{}→{}", pr.head, pr.base);10661124 let draft = if pr.draft { " draft" } else { "" };@@ -1352,6 +1410,198 @@ mod tests {13521410 assert_eq!(data.issues[1].number, 2);13531411 }13541412 1413+ /// m6/sort (f0rge #48 + #49): issues index shows open first1414+ /// (newest-first within), then closed (newest-first within).1415+ /// Numbers stay creation-rank so they appear unordered — correct.1416+ #[test]1417+ fn issues_open_first_then_reverse_chron_numbers_stable() {1418+ let repo = mktemp("sort-issues");1419+ for (id, title, state, created) in [1420+ (1421+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1422+ "Open older",1423+ "open",1424+ "2026-09-10T01:00:00Z",1425+ ),1426+ (1427+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1428+ "Closed older",1429+ "closed",1430+ "2026-09-10T02:00:00Z",1431+ ),1432+ (1433+ "cccccccc-cccc-cccc-cccc-cccccccccccc",1434+ "Open newer",1435+ "open",1436+ "2026-09-10T03:00:00Z",1437+ ),1438+ (1439+ "dddddddd-dddd-dddd-dddd-dddddddddddd",1440+ "Closed newer",1441+ "closed",1442+ "2026-09-10T04:00:00Z",1443+ ),1444+ ] {1445+ write_forge(1446+ &repo,1447+ &format!("issues/{id}/issue.md"),1448+ &format!(1449+ "---\nid: {id}\ntitle: {title}\nstate: {state}\ncreated: {created}\n---\nbody\n"1450+ ),1451+ );1452+ }1453+ let data = load(&repo);1454+ // Numbers stay creation-rank (may appear unordered in display).1455+ assert_eq!(data.issues[0].number, 1);1456+ assert_eq!(data.issues[0].title, "Open older");1457+ assert_eq!(data.issues[1].number, 2);1458+ assert_eq!(data.issues[2].number, 3);1459+ assert_eq!(data.issues[3].number, 4);1460+ // Display order: open newest-first, then closed newest-first.1461+ let ordered: Vec<&str> = issues_display_order(&data.issues)1462+ .iter()1463+ .map(|i| i.title.as_str())1464+ .collect();1465+ assert_eq!(1466+ ordered,1467+ vec!["Open newer", "Open older", "Closed newer", "Closed older"],1468+ "open-first, created desc within"1469+ );1470+ let numbers: Vec<usize> = issues_display_order(&data.issues)1471+ .iter()1472+ .map(|i| i.number)1473+ .collect();1474+ assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1475+1476+ let out = mktemp("sort-issues-out");1477+ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1478+ let html = std::fs::read_to_string(out.join("issues/index.html")).expect("issues");1479+ let pos = |needle: &str| html.find(needle).expect(needle);1480+ assert!(1481+ pos("Open newer") < pos("Open older")1482+ && pos("Open older") < pos("Closed newer")1483+ && pos("Closed newer") < pos("Closed older"),1484+ "exact row order: open newest-first then closed newest-first"1485+ );1486+ // Spot-check the stable numbers ride with their rows.1487+ assert!(1488+ html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"3\">3</td>"),1489+ "Open newer keeps #3"1490+ );1491+ assert!(1492+ html.contains("<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"),1493+ "Open older keeps #1"1494+ );1495+ }1496+1497+ /// m6/sort (f0rge #48 + #49): pulls index shows open incl drafts1498+ /// first (newest-first within), then closed/merged (newest-first).1499+ /// Numbers stay creation-rank. Commits stay newest-first (no change).1500+ #[test]1501+ fn pulls_open_first_then_reverse_chron_numbers_stable() {1502+ let repo = mktemp("sort-pulls");1503+ for (id, title, state, created, draft) in [1504+ (1505+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1506+ "Old open",1507+ "open",1508+ "2026-09-10T01:00:00Z",1509+ false,1510+ ),1511+ (1512+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1513+ "Old closed",1514+ "closed",1515+ "2026-09-10T02:00:00Z",1516+ false,1517+ ),1518+ (1519+ "cccccccc-cccc-cccc-cccc-cccccccccccc",1520+ "New draft",1521+ "open",1522+ "2026-09-10T03:00:00Z",1523+ true,1524+ ),1525+ (1526+ "dddddddd-dddd-dddd-dddd-dddddddddddd",1527+ "New merged",1528+ "merged",1529+ "2026-09-10T04:00:00Z",1530+ false,1531+ ),1532+ ] {1533+ write_forge(1534+ &repo,1535+ &format!("prs/{id}/pr.md"),1536+ &format!(1537+ "---\nid: {id}\ntitle: {title}\nstate: {state}\nbase: main\nhead: feat\ncreated: {created}\ndraft: {draft}\n---\ndesc\n"1538+ ),1539+ );1540+ }1541+ let data = load(&repo);1542+ assert_eq!(data.prs[0].number, 1);1543+ assert_eq!(data.prs[3].number, 4);1544+ let ordered: Vec<&str> = prs_display_order(&data.prs)1545+ .iter()1546+ .map(|p| p.title.as_str())1547+ .collect();1548+ assert_eq!(1549+ ordered,1550+ vec!["New draft", "Old open", "New merged", "Old closed"],1551+ "open incl drafts first, created desc within"1552+ );1553+ let numbers: Vec<usize> = prs_display_order(&data.prs)1554+ .iter()1555+ .map(|p| p.number)1556+ .collect();1557+ assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank");1558+1559+ let out = mktemp("sort-pulls-out");1560+ render_loaded(&data, &out, "test", "<nav>", "main", "abc1234", None).expect("render");1561+ let html = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");1562+ let pos = |needle: &str| html.find(needle).expect(needle);1563+ assert!(1564+ pos("New draft") < pos("Old open")1565+ && pos("Old open") < pos("New merged")1566+ && pos("New merged") < pos("Old closed"),1567+ "exact row order: open newest-first then closed/merged newest-first"1568+ );1569+ assert!(html.contains(" draft"), "draft suffix kept");1570+ }1571+1572+ /// m6/sort (f0rge #49): `created` tiebreak is `id` desc in display.1573+ #[test]1574+ fn display_order_tiebreak_is_id_desc() {1575+ let repo = mktemp("sort-tie");1576+ for id in [1577+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",1578+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1579+ ] {1580+ write_forge(1581+ &repo,1582+ &format!("issues/{id}/issue.md"),1583+ &format!(1584+ "---\nid: {id}\ntitle: T {id}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"1585+ ),1586+ );1587+ }1588+ let data = load(&repo);1589+ // Creation-rank still `id` asc...1590+ assert_eq!(data.issues[0].number, 1);1591+ // ...but display is `id` desc on ties (newest-first mirror).1592+ let ordered: Vec<&str> = issues_display_order(&data.issues)1593+ .iter()1594+ .map(|i| i.id.as_str())1595+ .collect();1596+ assert_eq!(1597+ ordered,1598+ vec![1599+ "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",1600+ "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"1601+ ]1602+ );1603+ }1604+13551605 #[test]13561606 fn broken_issue_skipped_build_lives() {13571607 let repo = mktemp("broken");