Skip to content

Commit 89c56ee

Browse files
authored
[rush] Remove deprecated shell: true usage. (#5496)
* Revert "Put the CI config back." This reverts commit 7175990. * Remove spawn: true errors. * Rush change. * fixup! Revert "Put the CI config back." * Test with Node 24. * Revert "Test with Node 24." This reverts commit 2b9fd77. * Revert "fixup! Revert "Put the CI config back."" This reverts commit c29726f. * Reapply "Put the CI config back." This reverts commit f495cf3. * Fix RushCommandLineParser tests on Windows.
1 parent 6fd0d6e commit 89c56ee

5 files changed

Lines changed: 151 additions & 180 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Remove use of the deprecated `shell: true` option in process spawn operations.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts

Lines changed: 94 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,48 @@ jest.mock(`@rushstack/package-deps-hash`, () => {
2828

2929
import './mockRushCommandLineParser';
3030

31+
import type { SpawnOptions } from 'node:child_process';
3132
import { FileSystem, JsonFile, Path } from '@rushstack/node-core-library';
3233
import type { IDetailedRepoState } from '@rushstack/package-deps-hash';
3334
import { Autoinstaller } from '../../logic/Autoinstaller';
3435
import type { ITelemetryData } from '../../logic/Telemetry';
35-
import { getCommandLineParserInstanceAsync } from './TestUtils';
36+
import { getCommandLineParserInstanceAsync, type SpawnMockArgs, type SpawnMockCall } from './TestUtils';
3637
import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration';
38+
import { IS_WINDOWS } from '../../utilities/executionUtilities';
3739

38-
function pathEquals(actual: string, expected: string): void {
39-
expect(Path.convertToSlashes(actual)).toEqual(Path.convertToSlashes(expected));
40+
// Ordinals into the `mock.calls` array referencing each of the arguments to `spawn`. Note that
41+
// the exact structure of these arguments differs between Windows and non-Windows platforms, so
42+
// we only reference the one that is common.
43+
const SPAWN_ARG_OPTIONS: number = 2;
44+
45+
function spawnOptionEquals<TOption extends keyof SpawnOptions, TExepcted>(
46+
spawnCall: SpawnMockCall,
47+
optionName: TOption,
48+
expected: TExepcted,
49+
tweakActual: (actual: SpawnOptions[TOption]) => TExepcted = (x) => x as TExepcted
50+
): void {
51+
const spawnOptions: SpawnOptions = spawnCall[SPAWN_ARG_OPTIONS] as SpawnOptions;
52+
expect(spawnOptions).toEqual(expect.any(Object));
53+
expect(tweakActual(spawnOptions[optionName])).toEqual(expected);
4054
}
4155

42-
// Ordinals into the `mock.calls` array referencing each of the arguments to `spawn`
43-
const SPAWN_ARG_ARGS: number = 1;
44-
const SPAWN_ARG_OPTIONS: number = 2;
56+
function cwdOptionEquals(spawnCall: SpawnMockCall, expected: string): void {
57+
spawnOptionEquals(spawnCall, 'cwd', Path.convertToSlashes(expected), (actual) =>
58+
Path.convertToSlashes(String(actual))
59+
);
60+
}
61+
62+
jest.setTimeout(1000000);
63+
64+
function expectSpawnToMatchRegexp(spawnCall: SpawnMockCall, expectedRegexp: RegExp): void {
65+
if (IS_WINDOWS) {
66+
// On Windows, the command is passed as a single string with the `shell: true` option
67+
spawnOptionEquals(spawnCall, 'shell', true);
68+
expect(spawnCall[0]).toMatch(expectedRegexp);
69+
} else {
70+
expect(spawnCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(expectedRegexp)]));
71+
}
72+
}
4573

4674
describe('RushCommandLineParser', () => {
4775
describe('execute', () => {
@@ -69,21 +97,13 @@ describe('RushCommandLineParser', () => {
6997
// Use regex for task name in case spaces were prepended or appended to spawned command
7098
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
7199

72-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73-
const firstSpawn: any[] = spawnMock.mock.calls[0];
74-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
75-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
76-
);
77-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
78-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
100+
const firstSpawn: SpawnMockArgs = spawnMock.mock.calls[0];
101+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
102+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
79103

80-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
81-
const secondSpawn: any[] = spawnMock.mock.calls[1];
82-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
83-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
84-
);
85-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
86-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
104+
const secondSpawn: SpawnMockArgs = spawnMock.mock.calls[1];
105+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
106+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
87107
});
88108
});
89109

@@ -104,21 +124,13 @@ describe('RushCommandLineParser', () => {
104124
// Use regex for task name in case spaces were prepended or appended to spawned command
105125
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
106126

107-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
108-
const firstSpawn: any[] = spawnMock.mock.calls[0];
109-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
110-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
111-
);
112-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
113-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
127+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
128+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
129+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
114130

115-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116-
const secondSpawn: any[] = spawnMock.mock.calls[1];
117-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
118-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
119-
);
120-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
121-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
131+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
132+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
133+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
122134
});
123135
});
124136
});
@@ -138,21 +150,13 @@ describe('RushCommandLineParser', () => {
138150
// Use regex for task name in case spaces were prepended or appended to spawned command
139151
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
140152

141-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
142-
const firstSpawn: any[] = spawnMock.mock.calls[0];
143-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
144-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
145-
);
146-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
147-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
153+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
154+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
155+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
148156

149-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
150-
const secondSpawn: any[] = spawnMock.mock.calls[1];
151-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
152-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
153-
);
154-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
155-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
157+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
158+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
159+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
156160
});
157161
});
158162

@@ -173,21 +177,13 @@ describe('RushCommandLineParser', () => {
173177
// Use regex for task name in case spaces were prepended or appended to spawned command
174178
const expectedBuildTaskRegexp: RegExp = /fake_REbuild_task_but_works_with_mock/;
175179

176-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
177-
const firstSpawn: any[] = spawnMock.mock.calls[0];
178-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
179-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
180-
);
181-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
182-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
180+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
181+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
182+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
183183

184-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
185-
const secondSpawn: any[] = spawnMock.mock.calls[1];
186-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
187-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
188-
);
189-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
190-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
184+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
185+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
186+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
191187
});
192188
});
193189
});
@@ -206,21 +202,13 @@ describe('RushCommandLineParser', () => {
206202
// Use regex for task name in case spaces were prepended or appended to spawned command
207203
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
208204

209-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
210-
const firstSpawn: any[] = spawnMock.mock.calls[0];
211-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
212-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
213-
);
214-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
215-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
205+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
206+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
207+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
216208

217-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
218-
const secondSpawn: any[] = spawnMock.mock.calls[1];
219-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
220-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
221-
);
222-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
223-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
209+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
210+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
211+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
224212
});
225213
});
226214

@@ -241,21 +229,13 @@ describe('RushCommandLineParser', () => {
241229
// Use regex for task name in case spaces were prepended or appended to spawned command
242230
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
243231

244-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
245-
const firstSpawn: any[] = spawnMock.mock.calls[0];
246-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
247-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
248-
);
249-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
250-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
232+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
233+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
234+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
251235

252-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
253-
const secondSpawn: any[] = spawnMock.mock.calls[1];
254-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
255-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
256-
);
257-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
258-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
236+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
237+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
238+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
259239
});
260240
});
261241
});
@@ -348,21 +328,13 @@ describe('RushCommandLineParser', () => {
348328
// Use regex for task name in case spaces were prepended or appended to spawned command
349329
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
350330

351-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
352-
const firstSpawn: any[] = spawnMock.mock.calls[0];
353-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
354-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
355-
);
356-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
357-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
331+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
332+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
333+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
358334

359-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
360-
const secondSpawn: any[] = spawnMock.mock.calls[1];
361-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
362-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
363-
);
364-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
365-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
335+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
336+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
337+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
366338
});
367339
});
368340

@@ -383,21 +355,13 @@ describe('RushCommandLineParser', () => {
383355
// Use regex for task name in case spaces were prepended or appended to spawned command
384356
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
385357

386-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
387-
const firstSpawn: any[] = spawnMock.mock.calls[0];
388-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
389-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
390-
);
391-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
392-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
358+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
359+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
360+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
393361

394-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
395-
const secondSpawn: any[] = spawnMock.mock.calls[1];
396-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
397-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
398-
);
399-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
400-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
362+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
363+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
364+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
401365
});
402366
});
403367
});
@@ -419,21 +383,13 @@ describe('RushCommandLineParser', () => {
419383
// Use regex for task name in case spaces were prepended or appended to spawned command
420384
const expectedBuildTaskRegexp: RegExp = /fake_build_task_but_works_with_mock/;
421385

422-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
423-
const firstSpawn: any[] = spawnMock.mock.calls[0];
424-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
425-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
426-
);
427-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
428-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
386+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
387+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
388+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
429389

430-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
431-
const secondSpawn: any[] = spawnMock.mock.calls[1];
432-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
433-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
434-
);
435-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
436-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
390+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
391+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
392+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
437393
});
438394
});
439395

@@ -454,21 +410,13 @@ describe('RushCommandLineParser', () => {
454410
// Use regex for task name in case spaces were prepended or appended to spawned command
455411
const expectedBuildTaskRegexp: RegExp = /fake_REbuild_task_but_works_with_mock/;
456412

457-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
458-
const firstSpawn: any[] = spawnMock.mock.calls[0];
459-
expect(firstSpawn[SPAWN_ARG_ARGS]).toEqual(
460-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
461-
);
462-
expect(firstSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
463-
pathEquals(firstSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/a`);
413+
const firstSpawn: SpawnMockCall = spawnMock.mock.calls[0];
414+
expectSpawnToMatchRegexp(firstSpawn, expectedBuildTaskRegexp);
415+
cwdOptionEquals(firstSpawn, `${repoPath}/a`);
464416

465-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
466-
const secondSpawn: any[] = spawnMock.mock.calls[1];
467-
expect(secondSpawn[SPAWN_ARG_ARGS]).toEqual(
468-
expect.arrayContaining([expect.stringMatching(expectedBuildTaskRegexp)])
469-
);
470-
expect(secondSpawn[SPAWN_ARG_OPTIONS]).toEqual(expect.any(Object));
471-
pathEquals(secondSpawn[SPAWN_ARG_OPTIONS].cwd, `${repoPath}/b`);
417+
const secondSpawn: SpawnMockCall = spawnMock.mock.calls[1];
418+
expectSpawnToMatchRegexp(secondSpawn, expectedBuildTaskRegexp);
419+
cwdOptionEquals(secondSpawn, `${repoPath}/b`);
472420
});
473421
});
474422
});

libraries/rush-lib/src/cli/test/TestUtils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import type { RushCommandLineParser as RushCommandLineParserType } from '../Rush
77
import { FlagFile } from '../../api/FlagFile';
88
import { RushConstants } from '../../logic/RushConstants';
99

10+
export type SpawnMockArgs = Parameters<typeof import('node:child_process').spawn>;
11+
export type SpawnMock = jest.Mock<ReturnType<typeof import('node:child_process').spawn>, SpawnMockArgs>;
12+
export type SpawnMockCall = SpawnMock['mock']['calls'][number];
13+
1014
/**
1115
* Interface definition for a test instance for the RushCommandLineParser.
1216
*/
1317
export interface IParserTestInstance {
1418
parser: RushCommandLineParserType;
15-
spawnMock: jest.Mock;
19+
spawnMock: SpawnMock;
1620
repoPath: string;
1721
}
1822

0 commit comments

Comments
 (0)