Compare commits

...

41 Commits

Author SHA1 Message Date
e69858f55e add the changelog 2023-05-18 16:12:17 -04:00
bc66ea09a2 prep the release 2023-05-18 16:09:50 -04:00
04e9e5f71a Merge branch 'lord14-changelog-next'
fix #14
2023-05-18 16:04:22 -04:00
f6141b139e add a test 2023-05-18 16:04:04 -04:00
9b9c15ecc4 cut supports next-directory 2023-05-18 15:52:37 -04:00
a841165c51 delete directory entries when cutting 2023-05-18 15:42:34 -04:00
251dce5b56 include the dir entries to the changelog 2023-05-18 15:27:08 -04:00
f759989698 add to the directory 2023-05-18 14:48:47 -04:00
09d60859e1 dog-food next_directory 2023-05-18 14:24:51 -04:00
8faa2ef1d8 add documentation 2023-05-18 14:24:32 -04:00
3f258a6e23 add the option to the schema 2023-05-18 14:24:22 -04:00
1c7b6158f4 Merge branch 'lord8-validate'
fix #8
2023-05-18 14:06:45 -04:00
031f2cb825 add changelog 2023-05-18 14:06:19 -04:00
5ba75a8e3d feat: add the validate command 2023-05-18 14:04:51 -04:00
2e9d0d4b66 Merge branch 'lord15-filter-on-descs'
fix #15
2023-05-18 13:14:33 -04:00
bef10869ce feat: git-gather also filters on descs 2023-05-18 13:13:32 -04:00
d826b4191d Merge branch 'lord12-cut-create-next'
fix #12
2023-05-18 12:53:10 -04:00
b10b599585 update changelog 2023-05-18 12:52:14 -04:00
167f631d1f feat: cutting a release also add a new NEXT release 2023-05-18 12:50:17 -04:00
e7f70f3aec Merge branch 'lord13-next'
fix #13
2023-05-18 12:04:39 -04:00
363c195477 feat: add 'next' to alias to 'upcoming' 2023-05-18 11:32:18 -04:00
0d34b92358 Merge branch 'git-gather' 2023-05-18 11:15:55 -04:00
d9f4244e3e add 'git-gather' command 2023-05-18 11:10:43 -04:00
467251bab6 cut v0.1.0 2023-05-16 11:45:23 -04:00
1b25881ede ignore files 2023-05-16 11:44:22 -04:00
063ee5d02b Merge branch 'README' 2023-05-16 11:41:51 -04:00
7d8cccf7f8 add integrate task 2023-05-16 11:41:21 -04:00
edfaf81b14 add project info to package.json 2023-05-16 11:36:13 -04:00
c8fb899d0d add a README 2023-05-16 11:24:15 -04:00
6cbfd2ed2b Merge branch 'cut-bug' 2023-05-15 09:58:45 -04:00
4f84450979 previous_version is an object, not a string 2023-05-15 09:58:41 -04:00
0d1fca8dbb Merge branch 'upcoming' 2023-05-11 08:55:25 -04:00
13f443b61c make yargs strict for commands 2023-05-11 08:54:58 -04:00
385c09896a add command upcoming 2023-05-10 17:29:37 -04:00
36e2aade92 Merge branch 'lord3-command-bump' 2023-05-10 16:47:49 -04:00
3f266fa068 add command cut 2023-05-10 16:46:53 -04:00
a326be7211 add more CR padding 2023-05-10 15:33:59 -04:00
8f8a16d025 add command add
fix #2
2023-05-10 15:33:59 -04:00
0ed4ca9cab Merge branch 'lord6-print' 2023-05-10 14:37:24 -04:00
01e5381b77 add print command
fix #6
2023-05-10 14:36:56 -04:00
7336efacbb Merge branch 'lord7-schema' 2023-05-10 11:59:19 -04:00
32 changed files with 1144 additions and 141 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
pnpm-lock.yaml
*.tgz

6
.npmignore Normal file
View File

@ -0,0 +1,6 @@
_templates
Taskfile.yaml
*.tgz
prettier.config.*
changelog-next/
*.test.js

37
CHANGELOG.md Normal file
View 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

View File

@ -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
View 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
View 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 -

View 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,
}

View File

@ -0,0 +1,5 @@
---
message: |
hygen {bold generator new} --name [NAME] --action [ACTION]
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
---

View 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)

View 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)

View 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?"
}
]

View File

@ -0,0 +1,4 @@
---
setup: <%= name %>
force: true # this is because mostly, people init into existing folders is safe
---

View File

@ -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
View 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
View 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",
},
],
},
},
};

View File

@ -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' ] }

View File

@ -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
})
.default('source', join( process.cwd(), 'CHANGELOG.yml' ))
.describe('source', 'changelog source')
.command({
...print,
command: '$0',
})
.command(init)
.command(schema)
.command(print).help().parse();
.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;
})
.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(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
View 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
View 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
View 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
View 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,
};

View 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");
});

View File

@ -1,50 +1,51 @@
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 = {
project: {
name: null,
homepage: null,
with_stats: true,
ticket_url: null,
},
releases: [
{ version: 'NEXT', changes: [] }
],
change_types,
export const base_changelog = {
project: {
name: null,
homepage: null,
with_stats: true,
},
releases: [{ version: "NEXT", changes: [] }],
change_types,
};
const handler = async (config) => {
if( await fs.pathExists(config.source) ) {
consola.error(`${config.source} already exist, aborting.`);
process.exit();
}
if (await fs.pathExists(config.source)) {
consola.error(`${config.source} already exist, aborting.`);
process.exit();
}
consola.start(`creating ${config.source}...`);
consola.start(`creating ${config.source}...`);
await fs.writeFile( config.source, stringify(base_changelog) );
consola.success('done!');
await fs.writeFile(config.source, stringify(base_changelog));
consola.success("done!");
};
export default {
command: 'init',
desc : 'initialize new changelog source file',
builder: (yargs) => {
yargs.boolean('json')
.boolean('next')
.default('json',false)
.default('next',true);
},
handler,
}
command: "init",
desc: "initialize new changelog source file",
builder: (yargs) => {
yargs
.boolean("json")
.boolean("next")
.default("json", false)
.default("next", true);
},
handler,
};

View 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,
};

View File

@ -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',
builder: (yargs) => {
yargs.boolean('json')
.boolean('next')
.default('json',false)
.default('next',true);
},
handler,
}
command: "print",
desc: "render the changelog",
builder: (yargs) => {
yargs
//.boolean('json')
.boolean("next")
// .default('json',false)
.default("next", true);
},
handler,
};

22
src/command/print.test.js Normal file
View 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');
});

View File

@ -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"
)
);
const handler = async ({ consola }) => {
consola.raw(yaml.stringify(schemaV1));
};
export default {

31
src/command/upcoming.js Normal file
View 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,
};

View 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
View 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,
};

View 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
View File

@ -0,0 +1,6 @@
export const base_next_version = {
version: "NEXT",
changes: [],
};
Object.freeze(base_next_version);