Skip to content

Commit 7d545df

Browse files
authored
Update release spec to only list changed packages (#23)
This PR adds a new property to the Package object, `hasChangesSinceLatestRelease`. This property is computed when a package is read from a project by running a diff between the last-created Git tag associated with that package and whatever the HEAD commit happens to be, then checking to see whether any of the files changed belong to that package. If they do, then `hasChangesSinceLatestRelease` is true, otherwise it's false. This property is then used to filter the list of packages that are placed within the release spec template when it is generated. There are a couple of things to consider here: * "the last-created Git tag associated with that package" — how do we know this? How do we map a package's version to a tag? We have to account for tags that were created by `action-create-release-pr` in the past as well as the tags that this tool will create in the future. We also have to know what kind of package this is — whether it's the root package of a monorepo or a workspace package — because the set of possible tags will differ. These differences are documented in `readMonorepoRootPackage` and `readMonorepoWorkspacePackage`. * What happens if a repo has no tags? Then all of the packages in that repo are considered to have changed, as they have yet to receive their initial release, so they will all be included in the release spec template.
1 parent 04b0496 commit 7d545df

15 files changed

+1165
-135
lines changed

src/fs.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import {
55
} from '@metamask/action-utils';
66
import { wrapError, isErrorWithCode } from './misc-utils';
77

8+
/**
9+
* Represents a writeable stream, such as that represented by `process.stdout`
10+
* or `process.stderr`, or a fake one provided in tests.
11+
*/
12+
export type WriteStreamLike = Pick<fs.WriteStream, 'write'>;
13+
814
/**
915
* Reads the file at the given path, assuming its content is encoded as UTF-8.
1016
*

src/initial-parameters.test.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import os from 'os';
22
import path from 'path';
33
import { when } from 'jest-when';
4-
import { buildMockProject, buildMockPackage } from '../tests/unit/helpers';
4+
import {
5+
buildMockProject,
6+
buildMockPackage,
7+
createNoopWriteStream,
8+
} from '../tests/unit/helpers';
59
import { determineInitialParameters } from './initial-parameters';
610
import * as commandLineArgumentsModule from './command-line-arguments';
711
import * as envModule from './env';
@@ -23,6 +27,7 @@ describe('initial-parameters', () => {
2327

2428
it('returns an object derived from command-line arguments and environment variables that contains data necessary to run the workflow', async () => {
2529
const project = buildMockProject();
30+
const stderr = createNoopWriteStream();
2631
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
2732
.calledWith(['arg1', 'arg2'])
2833
.mockResolvedValue({
@@ -34,13 +39,14 @@ describe('initial-parameters', () => {
3439
.spyOn(envModule, 'getEnvironmentVariables')
3540
.mockReturnValue({ EDITOR: undefined });
3641
when(jest.spyOn(projectModule, 'readProject'))
37-
.calledWith('/path/to/project')
42+
.calledWith('/path/to/project', { stderr })
3843
.mockResolvedValue(project);
3944

40-
const config = await determineInitialParameters(
41-
['arg1', 'arg2'],
42-
'/path/to/somewhere',
43-
);
45+
const config = await determineInitialParameters({
46+
argv: ['arg1', 'arg2'],
47+
cwd: '/path/to/somewhere',
48+
stderr,
49+
});
4450

4551
expect(config).toStrictEqual({
4652
project,
@@ -53,6 +59,7 @@ describe('initial-parameters', () => {
5359
const project = buildMockProject({
5460
rootPackage: buildMockPackage(),
5561
});
62+
const stderr = createNoopWriteStream();
5663
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
5764
.calledWith(['arg1', 'arg2'])
5865
.mockResolvedValue({
@@ -67,13 +74,20 @@ describe('initial-parameters', () => {
6774
.spyOn(projectModule, 'readProject')
6875
.mockResolvedValue(project);
6976

70-
await determineInitialParameters(['arg1', 'arg2'], '/path/to/cwd');
77+
await determineInitialParameters({
78+
argv: ['arg1', 'arg2'],
79+
cwd: '/path/to/cwd',
80+
stderr,
81+
});
7182

72-
expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project');
83+
expect(readProjectSpy).toHaveBeenCalledWith('/path/to/cwd/project', {
84+
stderr,
85+
});
7386
});
7487

7588
it('resolves the given temporary directory relative to the current working directory', async () => {
7689
const project = buildMockProject();
90+
const stderr = createNoopWriteStream();
7791
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
7892
.calledWith(['arg1', 'arg2'])
7993
.mockResolvedValue({
@@ -85,13 +99,14 @@ describe('initial-parameters', () => {
8599
.spyOn(envModule, 'getEnvironmentVariables')
86100
.mockReturnValue({ EDITOR: undefined });
87101
when(jest.spyOn(projectModule, 'readProject'))
88-
.calledWith('/path/to/project')
102+
.calledWith('/path/to/project', { stderr })
89103
.mockResolvedValue(project);
90104

91-
const config = await determineInitialParameters(
92-
['arg1', 'arg2'],
93-
'/path/to/cwd',
94-
);
105+
const config = await determineInitialParameters({
106+
argv: ['arg1', 'arg2'],
107+
cwd: '/path/to/cwd',
108+
stderr,
109+
});
95110

96111
expect(config.tempDirectoryPath).toStrictEqual('/path/to/cwd/tmp');
97112
});
@@ -100,6 +115,7 @@ describe('initial-parameters', () => {
100115
const project = buildMockProject({
101116
rootPackage: buildMockPackage('@foo/bar'),
102117
});
118+
const stderr = createNoopWriteStream();
103119
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
104120
.calledWith(['arg1', 'arg2'])
105121
.mockResolvedValue({
@@ -111,13 +127,14 @@ describe('initial-parameters', () => {
111127
.spyOn(envModule, 'getEnvironmentVariables')
112128
.mockReturnValue({ EDITOR: undefined });
113129
when(jest.spyOn(projectModule, 'readProject'))
114-
.calledWith('/path/to/project')
130+
.calledWith('/path/to/project', { stderr })
115131
.mockResolvedValue(project);
116132

117-
const config = await determineInitialParameters(
118-
['arg1', 'arg2'],
119-
'/path/to/cwd',
120-
);
133+
const config = await determineInitialParameters({
134+
argv: ['arg1', 'arg2'],
135+
cwd: '/path/to/cwd',
136+
stderr,
137+
});
121138

122139
expect(config.tempDirectoryPath).toStrictEqual(
123140
path.join(os.tmpdir(), 'create-release-branch', '@foo__bar'),
@@ -126,6 +143,7 @@ describe('initial-parameters', () => {
126143

127144
it('returns initial parameters including reset: true, derived from a command-line argument of "--reset true"', async () => {
128145
const project = buildMockProject();
146+
const stderr = createNoopWriteStream();
129147
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
130148
.calledWith(['arg1', 'arg2'])
131149
.mockResolvedValue({
@@ -137,19 +155,21 @@ describe('initial-parameters', () => {
137155
.spyOn(envModule, 'getEnvironmentVariables')
138156
.mockReturnValue({ EDITOR: undefined });
139157
when(jest.spyOn(projectModule, 'readProject'))
140-
.calledWith('/path/to/project')
158+
.calledWith('/path/to/project', { stderr })
141159
.mockResolvedValue(project);
142160

143-
const config = await determineInitialParameters(
144-
['arg1', 'arg2'],
145-
'/path/to/somewhere',
146-
);
161+
const config = await determineInitialParameters({
162+
argv: ['arg1', 'arg2'],
163+
cwd: '/path/to/somewhere',
164+
stderr,
165+
});
147166

148167
expect(config.reset).toBe(true);
149168
});
150169

151170
it('returns initial parameters including reset: false, derived from a command-line argument of "--reset false"', async () => {
152171
const project = buildMockProject();
172+
const stderr = createNoopWriteStream();
153173
when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments'))
154174
.calledWith(['arg1', 'arg2'])
155175
.mockResolvedValue({
@@ -161,13 +181,14 @@ describe('initial-parameters', () => {
161181
.spyOn(envModule, 'getEnvironmentVariables')
162182
.mockReturnValue({ EDITOR: undefined });
163183
when(jest.spyOn(projectModule, 'readProject'))
164-
.calledWith('/path/to/project')
184+
.calledWith('/path/to/project', { stderr })
165185
.mockResolvedValue(project);
166186

167-
const config = await determineInitialParameters(
168-
['arg1', 'arg2'],
169-
'/path/to/somewhere',
170-
);
187+
const config = await determineInitialParameters({
188+
argv: ['arg1', 'arg2'],
189+
cwd: '/path/to/somewhere',
190+
stderr,
191+
});
171192

172193
expect(config.reset).toBe(false);
173194
});

src/initial-parameters.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os from 'os';
22
import path from 'path';
33
import { readCommandLineArguments } from './command-line-arguments';
4+
import { WriteStreamLike } from './fs';
45
import { readProject, Project } from './project';
56

67
interface InitialParameters {
@@ -13,18 +14,25 @@ interface InitialParameters {
1314
* Reads the inputs given to this tool via `process.argv` and uses them to
1415
* gather information about the project the tool can use to run.
1516
*
16-
* @param argv - The arguments to this executable.
17-
* @param cwd - The directory in which this executable was run.
17+
* @param args - The arguments to this function.
18+
* @param args.argv - The arguments to this executable.
19+
* @param args.cwd - The directory in which this executable was run.
20+
* @param args.stderr - A stream that can be used to write to standard error.
1821
* @returns The initial parameters.
1922
*/
20-
export async function determineInitialParameters(
21-
argv: string[],
22-
cwd: string,
23-
): Promise<InitialParameters> {
23+
export async function determineInitialParameters({
24+
argv,
25+
cwd,
26+
stderr,
27+
}: {
28+
argv: string[];
29+
cwd: string;
30+
stderr: WriteStreamLike;
31+
}): Promise<InitialParameters> {
2432
const inputs = await readCommandLineArguments(argv);
2533

2634
const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory);
27-
const project = await readProject(projectDirectoryPath);
35+
const project = await readProject(projectDirectoryPath, { stderr });
2836
const tempDirectoryPath =
2937
inputs.tempDirectory === undefined
3038
? path.join(

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export async function main({
2626
stderr: Pick<WriteStream, 'write'>;
2727
}) {
2828
const { project, tempDirectoryPath, reset } =
29-
await determineInitialParameters(argv, cwd);
29+
await determineInitialParameters({ argv, cwd, stderr });
3030

3131
if (project.isMonorepo) {
3232
stdout.write(

src/misc-utils.test.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
isErrorWithStack,
77
wrapError,
88
resolveExecutable,
9-
getStdoutFromCommand,
109
runCommand,
10+
getStdoutFromCommand,
11+
getLinesFromCommand,
1112
} from './misc-utils';
1213

1314
jest.mock('which');
@@ -135,6 +136,24 @@ describe('misc-utils', () => {
135136
});
136137
});
137138

139+
describe('runCommand', () => {
140+
it('runs the command, discarding its output', async () => {
141+
const execaSpy = jest
142+
.spyOn(execaModule, 'default')
143+
// Typecast: It's difficult to provide a full return value for execa
144+
.mockResolvedValue({ stdout: ' some output ' } as any);
145+
146+
const result = await runCommand('some command', ['arg1', 'arg2'], {
147+
all: true,
148+
});
149+
150+
expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], {
151+
all: true,
152+
});
153+
expect(result).toBeUndefined();
154+
});
155+
});
156+
138157
describe('getStdoutFromCommand', () => {
139158
it('executes the given command and returns a version of the standard out from the command with whitespace trimmed', async () => {
140159
const execaSpy = jest
@@ -155,21 +174,43 @@ describe('misc-utils', () => {
155174
});
156175
});
157176

158-
describe('runCommand', () => {
159-
it('runs the command, discarding its output', async () => {
177+
describe('getLinesFromCommand', () => {
178+
it('executes the given command and returns the standard out from the command split into lines', async () => {
160179
const execaSpy = jest
161180
.spyOn(execaModule, 'default')
162181
// Typecast: It's difficult to provide a full return value for execa
163-
.mockResolvedValue({ stdout: ' some output ' } as any);
182+
.mockResolvedValue({ stdout: 'line 1\nline 2\nline 3' } as any);
164183

165-
const result = await runCommand('some command', ['arg1', 'arg2'], {
184+
const lines = await getLinesFromCommand(
185+
'some command',
186+
['arg1', 'arg2'],
187+
{ all: true },
188+
);
189+
190+
expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], {
166191
all: true,
167192
});
193+
expect(lines).toStrictEqual(['line 1', 'line 2', 'line 3']);
194+
});
195+
196+
it('does not strip leading and trailing whitespace from the output, but does remove empty lines', async () => {
197+
const execaSpy = jest
198+
.spyOn(execaModule, 'default')
199+
// Typecast: It's difficult to provide a full return value for execa
200+
.mockResolvedValue({
201+
stdout: ' line 1\nline 2\n\n line 3 \n',
202+
} as any);
203+
204+
const lines = await getLinesFromCommand(
205+
'some command',
206+
['arg1', 'arg2'],
207+
{ all: true },
208+
);
168209

169210
expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], {
170211
all: true,
171212
});
172-
expect(result).toBeUndefined();
213+
expect(lines).toStrictEqual([' line 1', 'line 2', ' line 3 ']);
173214
});
174215
});
175216
});

src/misc-utils.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ export async function resolveExecutable(
118118
}
119119
}
120120

121+
/**
122+
* Runs a command, discarding its output.
123+
*
124+
* @param command - The command to execute.
125+
* @param args - The positional arguments to the command.
126+
* @param options - The options to `execa`.
127+
* @throws An `execa` error object if the command fails in some way.
128+
* @see `execa`.
129+
*/
130+
export async function runCommand(
131+
command: string,
132+
args?: readonly string[] | undefined,
133+
options?: execa.Options<string> | undefined,
134+
): Promise<void> {
135+
await execa(command, args, options);
136+
}
137+
121138
/**
122139
* Runs a command, retrieving the standard output with leading and trailing
123140
* whitespace removed.
@@ -138,18 +155,20 @@ export async function getStdoutFromCommand(
138155
}
139156

140157
/**
141-
* Runs a command, discarding its output.
158+
* Runs a Git command, splitting up the immediate output into lines.
142159
*
143160
* @param command - The command to execute.
144161
* @param args - The positional arguments to the command.
145162
* @param options - The options to `execa`.
163+
* @returns The standard output of the command.
146164
* @throws An `execa` error object if the command fails in some way.
147165
* @see `execa`.
148166
*/
149-
export async function runCommand(
167+
export async function getLinesFromCommand(
150168
command: string,
151169
args?: readonly string[] | undefined,
152170
options?: execa.Options<string> | undefined,
153-
): Promise<void> {
154-
await execa(command, args, options);
171+
): Promise<string[]> {
172+
const { stdout } = await execa(command, args, options);
173+
return stdout.split('\n').filter((value) => value !== '');
155174
}

0 commit comments

Comments
 (0)