Compare commits
41 Commits
lord7-sche
...
main
Author | SHA1 | Date | |
---|---|---|---|
e69858f55e | |||
bc66ea09a2 | |||
04e9e5f71a | |||
f6141b139e | |||
9b9c15ecc4 | |||
a841165c51 | |||
251dce5b56 | |||
f759989698 | |||
09d60859e1 | |||
8faa2ef1d8 | |||
3f258a6e23 | |||
1c7b6158f4 | |||
031f2cb825 | |||
5ba75a8e3d | |||
2e9d0d4b66 | |||
bef10869ce | |||
d826b4191d | |||
b10b599585 | |||
167f631d1f | |||
e7f70f3aec | |||
363c195477 | |||
0d34b92358 | |||
d9f4244e3e | |||
467251bab6 | |||
1b25881ede | |||
063ee5d02b | |||
7d8cccf7f8 | |||
edfaf81b14 | |||
c8fb899d0d | |||
6cbfd2ed2b | |||
4f84450979 | |||
0d1fca8dbb | |||
13f443b61c | |||
385c09896a | |||
36e2aade92 | |||
3f266fa068 | |||
a326be7211 | |||
8f8a16d025 | |||
0ed4ca9cab | |||
01e5381b77 | |||
7336efacbb |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
*.tgz
|
||||
|
6
.npmignore
Normal file
6
.npmignore
Normal file
@ -0,0 +1,6 @@
|
||||
_templates
|
||||
Taskfile.yaml
|
||||
*.tgz
|
||||
prettier.config.*
|
||||
changelog-next/
|
||||
*.test.js
|
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Changelog for [changelord][homepage]
|
||||
|
||||
|
||||
|
||||
## 0.2.0 2023-05-18
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add `git-gather` command
|
||||
* add 'next' to alias to 'upcoming'
|
||||
* cutting a release also add a new NEXT release
|
||||
* git-gather also filters on descs
|
||||
* add the validate command
|
||||
* support changelog-next directory
|
||||
|
||||
### Statistics
|
||||
|
||||
* code churn: 21 files changed, 563 insertions(+), 153 deletions(-)
|
||||
|
||||
|
||||
|
||||
## 0.1.0 2023-05-16
|
||||
|
||||
* port the core of the Perl changelord to JavaScript.
|
||||
|
||||
### Statistics
|
||||
|
||||
* code churn: 23 files changed, 767 insertions(+)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[homepage]: https://git.babyl.ca/yanick/changelord.js
|
@ -1,12 +1,41 @@
|
||||
project:
|
||||
name: null
|
||||
homepage: null
|
||||
name: changelord
|
||||
homepage: https://git.babyl.ca/yanick/changelord.js
|
||||
with_stats: true
|
||||
ticket_url: null
|
||||
next_directory: ./changelog-next
|
||||
releases:
|
||||
- version: NEXT
|
||||
changes: []
|
||||
- version: 0.2.0
|
||||
changes:
|
||||
- type: feat
|
||||
desc: add `git-gather` command
|
||||
- type: feat
|
||||
desc: add 'next' to alias to 'upcoming'
|
||||
- type: feat
|
||||
desc: cutting a release also add a new NEXT release
|
||||
- type: feat
|
||||
desc: git-gather also filters on descs
|
||||
- type: feat
|
||||
desc: add the validate command
|
||||
- desc: support changelog-next directory
|
||||
type: feat
|
||||
- type: stats
|
||||
desc: |
|
||||
code churn: 21 files changed, 563 insertions(+), 153 deletions(-)
|
||||
date: 2023-05-18
|
||||
- version: 0.1.0
|
||||
changes:
|
||||
- port the core of the Perl changelord to JavaScript.
|
||||
- type: stats
|
||||
desc: |
|
||||
code churn: 23 files changed, 767 insertions(+)
|
||||
date: 2023-05-16
|
||||
change_types:
|
||||
- title: ""
|
||||
level: minor
|
||||
keywords:
|
||||
- ""
|
||||
- title: Features
|
||||
level: minor
|
||||
keywords:
|
||||
|
130
README.md
Normal file
130
README.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Changelord, registrar of deeds extraordinaire
|
||||
|
||||
Changelord is a changelog manager scratching my particular itches.
|
||||
It's cli-based, and keep its data in a YAML file adhering
|
||||
to a well-defined schema.
|
||||
|
||||
The first iteration of `changelord` was written [in Perl][original]. You can
|
||||
read its [introductory article][blog] on my blog.
|
||||
|
||||
## Installation
|
||||
|
||||
pnpm install changelord
|
||||
|
||||
## `changelog-next` directory
|
||||
|
||||
If you want to mininize merge conflicts in `CHANGELOG.yml`,
|
||||
you can set the option `project.next_directory` to a directory (typically
|
||||
`./changelog-next`) that will hold yaml files containing the
|
||||
changes for the NEXT release. Each of those files is expected to
|
||||
have a list of changes.
|
||||
|
||||
## CLI commands
|
||||
|
||||
### Global options
|
||||
|
||||
#### `--help`
|
||||
|
||||
Outputs the list of commands and options.
|
||||
|
||||
#### `--version`
|
||||
|
||||
Outputs the `changelord` version.
|
||||
|
||||
#### `--source`
|
||||
|
||||
Specifies which source yaml file to use. Defaults to the `CHANGELOG.yml` file
|
||||
in the current directory.
|
||||
|
||||
### `changelord init`
|
||||
|
||||
Initializes the changelog source file. The YAML file is made of three
|
||||
sections.
|
||||
|
||||
- `project` -- contains information and configuration about the project itself.
|
||||
- `releases` -- the entries for the changelog proper.
|
||||
- `change_types` -- defines all types of changes this project supports.
|
||||
|
||||
### `changelord add`
|
||||
|
||||
Adds an entry to the `NEXT` release.
|
||||
|
||||
If `project.next_directory` is defined, the entry will be added to that
|
||||
directory instead of directly into `CHANGELOG.yml`.
|
||||
|
||||
$ changelord add --type=maint added a changelog to the project.
|
||||
|
||||
#### Options
|
||||
|
||||
- `--type` -- type of change.
|
||||
- `--ticket` -- associated ticket.
|
||||
|
||||
### `changelord print`
|
||||
|
||||
Renders the changelog as markdown.
|
||||
|
||||
#### Options
|
||||
|
||||
- `--no-next` -- don't show the `NEXT` section.
|
||||
|
||||
### `changelord cut`
|
||||
|
||||
Cuts the next release. That is, resolves the `NEXT` version number based on the
|
||||
latest version and the changes in the `NEXT` section, and sets its date as
|
||||
today. Modifies the source file with the result.
|
||||
|
||||
If the `project.next_directory` option is present,
|
||||
all the changes in that directory are
|
||||
merged to `CHANGELOG.yml` and the files themselves are deleted.
|
||||
|
||||
#### Options
|
||||
|
||||
- `--dry` -- Resolves the next version but only outputs the resulting section
|
||||
without changing the source file.
|
||||
|
||||
### `changelord schema`
|
||||
|
||||
Outputs the JSON schema defining the structure of the source file.
|
||||
|
||||
### `changelord upcoming`
|
||||
|
||||
Outputs the changes listed in the `NEXT` release.
|
||||
|
||||
### `changelord latest-version`
|
||||
|
||||
Outputs the latest non-NEXT release.
|
||||
|
||||
$ changelord latest-version
|
||||
3.2.0
|
||||
|
||||
### `changelord validate`
|
||||
|
||||
Validates the changelog source against its json schema.
|
||||
|
||||
### `changelord git-gather`
|
||||
|
||||
Gathers change entries from git commits. If any are found, they are
|
||||
added to the changelog.
|
||||
|
||||
#### Lower bound of the git log
|
||||
|
||||
`git-gather` inspects the git log from the most recent of those
|
||||
three points:
|
||||
|
||||
- The last change in the NEXT release having a `commit` property.
|
||||
- The last tagged version.
|
||||
- The beginning of time.
|
||||
|
||||
#### Change-like git message
|
||||
|
||||
Git messages are compared to the regular expression
|
||||
configured at `project.commit_regex`. If none is found, it
|
||||
defaults to
|
||||
|
||||
^(?<type>[^: ]+):\s*(?<desc>.*?)(\[(?<ticket>[^\]]+)\])?$
|
||||
|
||||
The regular expression must capture a `desc` field, and may
|
||||
capture a `type` and `ticket` as well.
|
||||
|
||||
[blog]: https://techblog.babyl.ca/entry/changelord/
|
||||
[original]: https://metacpan.org/dist/App-Changelord/view/bin/changelord
|
22
Taskfile.yaml
Normal file
22
Taskfile.yaml
Normal file
@ -0,0 +1,22 @@
|
||||
# https://taskfile.dev
|
||||
version: "3"
|
||||
|
||||
vars:
|
||||
PARENT_BRANCH: main
|
||||
|
||||
tasks:
|
||||
test: vitest run src
|
||||
test:dev: vitest src
|
||||
|
||||
integrate:
|
||||
deps: [test]
|
||||
preconditions:
|
||||
- sh: git is-clean
|
||||
msg: checkout not clean
|
||||
- sh: git diff-ls {{.PARENT_BRANCH}} | grep test
|
||||
msg: no tests were added
|
||||
- sh: git diff-ls {{.PARENT_BRANCH}} | grep CHANGELOG.yml
|
||||
msg: no changelog entry detected
|
||||
cmds:
|
||||
- git checkout {{.PARENT_BRANCH}}
|
||||
- git weld -
|
17
_templates/command/new/command.ejs.t
Normal file
17
_templates/command/new/command.ejs.t
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
to: src/command/<%= name %>.js
|
||||
---
|
||||
|
||||
const handler = async (config) => {
|
||||
// DO SOMETHING
|
||||
};
|
||||
|
||||
export default {
|
||||
command: '<%= name %>',
|
||||
desc : 'TODO',
|
||||
builder: (yargs) => {
|
||||
yargs
|
||||
},
|
||||
handler,
|
||||
}
|
||||
|
5
_templates/generator/help/index.ejs.t
Normal file
5
_templates/generator/help/index.ejs.t
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
message: |
|
||||
hygen {bold generator new} --name [NAME] --action [ACTION]
|
||||
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
|
||||
---
|
18
_templates/generator/new/hello.ejs.t
Normal file
18
_templates/generator/new/hello.ejs.t
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
to: _templates/<%= name %>/<%= action || 'new' %>/hello.ejs.t
|
||||
---
|
||||
---
|
||||
to: app/hello.js
|
||||
---
|
||||
const hello = ```
|
||||
Hello!
|
||||
This is your first hygen template.
|
||||
|
||||
Learn what it can do here:
|
||||
|
||||
https://github.com/jondot/hygen
|
||||
```
|
||||
|
||||
console.log(hello)
|
||||
|
||||
|
18
_templates/generator/with-prompt/hello.ejs.t
Normal file
18
_templates/generator/with-prompt/hello.ejs.t
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
to: _templates/<%= name %>/<%= action || 'new' %>/hello.ejs.t
|
||||
---
|
||||
---
|
||||
to: app/hello.js
|
||||
---
|
||||
const hello = ```
|
||||
Hello!
|
||||
This is your first prompt based hygen template.
|
||||
|
||||
Learn what it can do here:
|
||||
|
||||
https://github.com/jondot/hygen
|
||||
```
|
||||
|
||||
console.log(hello)
|
||||
|
||||
|
14
_templates/generator/with-prompt/prompt.ejs.t
Normal file
14
_templates/generator/with-prompt/prompt.ejs.t
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
to: _templates/<%= name %>/<%= action || 'new' %>/prompt.js
|
||||
---
|
||||
|
||||
// see types of prompts:
|
||||
// https://github.com/enquirer/enquirer/tree/master/examples
|
||||
//
|
||||
module.exports = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'message',
|
||||
message: "What's your message?"
|
||||
}
|
||||
]
|
4
_templates/init/repo/new-repo.ejs.t
Normal file
4
_templates/init/repo/new-repo.ejs.t
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
setup: <%= name %>
|
||||
force: true # this is because mostly, people init into existing folders is safe
|
||||
---
|
26
package.json
26
package.json
@ -1,12 +1,20 @@
|
||||
{
|
||||
"name": "changelord",
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.0",
|
||||
"description": "cli-based changelog manager",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"main": "src/changelord.js",
|
||||
"bin": {
|
||||
"changelord": "./src/changelord.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.babyl.ca/yanick/changelord.js.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://git.babyl.ca/yanick/changelord.js/issues"
|
||||
},
|
||||
"homepage": "https://git.babyl.ca/yanick/changelord.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
@ -16,12 +24,24 @@
|
||||
"author": "Yanick Champoux <yanick@babyl.ca> (http://techblog.babyl.ca/)",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@yanick/updeep-remeda": "^2.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"consola": "^3.1.0",
|
||||
"filenamify": "^6.0.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"globby": "^13.1.4",
|
||||
"markdown-utils": "^1.0.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"remeda": "^1.14.0",
|
||||
"semver": "^7.5.0",
|
||||
"simple-git": "^3.18.0",
|
||||
"yaml": "^2.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.8.8"
|
||||
"prettier": "^2.8.8",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "^0.31.0"
|
||||
}
|
||||
}
|
||||
|
9
prettier.config.mjs
Normal file
9
prettier.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
export default {
|
||||
endOfLine: "lf",
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
tabWidth: 2,
|
||||
trailingComma: "es5",
|
||||
bracketSpacing: true,
|
||||
proseWrap: "always",
|
||||
};
|
118
src/changelog-schema.js
Normal file
118
src/changelog-schema.js
Normal file
@ -0,0 +1,118 @@
|
||||
export default {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
change_types: {
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
keywords: {
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
type: "array",
|
||||
},
|
||||
level: {
|
||||
enum: ["major", "minor", "patch"],
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
},
|
||||
type: "array",
|
||||
},
|
||||
project: {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
homepage: {
|
||||
description: "url of the project's homepage",
|
||||
examples: ["https://github.com/yanick/app-changelord"],
|
||||
type: ["string", "null"],
|
||||
},
|
||||
name: {
|
||||
description: "name of the project",
|
||||
examples: ["App::Changelord"],
|
||||
type: ["null", "string"],
|
||||
},
|
||||
ticket_url: {
|
||||
description:
|
||||
"perl code that takes a ticket string (e.g. 'GH123') via the `$_` variable and turns it into a link.",
|
||||
examples: [
|
||||
"s!GH(\\d+)!https://github.com/yanick/App-Changelord/issue/$1/",
|
||||
{
|
||||
'/^\\d+$/ ? "https://.../$_"': "undef",
|
||||
},
|
||||
],
|
||||
type: "string",
|
||||
},
|
||||
with_stats: {
|
||||
description: "if true, add git statistics when bumping the version.",
|
||||
},
|
||||
next_directory: {
|
||||
type: "string",
|
||||
description:
|
||||
"directory where the changes for the NEXT release are stashed",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
},
|
||||
releases: {
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
changes: {
|
||||
items: {
|
||||
$ref: "#/$defs/change",
|
||||
},
|
||||
type: "array",
|
||||
},
|
||||
date: {
|
||||
type: ["null", "string"],
|
||||
},
|
||||
version: {
|
||||
type: ["null", "string"],
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
},
|
||||
],
|
||||
},
|
||||
type: "array",
|
||||
},
|
||||
},
|
||||
$defs: {
|
||||
change: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
commit: {
|
||||
type: ["string", "null"],
|
||||
},
|
||||
desc: {
|
||||
type: "string",
|
||||
},
|
||||
ticket: {
|
||||
type: ["string", "null"],
|
||||
},
|
||||
type: {
|
||||
type: ["string", "null"],
|
||||
},
|
||||
},
|
||||
required: ["desc"],
|
||||
type: "object",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
@ -1,59 +0,0 @@
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
project:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
homepage:
|
||||
type: [ string, 'null' ]
|
||||
description: url of the project's homepage
|
||||
examples:
|
||||
- https://github.com/yanick/app-changelord
|
||||
name:
|
||||
type: [ 'null', string ]
|
||||
description: name of the project
|
||||
examples:
|
||||
- App::Changelord
|
||||
ticket_url:
|
||||
type: string
|
||||
description: perl code that takes a ticket string (e.g. 'GH123') via the `$_` variable and turns it into a link.
|
||||
examples:
|
||||
- s!GH(\d+)!https://github.com/yanick/App-Changelord/issue/$1/
|
||||
- /^\d+$/ ? "https://.../$_" : undef
|
||||
with_stats:
|
||||
description: if true, add git statistics when bumping the version.
|
||||
change_types:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
keywords:
|
||||
type: array
|
||||
items: { type: string }
|
||||
level: { enum: [ major, minor, patch ] }
|
||||
title: { type: string }
|
||||
releases:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
version: { type: [ 'null', string ] }
|
||||
date: { type: ['null',string] }
|
||||
changes: { type: 'array', items: { $ref: '#/$defs/change' } }
|
||||
$defs:
|
||||
change:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
required: [ desc ]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
desc: { type: string }
|
||||
ticket: { type: [ string, 'null' ] }
|
||||
type: { type: [ string, 'null' ] }
|
||||
commit: { type: [ string, 'null' ] }
|
@ -1,26 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import yargs from 'yargs';
|
||||
import { join } from 'path';
|
||||
import { hideBin } from "yargs/helpers";
|
||||
import yargs from "yargs";
|
||||
import { join } from "path";
|
||||
import yaml from "yaml";
|
||||
import fs from "fs-extra";
|
||||
import consola from "consola";
|
||||
import u from "@yanick/updeep-remeda";
|
||||
import { once } from "remeda";
|
||||
import simpleGit from "simple-git";
|
||||
import Ajv from "ajv";
|
||||
import { nanoid } from "nanoid";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
import print from './command/print.js'
|
||||
import init from './command/init.js'
|
||||
import schema from './command/schema.js';
|
||||
import consola from 'consola';
|
||||
import print from "./command/print.js";
|
||||
import init from "./command/init.js";
|
||||
import schema from "./command/schema.js";
|
||||
import add from "./command/add.js";
|
||||
import cut from "./command/cut.js";
|
||||
import upcoming, { next_release } from "./command/upcoming.js";
|
||||
import latest, { latest_version } from "./command/latest-version.js";
|
||||
import validate from "./command/validate.js";
|
||||
import git_gather from "./command/git-gather.js";
|
||||
import schemaV1 from "./changelog-schema.js";
|
||||
import { globby } from "globby";
|
||||
import { flatMap } from "remeda";
|
||||
|
||||
consola.raw = (...args) => console.log(...args);
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.config({
|
||||
consola
|
||||
.middleware((config) => {
|
||||
config.git = once(simpleGit);
|
||||
config.consola = consola;
|
||||
config.changelog = once(() =>
|
||||
fs
|
||||
.readFile(config.source, "utf-8")
|
||||
.then(yaml.parse)
|
||||
.then(async (doc) => {
|
||||
if (!doc.project.next_directory) return doc;
|
||||
|
||||
const changes = await globby([
|
||||
doc.project.next_directory + "/*.yml",
|
||||
doc.project.next_directory + "/*.yaml",
|
||||
])
|
||||
.then((files) =>
|
||||
Promise.all(
|
||||
files.map((f) => fs.readFile(f, "utf-8").then(yaml.parse))
|
||||
)
|
||||
)
|
||||
.then((r) => r.flat());
|
||||
|
||||
if (changes.length)
|
||||
doc.releases
|
||||
.find((r) => r.version === "NEXT")
|
||||
.changes.push(...changes);
|
||||
|
||||
return doc;
|
||||
})
|
||||
.default('source', join( process.cwd(), 'CHANGELOG.yml' ))
|
||||
.describe('source', 'changelog source')
|
||||
.command({
|
||||
...print,
|
||||
command: '$0',
|
||||
.then((doc) => {
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(schemaV1);
|
||||
const valid = validate(doc);
|
||||
if (valid) return doc;
|
||||
|
||||
config.consola.error("invalid changelog: ", validate.errors);
|
||||
throw "changelog is invalid";
|
||||
})
|
||||
);
|
||||
config.delete_next_dir_entries = async () => {
|
||||
const changelog = await config.changelog();
|
||||
return globby([
|
||||
changelog.project.next_directory + "/*.yml",
|
||||
changelog.project.next_directory + "/*.yaml",
|
||||
]).then((files) => Promise.all(files.map((f) => fs.remove(f))));
|
||||
};
|
||||
config.save_changelog = async (changelog) => {
|
||||
if (!changelog) changelog = await config.changelog();
|
||||
return fs.writeFile(config.source, yaml.stringify(changelog));
|
||||
};
|
||||
config.next_release = next_release.bind(config);
|
||||
config.latest_version = latest_version.bind(config);
|
||||
return config;
|
||||
})
|
||||
.middleware((argv, yargs) => {
|
||||
argv.yargs = yargs;
|
||||
|
||||
argv.add_to_next = async (entry) => {
|
||||
const dir = await argv.changelog().then((c) => c.project.next_directory);
|
||||
|
||||
if (dir) {
|
||||
await fs.ensureDir(dir);
|
||||
const filename = join(
|
||||
dir,
|
||||
[
|
||||
new Date().toISOString(),
|
||||
entry.ticket,
|
||||
entry.feat,
|
||||
slugify(entry.desc, {
|
||||
separator: "_",
|
||||
}).slice(0, 10),
|
||||
]
|
||||
.filter((x) => x)
|
||||
.join("-") + ".yml"
|
||||
);
|
||||
argv.consola.info(`writing change to ${filename}`);
|
||||
if (fs.existsSync(filename)) {
|
||||
argv.consola.error(`file ${filename} already exist`);
|
||||
yargs.exit(1);
|
||||
}
|
||||
if (Object.keys(entry).length === 1) {
|
||||
entry = entry.desc;
|
||||
}
|
||||
return fs.writeFile(filename, yaml.stringify([entry]));
|
||||
}
|
||||
|
||||
const changelog = await argv.changelog();
|
||||
|
||||
let next = changelog.releases.find(u.matches({ version: "NEXT" }));
|
||||
if (!next) {
|
||||
changelog.releases.unshift(base_next_version);
|
||||
}
|
||||
|
||||
if (Object.keys(entry).length === 1) {
|
||||
entry = entry.desc;
|
||||
}
|
||||
|
||||
next.changes.push(entry);
|
||||
|
||||
return argv.save_changelog();
|
||||
};
|
||||
})
|
||||
.default("source", join(process.cwd(), "CHANGELOG.yml"))
|
||||
.describe("source", "changelog source")
|
||||
.command(init)
|
||||
.command(add)
|
||||
.command(print)
|
||||
.command(cut)
|
||||
.command(schema)
|
||||
.command(print).help().parse();
|
||||
.command(upcoming)
|
||||
.command({
|
||||
...upcoming,
|
||||
command: "next",
|
||||
desc: 'alias for "upcoming"',
|
||||
})
|
||||
.command(latest)
|
||||
.command(git_gather)
|
||||
.command(validate)
|
||||
.strictCommands()
|
||||
.help()
|
||||
.parse();
|
||||
|
34
src/command/add.js
Normal file
34
src/command/add.js
Normal file
@ -0,0 +1,34 @@
|
||||
import fs from "fs-extra";
|
||||
import yaml from "yaml";
|
||||
import u from "@yanick/updeep-remeda";
|
||||
|
||||
const handler = async (config) => {
|
||||
if (!config.change)
|
||||
throw new Error("can't add a change without a description");
|
||||
|
||||
const entry = {
|
||||
desc: config.change.join(" "),
|
||||
};
|
||||
|
||||
if (config.ticket) entry.ticket = config.ticket;
|
||||
if (config.type) entry.type = config.type;
|
||||
|
||||
config.consola.start(`adding '${entry.desc}' to the changelog`);
|
||||
|
||||
await config.add_to_next(entry);
|
||||
|
||||
config.consola.success("done!");
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "add [change...]",
|
||||
desc: "add a change to the NEXT release",
|
||||
builder: (yargs) => {
|
||||
yargs
|
||||
.string("ticket")
|
||||
.describe("ticket", "ticket associated with the change")
|
||||
.string("type")
|
||||
.describe("type", "type of change");
|
||||
},
|
||||
handler,
|
||||
};
|
83
src/command/cut.js
Normal file
83
src/command/cut.js
Normal file
@ -0,0 +1,83 @@
|
||||
import yaml from "yaml";
|
||||
import fs from "fs-extra";
|
||||
import u from "@yanick/updeep-remeda";
|
||||
import semverInc from "semver/functions/inc.js";
|
||||
import { simpleGit } from "simple-git";
|
||||
|
||||
import { base_next_version } from "../utils.js";
|
||||
|
||||
const code_churn = async (previous_version) => {
|
||||
previous_version = previous_version
|
||||
? "v" + previous_version.version
|
||||
: "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; // empty tree sha1
|
||||
|
||||
return simpleGit().diff(["--shortstat", previous_version, "HEAD"]);
|
||||
};
|
||||
|
||||
const handler = async (config) => {
|
||||
config.consola.start("cutting the next version...");
|
||||
|
||||
const changelog = await config.changelog();
|
||||
|
||||
const next = changelog.releases.find(u.matches({ version: "NEXT" }));
|
||||
|
||||
if (!next) throw new Error("no changes since last version, aborting");
|
||||
|
||||
const previous_version = changelog.releases.find(
|
||||
({ version }) => version !== "NEXT"
|
||||
);
|
||||
|
||||
next.date = new Date().toISOString().replace(/T.*/, "");
|
||||
|
||||
const type_to_level = (type = "") =>
|
||||
changelog.change_types.find((t) => t.keywords.includes(type)).level;
|
||||
|
||||
const bump_level = (types) =>
|
||||
types.includes("major")
|
||||
? "major"
|
||||
: types.includes("minor")
|
||||
? "minor"
|
||||
: "patch";
|
||||
|
||||
const bumper = bump_level(
|
||||
next.changes.map(({ type }) => type_to_level(type))
|
||||
);
|
||||
|
||||
if (changelog.project?.with_stats) {
|
||||
next.changes.push({
|
||||
type: "stats",
|
||||
desc: "code churn:" + (await code_churn(previous_version)),
|
||||
});
|
||||
}
|
||||
|
||||
next.version = semverInc(
|
||||
previous_version ? previous_version.version : "0.0.0",
|
||||
bumper
|
||||
);
|
||||
|
||||
// add a new NEXT
|
||||
changelog.releases.unshift(base_next_version);
|
||||
|
||||
if (config.dry) {
|
||||
config.consola.info("running in dry mode, not saving\n", next);
|
||||
} else {
|
||||
await config.save_changelog(changelog);
|
||||
if (changelog.project?.next_directory) {
|
||||
config.consola.info(
|
||||
`removing files in ${changelog.project.next_directory}`
|
||||
);
|
||||
await config.delete_next_dir_entries();
|
||||
}
|
||||
}
|
||||
|
||||
config.consola.success(`version ${next.version} is cut!`);
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "cut",
|
||||
desc: "cut the next version",
|
||||
builder: (yargs) => {
|
||||
yargs.boolean("dry");
|
||||
},
|
||||
handler,
|
||||
};
|
23
src/command/cut.test.js
Normal file
23
src/command/cut.test.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import cut from "./cut.js";
|
||||
|
||||
test("add a new NEXT", async () => {
|
||||
const changelog = {
|
||||
releases: [{ version: "NEXT", changes: [] }, { version: "1.0.0" }],
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
const config = {
|
||||
consola: {
|
||||
start: noop,
|
||||
success: noop,
|
||||
},
|
||||
changelog: () => changelog,
|
||||
save_changelog: noop,
|
||||
};
|
||||
|
||||
await cut.handler(config);
|
||||
|
||||
expect(changelog.releases[0].version).toBe("NEXT");
|
||||
expect(changelog.releases).toHaveLength(3);
|
||||
});
|
55
src/command/git-gather.js
Normal file
55
src/command/git-gather.js
Normal file
@ -0,0 +1,55 @@
|
||||
import simpleGit from "simple-git";
|
||||
import { prop, compact, pickBy } from "remeda";
|
||||
|
||||
const handler = async (config) => {
|
||||
const next = await config.next_release();
|
||||
const seen_sha1s = compact(next?.changes.map(prop("commit")));
|
||||
const seen_descs = next?.changes.map((change) =>
|
||||
typeof change === "string" ? change : change.desc
|
||||
);
|
||||
|
||||
const git = config.git();
|
||||
|
||||
const { version } = await config.latest_version();
|
||||
|
||||
let { all: commits } = await git.log({
|
||||
from: "v" + version,
|
||||
});
|
||||
|
||||
config.consola.start(`gathering changes since v${version}`);
|
||||
|
||||
commits = commits.filter(({ hash }) => !seen_sha1s.includes(hash));
|
||||
|
||||
const regex = new RegExp(
|
||||
(await config.changelog().project?.commit_regex) ??
|
||||
/^(?<type>[^: ]+):\s*(?<desc>.*?)(\[(?<ticket>[^\]]+)\])?$/
|
||||
);
|
||||
|
||||
const changes = commits
|
||||
.map((commit) => [commit, commit.message.match(regex)])
|
||||
.filter((x) => x[1])
|
||||
.filter((x) => !seen_descs.includes(x[1].groups.desc))
|
||||
.map(([commit, res]) =>
|
||||
pickBy({ ...res.groups, commit: commit.hash }, (x) => x)
|
||||
);
|
||||
|
||||
if (changes.length === 0) {
|
||||
config.consola.success("no changes detected");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
config.consola.info(`${change.type}: ${change.desc}`);
|
||||
config.add_to_next(change);
|
||||
}
|
||||
config.consola.success("done");
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "git-gather",
|
||||
desc: "gather change entries from git commits",
|
||||
builder: (yargs) => {
|
||||
yargs;
|
||||
},
|
||||
handler,
|
||||
};
|
28
src/command/git-gather.test.js
Normal file
28
src/command/git-gather.test.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import gather from "./git-gather.js";
|
||||
|
||||
test("no changes detected", async () => {
|
||||
const changelog = {
|
||||
releases: [{ version: "NEXT", changes: [] }, { version: "1.0.0" }],
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
const config = {
|
||||
consola: {
|
||||
start: noop,
|
||||
success: vi.fn(),
|
||||
},
|
||||
changelog: () => changelog,
|
||||
next_release: () => ({
|
||||
changes: [],
|
||||
}),
|
||||
latest_version: () => ({ version: "1.2.3" }),
|
||||
git: () => ({
|
||||
log: () => ({ all: [] }),
|
||||
}),
|
||||
};
|
||||
|
||||
await gather.handler(config);
|
||||
|
||||
expect(config.consola.success).toHaveBeenCalledWith("no changes detected");
|
||||
});
|
@ -1,25 +1,26 @@
|
||||
import fs from 'fs-extra';
|
||||
import { consola } from 'consola';
|
||||
import { stringify } from 'yaml';
|
||||
import fs from "fs-extra";
|
||||
import { consola } from "consola";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
const change_types = [
|
||||
{ title: 'Features' , level: 'minor', keywords: [ 'feat' ] } ,
|
||||
{ title : 'Bug fixes' , level : 'patch', keywords : [ 'fix' ] },
|
||||
{ title : 'Package maintenance' , level : 'patch', keywords : [ 'chore', 'maint', 'refactor' ] },
|
||||
{ title : 'Statistics' , level : 'patch', keywords : [ 'stats' ] },
|
||||
{ title: "", level: "minor", keywords: [""] },
|
||||
{ title: "Features", level: "minor", keywords: ["feat"] },
|
||||
{ title: "Bug fixes", level: "patch", keywords: ["fix"] },
|
||||
{
|
||||
title: "Package maintenance",
|
||||
level: "patch",
|
||||
keywords: ["chore", "maint", "refactor"],
|
||||
},
|
||||
{ title: "Statistics", level: "patch", keywords: ["stats"] },
|
||||
];
|
||||
|
||||
|
||||
const base_changelog = {
|
||||
export const base_changelog = {
|
||||
project: {
|
||||
name: null,
|
||||
homepage: null,
|
||||
with_stats: true,
|
||||
ticket_url: null,
|
||||
},
|
||||
releases: [
|
||||
{ version: 'NEXT', changes: [] }
|
||||
],
|
||||
releases: [{ version: "NEXT", changes: [] }],
|
||||
change_types,
|
||||
};
|
||||
|
||||
@ -33,18 +34,18 @@ const handler = async (config) => {
|
||||
|
||||
await fs.writeFile(config.source, stringify(base_changelog));
|
||||
|
||||
consola.success('done!');
|
||||
|
||||
consola.success("done!");
|
||||
};
|
||||
|
||||
export default {
|
||||
command: 'init',
|
||||
desc : 'initialize new changelog source file',
|
||||
command: "init",
|
||||
desc: "initialize new changelog source file",
|
||||
builder: (yargs) => {
|
||||
yargs.boolean('json')
|
||||
.boolean('next')
|
||||
.default('json',false)
|
||||
.default('next',true);
|
||||
yargs
|
||||
.boolean("json")
|
||||
.boolean("next")
|
||||
.default("json", false)
|
||||
.default("next", true);
|
||||
},
|
||||
handler,
|
||||
}
|
||||
};
|
||||
|
29
src/command/latest-version.js
Normal file
29
src/command/latest-version.js
Normal file
@ -0,0 +1,29 @@
|
||||
import yaml from "yaml";
|
||||
import fs from "fs-extra";
|
||||
|
||||
export async function latest_version() {
|
||||
const changelog = await this.changelog();
|
||||
|
||||
return changelog.releases.find(({ version }) => version !== "NEXT");
|
||||
}
|
||||
|
||||
const handler = async (config) => {
|
||||
const latest_version = config.latest_version()?.version ?? "0.0.0";
|
||||
|
||||
if (config.json) {
|
||||
config.consola.raw(latest_version);
|
||||
} else {
|
||||
config.consola.raw(latest_version.version);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "latest-version",
|
||||
desc: "output the latest version present in the changelog",
|
||||
builder: (yargs) => {
|
||||
yargs
|
||||
.boolean("json")
|
||||
.describe("json", "output latest version entry as json");
|
||||
},
|
||||
handler,
|
||||
};
|
@ -1,16 +1,135 @@
|
||||
import * as R from "remeda";
|
||||
import fs from "fs-extra";
|
||||
import yaml from "yaml";
|
||||
import mkd from "markdown-utils";
|
||||
|
||||
const handler = (...args) => {
|
||||
console.log('hi!',args);
|
||||
const render_header = (source) => {
|
||||
let header = "# Changelog";
|
||||
|
||||
let name = source.project?.name;
|
||||
|
||||
const links = {};
|
||||
|
||||
if (source?.project?.homepage) {
|
||||
links.homepage = source?.project?.homepage;
|
||||
name = `[${name}][homepage]`;
|
||||
}
|
||||
|
||||
if (name) header += ` for ${name}`;
|
||||
|
||||
return { header, links };
|
||||
};
|
||||
|
||||
function render_links(links) {
|
||||
return Object.entries(links)
|
||||
.map(([name, url]) => ` [${name}]: ${url}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const render_change = (config, source) => (change) => {
|
||||
let body = change.desc;
|
||||
let links = {};
|
||||
|
||||
return {
|
||||
body,
|
||||
links,
|
||||
};
|
||||
};
|
||||
|
||||
export const render_release = (config, source) => (release) => {
|
||||
let links = {};
|
||||
|
||||
if (typeof release === "string")
|
||||
return {
|
||||
body: release,
|
||||
links,
|
||||
};
|
||||
|
||||
if (release.version === "NEXT" && !config.next)
|
||||
return {
|
||||
body: "",
|
||||
links,
|
||||
};
|
||||
|
||||
let body = "## " + release.version;
|
||||
|
||||
if (release.date) {
|
||||
body += " " + release.date;
|
||||
}
|
||||
|
||||
body += "\n\n";
|
||||
|
||||
const type_map = Object.fromEntries(
|
||||
source.change_types.flatMap((entry) =>
|
||||
entry.keywords.map((k) => [k, entry])
|
||||
)
|
||||
);
|
||||
|
||||
const changes = release.changes.map((change) =>
|
||||
typeof change === "string" ? { desc: change, type: "" } : change
|
||||
);
|
||||
|
||||
const unknown = R.uniq(
|
||||
changes
|
||||
.map(R.prop("type"))
|
||||
.map((t) => t ?? "")
|
||||
.filter((type) => !type_map[type])
|
||||
);
|
||||
|
||||
if (unknown.length) {
|
||||
throw new Error(
|
||||
"unknown change types encountered: " +
|
||||
unknown.map((t) => `'${t}'`).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
for (const change_type of source.change_types) {
|
||||
const type_changes = changes.filter(({ type }) =>
|
||||
change_type.keywords.includes(type)
|
||||
);
|
||||
|
||||
if (type_changes.length === 0) continue;
|
||||
|
||||
if (change_type.title) body += "\n" + mkd.h3(change_type.title) + "\n\n";
|
||||
|
||||
for (const c of type_changes) {
|
||||
const res = render_change(config, source)(c);
|
||||
body += mkd.li(res.body, 1) + "\n";
|
||||
links = { ...links, ...res.links };
|
||||
}
|
||||
}
|
||||
|
||||
return { body, links };
|
||||
};
|
||||
|
||||
const handler = async (config) => {
|
||||
const source = await config.changelog();
|
||||
|
||||
let output = "";
|
||||
|
||||
let { header, links } = render_header(source);
|
||||
|
||||
output += header + "\n\n";
|
||||
|
||||
for (const res of source.releases.map(render_release(config, source))) {
|
||||
output += res.body + "\n\n";
|
||||
links = { ...links, ...res.links };
|
||||
}
|
||||
|
||||
output += "\n\n\n\n" + render_links(links);
|
||||
|
||||
config.consola.raw(output);
|
||||
};
|
||||
|
||||
export default {
|
||||
command: 'print',
|
||||
desc : 'render the changelog',
|
||||
command: "print",
|
||||
desc: "render the changelog",
|
||||
builder: (yargs) => {
|
||||
yargs.boolean('json')
|
||||
.boolean('next')
|
||||
.default('json',false)
|
||||
.default('next',true);
|
||||
yargs
|
||||
//.boolean('json')
|
||||
.boolean("next")
|
||||
// .default('json',false)
|
||||
.default("next", true);
|
||||
},
|
||||
handler,
|
||||
}
|
||||
};
|
||||
|
22
src/command/print.test.js
Normal file
22
src/command/print.test.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { test, expect, vi } from 'vitest';
|
||||
import { render_release } from './print.js';
|
||||
import { base_changelog } from './init.js';
|
||||
|
||||
test( 'render_release a string', () => {
|
||||
const {body} = render_release({})("as is");
|
||||
expect(body).toEqual('as is');
|
||||
});
|
||||
|
||||
test( 'render_release', () => {
|
||||
const consola = { error: vi.fn() };
|
||||
const res= render_release({consola},base_changelog)({
|
||||
version: '1.2.3',
|
||||
date: 'today',
|
||||
changes: [
|
||||
"this",
|
||||
"that",
|
||||
]
|
||||
});
|
||||
expect(res.body).toContain('# 1.2.3 today');
|
||||
expect(res.body).toContain('* this');
|
||||
});
|
@ -1,12 +1,9 @@
|
||||
import fs from "fs-extra";
|
||||
import yaml from "yaml";
|
||||
import schemaV1 from "../changelog-schema.js";
|
||||
|
||||
const handler = async ({ consola }) => {
|
||||
consola.raw(
|
||||
await fs.readFile(
|
||||
new URL("../changelog-schema.yml", import.meta.url),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
consola.raw(yaml.stringify(schemaV1));
|
||||
};
|
||||
|
||||
export default {
|
||||
|
31
src/command/upcoming.js
Normal file
31
src/command/upcoming.js
Normal file
@ -0,0 +1,31 @@
|
||||
import u from "@yanick/updeep-remeda";
|
||||
import fs from "fs-extra";
|
||||
import yaml from "yaml";
|
||||
import { render_release } from "./print.js";
|
||||
|
||||
export async function next_release() {
|
||||
const source = await this.changelog();
|
||||
return (
|
||||
source.releases.find(u.matches({ version: "NEXT" })) ?? {
|
||||
version: "NEXT",
|
||||
changes: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const handler = async (config) => {
|
||||
const source = await config.changelog();
|
||||
|
||||
const { body } = render_release(
|
||||
{ ...config, next: true },
|
||||
source
|
||||
)(await config.next_release());
|
||||
|
||||
config.consola.raw("\n" + body);
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "upcoming",
|
||||
desc: "output the changes in NEXT",
|
||||
handler,
|
||||
};
|
29
src/command/upcoming.test.js
Normal file
29
src/command/upcoming.test.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import upcoming from "./upcoming.js";
|
||||
|
||||
test("basic", async () => {
|
||||
const changelog = {
|
||||
releases: [{ version: "NEXT", changes: [] }, { version: "1.0.0" }],
|
||||
change_types: [],
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
const config = {
|
||||
consola: {
|
||||
start: noop,
|
||||
raw: vi.fn(),
|
||||
},
|
||||
changelog: () => changelog,
|
||||
next_release: () => ({
|
||||
changes: [],
|
||||
}),
|
||||
latest_version: () => ({ version: "1.2.3" }),
|
||||
git: () => ({
|
||||
log: () => ({ all: [] }),
|
||||
}),
|
||||
};
|
||||
|
||||
await upcoming.handler(config);
|
||||
|
||||
expect(config.consola.raw).toHaveBeenCalled();
|
||||
});
|
16
src/command/validate.js
Normal file
16
src/command/validate.js
Normal file
@ -0,0 +1,16 @@
|
||||
const handler = async (config) => {
|
||||
config.consola.start("validating changelog...");
|
||||
|
||||
try {
|
||||
await config.changelog();
|
||||
config.consola.success("valid!");
|
||||
} catch (e) {
|
||||
config.yargs.exit(1, "changelog is invalid :-(");
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
command: "validate",
|
||||
desc: "validate the changelog against its json schema",
|
||||
handler,
|
||||
};
|
16
src/command/validate.test.js
Normal file
16
src/command/validate.test.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import validate from "./validate.js";
|
||||
|
||||
test("basic", async () => {
|
||||
const success = vi.fn();
|
||||
const noop = () => true;
|
||||
await validate.handler({
|
||||
changelog: () => ({}),
|
||||
consola: {
|
||||
start: noop,
|
||||
success,
|
||||
},
|
||||
});
|
||||
|
||||
expect(success).toHaveBeenCalled();
|
||||
});
|
6
src/utils.js
Normal file
6
src/utils.js
Normal file
@ -0,0 +1,6 @@
|
||||
export const base_next_version = {
|
||||
version: "NEXT",
|
||||
changes: [],
|
||||
};
|
||||
|
||||
Object.freeze(base_next_version);
|
Loading…
Reference in New Issue
Block a user