From 7415bf03e38e66e35594024fdaa9175555ace6d3 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 09:55:35 +0100 Subject: [PATCH] m6/sort: open-first + newest-first issues/pulls --- src/api.rs | 10 +- src/forge.rs | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 260 insertions(+), 8 deletions(-) diff --git a/src/api.rs b/src/api.rs index 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<(), Strin /// Build `api/issues.json`, `api/pulls.json`, `api/commits.json`, /// `api/releases.json`, and `api/wiki.json` from the same structs rendered /// to HTML. Callers must pass the exact slices/vecs used for the HTML pass. +/// Issues/pulls rows follow the index display order (open-first, +/// newest-first; numbers stay creation-rank). pub fn render( out: &Path, forge: &ForgeData, @@ -100,8 +102,8 @@ pub fn render( wiki: &[WikiPage], ) -> Result<(), String> { let issues: Vec = forge - .issues - .iter() + .issues_in_display_order() + .into_iter() .map(|i| IssueJson { number: i.number, id: i.id.clone(), @@ -116,8 +118,8 @@ pub fn render( }) .collect(); let pulls: Vec = forge - .prs - .iter() + .prs_in_display_order() + .into_iter() .map(|p| PullJson { number: p.number, id: p.id.clone(), diff --git a/src/forge.rs b/src/forge.rs index 40aa052..f92aabd 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -405,8 +405,10 @@ impl CiInfo { #[derive(Debug, Clone)] pub struct ForgeData { /// Issues sorted by `(created, id)` with 1-based numbers. + /// Display via [`issues_display_order`] (open-first, newest-first). pub issues: Vec, /// PRs sorted by `(created, id)` with 1-based numbers. + /// Display via [`prs_display_order`] (open-first, newest-first). pub prs: Vec, /// Tasks sorted by `(created, id)`. pub tasks: Vec, @@ -447,6 +449,58 @@ impl ForgeData { pub fn closed_pr_count(&self) -> usize { self.prs.len() - self.open_pr_count() } + + /// Issues in index display order (m6/sort, f0rge #48 + #49). + #[must_use] + pub fn issues_in_display_order(&self) -> Vec<&Issue> { + issues_display_order(&self.issues) + } + + /// PRs in index display order (m6/sort, f0rge #48 + #49). + #[must_use] + pub fn prs_in_display_order(&self) -> Vec<&Pr> { + prs_display_order(&self.prs) + } +} + +/// `true` when an issue/PR counts as open in the index (`state == "open"` +/// exactly; drafts stay open, `closed`/`merged` and friends stay closed). +fn is_open_state(state: &str) -> bool { + state == "open" +} + +/// Issues index display order (m6/sort, f0rge #48 + #49): open first +/// (reverse-chron within), then non-open (reverse-chron within). +/// Within each group: `created` desc, `id` desc tiebreak. +/// Numbers stay creation-rank; input stays `(created, id)` asc. +#[must_use] +pub fn issues_display_order(issues: &[Issue]) -> Vec<&Issue> { + let mut out: Vec<&Issue> = issues.iter().collect(); + out.sort_by(|a, b| { + let ao = if is_open_state(&a.state) { 0 } else { 1 }; + let bo = if is_open_state(&b.state) { 0 } else { 1 }; + ao.cmp(&bo) + .then_with(|| b.created.cmp(&a.created)) + .then_with(|| b.id.cmp(&a.id)) + }); + out +} + +/// Pulls index display order (m6/sort, f0rge #48 + #49): open (incl +/// drafts) first (reverse-chron within), then `closed`/`merged` +/// (reverse-chron within). Within each group: `created` desc, `id` +/// desc tiebreak. Numbers stay creation-rank. +#[must_use] +pub fn prs_display_order(prs: &[Pr]) -> Vec<&Pr> { + let mut out: Vec<&Pr> = prs.iter().collect(); + out.sort_by(|a, b| { + let ao = if is_open_state(&a.state) { 0 } else { 1 }; + let bo = if is_open_state(&b.state) { 0 } else { 1 }; + ao.cmp(&bo) + .then_with(|| b.created.cmp(&a.created)) + .then_with(|| b.id.cmp(&a.id)) + }); + out } // --------------------------------------------------------------------------- @@ -999,7 +1053,9 @@ fn render_issues_index( body.push_str("

No issues yet. Suggest one — no account needed.

\n"); } else { body.push_str("
\n\n\n"); - for issue in &data.issues { + // m6/sort (#48 + #49): open first (newest-first within), then + // non-open (newest-first within). Numbers stay creation-rank. + for issue in data.issues_in_display_order() { body.push_str(&format!( "\n", n = issue.number, @@ -1053,14 +1109,16 @@ fn render_pulls_index( short_sha: &str, description: Option<&str>, ) -> String { - let prs = &data.prs; let mut body = String::from("\n"); - if prs.is_empty() { + if data.prs.is_empty() { body.push_str("

No pull requests yet. Suggest one — no account needed.

\n"); } else { body.push_str("
#titlestatecommentscreated
{n}{title}{state}{comments}{created}
\n\n\n"); - for pr in prs { + // m6/sort (#48 + #49): open (incl drafts) first (newest-first + // within), then closed/merged (newest-first within). + // Numbers stay creation-rank. + for pr in data.prs_in_display_order() { let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); let lanes_plain = format!("{}→{}", pr.head, pr.base); let draft = if pr.draft { " draft" } else { "" }; @@ -1352,6 +1410,198 @@ mod tests { assert_eq!(data.issues[1].number, 2); } + /// m6/sort (f0rge #48 + #49): issues index shows open first + /// (newest-first within), then closed (newest-first within). + /// Numbers stay creation-rank so they appear unordered — correct. + #[test] + fn issues_open_first_then_reverse_chron_numbers_stable() { + let repo = mktemp("sort-issues"); + for (id, title, state, created) in [ + ( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "Open older", + "open", + "2026-09-10T01:00:00Z", + ), + ( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "Closed older", + "closed", + "2026-09-10T02:00:00Z", + ), + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + "Open newer", + "open", + "2026-09-10T03:00:00Z", + ), + ( + "dddddddd-dddd-dddd-dddd-dddddddddddd", + "Closed newer", + "closed", + "2026-09-10T04:00:00Z", + ), + ] { + write_forge( + &repo, + &format!("issues/{id}/issue.md"), + &format!( + "---\nid: {id}\ntitle: {title}\nstate: {state}\ncreated: {created}\n---\nbody\n" + ), + ); + } + let data = load(&repo); + // Numbers stay creation-rank (may appear unordered in display). + assert_eq!(data.issues[0].number, 1); + assert_eq!(data.issues[0].title, "Open older"); + assert_eq!(data.issues[1].number, 2); + assert_eq!(data.issues[2].number, 3); + assert_eq!(data.issues[3].number, 4); + // Display order: open newest-first, then closed newest-first. + let ordered: Vec<&str> = issues_display_order(&data.issues) + .iter() + .map(|i| i.title.as_str()) + .collect(); + assert_eq!( + ordered, + vec!["Open newer", "Open older", "Closed newer", "Closed older"], + "open-first, created desc within" + ); + let numbers: Vec = issues_display_order(&data.issues) + .iter() + .map(|i| i.number) + .collect(); + assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank"); + + let out = mktemp("sort-issues-out"); + render_loaded(&data, &out, "test", ""), + "Open newer keeps #3" + ); + assert!( + html.contains(""), + "Open older keeps #1" + ); + } + + /// m6/sort (f0rge #48 + #49): pulls index shows open incl drafts + /// first (newest-first within), then closed/merged (newest-first). + /// Numbers stay creation-rank. Commits stay newest-first (no change). + #[test] + fn pulls_open_first_then_reverse_chron_numbers_stable() { + let repo = mktemp("sort-pulls"); + for (id, title, state, created, draft) in [ + ( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "Old open", + "open", + "2026-09-10T01:00:00Z", + false, + ), + ( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "Old closed", + "closed", + "2026-09-10T02:00:00Z", + false, + ), + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + "New draft", + "open", + "2026-09-10T03:00:00Z", + true, + ), + ( + "dddddddd-dddd-dddd-dddd-dddddddddddd", + "New merged", + "merged", + "2026-09-10T04:00:00Z", + false, + ), + ] { + write_forge( + &repo, + &format!("prs/{id}/pr.md"), + &format!( + "---\nid: {id}\ntitle: {title}\nstate: {state}\nbase: main\nhead: feat\ncreated: {created}\ndraft: {draft}\n---\ndesc\n" + ), + ); + } + let data = load(&repo); + assert_eq!(data.prs[0].number, 1); + assert_eq!(data.prs[3].number, 4); + let ordered: Vec<&str> = prs_display_order(&data.prs) + .iter() + .map(|p| p.title.as_str()) + .collect(); + assert_eq!( + ordered, + vec!["New draft", "Old open", "New merged", "Old closed"], + "open incl drafts first, created desc within" + ); + let numbers: Vec = prs_display_order(&data.prs) + .iter() + .map(|p| p.number) + .collect(); + assert_eq!(numbers, vec![3, 1, 4, 2], "numbers stay creation-rank"); + + let out = mktemp("sort-pulls-out"); + render_loaded(&data, &out, "test", "
#titlestatehead→basecreated
31