Skip to content

Commit 2aabfc0

Browse files
feat: implement action execution logic and add tests for failure modes
1 parent 851d1c4 commit 2aabfc0

File tree

7 files changed

+655
-12
lines changed

7 files changed

+655
-12
lines changed

docs/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ Use Context7 MCP for up to date documentation.
225225

226226
## 9) Quality and guardrails
227227

228-
36. [ ] **Failure modes and messages**
228+
36. [x] **Failure modes and messages**
229229
Emit `multiple_tracks_detected`, `runners_missing`, `no_matches_found`, `already_latest`, `pr_exists`, `pr_creation_failed`.
230230
Verify: Tests assert outputs and logs.
231231

src/action-execution.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import {
2+
determineSingleTrack,
3+
type TrackAlignmentResult,
4+
type VersionMatch,
5+
scanForPythonVersions,
6+
} from './scanning';
7+
import {
8+
enforcePreReleaseGuard,
9+
fetchLatestFromPythonOrg,
10+
fetchRunnerAvailability,
11+
resolveLatestPatch,
12+
type LatestPatchResult,
13+
} from './versioning';
14+
import { createOrUpdatePullRequest, findExistingPullRequest, type PullRequestResult } from './git';
15+
16+
export type SkipReason =
17+
| 'no_matches_found'
18+
| 'multiple_tracks_detected'
19+
| 'runners_missing'
20+
| 'already_latest'
21+
| 'pr_exists'
22+
| 'pr_creation_failed'
23+
| 'pre_release_guarded';
24+
25+
export interface ExecuteOptions {
26+
workspace: string;
27+
track: string;
28+
includePrerelease: boolean;
29+
paths: string[];
30+
dryRun: boolean;
31+
automerge: boolean;
32+
githubToken?: string;
33+
repository?: { owner: string; repo: string } | null;
34+
defaultBranch?: string;
35+
allowPrCreation?: boolean;
36+
}
37+
38+
export interface ExecuteDependencies {
39+
scanForPythonVersions: typeof scanForPythonVersions;
40+
determineSingleTrack: typeof determineSingleTrack;
41+
resolveLatestPatch: typeof resolveLatestPatch;
42+
fetchLatestFromPythonOrg: typeof fetchLatestFromPythonOrg;
43+
enforcePreReleaseGuard: typeof enforcePreReleaseGuard;
44+
fetchRunnerAvailability: typeof fetchRunnerAvailability;
45+
findExistingPullRequest?: typeof findExistingPullRequest;
46+
createOrUpdatePullRequest?: typeof createOrUpdatePullRequest;
47+
}
48+
49+
export interface SkipResult {
50+
status: 'skip';
51+
reason: SkipReason;
52+
newVersion?: string | null;
53+
filesChanged?: string[];
54+
details?: Record<string, unknown>;
55+
}
56+
57+
export interface SuccessResult {
58+
status: 'success';
59+
newVersion: string;
60+
filesChanged: string[];
61+
dryRun: boolean;
62+
pullRequest?: PullRequestResult;
63+
}
64+
65+
export type ExecuteResult = SkipResult | SuccessResult;
66+
67+
const DEFAULT_IGNORES = ['**/node_modules/**', '**/.git/**', '**/dist/**'];
68+
69+
function uniqueFiles(matches: VersionMatch[]): string[] {
70+
return Array.from(new Set(matches.map((match) => match.file))).sort();
71+
}
72+
73+
function determineMissingRunners(
74+
availability: Awaited<ReturnType<typeof fetchRunnerAvailability>>,
75+
): string[] {
76+
if (!availability) {
77+
return ['linux', 'mac', 'win'];
78+
}
79+
80+
return (Object.entries(availability.availableOn) as Array<[string, boolean]>)
81+
.filter(([, isAvailable]) => !isAvailable)
82+
.map(([name]) => name)
83+
.sort();
84+
}
85+
86+
function selectLatestVersion(
87+
track: string,
88+
latestPatch: LatestPatchResult | null,
89+
fallback: Awaited<ReturnType<typeof fetchLatestFromPythonOrg>>,
90+
): string {
91+
if (latestPatch) {
92+
return latestPatch.version;
93+
}
94+
95+
if (fallback) {
96+
return fallback.version;
97+
}
98+
99+
throw new Error(`Unable to resolve latest patch version for track "${track}".`);
100+
}
101+
102+
export async function executeAction(
103+
options: ExecuteOptions,
104+
dependencies: ExecuteDependencies,
105+
): Promise<ExecuteResult> {
106+
const {
107+
workspace,
108+
track,
109+
includePrerelease,
110+
paths,
111+
dryRun,
112+
githubToken,
113+
repository,
114+
defaultBranch = 'main',
115+
allowPrCreation = false,
116+
} = options;
117+
118+
const scanResult = await dependencies.scanForPythonVersions({
119+
root: workspace,
120+
patterns: paths,
121+
ignore: DEFAULT_IGNORES,
122+
followSymbolicLinks: false,
123+
});
124+
125+
if (scanResult.matches.length === 0) {
126+
return {
127+
status: 'skip',
128+
reason: 'no_matches_found',
129+
filesChanged: [],
130+
} satisfies SkipResult;
131+
}
132+
133+
const alignment: TrackAlignmentResult = dependencies.determineSingleTrack(scanResult.matches);
134+
if (alignment.conflicts.length > 0) {
135+
return {
136+
status: 'skip',
137+
reason: 'multiple_tracks_detected',
138+
details: { conflicts: alignment.conflicts },
139+
} satisfies SkipResult;
140+
}
141+
142+
const latestPatch = await dependencies.resolveLatestPatch(track, {
143+
includePrerelease,
144+
token: githubToken,
145+
});
146+
const fallback = await dependencies.fetchLatestFromPythonOrg({ track });
147+
const latestVersion = selectLatestVersion(track, latestPatch, fallback);
148+
149+
const guard = dependencies.enforcePreReleaseGuard(includePrerelease, latestVersion);
150+
if (!guard.allowed) {
151+
return {
152+
status: 'skip',
153+
reason: guard.reason ?? 'pre_release_guarded',
154+
newVersion: latestVersion,
155+
} satisfies SkipResult;
156+
}
157+
158+
const availability = await dependencies.fetchRunnerAvailability(latestVersion);
159+
const missingRunners = determineMissingRunners(availability);
160+
if (missingRunners.length > 0) {
161+
return {
162+
status: 'skip',
163+
reason: 'runners_missing',
164+
newVersion: latestVersion,
165+
details: { missing: missingRunners },
166+
filesChanged: uniqueFiles(scanResult.matches),
167+
} satisfies SkipResult;
168+
}
169+
170+
const matchesNeedingUpdate = scanResult.matches.filter(
171+
(match) => match.matched !== latestVersion,
172+
);
173+
174+
if (matchesNeedingUpdate.length === 0) {
175+
return {
176+
status: 'skip',
177+
reason: 'already_latest',
178+
newVersion: latestVersion,
179+
filesChanged: [],
180+
} satisfies SkipResult;
181+
}
182+
183+
const filesChanged = uniqueFiles(matchesNeedingUpdate);
184+
185+
if (dryRun || !allowPrCreation) {
186+
return {
187+
status: 'success',
188+
newVersion: latestVersion,
189+
filesChanged,
190+
dryRun: true,
191+
} satisfies SuccessResult;
192+
}
193+
194+
if (!githubToken || !repository) {
195+
return {
196+
status: 'success',
197+
newVersion: latestVersion,
198+
filesChanged,
199+
dryRun: false,
200+
} satisfies SuccessResult;
201+
}
202+
203+
const branchName = `chore/bump-python-${track}`;
204+
205+
if (dependencies.findExistingPullRequest) {
206+
const existing = await dependencies.findExistingPullRequest({
207+
owner: repository.owner,
208+
repo: repository.repo,
209+
head: branchName,
210+
authToken: githubToken,
211+
});
212+
213+
if (existing) {
214+
return {
215+
status: 'skip',
216+
reason: 'pr_exists',
217+
newVersion: latestVersion,
218+
filesChanged,
219+
details: existing,
220+
} satisfies SkipResult;
221+
}
222+
}
223+
224+
if (!dependencies.createOrUpdatePullRequest) {
225+
return {
226+
status: 'success',
227+
newVersion: latestVersion,
228+
filesChanged,
229+
dryRun: false,
230+
} satisfies SuccessResult;
231+
}
232+
233+
try {
234+
const pullRequest = await dependencies.createOrUpdatePullRequest({
235+
owner: repository.owner,
236+
repo: repository.repo,
237+
head: branchName,
238+
base: defaultBranch,
239+
title: `chore: bump python ${track} to ${latestVersion}`,
240+
body: `Update CPython ${track} pins to ${latestVersion}.`,
241+
authToken: githubToken,
242+
});
243+
244+
return {
245+
status: 'success',
246+
newVersion: latestVersion,
247+
filesChanged,
248+
dryRun: false,
249+
pullRequest,
250+
} satisfies SuccessResult;
251+
} catch (error) {
252+
const message = error instanceof Error ? error.message : String(error);
253+
return {
254+
status: 'skip',
255+
reason: 'pr_creation_failed',
256+
newVersion: latestVersion,
257+
filesChanged,
258+
details: { message },
259+
} satisfies SkipResult;
260+
}
261+
}

src/git/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { createBranchAndCommit } from './branch';
22
export type { BranchCommitOptions, BranchCommitResult } from './branch';
33

4-
export { createOrUpdatePullRequest } from './pull-request';
4+
export { createOrUpdatePullRequest, findExistingPullRequest } from './pull-request';
55
export type { PullRequestOptions, PullRequestResult, OctokitClient } from './pull-request';

src/git/pull-request.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,29 @@ export async function createOrUpdatePullRequest(
108108

109109
return { action: 'created', number: response.data.number, url: response.data.html_url };
110110
}
111+
112+
export async function findExistingPullRequest(options: {
113+
owner: string;
114+
repo: string;
115+
head: string;
116+
authToken: string;
117+
client?: OctokitClient;
118+
}): Promise<{ number: number; url?: string } | null> {
119+
const { owner, repo, head, authToken, client } = options;
120+
const octokit = client ?? createClient(authToken);
121+
122+
const { data } = await octokit.pulls.list({
123+
owner,
124+
repo,
125+
head: `${owner}:${head}`,
126+
state: 'open',
127+
per_page: 1,
128+
});
129+
130+
if (data.length === 0) {
131+
return null;
132+
}
133+
134+
const pull = data[0];
135+
return { number: pull.number, url: pull.html_url };
136+
}

0 commit comments

Comments
 (0)