Skip to content

Commit 718ca3b

Browse files
feat: implement resolveLatestPatch function and associated tests for stable patch resolution
1 parent 32e5011 commit 718ca3b

File tree

4 files changed

+180
-1
lines changed

4 files changed

+180
-1
lines changed

docs/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Use Context7 MCP for up to date documentation.
9494
Logic: GitHub tags pagination, filter out `a/b/rc/dev`.
9595
Verify: Unit test with mocked pages returns only stable tags.
9696

97-
7. [ ] **Latest patch resolver for X.Y**
97+
7. [x] **Latest patch resolver for X.Y**
9898
Input: `3.13` → output highest `3.13.Z`.
9999
Verify: Unit test picks max.
100100

src/versioning/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { resolveLatestPatch } from './latest-patch-resolver';
2+
export type { LatestPatchResult, ResolveLatestPatchOptions } from './latest-patch-resolver';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import semver from 'semver';
2+
3+
import type { FetchStableTagsOptions, StableTag } from '../github';
4+
import { fetchStableCpythonTags } from '../github';
5+
6+
export interface ResolveLatestPatchOptions extends FetchStableTagsOptions {
7+
/// When true, include pre-release versions in the output. Defaults to false.
8+
includePrerelease?: boolean;
9+
/// Optional override for the tag list, used primarily for testing.
10+
tags?: StableTag[];
11+
}
12+
13+
export interface LatestPatchResult {
14+
/// Resolved CPython version, e.g. 3.13.2.
15+
version: string;
16+
/// Underlying tag name on GitHub.
17+
tagName: string;
18+
/// Commit SHA associated with the tag.
19+
commitSha: string;
20+
}
21+
22+
function filterByTrack(tags: StableTag[], track: string, includePrerelease: boolean): StableTag[] {
23+
return tags.filter((tag) => {
24+
const matchesTrack = `${tag.major}.${tag.minor}` === track;
25+
if (!matchesTrack) {
26+
return false;
27+
}
28+
29+
if (includePrerelease) {
30+
return true;
31+
}
32+
33+
return semver.prerelease(tag.version) === null;
34+
});
35+
}
36+
37+
export async function resolveLatestPatch(
38+
track: string,
39+
options: ResolveLatestPatchOptions = {},
40+
): Promise<LatestPatchResult | null> {
41+
const normalizedTrack = track.trim();
42+
if (!/^[0-9]+\.[0-9]+$/.test(normalizedTrack)) {
43+
throw new Error(`Track "${track}" must be in the form X.Y`);
44+
}
45+
46+
const { includePrerelease = false, tags, ...fetchOptions } = options;
47+
48+
const stableTags = tags ?? (await fetchStableCpythonTags(fetchOptions));
49+
const candidates = filterByTrack(stableTags, normalizedTrack, includePrerelease);
50+
51+
if (candidates.length === 0) {
52+
return null;
53+
}
54+
55+
const latest = candidates.reduce((acc, current) =>
56+
semver.gt(current.version, acc.version) ? current : acc,
57+
);
58+
59+
return {
60+
version: latest.version,
61+
tagName: latest.tagName,
62+
commitSha: latest.commitSha,
63+
};
64+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import type { StableTag } from '../src/github';
4+
import { fetchStableCpythonTags } from '../src/github';
5+
import { resolveLatestPatch } from '../src/versioning/latest-patch-resolver';
6+
7+
vi.mock('../src/github', async () => {
8+
const actual = await vi.importActual<typeof import('../src/github')>('../src/github');
9+
return {
10+
...actual,
11+
fetchStableCpythonTags: vi.fn(),
12+
};
13+
});
14+
15+
describe('resolveLatestPatch', () => {
16+
const mockedFetch = vi.mocked(fetchStableCpythonTags);
17+
18+
const sampleTags: StableTag[] = [
19+
{
20+
tagName: 'v3.13.3rc1',
21+
version: '3.13.3-rc.1',
22+
major: 3,
23+
minor: 13,
24+
patch: 3,
25+
commitSha: 'sha-3-13-3-rc1',
26+
},
27+
{
28+
tagName: 'v3.13.2',
29+
version: '3.13.2',
30+
major: 3,
31+
minor: 13,
32+
patch: 2,
33+
commitSha: 'sha-3-13-2',
34+
},
35+
{
36+
tagName: 'v3.13.1',
37+
version: '3.13.1',
38+
major: 3,
39+
minor: 13,
40+
patch: 1,
41+
commitSha: 'sha-3-13-1',
42+
},
43+
{
44+
tagName: 'v3.12.5',
45+
version: '3.12.5',
46+
major: 3,
47+
minor: 12,
48+
patch: 5,
49+
commitSha: 'sha-3-12-5',
50+
},
51+
];
52+
53+
beforeEach(() => {
54+
mockedFetch.mockReset();
55+
});
56+
57+
it('selects the highest stable patch for a track', async () => {
58+
const result = await resolveLatestPatch('3.13', { tags: sampleTags });
59+
60+
expect(result).toEqual({
61+
version: '3.13.2',
62+
tagName: 'v3.13.2',
63+
commitSha: 'sha-3-13-2',
64+
});
65+
});
66+
67+
it('returns null when no matching tags are found', async () => {
68+
const result = await resolveLatestPatch('3.11', { tags: sampleTags });
69+
70+
expect(result).toBeNull();
71+
});
72+
73+
it('can include prereleases when requested', async () => {
74+
const result = await resolveLatestPatch('3.13', {
75+
includePrerelease: true,
76+
tags: sampleTags,
77+
});
78+
79+
expect(result).toEqual({
80+
version: '3.13.3-rc.1',
81+
tagName: 'v3.13.3rc1',
82+
commitSha: 'sha-3-13-3-rc1',
83+
});
84+
});
85+
86+
it('falls back to fetching when tags are not provided', async () => {
87+
mockedFetch.mockResolvedValue(sampleTags);
88+
89+
const result = await resolveLatestPatch('3.13');
90+
91+
expect(mockedFetch).toHaveBeenCalled();
92+
expect(result).toEqual({
93+
version: '3.13.2',
94+
tagName: 'v3.13.2',
95+
commitSha: 'sha-3-13-2',
96+
});
97+
});
98+
99+
it('supports bypassing network by providing tags directly', async () => {
100+
const result = await resolveLatestPatch('3.12', { tags: sampleTags });
101+
102+
expect(mockedFetch).not.toHaveBeenCalled();
103+
expect(result).toEqual({
104+
version: '3.12.5',
105+
tagName: 'v3.12.5',
106+
commitSha: 'sha-3-12-5',
107+
});
108+
});
109+
110+
it('rejects invalid track values', async () => {
111+
await expect(resolveLatestPatch('3')).rejects.toThrow(/Track "3" must be in the form X.Y/);
112+
});
113+
});

0 commit comments

Comments
 (0)