land #48+#49 sort + f0rge state

35d4f34gl0w

all commits

Message

land #48+#49 sort + f0rge state

Diff

.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/comments/999e6f66-7b73-4f64-87bf-49e27d6bd722.md+6 −0
diff --git a/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/comments/999e6f66-7b73-4f64-87bf-49e27d6bd722.md b/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/comments/999e6f66-7b73-4f64-87bf-49e27d6bd722.mdnew file mode 100644index 0000000..3b1d321--- /dev/null+++ b/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/comments/999e6f66-7b73-4f64-87bf-49e27d6bd722.md@@ -0,0 +1,6 @@1+---2+id: 999e6f66-7b73-4f64-87bf-49e27d6bd7223+author: null4+created: 2026-09-10T08:56:44.482004158Z5+---6+Merged PR 3056cde2 (m6/sort, joint #49): open-first + newest-first, numbers stay creation-rank. Verified live-data order. CI pass. Rubric verified.
.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/issue.md+2 −2
diff --git a/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/issue.md b/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/issue.mdindex 8d8e0b8..50aeb06 100644--- a/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/issue.md+++ b/.forge/issues/78c8e17f-f558-4544-b88d-d29ba5a76e65/issue.md@@ -1,13 +1,13 @@11 ---22 id: 78c8e17f-f558-4544-b88d-d29ba5a76e6533 title: Open issues first in table4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:11.444576394Z10-updated: null10+updated: 2026-09-10T08:56:44.485385218Z1111 ---1212 Context: Owner QA — open issues should top the issues table.1313 Tasks:
.forge/issues/99db5711-d98e-4098-bf26-448874da8280/comments/993f1d44-fe30-41bc-ab4e-c43dd82c063e.md+6 −0
diff --git a/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/comments/993f1d44-fe30-41bc-ab4e-c43dd82c063e.md b/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/comments/993f1d44-fe30-41bc-ab4e-c43dd82c063e.mdnew file mode 100644index 0000000..d8db436--- /dev/null+++ b/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/comments/993f1d44-fe30-41bc-ab4e-c43dd82c063e.md@@ -0,0 +1,6 @@1+---2+id: 993f1d44-fe30-41bc-ab4e-c43dd82c063e3+author: null4+created: 2026-09-10T08:56:44.489282555Z5+---6+Covered by same PR 3056cde2. Closed.
.forge/issues/99db5711-d98e-4098-bf26-448874da8280/issue.md+2 −2
diff --git a/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/issue.md b/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/issue.mdindex fb5cc39..f0c2b47 100644--- a/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/issue.md+++ b/.forge/issues/99db5711-d98e-4098-bf26-448874da8280/issue.md@@ -1,13 +1,13 @@11 ---22 id: 99db5711-d98e-4098-bf26-448874da828033 title: Issues/pulls newest-first4-state: open4+state: closed55 labels:66 - area:render77 assignees: []88 milestone: m6-polish99 created: 2026-09-10T08:48:11.450209380Z10-updated: null10+updated: 2026-09-10T08:56:44.492513736Z1111 ---1212 Context: Owner QA — issues and pulls should read newest-first.1313 Tasks:
.forge/merges/2026-09-10T08-55-35.251185639Z.md+10 −0
diff --git a/.forge/merges/2026-09-10T08-55-35.251185639Z.md b/.forge/merges/2026-09-10T08-55-35.251185639Z.mdnew file mode 100644index 0000000..48393e3--- /dev/null+++ b/.forge/merges/2026-09-10T08-55-35.251185639Z.md@@ -0,0 +1,10 @@1+---2+pr: 3056cde2-ba14-4366-aea6-d5c6a87539053+applied: true4+verdict: pass5+resolved: []6+created: 2026-09-10T08:55:35.251185639Z7+---8+applied: a9b6286a0d1346da9c983b3a3593bca28abe904d9+patch: f7a8d9b5a4c3963abbbc071b879a0800a190c4b110+pass.
.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/patch.diff+352 −0
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+
.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/pr.md+12 −0
diff --git a/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/pr.md b/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/pr.mdnew file mode 100644index 0000000..893d56c--- /dev/null+++ b/.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/pr.md@@ -0,0 +1,12 @@1+---2+id: 3056cde2-ba14-4366-aea6-d5c6a87539053+title: m6/sort4+state: merged5+base: master6+head: m6/sort7+draft: false8+issues: []9+ci: pass10+created: 2026-09-10T08:55:35.218288045Z11+updated: 2026-09-10T08:56:11.413856676Z12+---