Browse / github.com/storytold/craft-repo-status-app / patch
fix(ui): sort prerelease builds by every version identifier
fix(ui): sort prerelease builds by every version identifier Build-column sorting drops prerelease names, so `v1.0.0-beta.11` sorts above `v1.0.0-rc.1`, and equal numeric suffixes fall back to repository name. Keep every prerelease identifier and compare numeric identifiers, ASCII names and equal prefixes in semantic version order. The regression covers ascending and descending precedence (including numeric identifiers larger than Number’s safe integer range), metadata ties, optional `v`, short versions and unknown-tag fallback. It fails with the old comparator. Validation: all 43 frontend tests pass with `node --test "ui/src/**/*.test.ts"`; `tsc -p t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- MMexicann (imported from pull request #28, open)
- Base
6b60872955235d3a2f376ac39c96cec92bedc0d8- Size
- +39 −12 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2b2fb45999b9c325046d3439
How it went
No other operator has reported using it yet.
Diff
raw · ui/src/model.test.ts ui/src/model.ts
@@ -40,12 +40,29 @@ test('sorting leaves its input alone', () => { test('versions sort semantically, prereleases below their release', () => { assert.equal(names(sortEntries(rows, 'latest_build', 'desc')), 'bdac');- assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 2]);- assert.deepEqual(versionKey('V2'), [2, 0, 0, 0, 0]);- assert.deepEqual(versionKey('1.2.3-beta'), [1, 2, 3, -1, 0]);+ assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 'rc', '2']);+ assert.deepEqual(versionKey('V2'), [2, 0, 0, 0]);+ assert.deepEqual(versionKey('1.2.3-beta'), [1, 2, 3, -1, 'beta']); assert.deepEqual(versionKey('nightly'), [-1]); }); +test('build versions compare every prerelease identifier before their release', () => {+ const tags = ['v1.0.0-1', 'v1.0.0-9007199254740992', 'v1.0.0-9007199254740993',+ 'v1.0.0-alpha', 'v1.0.0-alpha.1', 'v1.0.0-alpha.beta',+ 'v1.0.0-beta', 'v1.0.0-beta.2', 'v1.0.0-beta.11', 'v1.0.0-rc.1', 'v1.0.0'];+ const builds = tags.map((latest_build, i) => entry(String(tags.length - i), { latest_build }));+ /** Read build tags back in the requested table order. */+ const sortedTags = (dir: 'asc' | 'desc') => sortEntries(builds, 'latest_build', dir).map((r) => r.stats!.latest_build);+ assert.deepEqual(sortedTags('asc'), tags);+ assert.deepEqual(sortedTags('desc'), [...tags].reverse());+});++test('build metadata does not change version precedence, and short tags still work', () => {+ const builds = [entry('b', { latest_build: 'V2+build.1' }), entry('a', { latest_build: '2.0.0+build.9' }),+ entry('c', { latest_build: 'v2.0-rc.1+build.99' }), entry('d', { latest_build: 'nightly' }), entry('e', null)];+ assert.equal(names(sortEntries(builds, 'latest_build', 'desc')), 'abcde');+});+ test('commits since the build read through to since_build', () => { const lagged = [ entry('a', { since_build: { commits: 3, basis: 'exact', from_sha: 'x' } }),@@ -203,21 +203,31 @@ export type Dir = 'asc' | 'desc'; /** The table's order: by a column, or `MANUAL` (the rows as last dragged). */ export type Sort = { key: string; dir?: Dir }; -/** `v0.10.2-rc.1` → [0, 10, 2, -1, 1] so pre-releases sort below the release. */-export function versionKey(tag: string): number[] {- const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([A-Za-z]*)\.?(\d+)?)?/);+/** Core version, release flag, then prerelease identifiers; build metadata is ignored. */+export function versionKey(tag: string): (number | string)[] {+ const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?/); if (!m) return [-1];- return [+m[1]!, +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, +(m[5] ?? 0)];+ return [+m[1]!, +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, ...(m[4]?.split('.') ?? [])]; } -function cmpArrays(a: number[], b: number[]): number {- for (let i = 0; i < Math.max(a.length, b.length); i++) {- const d = (a[i] ?? 0) - (b[i] ?? 0);- if (d) return d;+/** Compare version keys by core numbers, release flag, then SemVer prerelease identifiers. */+function cmpArrays(a: (number | string)[], b: (number | string)[]): number {+ for (let i = 0; i < Math.min(a.length, b.length); i++) {+ const av = a[i], bv = b[i];+ if (av === bv) continue;+ if (typeof av === 'number' && typeof bv === 'number') return av - bv;+ const as = String(av), bs = String(bv);+ const an = /^\d+$/.test(as), bn = /^\d+$/.test(bs);+ if (an !== bn) return Number(bn) - Number(an);+ // Compare numeric identifiers without losing precision to Number.+ if (an && as.length !== bs.length) return as.length - bs.length;+ if (as < bs) return -1;+ if (as > bs) return 1; }- return 0;+ return a.length - b.length; } +/** Compare column values using the column's text, date, age, numeric or version order. */ function cmpValues(kind: Kind, a: string | number, b: string | number): number { switch (kind) { case 'text': return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });