1 From 7415bf03e38e66e35594024fdaa9175555ace6d3 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