.forge/prs/3056cde2-ba14-4366-aea6-d5c6a8753905/patch.diff 13.8 KiB 352 lines Raw
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