` to filter the commit
70 | history by this directory.
71 | - `--workspace`: Is a convenience flag which sets `--dir` to the current
72 | directory name and `--tag` to `${dir}/v${version}`.
73 |
74 | Configure your preferred editor with the `$EDITOR` environment variable.
75 |
76 | ## Preview next release
77 |
78 | Preview the release notes for the next release by running:
79 |
80 | ```bash
81 | ❯ npx changes
82 | ```
83 |
84 | 
85 |
86 | ## License
87 |
88 | MIT
89 |
90 | Made with ❤️ on 🌍
91 |
92 | [1]: https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b
93 |
--------------------------------------------------------------------------------
/bin/cmd.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /*
3 | * Copyright (c) Maximilian Antoni
4 | *
5 | * @license MIT
6 | */
7 | 'use strict';
8 |
9 | const editor = require('@studio/editor');
10 | const changes = require('..');
11 |
12 | const argv = require('minimist')(process.argv.slice(2), {
13 | alias: {
14 | commits: 'c',
15 | file: 'f',
16 | tag: 't',
17 | dir: 'd',
18 | workspace: 'w',
19 | help: 'h'
20 | }
21 | });
22 |
23 | if (argv.help) {
24 | console.log(`Usage: changes [options]
25 |
26 | Options:
27 | --init Add version lifecycle scripts to package.json.
28 | -c, --commits [URL] Generate links to commits using the given URL as base.
29 | If no URL is given it defaults to "\${homepage}/commit".
30 | --footer Generate a footer with the git author and release date.
31 | -f, --file [FILENAME] Specify the name of the changelog file. Defaults to CHANGES.md.
32 | -t, --tag [FORMAT] Specify a custom git tag format to use. Defaults to "v\${version}".
33 | -d, --dir [PATH] Specify a directory to filter git log entries.
34 | -w, --workspace Convenience flag to set --dir to the current directory name and --tag to "\${dir} v\${version}"
35 | -h, --help Display this help message.
36 | `);
37 | process.exit();
38 | }
39 |
40 | if (argv.init) {
41 | // eslint-disable-next-line n/global-require
42 | if (require('../lib/init')()) {
43 | process.exit();
44 | }
45 | console.error('"version" script already exists');
46 | process.exit(1);
47 | }
48 |
49 | const options = {};
50 | if (argv.file) {
51 | options.changes_file = argv.file;
52 | }
53 | if (argv.tag) {
54 | options.tag_format = argv.tag;
55 | }
56 | if (argv.dir) {
57 | options.dir = argv.dir;
58 | }
59 | if (argv.commits) {
60 | options.commits = argv.commits;
61 | }
62 | if (argv.footer) {
63 | options.footer = argv.footer;
64 | }
65 | if (argv.workspace) {
66 | options.workspace = true;
67 | }
68 |
69 | // Write the commit history to the changes file
70 | changes
71 | .write(options)
72 | .then((state) => {
73 | // Let the user edit the changes
74 | editor(state.changes_file, (code) => {
75 | if (code === 0) {
76 | // Add the changes file to git
77 | changes.add(state);
78 | } else {
79 | // Roll back
80 | changes.abort(state);
81 | }
82 | });
83 | })
84 | .catch((err) => {
85 | console.error(err);
86 | process.exit(1);
87 | });
88 |
--------------------------------------------------------------------------------
/lib/changelog.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) Maximilian Antoni
3 | *
4 | * @license MIT
5 | */
6 | 'use strict';
7 |
8 | const $ = require('child_process');
9 |
10 | const VARIABLE_RE = /\$\{([^}]+)\}/g;
11 |
12 | function parseAuthor(author) {
13 | const m = author.match(/[<(]/);
14 | return m ? author.substring(0, m.index).trim() : author;
15 | }
16 |
17 | module.exports = function ({ log_range, dir, commits, newline, pkg }) {
18 | let flags = '--format="» ';
19 | if (commits) {
20 | commits = commits.replace(VARIABLE_RE, (match, key) => pkg[key]);
21 | flags += `[\\\`%h\\\`](${commits}/%H)« `;
22 | }
23 | flags += '%s (%an)%n%n%b" --no-merges';
24 | if (dir) {
25 | flags += ` -- ${dir}`;
26 | }
27 | let changes;
28 | try {
29 | changes = $.execSync(`git log ${log_range} ${flags}`, {
30 | encoding: 'utf8'
31 | });
32 | } catch (e) {
33 | process.exit(1);
34 | return null;
35 | }
36 | // Remove blanks (if no body) and indent body
37 | changes = changes
38 | .replace(/\n{3,}/g, '\n')
39 | // Indent body with quotes:
40 | .replace(/^([^»])/gm, ' > $1')
41 | // Remove trainling whitespace on blank quote lines
42 | .replace(/^ {4}> \n/gm, ' >\n')
43 | // Replace commit markers with dashes:
44 | .replace(/^»/gm, '-')
45 | // Replace newline markers with newlines:
46 | .replace(/«/gm, '\n')
47 | // Restore original newlines:
48 | .replace(/\n/gm, newline);
49 |
50 | // Only mention contributors
51 | const { author } = pkg;
52 | if (author) {
53 | const author_name =
54 | typeof author === 'object' ? author.name : parseAuthor(author);
55 | changes = changes.replace(new RegExp(` \\(${author_name}\\)$`, 'gm'), '');
56 | }
57 |
58 | return changes;
59 | };
60 |
--------------------------------------------------------------------------------
/lib/changes.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) Maximilian Antoni
3 | *
4 | * @license MIT
5 | */
6 | 'use strict';
7 |
8 | const fs = require('fs');
9 | const path = require('path');
10 | const $ = require('child_process');
11 | const hostedGitInfo = require('hosted-git-info');
12 | const changelog = require('./changelog');
13 | const footer = require('./footer');
14 |
15 | const CHANGES_HEADING = '# Changes';
16 | const DEFAULT_CHANGES_FILE = 'CHANGES.md';
17 | const DEFAULT_TAG_FORMAT = 'v${version}';
18 | const DEFAULT_COMMIT_URL_FORMAT = '${homepage}/commit';
19 | const VARIABLE_RE = /\$\{([^}]+)\}/g;
20 |
21 | function exists(changes, version) {
22 | const escaped_version = version.replace(/([.-])/g, '\\$1');
23 | const regexp = new RegExp(`\r?\n## ${escaped_version}\r?\n`);
24 | return regexp.test(changes);
25 | }
26 |
27 | function buildTag(tag_format, version, pkg) {
28 | return tag_format.replace(VARIABLE_RE, (match, key) =>
29 | key === 'version' ? version : pkg[key]
30 | );
31 | }
32 |
33 | // Write the commit history to the changes file
34 | exports.write = async function (options = {}) {
35 | let tag_format = options.tag_format || DEFAULT_TAG_FORMAT;
36 | let dir = options.dir;
37 | if (options.workspace) {
38 | dir = path.basename(process.cwd());
39 | tag_format = `${dir}/v\${version}`;
40 | }
41 |
42 | const changes_file = options.changes_file || DEFAULT_CHANGES_FILE;
43 | const package_json = fs.readFileSync('package.json', 'utf8');
44 | const pkg = JSON.parse(package_json);
45 | const { version } = pkg;
46 |
47 | // Get previous file content
48 | let previous;
49 | let heading;
50 | let newline;
51 | try {
52 | previous = fs.readFileSync(changes_file, 'utf8');
53 | const match = previous.match(new RegExp(`^${CHANGES_HEADING}(\r?\n){2}`));
54 | if (!match) {
55 | console.error(`Unexpected ${changes_file} file header`);
56 | process.exit(1);
57 | return null;
58 | }
59 | heading = match[0];
60 | newline = match[1];
61 | } catch (e) {
62 | previous = heading = `${CHANGES_HEADING}\n\n`;
63 | newline = '\n';
64 | }
65 |
66 | // Generate changes for this release
67 | const version_match = previous.match(/^## ([0-9a-z.-]+)$/m);
68 | let log_range = '';
69 | if (version_match) {
70 | log_range = `${buildTag(tag_format, version_match[1], pkg)}..HEAD`;
71 | }
72 |
73 | let commits = options.commits;
74 | if (commits) {
75 | if (commits === true) {
76 | commits = DEFAULT_COMMIT_URL_FORMAT;
77 | if (pkg.repository && pkg.repository.type === 'git') {
78 | const info = hostedGitInfo.fromUrl(pkg.repository.url);
79 | if (!info) {
80 | console.error('Failed to parse "repository" from package.json\n');
81 | process.exit(1);
82 | return null;
83 | }
84 | pkg.homepage = info.browse();
85 | }
86 | if (!pkg.homepage) {
87 | console.error(
88 | '--commits option requires base URL, "repository" or ' +
89 | '"homepage" in package.json\n'
90 | );
91 | process.exit(1);
92 | return null;
93 | }
94 | }
95 | }
96 |
97 | let changes = changelog({
98 | log_range,
99 | dir,
100 | commits,
101 | newline,
102 | pkg
103 | });
104 |
105 | if (options.footer) {
106 | const foot = await footer.generate();
107 | changes += `${newline}${foot}${newline}`;
108 | }
109 |
110 | // Do not allow version to be added twice
111 | if (exists(previous, version)) {
112 | console.error(`Version ${version} is already in ${changes_file}\n`);
113 | if (changes) {
114 | console.error('# Changes for next release:\n');
115 | console.error(changes);
116 | }
117 | process.exit(1);
118 | return null;
119 | }
120 |
121 | // Generate new changes
122 | let next = `${heading}## ${version}${newline}${newline}${changes}`;
123 | const remain = previous.substring(heading.length);
124 | if (remain) {
125 | next += `${newline}${remain}`;
126 | }
127 | fs.writeFileSync(changes_file, next);
128 |
129 | return { previous, changes_file };
130 | };
131 |
132 | // Roll back changes
133 | exports.abort = function (state) {
134 | fs.writeFileSync(state.changes_file, state.previous);
135 | process.exitCode = 1;
136 | };
137 |
138 | // Add changes to git, unless the user removed the current version to abort
139 | exports.add = function (state) {
140 | const package_json = fs.readFileSync('package.json', 'utf8');
141 | const { version } = JSON.parse(package_json);
142 | if (exists(fs.readFileSync(state.changes_file, 'utf8'), version)) {
143 | // Add changes file to git so that npm includes it in the release commit
144 | $.execSync(`git add ${state.changes_file}`);
145 | } else {
146 | exports.abort(state);
147 | }
148 | };
149 |
--------------------------------------------------------------------------------
/lib/changes.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const $ = require('child_process');
6 | const { assert, refute, match, sinon } = require('@sinonjs/referee-sinon');
7 | const footer = require('./footer');
8 | const changes = require('./changes');
9 |
10 | describe('lib/changes', () => {
11 | beforeEach(() => {
12 | sinon.stub(fs, 'readFileSync');
13 | sinon.stub(fs, 'writeFileSync');
14 | sinon.stub($, 'execSync');
15 | sinon.stub(process, 'exit');
16 | sinon.stub(console, 'error');
17 | });
18 |
19 | afterEach(() => {
20 | sinon.restore();
21 | });
22 |
23 | function packageJson(json) {
24 | fs.readFileSync.withArgs('package.json').returns(
25 | JSON.stringify(
26 | json || {
27 | name: '@studio/changes',
28 | version: '1.0.0',
29 | author: 'Studio ',
30 | homepage: 'https://github.com/javascript-studio/studio-changes'
31 | }
32 | )
33 | );
34 | }
35 |
36 | function missingChanges() {
37 | fs.readFileSync.withArgs('CHANGES.md').throws(new Error());
38 | }
39 |
40 | function setChanges(str) {
41 | fs.readFileSync.withArgs('CHANGES.md').returns(str);
42 | }
43 |
44 | function setLog(log) {
45 | $.execSync.returns(log);
46 | }
47 |
48 | it('generates new changes file to default location', async () => {
49 | packageJson();
50 | missingChanges();
51 | setLog('» Inception (That Dude)\n\n\n');
52 |
53 | const state = await changes.write();
54 |
55 | assert.calledOnceWith(
56 | fs.writeFileSync,
57 | 'CHANGES.md',
58 | '# Changes\n\n## 1.0.0\n\n- Inception (That Dude)\n'
59 | );
60 | assert.calledOnce($.execSync);
61 | assert.calledWithMatch($.execSync, 'git log --format=');
62 | assert.equals(state.changes_file, 'CHANGES.md');
63 | });
64 |
65 | it('generates new changes file to custom location', async () => {
66 | packageJson();
67 | missingChanges();
68 | setLog('» Inception (That Dude)\n\n\n');
69 |
70 | const state = await changes.write({ changes_file: 'foo.txt' });
71 |
72 | assert.calledOnceWith(
73 | fs.writeFileSync,
74 | 'foo.txt',
75 | '# Changes\n\n## 1.0.0\n\n- Inception (That Dude)\n'
76 | );
77 | assert.equals(state.changes_file, 'foo.txt');
78 | });
79 |
80 | it('removes package author', async () => {
81 | packageJson();
82 | missingChanges();
83 | setLog('» Inception (Studio)\n\n\n');
84 |
85 | await changes.write();
86 |
87 | assert.calledOnceWith(
88 | fs.writeFileSync,
89 | 'CHANGES.md',
90 | '# Changes\n\n## 1.0.0\n\n- Inception\n'
91 | );
92 | });
93 |
94 | async function verifyAuthorRemoval(author) {
95 | packageJson({ name: '@studio/changes', version: '1.0.0', author });
96 | missingChanges();
97 | setLog('» Inception (Studio)\n\n\n');
98 |
99 | await changes.write();
100 |
101 | assert.calledOnceWith(
102 | fs.writeFileSync,
103 | 'CHANGES.md',
104 | '# Changes\n\n## 1.0.0\n\n- Inception\n'
105 | );
106 | }
107 |
108 | it('removes package author (with homepage)', async () => {
109 | await verifyAuthorRemoval('Studio (https://javascript.studio)');
110 | });
111 |
112 | it('removes package author (without email or homepage)', async () => {
113 | await verifyAuthorRemoval('Studio');
114 | });
115 |
116 | it('removes package author (with email and homepage)', async () => {
117 | await verifyAuthorRemoval(
118 | 'Studio (https://javascript.studio)'
119 | );
120 | });
121 |
122 | it('removes package author (with homepage and email)', async () => {
123 | await verifyAuthorRemoval(
124 | 'Studio (https://javascript.studio) '
125 | );
126 | });
127 |
128 | it('removes package author (with object)', async () => {
129 | await verifyAuthorRemoval({
130 | name: 'Studio',
131 | email: 'support@javascript.studio'
132 | });
133 | });
134 |
135 | it('add commit log to existing changes file', async () => {
136 | packageJson();
137 | const initial = '# Changes\n\n## 0.1.0\n\nSome foo.\n';
138 | setChanges(initial);
139 | setLog('» Inception (Studio)\n\n\n');
140 |
141 | const state = await changes.write();
142 |
143 | assert.calledOnceWith(
144 | fs.writeFileSync,
145 | 'CHANGES.md',
146 | '# Changes\n\n## 1.0.0\n\n- Inception\n\n## 0.1.0\n\nSome foo.\n'
147 | );
148 | assert.calledOnce($.execSync);
149 | assert.calledWithMatch($.execSync, 'git log v0.1.0..HEAD');
150 | assert.equals(state.previous, initial);
151 | });
152 |
153 | it('identifies previous commit with -beta suffix', async () => {
154 | packageJson();
155 | setChanges('# Changes\n\n## 0.1.0-beta\n\nSome foo.\n');
156 | setLog('» Inception (Studio)\n\n\n');
157 |
158 | await changes.write();
159 |
160 | assert.calledWithMatch($.execSync, 'git log v0.1.0-beta..HEAD');
161 | });
162 |
163 | it('adds `-- my-module` to git log command', async () => {
164 | packageJson();
165 | missingChanges();
166 | setLog('» Inception\n\n\n');
167 |
168 | await changes.write({ dir: 'my-module' });
169 |
170 | assert.calledWith($.execSync, match(/ -- my-module$/));
171 | });
172 |
173 | it('adds body indented on new line', async () => {
174 | packageJson();
175 | missingChanges();
176 | setLog(
177 | '» Inception (Studio)\n\nFoo Bar Doo\n\n» Other (Dude)\n\n\n' +
178 | '» Third (Person)\n\nDoes\nstuff\n\n'
179 | );
180 |
181 | await changes.write();
182 |
183 | assert.calledOnceWith(
184 | fs.writeFileSync,
185 | 'CHANGES.md',
186 | '# Changes\n\n## 1.0.0\n\n' +
187 | '- Inception\n >\n > Foo Bar Doo\n >\n' +
188 | '- Other (Dude)\n' +
189 | '- Third (Person)\n >\n > Does\n > stuff\n >\n'
190 | );
191 | });
192 |
193 | it('keeps body with two paragraphs together', async () => {
194 | packageJson();
195 | missingChanges();
196 | setLog('» Inception (Studio)\n\nFoo\n\nBar\n\n');
197 |
198 | await changes.write();
199 |
200 | assert.calledOnceWith(
201 | fs.writeFileSync,
202 | 'CHANGES.md',
203 | '# Changes\n\n## 1.0.0\n\n- Inception\n' +
204 | ' >\n > Foo\n >\n > Bar\n >\n'
205 | );
206 | });
207 |
208 | it('keeps body with three paragraphs together', async () => {
209 | packageJson();
210 | missingChanges();
211 | setLog('» Inception (Studio)\n\nFoo\n\nBar\n\nDoo\n\n');
212 |
213 | await changes.write();
214 |
215 | assert.calledOnceWith(
216 | fs.writeFileSync,
217 | 'CHANGES.md',
218 | '# Changes\n\n## 1.0.0\n\n- Inception\n' +
219 | ' >\n > Foo\n >\n > Bar\n >\n > Doo\n >\n'
220 | );
221 | });
222 |
223 | it('properly indents lists', async () => {
224 | packageJson();
225 | missingChanges();
226 | setLog('» Inception (Studio)\n\n- Foo\n- Bar\n- Doo\n\n');
227 |
228 | await changes.write();
229 |
230 | assert.calledOnceWith(
231 | fs.writeFileSync,
232 | 'CHANGES.md',
233 | '# Changes\n\n## 1.0.0\n\n- Inception\n' +
234 | ' >\n > - Foo\n > - Bar\n > - Doo\n >\n'
235 | );
236 | });
237 |
238 | it('properly indents list with multiline entry', async () => {
239 | packageJson();
240 | missingChanges();
241 | setLog('» Inception (Studio)\n\n- Foo\n next line\n- Bar\n\n');
242 |
243 | await changes.write();
244 |
245 | assert.calledOnceWith(
246 | fs.writeFileSync,
247 | 'CHANGES.md',
248 | '# Changes\n\n## 1.0.0\n\n- Inception\n' +
249 | ' >\n > - Foo\n > next line\n > - Bar\n >\n'
250 | );
251 | });
252 |
253 | it('fails if changes file has not the right format', async () => {
254 | packageJson();
255 | setChanges('# Something else\n\n## 1.0.0\n\nFoo');
256 |
257 | await changes.write();
258 |
259 | assert.calledOnceWith(console.error, 'Unexpected CHANGES.md file header');
260 | assert.calledOnceWith(process.exit, 1);
261 | });
262 |
263 | it('fails if version is already in changes file', async () => {
264 | packageJson();
265 | setChanges('# Changes\n\n## 1.0.0\n\nFoo');
266 | setLog('foo');
267 |
268 | await changes.write();
269 |
270 | assert.calledWith(
271 | console.error,
272 | 'Version 1.0.0 is already in CHANGES.md\n'
273 | );
274 | assert.calledOnceWith(process.exit, 1);
275 | });
276 |
277 | it('shows outstanding changes if version is already in changes file', async () => {
278 | packageJson();
279 | setChanges('# Changes\n\n## 1.0.0\n\nFoo');
280 | setLog('» Up next (Studio)\n\n\n');
281 |
282 | await changes.write();
283 |
284 | assert.calledWith(console.error, '# Changes for next release:\n');
285 | assert.calledWith(console.error, '- Up next\n');
286 | });
287 |
288 | it('does not show outstanding changes if no new commits where found', async () => {
289 | packageJson();
290 | setChanges('# Changes\n\n## 1.0.0\n\nFoo');
291 | setLog('');
292 |
293 | await changes.write();
294 |
295 | assert.calledWith(
296 | console.error,
297 | 'Version 1.0.0 is already in CHANGES.md\n'
298 | );
299 | refute.calledWith(console.error, '# Changes for next release:\n');
300 | });
301 |
302 | it('works if changes file was checked out with CRLF', async () => {
303 | packageJson();
304 | const initial = '# Changes\r\n\r\n## 0.0.1\r\n\r\n- Inception\r\n';
305 | setChanges(initial);
306 | setLog('» JavaScript (Studio)\n\nWhat else?\n\n\n');
307 |
308 | const state = await changes.write();
309 |
310 | assert.calledOnceWith(
311 | fs.writeFileSync,
312 | 'CHANGES.md',
313 | '# Changes\r\n\r\n' +
314 | '## 1.0.0\r\n\r\n- JavaScript\r\n >\r\n > What else?\r\n\r\n' +
315 | '## 0.0.1\r\n\r\n- Inception\r\n'
316 | );
317 | assert.calledOnce($.execSync);
318 | assert.calledWithMatch($.execSync, 'git log v0.0.1..HEAD');
319 | assert.equals(state.previous, initial);
320 | });
321 |
322 | it('fails if version is already in changes file with CRLF', async () => {
323 | packageJson();
324 | setChanges('# Changes\r\n\r\n## 1.0.0\r\n\r\nFoo');
325 | setLog('foo');
326 |
327 | await changes.write();
328 |
329 | assert.calledWith(
330 | console.error,
331 | 'Version 1.0.0 is already in CHANGES.md\n'
332 | );
333 | assert.calledOnceWith(process.exit, 1);
334 | });
335 |
336 | it('supports custom tag formats when updating a file', async () => {
337 | packageJson();
338 | const initial = '# Changes\n\n## 0.1.0\n\nSome foo.\n';
339 | setChanges(initial);
340 | setLog('» Inception (Studio)\n\n\n');
341 |
342 | const state = await changes.write({
343 | tag_format: '${name}@${version}'
344 | });
345 |
346 | assert.calledOnceWith(
347 | fs.writeFileSync,
348 | 'CHANGES.md',
349 | '# Changes\n\n## 1.0.0\n\n- Inception\n\n## 0.1.0\n\nSome foo.\n'
350 | );
351 | assert.calledOnce($.execSync);
352 | assert.calledWithMatch($.execSync, 'git log @studio/changes@0.1.0..HEAD');
353 | assert.equals(state.previous, initial);
354 | });
355 |
356 | it('uses current directory as the workspace convention', async () => {
357 | packageJson();
358 | const initial = '# Changes\n\n## 0.1.0\n\nSome foo.\n';
359 | setChanges(initial);
360 | setLog('» Inception (Studio)\n\n\n');
361 |
362 | const state = await changes.write({
363 | workspace: true
364 | });
365 |
366 | assert.calledOnceWith(
367 | fs.writeFileSync,
368 | 'CHANGES.md',
369 | '# Changes\n\n## 1.0.0\n\n- Inception\n\n## 0.1.0\n\nSome foo.\n'
370 | );
371 | assert.calledOnce($.execSync);
372 | const current_dir = path.basename(process.cwd());
373 | assert.calledWithMatch($.execSync, `git log ${current_dir}/v0.1.0..HEAD`);
374 | assert.calledWithMatch($.execSync, new RegExp(`-- ${current_dir}$`));
375 | assert.equals(state.previous, initial);
376 | });
377 |
378 | it('adds commits with specified base', async () => {
379 | packageJson();
380 | missingChanges();
381 | setLog(
382 | '» [`cbac1d0`](https://javascript.studio/commit/' +
383 | 'cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)« Message (Author)\n\n\n'
384 | );
385 |
386 | await changes.write({
387 | commits: 'https://javascript.studio/commit'
388 | });
389 |
390 | assert.calledOnceWith(
391 | fs.writeFileSync,
392 | 'CHANGES.md',
393 | '# Changes\n\n## 1.0.0\n\n' +
394 | '- [`cbac1d0`](https://javascript.studio/commit/' +
395 | 'cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)\n Message (Author)\n'
396 | );
397 | assert.calledWithMatch(
398 | $.execSync,
399 | 'git log --format="» [\\`%h\\`](https://javascript.studio/commit/%H)' +
400 | '« %s'
401 | );
402 | });
403 |
404 | it('adds commits with base from package.json homepage + /commit', async () => {
405 | packageJson();
406 | missingChanges();
407 | setLog(
408 | '» [`cbac1d0`](https://github.com/javascript-studio/studio-changes/' +
409 | 'commit/cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)«' +
410 | ' Message (Author)\n\n\n'
411 | );
412 |
413 | await changes.write({
414 | commits: true
415 | });
416 |
417 | assert.calledOnceWith(
418 | fs.writeFileSync,
419 | 'CHANGES.md',
420 | '# Changes\n\n## 1.0.0\n\n' +
421 | '- [`cbac1d0`](https://github.com/javascript-studio/studio-changes/' +
422 | 'commit/cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)\n' +
423 | ' Message (Author)\n'
424 | );
425 | assert.calledWithMatch(
426 | $.execSync,
427 | 'git log --format="» [\\`%h\\`](https://github.com/javascript-studio/' +
428 | 'studio-changes/commit/%H)« %s'
429 | );
430 | });
431 |
432 | it('resolves base from package.json "repository" field', async () => {
433 | packageJson({
434 | name: '@studio/changes',
435 | version: '1.0.0',
436 | repository: {
437 | type: 'git',
438 | url: 'https://github.com/javascript-studio/studio-changes.git'
439 | }
440 | });
441 | missingChanges();
442 | setLog('» Test');
443 |
444 | await changes.write({
445 | commits: true
446 | });
447 |
448 | assert.calledWithMatch(
449 | $.execSync,
450 | 'git log --format="» [\\`%h\\`](https://github.com/javascript-studio/' +
451 | 'studio-changes/commit/%H)« %s'
452 | );
453 | });
454 |
455 | it(`ignores package.json "repository" field and uses "homepage" instead if not
456 | type "git"`, async () => {
457 | packageJson({
458 | name: '@studio/changes',
459 | version: '1.0.0',
460 | repository: {
461 | type: 'foo',
462 | url: 'https://github.com/mantoni/eslint_d.js.git'
463 | },
464 | homepage: 'https://github.com/javascript-studio/studio-changes'
465 | });
466 | missingChanges();
467 | setLog('» Test');
468 |
469 | await changes.write({
470 | commits: true
471 | });
472 |
473 | assert.calledWithMatch(
474 | $.execSync,
475 | 'git log --format="» [\\`%h\\`](https://github.com/javascript-studio/' +
476 | 'studio-changes/commit/%H)« %s'
477 | );
478 | });
479 |
480 | it('fails if repository info cannot be parsed', async () => {
481 | packageJson({
482 | name: '@studio/changes',
483 | version: '1.0.0',
484 | repository: {
485 | type: 'git',
486 | url: 'https://foo.com/mantoni/eslint_d.js.git'
487 | }
488 | });
489 | missingChanges();
490 | setLog('» Test');
491 |
492 | await changes.write({
493 | commits: true
494 | });
495 |
496 | assert.calledWith(
497 | console.error,
498 | 'Failed to parse "repository" from package.json\n'
499 | );
500 | assert.calledOnceWith(process.exit, 1);
501 | });
502 |
503 | it(`fails if --commits but missing "repository" and "homepage" in
504 | package.json`, async () => {
505 | packageJson({
506 | name: '@studio/changes',
507 | version: '1.0.0'
508 | });
509 |
510 | await changes.write({
511 | commits: true
512 | });
513 |
514 | assert.calledWith(
515 | console.error,
516 | '--commits option requires base URL, ' +
517 | '"repository" or "homepage" in package.json\n'
518 | );
519 | assert.calledOnceWith(process.exit, 1);
520 | });
521 |
522 | it('adds commits using base URL template', async () => {
523 | packageJson();
524 | missingChanges();
525 | setLog(
526 | '» [`cbac1d0`](https://github.com/javascript-studio/studio-changes/' +
527 | 'foo/cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)«' +
528 | ' Message (Author)\n\n\n'
529 | );
530 |
531 | await changes.write({
532 | commits: '${homepage}/foo'
533 | });
534 |
535 | assert.calledOnceWith(
536 | fs.writeFileSync,
537 | 'CHANGES.md',
538 | '# Changes\n\n## 1.0.0\n\n' +
539 | '- [`cbac1d0`](https://github.com/javascript-studio/studio-changes/' +
540 | 'foo/cbac1d01d3e7c5d9ab1cf7cd9efee4cfc2988a85)\n' +
541 | ' Message (Author)\n'
542 | );
543 | assert.calledWithMatch(
544 | $.execSync,
545 | 'git log --format="» [\\`%h\\`](https://github.com/javascript-studio/' +
546 | 'studio-changes/foo/%H)« %s'
547 | );
548 | });
549 |
550 | it('generates footer', async () => {
551 | sinon.replace(footer, 'generate', sinon.fake.resolves('**The footer**'));
552 | packageJson();
553 | missingChanges();
554 | setLog('» Inception (Studio)\n\n\n');
555 |
556 | await changes.write({ footer: true });
557 |
558 | assert.calledOnceWith(
559 | fs.writeFileSync,
560 | 'CHANGES.md',
561 | `# Changes\n\n## 1.0.0\n\n- Inception\n\n**The footer**\n`
562 | );
563 | });
564 | });
565 |
--------------------------------------------------------------------------------
/lib/footer.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const $ = require('child_process');
4 | const github = require('./github');
5 |
6 | function buildFooter(author, homepage) {
7 | let footer = '_Released';
8 | if (author) {
9 | footer += homepage ? ` by [${author}](${homepage})` : ` by ${author}`;
10 | }
11 | const today = new Date().toISOString().split('T')[0];
12 | return `${footer} on ${today}._`;
13 | }
14 |
15 | function readGitConfig(name) {
16 | try {
17 | return $.execSync(`git config --get ${name}`).toString().trim();
18 | } catch (e) {
19 | return '';
20 | }
21 | }
22 |
23 | async function generateFooter() {
24 | const author = readGitConfig('user.name') || process.env.GIT_AUTHOR_NAME;
25 | if (author) {
26 | const email = readGitConfig('user.email') || process.env.GIT_AUTHOR_EMAIL;
27 | if (email) {
28 | const homepage = await github.fetchUserHomepage(email);
29 | return buildFooter(author, homepage);
30 | }
31 | return buildFooter(author);
32 | }
33 | return buildFooter();
34 | }
35 |
36 | exports.generate = generateFooter;
37 |
--------------------------------------------------------------------------------
/lib/footer.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const $ = require('child_process');
4 | const { assert, sinon } = require('@sinonjs/referee-sinon');
5 | const github = require('../lib/github');
6 | const footer = require('../lib/footer');
7 |
8 | function today() {
9 | return new Date().toISOString().split('T')[0];
10 | }
11 |
12 | describe('footer', () => {
13 | beforeEach(() => {
14 | delete process.env.GIT_AUTHOR_NAME;
15 | delete process.env.GIT_AUTHOR_EMAIL;
16 | });
17 |
18 | afterEach(() => {
19 | sinon.restore();
20 | delete process.env.GIT_AUTHOR_NAME;
21 | delete process.env.GIT_AUTHOR_EMAIL;
22 | });
23 |
24 | it('generates footer without author', async () => {
25 | sinon.replace($, 'execSync', sinon.fake.throws(new Error()));
26 |
27 | const foot = await footer.generate();
28 |
29 | assert.equals(foot, `_Released on ${today()}._`);
30 | });
31 |
32 | it('generates footer with author from environment variable and without link', async () => {
33 | sinon.replace($, 'execSync', sinon.fake.throws(new Error()));
34 | process.env.GIT_AUTHOR_NAME = 'Maximilian Antoni';
35 |
36 | const foot = await footer.generate();
37 |
38 | assert.equals(foot, `_Released by Maximilian Antoni on ${today()}._`);
39 | });
40 |
41 | it('generates footer with author from git config and without link', async () => {
42 | sinon.replace(
43 | $,
44 | 'execSync',
45 | sinon.fake((cmd) => {
46 | if (cmd === 'git config --get user.name') {
47 | return Buffer.from('Maximilian Antoni\n');
48 | }
49 | throw new Error();
50 | })
51 | );
52 |
53 | const foot = await footer.generate();
54 |
55 | assert.calledTwice($.execSync);
56 | assert.calledWith($.execSync, 'git config --get user.name');
57 | assert.calledWith($.execSync, 'git config --get user.email');
58 | assert.equals(foot, `_Released by Maximilian Antoni on ${today()}._`);
59 | });
60 |
61 | it('generates footer with author from environment variable and github homepage link', async () => {
62 | sinon.replace(
63 | github,
64 | 'fetchUserHomepage',
65 | sinon.fake.resolves('https://github.com/mantoni')
66 | );
67 | sinon.replace(
68 | $,
69 | 'execSync',
70 | sinon.fake((cmd) => {
71 | if (cmd === 'git config --get user.name') {
72 | return Buffer.from('Maximilian Antoni\n');
73 | }
74 | return Buffer.from('mail@maxantoni.de\n');
75 | })
76 | );
77 |
78 | const foot = await footer.generate();
79 |
80 | assert.calledOnceWith(github.fetchUserHomepage, 'mail@maxantoni.de');
81 | assert.equals(
82 | foot,
83 | '_Released by [Maximilian Antoni](https://github.com/mantoni) on ' +
84 | `${today()}._`
85 | );
86 | });
87 |
88 | it('generates footer with author from git config and github homepage link', async () => {
89 | sinon.replace(
90 | github,
91 | 'fetchUserHomepage',
92 | sinon.fake.resolves('https://github.com/mantoni')
93 | );
94 | process.env.GIT_AUTHOR_NAME = 'Maximilian Antoni';
95 | process.env.GIT_AUTHOR_EMAIL = 'mail@maxantoni.de';
96 |
97 | const foot = await footer.generate();
98 |
99 | assert.calledOnceWith(github.fetchUserHomepage, 'mail@maxantoni.de');
100 | assert.equals(
101 | foot,
102 | '_Released by [Maximilian Antoni](https://github.com/mantoni) on ' +
103 | `${today()}._`
104 | );
105 | });
106 |
107 | it('fails if github homepage link can not be retrieved', async () => {
108 | const error = new Error('Oh noes!');
109 | sinon.replace(github, 'fetchUserHomepage', sinon.fake.rejects(error));
110 | process.env.GIT_AUTHOR_NAME = 'Maximilian Antoni';
111 | process.env.GIT_AUTHOR_EMAIL = 'mail@maxantoni.de';
112 |
113 | const promise = footer.generate();
114 |
115 | await assert.rejects(promise, error);
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/lib/github.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) Maximilian Antoni
3 | *
4 | * @license MIT
5 | */
6 | 'use strict';
7 |
8 | const { promisify } = require('util');
9 | const request = promisify(require('@studio/json-request'));
10 |
11 | exports.fetchUserHomepage = async function (email) {
12 | const json = await request({
13 | hostname: 'api.github.com',
14 | path: `/search/users?q=${encodeURIComponent(email)}&in=email`,
15 | headers: {
16 | 'User-Agent': '@studio/changes'
17 | },
18 | timeout: 5000
19 | });
20 |
21 | if (json.items && json.items.length === 1) {
22 | return json.items[0].html_url;
23 | }
24 | return null;
25 | };
26 |
--------------------------------------------------------------------------------
/lib/github.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const https = require('https');
4 | const EventEmitter = require('events');
5 | const { assert, sinon, match } = require('@sinonjs/referee-sinon');
6 | const github = require('../lib/github');
7 |
8 | describe('github', () => {
9 | let clock;
10 | let request;
11 |
12 | beforeEach(() => {
13 | clock = sinon.useFakeTimers();
14 | request = new EventEmitter();
15 | request.end = sinon.fake();
16 | request.abort = sinon.fake();
17 | sinon.replace(https, 'request', sinon.fake.returns(request));
18 | });
19 |
20 | afterEach(() => {
21 | sinon.restore();
22 | });
23 |
24 | it('searches user for given email', () => {
25 | github.fetchUserHomepage('mail@maxantoni.de');
26 |
27 | assert.calledOnceWith(https.request, {
28 | hostname: 'api.github.com',
29 | path: '/search/users?q=mail%40maxantoni.de&in=email',
30 | headers: { 'User-Agent': '@studio/changes' }
31 | });
32 | });
33 |
34 | it('yields error on timeout', async () => {
35 | const promise = github.fetchUserHomepage('mail@maxantoni.de');
36 | clock.tick(5000);
37 |
38 | assert.calledOnce(request.abort);
39 | await assert.rejects(
40 | promise,
41 | match({
42 | code: 'E_TIMEOUT'
43 | })
44 | );
45 | });
46 |
47 | function respond(json) {
48 | const response = new EventEmitter();
49 | response.setEncoding = () => {};
50 | response.headers = { 'content-type': 'application/json' };
51 | response.statusCode = 200;
52 | https.request.callback(response);
53 | response.emit('data', JSON.stringify(json));
54 | response.emit('end');
55 | }
56 |
57 | it('resolves to null if no results', async () => {
58 | const promise = github.fetchUserHomepage('mail@maxantoni.de');
59 | respond({ items: [] });
60 |
61 | await assert.resolves(promise, null);
62 | });
63 |
64 | it('resolves to null if more than one result', async () => {
65 | const promise = github.fetchUserHomepage('mail@maxantoni.de');
66 | respond({ items: [{}, {}] });
67 |
68 | await assert.resolves(promise, null);
69 | });
70 |
71 | it('resolves to homepage if exactly one result', async () => {
72 | const html_url = 'https://github.com/mantoni';
73 |
74 | const promise = github.fetchUserHomepage('mail@maxantoni.de');
75 | respond({ items: [{ html_url }] });
76 |
77 | await assert.resolves(promise, html_url);
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/lib/init.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) Maximilian Antoni
3 | *
4 | * @license MIT
5 | */
6 | 'use strict';
7 |
8 | const fs = require('fs');
9 | const detectIndent = require('detect-indent');
10 |
11 | function addScript(scripts, name, source) {
12 | if (!scripts[name]) {
13 | scripts[name] = source;
14 | return true;
15 | }
16 | return false;
17 | }
18 |
19 | function isScopedPublicPackage(pkg) {
20 | return (
21 | pkg.private === false &&
22 | typeof pkg.name === 'string' &&
23 | pkg.name.indexOf('@') === 0
24 | );
25 | }
26 |
27 | module.exports = function (argv) {
28 | const json = fs.readFileSync('package.json', 'utf8');
29 | const pkg = JSON.parse(json);
30 |
31 | let scripts = pkg.scripts;
32 | if (!scripts) {
33 | scripts = pkg.scripts = {};
34 | }
35 | if (scripts.version) {
36 | return false;
37 | }
38 |
39 | let version_script = 'changes';
40 | let has_commits = false;
41 | if (argv) {
42 | if (argv.file) {
43 | version_script += ` --file ${argv.file}`;
44 | }
45 | if (argv.commits) {
46 | if (typeof argv.commits === 'boolean') {
47 | if (!pkg.homepage) {
48 | console.error(
49 | '--commits option requires base URL or "homepage" in ' +
50 | 'package.json\n'
51 | );
52 | return false;
53 | }
54 | } else {
55 | version_script += ` --commits ${argv.commits}`;
56 | has_commits = true;
57 | }
58 | }
59 | }
60 |
61 | let postversion_script = 'git push --follow-tags && npm publish';
62 | if (isScopedPublicPackage(pkg)) {
63 | postversion_script += ' --access public';
64 | }
65 |
66 | if (!has_commits && pkg.homepage) {
67 | version_script += ' --commits';
68 | }
69 |
70 | addScript(scripts, 'preversion', 'npm test');
71 | addScript(scripts, 'version', version_script);
72 | addScript(scripts, 'postversion', postversion_script);
73 |
74 | const indent = detectIndent(json).indent || ' ';
75 | const out = JSON.stringify(pkg, null, indent);
76 | fs.writeFileSync('package.json', `${out}\n`, 'utf8');
77 | return true;
78 | };
79 |
--------------------------------------------------------------------------------
/lib/init.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const { assert, refute, sinon } = require('@sinonjs/referee-sinon');
5 | const init = require('../lib/init');
6 |
7 | const SCRIPT_PREVERSION = 'npm test';
8 | const SCRIPT_VERSION = 'changes';
9 | const SCRIPT_POSTVERSION = 'git push --follow-tags && npm publish';
10 |
11 | describe('init', () => {
12 | beforeEach(() => {
13 | sinon.stub(fs, 'readFileSync');
14 | sinon.stub(fs, 'writeFileSync');
15 | fs.readFileSync.withArgs('package.json').returns(
16 | JSON.stringify({
17 | version: '1.0.0',
18 | author: 'Studio '
19 | })
20 | );
21 | sinon.stub(console, 'error');
22 | });
23 |
24 | afterEach(() => {
25 | sinon.restore();
26 | });
27 |
28 | it('adds entire scripts section with default indent', () => {
29 | fs.readFileSync.withArgs('package.json').returns('{}');
30 |
31 | const result = init();
32 |
33 | assert.isTrue(result);
34 | assert.calledOnceWith(
35 | fs.writeFileSync,
36 | 'package.json',
37 | `{
38 | "scripts": {
39 | "preversion": "${SCRIPT_PREVERSION}",
40 | "version": "${SCRIPT_VERSION}",
41 | "postversion": "${SCRIPT_POSTVERSION}"
42 | }
43 | }
44 | `,
45 | 'utf8'
46 | );
47 | });
48 |
49 | it('adds scripts to existing scripts with 4 space indent', () => {
50 | fs.readFileSync.withArgs('package.json').returns(`{
51 | "scripts": {
52 | "test": "echo 'no tests'"
53 | }
54 | }`);
55 |
56 | const result = init();
57 |
58 | assert.isTrue(result);
59 | assert.calledOnceWith(
60 | fs.writeFileSync,
61 | 'package.json',
62 | `{
63 | "scripts": {
64 | "test": "echo 'no tests'",
65 | "preversion": "${SCRIPT_PREVERSION}",
66 | "version": "${SCRIPT_VERSION}",
67 | "postversion": "${SCRIPT_POSTVERSION}"
68 | }
69 | }
70 | `
71 | );
72 | });
73 |
74 | it('does not replace existing "preversion" script', () => {
75 | fs.readFileSync.withArgs('package.json').returns(`{
76 | "scripts": {
77 | "preversion": "echo 'Already defined'"
78 | }
79 | }`);
80 |
81 | const result = init();
82 |
83 | assert.isTrue(result);
84 | assert.calledOnceWith(
85 | fs.writeFileSync,
86 | 'package.json',
87 | `{
88 | "scripts": {
89 | "preversion": "echo 'Already defined'",
90 | "version": "${SCRIPT_VERSION}",
91 | "postversion": "${SCRIPT_POSTVERSION}"
92 | }
93 | }
94 | `
95 | );
96 | });
97 |
98 | it('does not replace existing "postversion" script', () => {
99 | fs.readFileSync.withArgs('package.json').returns(`{
100 | "scripts": {
101 | "postversion": "echo 'Already defined'"
102 | }
103 | }`);
104 |
105 | const result = init();
106 |
107 | assert.isTrue(result);
108 | assert.calledOnceWith(
109 | fs.writeFileSync,
110 | 'package.json',
111 | `{
112 | "scripts": {
113 | "postversion": "echo 'Already defined'",
114 | "preversion": "${SCRIPT_PREVERSION}",
115 | "version": "${SCRIPT_VERSION}"
116 | }
117 | }
118 | `
119 | );
120 | });
121 |
122 | it('does nothing if "version" script is already defined', () => {
123 | fs.readFileSync.withArgs('package.json').returns(`{
124 | "scripts": {
125 | "version": "echo 'Already defined'"
126 | }
127 | }`);
128 |
129 | const result = init();
130 |
131 | assert.isFalse(result);
132 | refute.called(fs.writeFileSync);
133 | });
134 |
135 | it('adds --file options if passed', () => {
136 | fs.readFileSync.withArgs('package.json').returns('{}');
137 |
138 | const result = init({ file: 'changelog.md' });
139 |
140 | assert.isTrue(result);
141 | assert.calledOnceWith(
142 | fs.writeFileSync,
143 | 'package.json',
144 | `{
145 | "scripts": {
146 | "preversion": "${SCRIPT_PREVERSION}",
147 | "version": "${SCRIPT_VERSION} --file changelog.md",
148 | "postversion": "${SCRIPT_POSTVERSION}"
149 | }
150 | }
151 | `
152 | );
153 | });
154 |
155 | it('adds --commits if homepage is configured', () => {
156 | fs.readFileSync.withArgs('package.json').returns(`{
157 | "homepage": "https://github.com/javascript-studio/studio-changes"
158 | }`);
159 |
160 | const result = init();
161 |
162 | assert.isTrue(result);
163 | assert.calledOnceWith(
164 | fs.writeFileSync,
165 | 'package.json',
166 | `{
167 | "homepage": "https://github.com/javascript-studio/studio-changes",
168 | "scripts": {
169 | "preversion": "${SCRIPT_PREVERSION}",
170 | "version": "${SCRIPT_VERSION} --commits",
171 | "postversion": "${SCRIPT_POSTVERSION}"
172 | }
173 | }
174 | `
175 | );
176 | });
177 |
178 | it('adds --commits option if passed', () => {
179 | fs.readFileSync.withArgs('package.json').returns('{}');
180 |
181 | const result = init({ commits: 'https://javascript.studio' });
182 |
183 | assert.isTrue(result);
184 | assert.calledOnceWith(
185 | fs.writeFileSync,
186 | 'package.json',
187 | `{
188 | "scripts": {
189 | "preversion": "${SCRIPT_PREVERSION}",
190 | "version": "${SCRIPT_VERSION} --commits https://javascript.studio",
191 | "postversion": "${SCRIPT_POSTVERSION}"
192 | }
193 | }
194 | `
195 | );
196 | });
197 |
198 | it('adds --commits if homepage is configured and --commits is given', () => {
199 | fs.readFileSync.withArgs('package.json').returns(`{
200 | "homepage": "https://github.com/javascript-studio/studio-changes"
201 | }`);
202 |
203 | const result = init({ commits: true }); // no argument provided, but present
204 |
205 | assert.isTrue(result);
206 | assert.calledOnceWith(
207 | fs.writeFileSync,
208 | 'package.json',
209 | `{
210 | "homepage": "https://github.com/javascript-studio/studio-changes",
211 | "scripts": {
212 | "preversion": "${SCRIPT_PREVERSION}",
213 | "version": "${SCRIPT_VERSION} --commits",
214 | "postversion": "${SCRIPT_POSTVERSION}"
215 | }
216 | }
217 | `
218 | );
219 | });
220 |
221 | it('fails if --commits is given but homepage is missing', () => {
222 | fs.readFileSync.withArgs('package.json').returns('{}');
223 |
224 | const result = init({ commits: true }); // no argument provided, but present
225 |
226 | assert.isFalse(result);
227 | refute.called(fs.writeFileSync);
228 | assert.calledOnceWith(
229 | console.error,
230 | '--commits option requires base URL or "homepage" in package.json\n'
231 | );
232 | });
233 |
234 | it('adds --file and --commits options if passed', () => {
235 | fs.readFileSync.withArgs('package.json').returns('{}');
236 |
237 | const result = init({
238 | file: 'changelog.md',
239 | commits: 'https://studio'
240 | });
241 |
242 | assert.isTrue(result);
243 | assert.calledOnceWith(
244 | fs.writeFileSync,
245 | 'package.json',
246 | `{
247 | "scripts": {
248 | "preversion": "${SCRIPT_PREVERSION}",
249 | "version": "${SCRIPT_VERSION} --file changelog.md --commits https://studio",
250 | "postversion": "${SCRIPT_POSTVERSION}"
251 | }
252 | }
253 | `
254 | );
255 | });
256 |
257 | it('prefers explicitly specified --commits config over homepage', () => {
258 | fs.readFileSync.withArgs('package.json').returns(`{
259 | "homepage": "https://github.com/javascript-studio/studio-changes"
260 | }`);
261 |
262 | const result = init({ commits: 'https://javascript.studio' });
263 |
264 | assert.isTrue(result);
265 | assert.calledOnceWith(
266 | fs.writeFileSync,
267 | 'package.json',
268 | `{
269 | "homepage": "https://github.com/javascript-studio/studio-changes",
270 | "scripts": {
271 | "preversion": "${SCRIPT_PREVERSION}",
272 | "version": "${SCRIPT_VERSION} --commits https://javascript.studio",
273 | "postversion": "${SCRIPT_POSTVERSION}"
274 | }
275 | }
276 | `
277 | );
278 | });
279 |
280 | it('combines --file with package.json homepage', () => {
281 | fs.readFileSync.withArgs('package.json').returns(`{
282 | "homepage": "https://github.com/javascript-studio/studio-changes"
283 | }`);
284 |
285 | const result = init({ file: 'changelog.md' });
286 |
287 | assert.isTrue(result);
288 | assert.calledOnceWith(
289 | fs.writeFileSync,
290 | 'package.json',
291 | `{
292 | "homepage": "https://github.com/javascript-studio/studio-changes",
293 | "scripts": {
294 | "preversion": "${SCRIPT_PREVERSION}",
295 | "version": "${SCRIPT_VERSION} --file changelog.md --commits",
296 | "postversion": "${SCRIPT_POSTVERSION}"
297 | }
298 | }
299 | `
300 | );
301 | });
302 |
303 | it('automatically passes `--access public` to scoped public packages', () => {
304 | fs.readFileSync
305 | .withArgs('package.json')
306 | .returns('{"name":"@studio/changes","private":false}');
307 |
308 | const result = init();
309 |
310 | assert.isTrue(result);
311 | assert.calledOnceWith(
312 | fs.writeFileSync,
313 | 'package.json',
314 | `{
315 | "name": "@studio/changes",
316 | "private": false,
317 | "scripts": {
318 | "preversion": "${SCRIPT_PREVERSION}",
319 | "version": "${SCRIPT_VERSION}",
320 | "postversion": "${SCRIPT_POSTVERSION} --access public"
321 | }
322 | }
323 | `,
324 | 'utf8'
325 | );
326 | });
327 |
328 | it('does not pass `--access` to packages implicitly restricted by a scoped name', () => {
329 | fs.readFileSync
330 | .withArgs('package.json')
331 | .returns('{"name":"@acme-corp/ledger-tool"}');
332 |
333 | const result = init();
334 |
335 | assert.isTrue(result);
336 | assert.calledOnceWith(
337 | fs.writeFileSync,
338 | 'package.json',
339 | `{
340 | "name": "@acme-corp/ledger-tool",
341 | "scripts": {
342 | "preversion": "${SCRIPT_PREVERSION}",
343 | "version": "${SCRIPT_VERSION}",
344 | "postversion": "${SCRIPT_POSTVERSION}"
345 | }
346 | }
347 | `,
348 | 'utf8'
349 | );
350 | });
351 | });
352 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@studio/changes",
3 | "version": "3.0.0",
4 | "description": "Generate a changelog as part of the npm version command",
5 | "bin": {
6 | "changes": "bin/cmd.js"
7 | },
8 | "main": "lib/changes.js",
9 | "scripts": {
10 | "lint": "eslint .",
11 | "test": "mocha '**/*.test.js'",
12 | "watch": "npm test -- --watch",
13 | "preversion": "npm run lint && npm run prettier:check && npm test",
14 | "version": "bin/cmd.js -c --footer",
15 | "postversion": "git push --follow-tags && npm publish",
16 | "prettier:check": "prettier --check '**/*.{js,json,md}'",
17 | "prettier:write": "prettier --write '**/*.{js,json,md}'",
18 | "prepare": "husky install"
19 | },
20 | "keywords": [
21 | "changelog",
22 | "version",
23 | "release",
24 | "productivity"
25 | ],
26 | "author": "Maximilian Antoni ",
27 | "contributors": [
28 | "Blade Barringer ",
29 | "Pat Cavit "
30 | ],
31 | "homepage": "https://github.com/javascript-studio/studio-changes",
32 | "eslintConfig": {
33 | "extends": "@studio",
34 | "rules": {
35 | "n/no-sync": 0,
36 | "n/no-process-exit": 0,
37 | "no-template-curly-in-string": 0
38 | }
39 | },
40 | "mocha": {
41 | "require": "test/hooks.js",
42 | "ignore": "node_modules/**",
43 | "reporter": "dot"
44 | },
45 | "dependencies": {
46 | "@studio/editor": "^1.1.1",
47 | "@studio/json-request": "^3.0.1",
48 | "detect-indent": "^6.1.0",
49 | "hosted-git-info": "^7.0.1",
50 | "minimist": "^1.2.8"
51 | },
52 | "devDependencies": {
53 | "@sinonjs/referee-sinon": "^11.0.0",
54 | "@studio/eslint-config": "^6.0.0",
55 | "@studio/related-tests": "^0.2.0",
56 | "eslint": "^8.56.0",
57 | "husky": "^8.0.3",
58 | "lint-staged": "^15.2.0",
59 | "mocha": "^10.2.0",
60 | "prettier": "^3.1.1"
61 | },
62 | "repository": {
63 | "type": "git",
64 | "url": "https://github.com/javascript-studio/studio-changes.git"
65 | },
66 | "files": [
67 | "CHANGES.md",
68 | "**/*.js",
69 | "!**/*.test.js",
70 | "!test/**",
71 | "!.*"
72 | ],
73 | "license": "MIT"
74 | }
75 |
--------------------------------------------------------------------------------
/test/hooks.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { sinon } = require('@sinonjs/referee-sinon');
4 |
5 | exports.mochaHooks = {
6 | afterEach() {
7 | sinon.restore();
8 | }
9 | };
10 |
--------------------------------------------------------------------------------