m6/sort: open-first + newest-first issues/pulls

7415bf0gl0w

all commits

Message

m6/sort: open-first + newest-first issues/pulls

Diff

src/api.rs+6 −4
diff --git a/src/api.rs b/src/api.rsindex cf43f6a..d54aff7 100644--- a/src/api.rs+++ b/src/api.rs@@ -92,6 +92,8 @@ fn write_json(out: &Path, rel: &str, value: &impl Serialize) -> Result<(), Strin9292 /// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`,9393 /// `api/releases.json`, and `api/wiki.json` from the same structs rendered9494 /// to HTML. Callers must pass the exact slices/vecs used for the HTML pass.95+/// Issues/pulls rows follow the index display order (open-first,96+/// newest-first; numbers stay creation-rank).9597 pub fn render(9698     out: &Path,9799     forge: &ForgeData,@@ -100,8 +102,8 @@ pub fn render(100102     wiki: &[WikiPage],101103 ) -> Result<(), String> {102104     let issues: Vec<IssueJson> = forge103-        .issues104-        .iter()105+        .issues_in_display_order()106+        .into_iter()105107         .map(|i| IssueJson {106108             number: i.number,107109             id: i.id.clone(),@@ -116,8 +118,8 @@ pub fn render(116118         })117119         .collect();118120     let pulls: Vec<PullJson> = forge119-        .prs120-        .iter()121+        .prs_in_display_order()122+        .into_iter()121123         .map(|p| PullJson {122124             number: p.number,123125             id: p.id.clone(),
src/forge.rs+254 −4
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");