@chamoji/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import { type CommitClassifier } from '@chamoji/core';
2
+ /** The slice of the TypeSafe client this classifier uses (easy to fake in tests). */
3
+ export type JevClient = {
4
+ systemOne(request: {
5
+ state: {
6
+ commits: Array<{
7
+ subject: string;
8
+ body: string;
9
+ }>;
10
+ };
11
+ questions: Record<string, {
12
+ type: 'choice';
13
+ instructions: string;
14
+ criteria: Record<string, string>;
15
+ }>;
16
+ }): Promise<{
17
+ answers: Record<string, {
18
+ choice: string;
19
+ confidence: number;
20
+ }>;
21
+ }>;
22
+ };
23
+ export type JevClassifierOptions = {
24
+ /** Commits per request; each question carries all the gitmoji options. */
25
+ batchSize?: number;
26
+ /** Requests in flight at once. */
27
+ concurrency?: number;
28
+ /** Picks below this confidence are dropped and the commit stays unsorted. */
29
+ minConfidence?: number;
30
+ /** Receives one-line progress notes: failures and a final summary. */
31
+ report?: (line: string) => void;
32
+ };
33
+ /**
34
+ * Sorts commits with Jev (TypeSafe System One): one `choice` question per
35
+ * commit over the gitmoji list. Only subjects and the start of each body are
36
+ * sent, never diffs or code.
37
+ */
38
+ export declare function createJevClassifier(client: JevClient, options?: JevClassifierOptions): CommitClassifier;
@@ -0,0 +1,118 @@
1
+ import { GITMOJIS, } from '@chamoji/core';
2
+ /** Characters of each subject and body sent. */
3
+ const MAX_CHARS = 300;
4
+ /**
5
+ * Upper bound on batch size. Each question repeats the ~75 gitmoji options
6
+ * (about 1.4k tokens), and a request may hold 64k tokens, state included.
7
+ */
8
+ const MAX_BATCH = 30;
9
+ const NONE = 'none';
10
+ /**
11
+ * Not offered: 💥, because only an author can declare a breaking change, and
12
+ * the codes the default config hides, so a wrong pick can't make a commit
13
+ * vanish from the changelog.
14
+ */
15
+ const NOT_OFFERED = new Set(['boom', 'bookmark', 'twisted_rightwards_arrows']);
16
+ const CRITERIA = Object.fromEntries([
17
+ ...GITMOJIS.filter(gitmoji => !NOT_OFFERED.has(gitmoji.code)).map(gitmoji => [
18
+ gitmoji.code,
19
+ gitmoji.description,
20
+ ]),
21
+ [
22
+ NONE,
23
+ 'None of these fit, or the message is too vague to tell what changed.',
24
+ ],
25
+ ]);
26
+ /**
27
+ * Sorts commits with Jev (TypeSafe System One): one `choice` question per
28
+ * commit over the gitmoji list. Only subjects and the start of each body are
29
+ * sent, never diffs or code.
30
+ */
31
+ export function createJevClassifier(client, options = {}) {
32
+ const { batchSize = 25, concurrency = 4,
33
+ // A starting point; tune it against labelled history.
34
+ minConfidence = 0.6, report, } = options;
35
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > MAX_BATCH) {
36
+ throw new RangeError(`batchSize must be an integer from 1 to ${MAX_BATCH}`);
37
+ }
38
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
39
+ throw new RangeError('concurrency must be a positive integer');
40
+ }
41
+ const classifyBatch = async (batch) => {
42
+ const questions = {};
43
+ for (const index of batch.keys()) {
44
+ questions[`commit_${index}`] = {
45
+ type: 'choice',
46
+ instructions: `Which gitmoji best describes the change made by commit \`commits[${index}]\`, judging from its subject and body? Pick the one a maintainer would use when sorting it into a changelog, or ${NONE} if the message is too vague to tell.`,
47
+ criteria: CRITERIA,
48
+ };
49
+ }
50
+ const { answers } = await client.systemOne({
51
+ state: {
52
+ commits: batch.map(commit => ({
53
+ subject: redact(commit.subject).slice(0, MAX_CHARS),
54
+ body: redact(withoutTrailers(commit.body)).trim().slice(0, MAX_CHARS),
55
+ })),
56
+ },
57
+ questions,
58
+ });
59
+ const picks = [];
60
+ for (const [index, commit] of batch.entries()) {
61
+ const answer = answers[`commit_${index}`];
62
+ if (answer &&
63
+ answer.choice !== NONE &&
64
+ answer.confidence >= minConfidence) {
65
+ picks.push([commit.hash, answer.choice]);
66
+ }
67
+ }
68
+ return picks;
69
+ };
70
+ return async (commits) => {
71
+ const batches = [];
72
+ for (let start = 0; start < commits.length; start += batchSize) {
73
+ batches.push(commits.slice(start, start + batchSize));
74
+ }
75
+ const result = new Map();
76
+ let next = 0;
77
+ let sent = 0;
78
+ let failure;
79
+ // `--ai` only improves the output, so a failed request stops further
80
+ // requests but keeps what earlier ones returned.
81
+ const worker = async () => {
82
+ while (next < batches.length && !failure) {
83
+ const batch = batches[next++];
84
+ try {
85
+ for (const [hash, code] of await classifyBatch(batch)) {
86
+ result.set(hash, code);
87
+ }
88
+ sent += batch.length;
89
+ }
90
+ catch (error) {
91
+ failure ??= error;
92
+ }
93
+ }
94
+ };
95
+ await Promise.all(Array.from({ length: Math.min(concurrency, batches.length) }, worker));
96
+ if (failure) {
97
+ report?.(`Jev request failed (${failure.message}); ${commits.length - sent} commits left unsorted`);
98
+ }
99
+ report?.(`Jev sorted ${result.size} of ${commits.length} commits no rule could place`);
100
+ return result;
101
+ };
102
+ }
103
+ const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
104
+ const TRAILER_LINE = /^[\w-]+:\s/;
105
+ /** Replaces email addresses, which often appear in commit text. */
106
+ function redact(text) {
107
+ return text.replaceAll(EMAIL, '<email>');
108
+ }
109
+ /** Drops the final paragraph when it is all `Key: value` trailer lines. */
110
+ function withoutTrailers(body) {
111
+ const paragraphs = body.trim().split(/\n\s*\n/);
112
+ const last = paragraphs.at(-1)?.split('\n') ?? [];
113
+ if (last.length > 0 && last.every(line => TRAILER_LINE.test(line.trim()))) {
114
+ paragraphs.pop();
115
+ }
116
+ return paragraphs.join('\n\n');
117
+ }
118
+ //# sourceMappingURL=jev-classifier.js.map
package/dist/run.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { buildChangelog, loadConfig, render, } from '@chamoji/core';
4
+ import { TypeSafeClient } from '@typesafe-ai/sdk';
4
5
  import meow from 'meow';
6
+ import { createJevClassifier } from './jev-classifier.js';
5
7
  const HELP = `
6
8
  Usage
7
9
  $ chamoji changelog [options] Generate a changelog from git history
@@ -15,6 +17,10 @@ const HELP = `
15
17
  --to Ref to treat as the head of history (default: HEAD)
16
18
  --release Name the unreleased section with this version
17
19
  --config Config file (default: chamoji.config.json if present)
20
+ --ai Sort commits no rule can place with Jev (TypeSafe).
21
+ Needs TYPESAFE_API_KEY. Sends up to 300 characters of
22
+ each such commit's subject and body, with emails and
23
+ trailer lines removed; never diffs, code or authors.
18
24
 
19
25
  Examples
20
26
  $ chamoji changelog --output CHANGELOG.md
@@ -41,6 +47,7 @@ export async function run(argv, io) {
41
47
  to: { type: 'string', default: 'HEAD' },
42
48
  release: { type: 'string' },
43
49
  config: { type: 'string' },
50
+ ai: { type: 'boolean' },
44
51
  help: { type: 'boolean', shortFlag: 'h' },
45
52
  version: { type: 'boolean', shortFlag: 'v' },
46
53
  },
@@ -56,6 +63,7 @@ export async function run(argv, io) {
56
63
  }
57
64
  const [command] = cli.input;
58
65
  try {
66
+ const classify = flags.ai ? jevClassifier(io) : undefined;
59
67
  switch (command) {
60
68
  case 'changelog': {
61
69
  const format = Object.hasOwn(FORMATS, flags.format)
@@ -71,6 +79,7 @@ export async function run(argv, io) {
71
79
  from: flags.from,
72
80
  release: flags.release,
73
81
  today: io.today,
82
+ classify,
74
83
  });
75
84
  const output = render(changelog, format);
76
85
  if (flags.output) {
@@ -87,6 +96,8 @@ export async function run(argv, io) {
87
96
  cwd: io.cwd,
88
97
  config: await loadConfig(io.cwd, flags.config),
89
98
  to: flags.to,
99
+ classify,
100
+ classifyReleases: 'unreleased',
90
101
  });
91
102
  const next = releases.find(release => release.tag === null)?.nextVersion;
92
103
  if (!next) {
@@ -109,4 +120,19 @@ export async function run(argv, io) {
109
120
  return 1;
110
121
  }
111
122
  }
123
+ function jevClassifier(io) {
124
+ const key = process.env['TYPESAFE_API_KEY']?.trim();
125
+ if (!key) {
126
+ throw new Error('--ai needs a TypeSafe API key: set TYPESAFE_API_KEY (get one at https://typesafe.ai)');
127
+ }
128
+ // Keys copied from a dashboard often end in "…"; headers can't carry it.
129
+ const invalid = /[^\x21-\x7E]/.exec(key);
130
+ if (invalid) {
131
+ throw new Error(invalid[0] === '\u2026'
132
+ ? 'TYPESAFE_API_KEY contains "…", so it looks truncated; copy the full key again'
133
+ : 'TYPESAFE_API_KEY contains a character that is not plain ASCII; copy the full key again');
134
+ }
135
+ const client = new TypeSafeClient();
136
+ return createJevClassifier({ systemOne: async (request) => client.systemOne(request) }, { report: line => io.stderr(`${line}\n`) });
137
+ }
112
138
  //# sourceMappingURL=run.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chamoji/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Changelogs from gitmoji and Conventional Commits",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,8 +15,9 @@
15
15
  "!dist/**/*.map"
16
16
  ],
17
17
  "dependencies": {
18
+ "@typesafe-ai/sdk": "^0.7.0",
18
19
  "meow": "^14.1.0",
19
- "@chamoji/core": "0.1.0"
20
+ "@chamoji/core": "0.2.0"
20
21
  },
21
22
  "keywords": [
22
23
  "changelog",
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # chamoji
2
2
 
3
- Changelogs from your git history. Chamoji reads [gitmoji](https://gitmoji.dev) and [Conventional Commits](https://www.conventionalcommits.org) messages, groups them into Keep a Changelog sections, and suggests the next semver version.
3
+ Changelogs from your git history. Chamoji reads [gitmoji](https://gitmoji.dev), [Conventional Commits](https://www.conventionalcommits.org) and plain messages like "Fix crash on startup". It groups them into Keep a Changelog sections and suggests the next semver version.
4
4
 
5
5
  ## Install
6
6
 
@@ -32,6 +32,7 @@ chamoji next-version # e.g. 1.3.0
32
32
  | `--to <ref>` | Ref to treat as the head of history (default `HEAD`) |
33
33
  | `--release <version>` | Name and date the unreleased section, e.g. when cutting a release. Its compare link targets the tag it will get (`v<version>` when your tags use a `v`) |
34
34
  | `--config <file>` | Config file (default `chamoji.config.json` when present) |
35
+ | `--ai` | Sort the commits no rule can place with [Jev](https://typesafe.ai) (see below) |
35
36
 
36
37
  Typical release flow:
37
38
 
@@ -54,10 +55,31 @@ git tag "v$version"
54
55
  - `✨ (auth): Add login`
55
56
  - `feat(auth): ✨ Add login`
56
57
  - `feat: Add login`. A Conventional type without an emoji maps to one: `feat`→✨, `fix`→🐛, `docs`→📝, and so on.
58
+ - **Plain messages** are sorted by their wording:
59
+ - "Add …" goes to Added, "Fix …" to Fixed, "Remove …" to Removed, "Deprecate …" to Deprecated.
60
+ - Messages about docs, typos, tests, CI, performance, security and refactoring each go to their own section.
61
+ - Version-only commits (`1.2.1`, `Release 3.0.0`) are hidden.
62
+ - "Require Node.js 20" counts as a breaking change.
63
+ - Chamoji doesn't add emojis to plain messages, so they appear as written.
64
+ - **Dependency updates** (⬆️ ⬇️ 📌 ➕ ➖, `chore(deps):`, `fix(deps):` and Dependabot or Renovate subjects) are collapsed into a single line, such as `⬆️ 47 dependency updates: jest, vite, … and 12 more`. The JSON output keeps every commit.
57
65
  - **Breaking changes** are 💥, a `!` after any type (`feat!:`, `deps!:`), or a `BREAKING CHANGE:` footer. They are listed under Breaking changes and give a major bump.
58
66
  - **The next version** comes from each gitmoji's `semver` field (✨ minor, 🐛 patch, …).
59
67
  - **Commit trailers** (`Key: value` lines at the end of a message) are parsed and included in the JSON output.
60
68
 
69
+ ## Sorting the rest with AI (`--ai`)
70
+
71
+ Some messages are too vague for rules, such as "Accept camelCase flags" or "Tidelift tasks". `--ai` sends just those commits to [Jev](https://typesafe.ai), TypeSafe's fast classification model. It picks a gitmoji for each one. Picks below 60% confidence are dropped, and those commits stay in Miscellaneous.
72
+
73
+ ```bash
74
+ export TYPESAFE_API_KEY=... # from https://typesafe.ai
75
+ chamoji changelog --ai
76
+ ```
77
+
78
+ - **What is sent:** only the commits no rule could sort, at most 300 characters each of subject and body, with email addresses and trailer lines such as `Signed-off-by:` removed. Never diffs, code or author details. `next-version --ai` sends only unreleased commits.
79
+ - **If a request fails:** chamoji stops sending, keeps the picks it already has, and leaves the rest in Miscellaneous.
80
+ - **What it never does:** override an emoji or type the author wrote, or mark a change as breaking. Only authors declare breaking changes.
81
+ - **Without `--ai`:** chamoji makes no network calls.
82
+
61
83
  ## Config
62
84
 
63
85
  `chamoji.config.json`:
@@ -80,7 +102,7 @@ git tag "v$version"
80
102
  ```
81
103
 
82
104
  - `repositoryUrl` defaults to the `origin` remote. It is used for commit, issue (`#12`) and compare links.
83
- - `groups` replaces the default sections. Emojis are gitmoji codes without colons. Commits that match no group go to `misc` when that group exists. Hidden groups are left out of the output.
105
+ - `groups` replaces the default sections. Emojis are gitmoji codes without colons. Commits that match no group go to `misc` when that group exists. Hidden groups are left out of the output, and `"collapse": true` renders a group as one summary line, as the default Dependencies group does.
84
106
  - A plain JSON array of groups, the older format, is also accepted.
85
107
 
86
108
  ## Development