-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
check.tsx
418 lines (349 loc) Β· 17 KB
/
check.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
import {BaseCommand, WorkspaceRequiredError} from '@yarnpkg/cli';
import {Configuration, MessageName, Project, StreamReport, Workspace, formatUtils, structUtils} from '@yarnpkg/core';
import {npath} from '@yarnpkg/fslib';
import type {FocusRequest} from '@yarnpkg/libui/sources/hooks/useFocusRequest';
import * as libuiUtils from '@yarnpkg/libui/sources/libuiUtils';
import {Command, Option, Usage, UsageError} from 'clipanion';
import semver from 'semver';
import * as versionUtils from '../../versionUtils';
// eslint-disable-next-line arca/no-default-export
export default class VersionCheckCommand extends BaseCommand {
static paths = [
[`version`, `check`],
];
static usage: Usage = Command.Usage({
category: `Release-related commands`,
description: `check that all the relevant packages have been bumped`,
details: `
**Warning:** This command currently requires Git.
This command will check that all the packages covered by the files listed in argument have been properly bumped or declined to bump.
In the case of a bump, the check will also cover transitive packages - meaning that should \`Foo\` be bumped, a package \`Bar\` depending on \`Foo\` will require a decision as to whether \`Bar\` will need to be bumped. This check doesn't cross packages that have declined to bump.
In case no arguments are passed to the function, the list of modified files will be generated by comparing the HEAD against \`master\`.
`,
examples: [[
`Check whether the modified packages need a bump`,
`yarn version check`,
]],
});
interactive = Option.Boolean(`-i,--interactive`, {
description: `Open an interactive interface used to set version bumps`,
});
async execute() {
if (this.interactive) {
return await this.executeInteractive();
} else {
return await this.executeStandard();
}
}
async executeInteractive() {
libuiUtils.checkRequirements(this.context);
const {Gem} = await import(`@yarnpkg/libui/sources/components/Gem`);
const {ScrollableItems} = await import(`@yarnpkg/libui/sources/components/ScrollableItems`);
const {FocusRequest} = await import(`@yarnpkg/libui/sources/hooks/useFocusRequest`);
const {useListInput} = await import(`@yarnpkg/libui/sources/hooks/useListInput`);
const {renderForm} = await import(`@yarnpkg/libui/sources/misc/renderForm`);
const {Box, Text} = await import(`ink`);
const {default: React, useCallback, useState} = await import(`react`);
const configuration = await Configuration.find(this.context.cwd, this.context.plugins);
const {project, workspace} = await Project.find(configuration, this.context.cwd);
if (!workspace)
throw new WorkspaceRequiredError(project.cwd, this.context.cwd);
await project.restoreInstallState();
const versionFile = await versionUtils.openVersionFile(project);
if (versionFile === null || versionFile.releaseRoots.size === 0)
return 0;
if (versionFile.root === null)
throw new UsageError(`This command can only be run on Git repositories`);
const Prompt = () => {
return (
<Box flexDirection={`row`} paddingBottom={1}>
<Box flexDirection={`column`} width={60}>
<Box>
<Text>
Press <Text bold color={`cyanBright`}>{`<up>`}</Text>/<Text bold color={`cyanBright`}>{`<down>`}</Text> to select workspaces.
</Text>
</Box>
<Box>
<Text>
Press <Text bold color={`cyanBright`}>{`<left>`}</Text>/<Text bold color={`cyanBright`}>{`<right>`}</Text> to select release strategies.
</Text>
</Box>
</Box>
<Box flexDirection={`column`}>
<Box marginLeft={1}>
<Text>
Press <Text bold color={`cyanBright`}>{`<enter>`}</Text> to save.
</Text>
</Box>
<Box marginLeft={1}>
<Text>
Press <Text bold color={`cyanBright`}>{`<ctrl+c>`}</Text> to abort.
</Text>
</Box>
</Box>
</Box>
);
};
const Undecided = ({workspace, active, decision, setDecision}: {workspace: Workspace, active?: boolean, decision: string, setDecision: (decision: versionUtils.Decision) => void}) => {
const currentVersion = workspace.manifest.raw.stableVersion ?? workspace.manifest.version;
if (currentVersion === null)
throw new Error(`Assertion failed: The version should have been set (${structUtils.prettyLocator(configuration, workspace.anchoredLocator)})`);
if (semver.prerelease(currentVersion) !== null)
throw new Error(`Assertion failed: Prerelease identifiers shouldn't be found (${currentVersion})`);
const strategies: Array<versionUtils.Decision> = [
versionUtils.Decision.UNDECIDED,
versionUtils.Decision.DECLINE,
versionUtils.Decision.PATCH,
versionUtils.Decision.MINOR,
versionUtils.Decision.MAJOR,
];
useListInput(decision, strategies, {
active: active!,
minus: `left`,
plus: `right`,
set: setDecision,
});
const nextVersion = decision === versionUtils.Decision.UNDECIDED
? <Text color={`yellow`}>{currentVersion}</Text>
: decision === versionUtils.Decision.DECLINE
? <Text color={`green`}>{currentVersion}</Text>
: <Text><Text color={`magenta`}>{currentVersion}</Text> β <Text color={`green`}>{
semver.valid(decision)
? decision
: semver.inc(currentVersion, decision as versionUtils.IncrementDecision)
}</Text></Text>;
return (
<Box flexDirection={`column`}>
<Box>
<Text>
{structUtils.prettyLocator(configuration, workspace.anchoredLocator)} - {nextVersion}
</Text>
</Box>
<Box>
{strategies.map(strategy => {
const isGemActive = strategy === decision;
return (
<Box key={strategy} paddingLeft={2}>
<Text>
<Gem active={isGemActive} /> {strategy}
</Text>
</Box>
);
})}
</Box>
</Box>
);
};
const getRelevancy = (releases: versionUtils.Releases) => {
// Now, starting from all the workspaces that changed, we'll detect
// which ones are affected by the choices that the user picked. By
// doing this we'll "forget" all choices that aren't relevant any
// longer (for example, imagine that the user decided to re-release
// something, then its dependents, but then decided to not release
// the original package anymore; then the dependents don't need to
// released anymore)
const relevantWorkspaces = new Set(versionFile.releaseRoots);
const relevantReleases = new Map([...releases].filter(([workspace]) => {
return relevantWorkspaces.has(workspace);
}));
while (true) {
const undecidedDependentWorkspaces = versionUtils.getUndecidedDependentWorkspaces({
project: versionFile.project,
releases: relevantReleases,
});
let hasNewDependents = false;
if (undecidedDependentWorkspaces.length > 0) {
for (const [workspace] of undecidedDependentWorkspaces) {
if (!relevantWorkspaces.has(workspace)) {
relevantWorkspaces.add(workspace);
hasNewDependents = true;
const release = releases.get(workspace);
if (typeof release !== `undefined`) {
relevantReleases.set(workspace, release);
}
}
}
}
if (!hasNewDependents) {
break;
}
}
return {
relevantWorkspaces,
relevantReleases,
};
};
const useReleases = (): [versionUtils.Releases, (workspace: Workspace, decision: versionUtils.Decision) => void] => {
const [releases, setReleases] = useState<versionUtils.Releases>(() => new Map(versionFile.releases));
const setWorkspaceRelease = useCallback((workspace: Workspace, decision: versionUtils.Decision) => {
const copy = new Map(releases);
if (decision !== versionUtils.Decision.UNDECIDED)
copy.set(workspace, decision);
else
copy.delete(workspace);
const {relevantReleases} = getRelevancy(copy);
setReleases(relevantReleases);
}, [releases, setReleases]);
return [releases, setWorkspaceRelease];
};
const Stats = ({workspaces, releases}: {workspaces: Set<Workspace>, releases: versionUtils.Releases}) => {
const parts = [];
parts.push(`${workspaces.size} total`);
let releaseCount = 0;
let remainingCount = 0;
for (const workspace of workspaces) {
const release = releases.get(workspace);
if (typeof release === `undefined`) {
remainingCount += 1;
} else if (release !== versionUtils.Decision.DECLINE) {
releaseCount += 1;
}
}
parts.push(`${releaseCount} release${releaseCount === 1 ? `` : `s`}`);
parts.push(`${remainingCount} remaining`);
return <Text color={`yellow`}>{parts.join(`, `)}</Text>;
};
const App = ({useSubmit}: {useSubmit: (value: versionUtils.Releases) => void}) => {
const [releases, setWorkspaceRelease] = useReleases();
useSubmit(releases);
const {relevantWorkspaces} = getRelevancy(releases);
const dependentWorkspaces = new Set([...relevantWorkspaces].filter(workspace => {
return !versionFile.releaseRoots.has(workspace);
}));
const [focus, setFocus] = useState(0);
const handleFocusRequest = useCallback((request: FocusRequest) => {
switch (request) {
case FocusRequest.BEFORE: {
setFocus(focus - 1);
} break;
case FocusRequest.AFTER: {
setFocus(focus + 1);
} break;
}
}, [focus, setFocus]);
return (
<Box flexDirection={`column`}>
<Prompt />
<Box>
<Text wrap={`wrap`}>
The following files have been modified in your local checkout.
</Text>
</Box>
<Box flexDirection={`column`} marginTop={1} paddingLeft={2}>
{[...versionFile.changedFiles].map(file => (
<Box key={file}>
<Text>
<Text color={`grey`}>{npath.fromPortablePath(versionFile.root)}</Text>{npath.sep}{npath.relative(npath.fromPortablePath(versionFile.root), npath.fromPortablePath(file))}
</Text>
</Box>
))}
</Box>
{versionFile.releaseRoots.size > 0 && <>
<Box marginTop={1}>
<Text wrap={`wrap`}>
Because of those files having been modified, the following workspaces may need to be released again (note that private workspaces are also shown here, because even though they won't be published, releasing them will allow us to flag their dependents for potential re-release):
</Text>
</Box>
{dependentWorkspaces.size > 3 ? <Box marginTop={1}>
<Stats workspaces={versionFile.releaseRoots} releases={releases} />
</Box> : null}
<Box marginTop={1} flexDirection={`column`}>
<ScrollableItems active={focus % 2 === 0} radius={1} size={2} onFocusRequest={handleFocusRequest}>
{[...versionFile.releaseRoots].map(workspace => (
<Undecided key={workspace.cwd} workspace={workspace} decision={releases.get(workspace) || versionUtils.Decision.UNDECIDED} setDecision={decision => setWorkspaceRelease(workspace, decision)} />
))}
</ScrollableItems>
</Box>
</>}
{dependentWorkspaces.size > 0 ? (
<>
<Box marginTop={1}>
<Text wrap={`wrap`}>
The following workspaces depend on other workspaces that have been marked for release, and thus may need to be released as well:
</Text>
</Box>
<Box>
<Text>
(Press <Text bold color={`cyanBright`}>{`<tab>`}</Text> to move the focus between the workspace groups.)
</Text>
</Box>
{dependentWorkspaces.size > 5 ? (
<Box marginTop={1}>
<Stats workspaces={dependentWorkspaces} releases={releases} />
</Box>
) : null}
<Box marginTop={1} flexDirection={`column`}>
<ScrollableItems active={focus % 2 === 1} radius={2} size={2} onFocusRequest={handleFocusRequest}>
{[...dependentWorkspaces].map(workspace => (
<Undecided key={workspace.cwd} workspace={workspace} decision={releases.get(workspace) || versionUtils.Decision.UNDECIDED} setDecision={decision => setWorkspaceRelease(workspace, decision)} />
))}
</ScrollableItems>
</Box>
</>
) : null}
</Box>
);
};
const decisions = await renderForm<versionUtils.Releases>(App, {versionFile}, {
stdin: this.context.stdin,
stdout: this.context.stdout,
stderr: this.context.stderr,
});
if (typeof decisions === `undefined`)
return 1;
versionFile.releases.clear();
for (const [workspace, decision] of decisions)
versionFile.releases.set(workspace, decision);
await versionFile.saveAll();
return undefined;
}
async executeStandard() {
const configuration = await Configuration.find(this.context.cwd, this.context.plugins);
const {project, workspace} = await Project.find(configuration, this.context.cwd);
if (!workspace)
throw new WorkspaceRequiredError(project.cwd, this.context.cwd);
await project.restoreInstallState();
const report = await StreamReport.start({
configuration,
stdout: this.context.stdout,
}, async report => {
const versionFile = await versionUtils.openVersionFile(project);
if (versionFile === null || versionFile.releaseRoots.size === 0)
return;
if (versionFile.root === null)
throw new UsageError(`This command can only be run on Git repositories`);
report.reportInfo(MessageName.UNNAMED, `Your PR was started right after ${formatUtils.pretty(configuration, versionFile.baseHash.slice(0, 7), `yellow`)} ${formatUtils.pretty(configuration, versionFile.baseTitle, `magenta`)}`);
if (versionFile.changedFiles.size > 0) {
report.reportInfo(MessageName.UNNAMED, `You have changed the following files since then:`);
report.reportSeparator();
for (const file of versionFile.changedFiles) {
report.reportInfo(null, `${formatUtils.pretty(configuration, npath.fromPortablePath(versionFile.root), `gray`)}${npath.sep}${npath.relative(npath.fromPortablePath(versionFile.root), npath.fromPortablePath(file))}`);
}
}
let hasDiffErrors = false;
let hasDepsErrors = false;
const undecided = versionUtils.getUndecidedWorkspaces(versionFile);
if (undecided.size > 0) {
if (!hasDiffErrors)
report.reportSeparator();
for (const workspace of undecided)
report.reportError(MessageName.UNNAMED, `${structUtils.prettyLocator(configuration, workspace.anchoredLocator)} has been modified but doesn't have a release strategy attached`);
hasDiffErrors = true;
}
const undecidedDependents = versionUtils.getUndecidedDependentWorkspaces(versionFile);
// Then we check which workspaces depend on packages that will be released again but have no release strategies themselves
for (const [workspace, dependency] of undecidedDependents) {
if (!hasDepsErrors)
report.reportSeparator();
report.reportError(MessageName.UNNAMED, `${structUtils.prettyLocator(configuration, workspace.anchoredLocator)} doesn't have a release strategy attached, but depends on ${structUtils.prettyWorkspace(configuration, dependency)} which is planned for release.`);
hasDepsErrors = true;
}
if (hasDiffErrors || hasDepsErrors) {
report.reportSeparator();
report.reportInfo(MessageName.UNNAMED, `This command detected that at least some workspaces have received modifications without explicit instructions as to how they had to be released (if needed).`);
report.reportInfo(MessageName.UNNAMED, `To correct these errors, run \`yarn version check --interactive\` then follow the instructions.`);
}
});
return report.exitCode();
}
}