kaloko 0.5.1 → 0.5.2
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.
- package/README.md +28 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +46 -0
- package/schemas/run.v1.json +8 -0
- package/schemas/scenario.v1.json +8 -0
- package/skills/kaloko/SKILL.md +16 -2
- package/skills/kaloko/reference/docs.md +127 -0
- package/skills/kaloko/reference/mcp.md +3 -0
- package/src/cli.mjs +96 -5
- package/src/lib/changelog-core.mjs +323 -0
- package/src/lib/changelog.mjs +333 -0
- package/src/lib/doctor.mjs +6 -1
- package/src/lib/run.mjs +2 -2
package/README.md
CHANGED
|
@@ -210,6 +210,34 @@ link) and reads names from the accessibility tree. Pixel diffs skip the
|
|
|
210
210
|
regions a step ignores; `kaloko walk --stability 3` and `kaloko stability`
|
|
211
211
|
find the clocks, ads and carousels worth ignoring.
|
|
212
212
|
|
|
213
|
+
## A changelog with pictures
|
|
214
|
+
|
|
215
|
+
Your accepted runs already hold every screen of the release. `CHANGELOG.md`
|
|
216
|
+
can show them: a picture names a step instead of a file, and Kaloko shows the
|
|
217
|
+
accepted capture of it.
|
|
218
|
+
|
|
219
|
+
```markdown
|
|
220
|
+
- You can pay with Apple Pay in the checkout.
|
|
221
|
+
 
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npx kaloko changelog draft # the Unreleased entry: merged PRs and changed screens, before and after
|
|
226
|
+
npx kaloko changelog draft --apply # the same, written into CHANGELOG.md (Keep a Changelog)
|
|
227
|
+
npx kaloko release 2.4.0 # record the release and its pictures; prints the changelog page
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The draft groups pull requests and commits (Conventional Commits when you
|
|
231
|
+
use them) into Added, Changed, Fixed and Removed; your agent rewrites the
|
|
232
|
+
lines for people. The version comes from what the repository already uses
|
|
233
|
+
(git tags, `package.json`, release-please, changesets, semantic-release);
|
|
234
|
+
when a release tool owns `CHANGELOG.md`, Kaloko only reads it. The page on
|
|
235
|
+
kaloko.app has a slider between before and after, switches language and
|
|
236
|
+
screen size, and links each picture to its flow on the canvas. Share it with
|
|
237
|
+
guests, readers from your company's domain, or publish it with a feed.
|
|
238
|
+
Repositories with several apps list them under `products:` in
|
|
239
|
+
`kaloko.config.yml`.
|
|
240
|
+
|
|
213
241
|
## Design (Kaloko Design)
|
|
214
242
|
|
|
215
243
|
Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
|
package/package.json
CHANGED
package/schemas/config.v1.json
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
"schema": { "const": "qawalk.config.v1" },
|
|
10
10
|
"org": { "type": "string", "description": "Organization slug on kaloko.app." },
|
|
11
11
|
"project": { "type": "string" },
|
|
12
|
+
"products": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"description": "Optional: what is released and versioned as a whole and has its own visual changelog (docs/DOCS-CHANGELOG.md). Without it the project is the product. Keys are product slugs.",
|
|
15
|
+
"propertyNames": { "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" },
|
|
16
|
+
"additionalProperties": { "$ref": "#/$defs/product" }
|
|
17
|
+
},
|
|
18
|
+
"docs": { "$ref": "#/$defs/docs" },
|
|
12
19
|
"scenarios": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns of scenario files." },
|
|
13
20
|
"output": { "type": "string", "description": "Run folder; default tmp/kaloko (tmp/qawalk when only that one exists)" },
|
|
14
21
|
"browser": {
|
|
@@ -106,6 +113,45 @@
|
|
|
106
113
|
}
|
|
107
114
|
},
|
|
108
115
|
"$defs": {
|
|
116
|
+
"product": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"additionalProperties": false,
|
|
119
|
+
"properties": {
|
|
120
|
+
"name": { "oneOf": [{ "type": "string" }, { "type": "object", "additionalProperties": { "type": "string" }, "description": "Name per language, e.g. { en: Web shop, cs: E-shop }." }] },
|
|
121
|
+
"version": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"additionalProperties": false,
|
|
124
|
+
"description": "Where the product's version comes from. Default: detected (release-please, changesets, semantic-release, package.json, pyproject.toml, Cargo.toml, else git tags v*).",
|
|
125
|
+
"properties": {
|
|
126
|
+
"source": { "enum": ["auto", "git-tag", "package.json", "release-please", "changesets", "semantic-release", "pyproject", "cargo", "gemspec", "manual"] },
|
|
127
|
+
"path": { "type": "string", "description": "File that holds the version (package.json of the app, the release-please manifest)." },
|
|
128
|
+
"pattern": { "type": "string", "description": "Git tag pattern with one *, e.g. shop-v* (default v*)." }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"docs": { "$ref": "#/$defs/docs" },
|
|
132
|
+
"project": { "type": "string", "description": "The Kaloko project people review this product in." },
|
|
133
|
+
"projects": { "type": "array", "items": { "type": "string" }, "description": "Several projects that work on this product." },
|
|
134
|
+
"modules": { "type": "array", "items": { "type": "string" } },
|
|
135
|
+
"reference_env": { "type": "string", "description": "Environment whose accepted runs the pictures come from (default: any)." },
|
|
136
|
+
"default_scenario": { "type": "string", "description": "Scenario of short step references (kaloko:<step>)." },
|
|
137
|
+
"publish": { "enum": ["members", "domain", "public"], "description": "Who reads the changelog on Kaloko; domain and public need an admin on the service." }
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"docs": {
|
|
141
|
+
"type": "object",
|
|
142
|
+
"additionalProperties": false,
|
|
143
|
+
"description": "Visual changelog and docs settings (all optional).",
|
|
144
|
+
"properties": {
|
|
145
|
+
"dir": { "type": "string", "description": "Docs sources (default docs)." },
|
|
146
|
+
"changelog": { "type": "string", "description": "Changelog file (default CHANGELOG.md)." },
|
|
147
|
+
"style": { "enum": ["keep-a-changelog", "release-notes"] },
|
|
148
|
+
"version_source": { "enum": ["auto", "git-tag", "package.json", "release-please", "changesets", "semantic-release", "pyproject", "cargo", "gemspec", "manual"] },
|
|
149
|
+
"languages": { "type": "array", "items": { "type": "string" } },
|
|
150
|
+
"framework": { "enum": ["auto", "docusaurus", "vitepress", "mkdocs", "nextra", "plain"] },
|
|
151
|
+
"publish": { "enum": ["members", "domain", "public"], "description": "Who reads the changelog on Kaloko; domain and public need an admin on the service." },
|
|
152
|
+
"images": { "type": "object", "additionalProperties": false, "properties": { "viewport": { "type": "string" }, "locale": { "type": "string", "description": "reader (the reader's language) or a locale." } } }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
109
155
|
"record": {
|
|
110
156
|
"description": "Recordings of walked steps (opt-in): true or step records each step from the previous screen to its capture; flow records the whole walk per locale with a chapter per step; failed keeps only steps that fail; false or off turns it off. Uploaded on Business and Enterprise.",
|
|
111
157
|
"oneOf": [
|
package/schemas/run.v1.json
CHANGED
|
@@ -58,6 +58,14 @@
|
|
|
58
58
|
},
|
|
59
59
|
"archived": {
|
|
60
60
|
"type": "boolean"
|
|
61
|
+
},
|
|
62
|
+
"product": {
|
|
63
|
+
"type": ["string", "null"],
|
|
64
|
+
"description": "Product the flow documents (scenario product tag, or the only product of the config)."
|
|
65
|
+
},
|
|
66
|
+
"module": {
|
|
67
|
+
"type": ["string", "null"],
|
|
68
|
+
"description": "Module of the product (scenario module tag)."
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
},
|
package/schemas/scenario.v1.json
CHANGED
|
@@ -166,6 +166,14 @@
|
|
|
166
166
|
"$ref": "#/$defs/slug",
|
|
167
167
|
"description": "Optional project this flow belongs to; overrides `project` from kaloko.config.yml and the repository name for its runs."
|
|
168
168
|
},
|
|
169
|
+
"product": {
|
|
170
|
+
"$ref": "#/$defs/slug",
|
|
171
|
+
"description": "Product this flow documents (a key of `products:` in kaloko.config.yml). The visual changelog and docs of that product use its steps; with one product in the config, untagged flows belong to it."
|
|
172
|
+
},
|
|
173
|
+
"module": {
|
|
174
|
+
"$ref": "#/$defs/slug",
|
|
175
|
+
"description": "Module of the product (one of its `modules:`), for changelog and docs sections per module."
|
|
176
|
+
},
|
|
169
177
|
"capture": {
|
|
170
178
|
"$ref": "#/$defs/capture"
|
|
171
179
|
}
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -16,7 +16,9 @@ description: >
|
|
|
16
16
|
reference, checking implementation against it), and when implementing a
|
|
17
17
|
screen that has an approved design (read the step spec, build with its
|
|
18
18
|
tokens, check drift), and when a step should be recorded on video or its
|
|
19
|
-
trace read (actions, requests, console)
|
|
19
|
+
trace read (actions, requests, console), and when a user-visible change
|
|
20
|
+
should land in the visual changelog (CHANGELOG.md with step references,
|
|
21
|
+
kaloko changelog draft, kaloko release) or a guide should show a step.
|
|
20
22
|
---
|
|
21
23
|
|
|
22
24
|
# Kaloko
|
|
@@ -37,6 +39,11 @@ the canvas, storage and collaboration are the service.
|
|
|
37
39
|
for them (`kaloko share` prints it; `kaloko review` repeats it), and you
|
|
38
40
|
read `kaloko feedback` and `kaloko inbox` before continuing on that task.
|
|
39
41
|
- Changes to intent are written back to `ACCEPTANCE.md` (change log row).
|
|
42
|
+
- When the project keeps a visual changelog (`CHANGELOG.md`, `docs:` or
|
|
43
|
+
`products:` in the config) and the change is user-visible: the Unreleased
|
|
44
|
+
entry says it for people, with step references for the screens it changed
|
|
45
|
+
(`kaloko changelog draft`), and new scenarios carry `product:` (and
|
|
46
|
+
`module:`) when the config has products.
|
|
40
47
|
- When you implemented a step from an approved design: you read its spec
|
|
41
48
|
(`kaloko spec <step>`), walked the implementation with `packs: [design]`,
|
|
42
49
|
and `kaloko drift` is clean or the PR names each difference left for a
|
|
@@ -61,6 +68,8 @@ the design folder, walk, and sync the draft (including taking others' steps
|
|
|
61
68
|
with `--theirs`). Move to the trash runs you uploaded in this session that
|
|
62
69
|
were superseded before anyone looked at them (debug rounds: no comments, no
|
|
63
70
|
verdict), and run `kaloko prune` without `--yes` to see what a clean-up would do.
|
|
71
|
+
Draft and edit the Unreleased section of the changelog (`kaloko changelog draft
|
|
72
|
+
--apply`) and add step references to it and to guides.
|
|
64
73
|
|
|
65
74
|
## Ask first
|
|
66
75
|
|
|
@@ -70,7 +79,8 @@ replacing a CI workflow (`kaloko ci github|gitlab`), uploading with
|
|
|
70
79
|
design reference or baseline, merging a design branch, keeping your version of
|
|
71
80
|
a step in a sync conflict (`--mine`), turning on `html_view`, turning on recordings for
|
|
72
81
|
a whole project (`capture.record` in the config: storage), a real clean-up
|
|
73
|
-
(`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
|
|
82
|
+
(`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
|
|
83
|
+
recording a release (`kaloko release`) or asking to publish a changelog, and
|
|
74
84
|
anything the environment profile blocks. Never trash an accepted run, a
|
|
75
85
|
baseline, a run with review comments, or a run linked from an open PR or issue
|
|
76
86
|
comment. Never approve a
|
|
@@ -120,6 +130,10 @@ they can do; never write secrets into the config, a scenario or a script.
|
|
|
120
130
|
[reference/collaboration.md](reference/collaboration.md)
|
|
121
131
|
- Reading runs, feedback and the inbox over MCP instead of the CLI:
|
|
122
132
|
[reference/mcp.md](reference/mcp.md)
|
|
133
|
+
- The visual changelog and guides with step references (`kaloko:<scenario>/<step>`):
|
|
134
|
+
drafting the Unreleased entry, templates for entries, client release notes and
|
|
135
|
+
how-tos, products, modules and versions, who reads it:
|
|
136
|
+
[reference/docs.md](reference/docs.md)
|
|
123
137
|
- Refreshing a manual, course or product documentation from the newest
|
|
124
138
|
captures (latest URLs, changed steps, webhooks, export):
|
|
125
139
|
[reference/documentation.md](reference/documentation.md)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Visual changelog and docs with step references
|
|
2
|
+
|
|
3
|
+
The project keeps `CHANGELOG.md` (and guides in `docs/`) in the repository. You write and update them as part of
|
|
4
|
+
normal work; Kaloko turns step references into pictures from accepted runs and shows the changelog as a page that
|
|
5
|
+
clients, support and marketing can read.
|
|
6
|
+
|
|
7
|
+
## Step references
|
|
8
|
+
|
|
9
|
+
A picture in Markdown names a step instead of a PNG file:
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
 newest accepted capture
|
|
13
|
+
 the capture of release 2.4.0
|
|
14
|
+
 the release before the one this entry belongs to
|
|
15
|
+

|
|
16
|
+
 a step of the product's default_scenario
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`<scenario>` is the scenario id, `<step>` the node id. Without `locale` and `viewport` the reader's language and
|
|
20
|
+
the product's default screen are used. `state` picks a capture variant (`dark`, `webkit`, `reduce`: the values in
|
|
21
|
+
capture keys like `mobile.webkit.dark`). Check a reference with MCP `resolve_step_image` or
|
|
22
|
+
`GET /api/v1/orgs/<org>/steps/resolve?ref=…`: it answers the signed image URL, the run and how the version was found
|
|
23
|
+
(`release`, `app_version`, `reference_env`, `accepted`; `exact: false` means a fallback).
|
|
24
|
+
|
|
25
|
+
Never invent a step id or a version. Use ids from the scenario files and versions from `CHANGELOG.md` or git tags.
|
|
26
|
+
|
|
27
|
+
## After a user-visible change
|
|
28
|
+
|
|
29
|
+
1. Walk the affected steps and share the run as usual; a person accepts it on the canvas.
|
|
30
|
+
2. `kaloko changelog draft` prints the Unreleased entry: merged pull requests and commits since the last release,
|
|
31
|
+
grouped Added / Changed / Fixed / Removed, plus before/after pictures of the screens that accepted runs show
|
|
32
|
+
differently (it needs a token for the pictures). `--apply` writes it into `CHANGELOG.md`.
|
|
33
|
+
3. Edit the draft for people: what they can do now or what looks different, one change per line, no commit jargon.
|
|
34
|
+
Delete internal items (refactors, tests, CI). Keep the step references; move a picture under the line it shows.
|
|
35
|
+
Remove the `<!-- … -->` hints.
|
|
36
|
+
4. Commit `CHANGELOG.md` with the change. At release time `kaloko release <version>` (ask first: it records the
|
|
37
|
+
release on Kaloko) turns Unreleased into the version, records its pictures and prints the changelog page.
|
|
38
|
+
|
|
39
|
+
`--since v2.3.0` drafts from another tag; `--product shop` picks a product when the config has several.
|
|
40
|
+
|
|
41
|
+
## Templates
|
|
42
|
+
|
|
43
|
+
A changelog entry (Keep a Changelog):
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
## [2.4.0] - 2026-10-12
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- You can pay with Apple Pay in the checkout. ([#412](https://github.com/acme/shop/pull/412))
|
|
51
|
+
 
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- The cart total no longer jumps while prices load on phones.
|
|
56
|
+

|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Release notes for a client (a sibling file, or the text of a release on your Git host):
|
|
60
|
+
|
|
61
|
+
```markdown
|
|
62
|
+
# What's new in the web shop, October
|
|
63
|
+
|
|
64
|
+
Customers can pay with Apple Pay. The button sits above the card form on phones and computers.
|
|
65
|
+
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
Also fixed: the cart total no longer jumps on phones.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A how-to guide (`docs/how-to/pay-with-apple-pay.md`):
|
|
72
|
+
|
|
73
|
+
```markdown
|
|
74
|
+
# Pay with Apple Pay
|
|
75
|
+
|
|
76
|
+
1. Open the cart and choose **Checkout**.
|
|
77
|
+

|
|
78
|
+
2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
|
|
79
|
+

|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Rules: write for the person using the product; one change per line; before/after pictures for changes people see;
|
|
83
|
+
no internal names, prices or numbers you cannot show.
|
|
84
|
+
|
|
85
|
+
## Products, modules and versions
|
|
86
|
+
|
|
87
|
+
Without `products:` in `kaloko.config.yml` the project is the product and `CHANGELOG.md` in the root is its
|
|
88
|
+
changelog. Pick the pattern that fits; tag new scenarios accordingly without asking each time:
|
|
89
|
+
|
|
90
|
+
1. **One app, one team**: no products. Optional `docs: { changelog: CHANGELOG.md }`.
|
|
91
|
+
2. **Monorepo with several apps**: one product per app, each with its version file and changelog; every scenario
|
|
92
|
+
says `product: <slug>`.
|
|
93
|
+
```yaml
|
|
94
|
+
products:
|
|
95
|
+
shop:
|
|
96
|
+
name: { en: Web shop, cs: E-shop }
|
|
97
|
+
version: { source: package.json, path: apps/shop/package.json }
|
|
98
|
+
docs: { changelog: apps/shop/CHANGELOG.md }
|
|
99
|
+
project: eshop
|
|
100
|
+
reference_env: staging
|
|
101
|
+
admin:
|
|
102
|
+
version: { source: git-tag, pattern: "admin-v*" }
|
|
103
|
+
```
|
|
104
|
+
3. **Agency**: a project per client, a product per client deliverable; client guests of the project read only that
|
|
105
|
+
product's changelog.
|
|
106
|
+
4. **Large app with modules**: one product with `modules: [checkout, account, …]`; scenarios carry
|
|
107
|
+
`product:` and `module:`; one combined release changelog with a section per module if the team wants it.
|
|
108
|
+
|
|
109
|
+
The version comes from what the repository already uses: release-please, changesets, semantic-release,
|
|
110
|
+
`package.json` (or pyproject/Cargo/gemspec), else git tags `v*`. When a release tool writes `CHANGELOG.md`, Kaloko
|
|
111
|
+
reads its entry and never rewrites the file; put your draft into the pull request or release notes instead.
|
|
112
|
+
`reference_env` says which environment the pictures come from (default: any accepted run).
|
|
113
|
+
|
|
114
|
+
`kaloko doctor` warns when a product's version cannot be read or a scenario names no product while several exist.
|
|
115
|
+
|
|
116
|
+
## Readers
|
|
117
|
+
|
|
118
|
+
The changelog page (`/<org>/products/<product>/changelog`, or `/<org>/projects/<project>/changelog`) is read by
|
|
119
|
+
everyone who sees one of the product's projects, guests included. An admin of those projects may open it to
|
|
120
|
+
readers from the organization's verified domains or publish it (`/p/<org>/<product>/changelog` with an Atom feed;
|
|
121
|
+
not indexed until the organization allows it). `publish:` in the config only asks for it; an admin confirms on the
|
|
122
|
+
page. Never publish without the person asking.
|
|
123
|
+
|
|
124
|
+
## MCP
|
|
125
|
+
|
|
126
|
+
`get_changelog` (releases with entries; `resolve: true` adds the pictures), `draft_changelog_entry` (the service's
|
|
127
|
+
half of a draft: changed screens with before/after references), `resolve_step_image` (one reference).
|
|
@@ -33,6 +33,9 @@ file.
|
|
|
33
33
|
| `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
|
|
34
34
|
| `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
|
|
35
35
|
| `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
|
|
36
|
+
| `get_changelog` | the visual changelog of a product (`product`, `version`, `resolve: true` for the pictures, `limit`): releases newest first with the entry as written and its groups; see [docs.md](docs.md) |
|
|
37
|
+
| `draft_changelog_entry` | what accepted runs since the last release (or `since`) show differently, as a Keep a Changelog draft with before/after step references (`product`, `since`) |
|
|
38
|
+
| `resolve_step_image` | one step reference (`ref` = `kaloko:<scenario>/<step>?…`, `product`, `version`, `locale`, `viewport`) as a signed image URL with its run, how the version was found and canvas links |
|
|
36
39
|
| `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
|
|
37
40
|
| `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
|
|
38
41
|
| `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
|
package/src/cli.mjs
CHANGED
|
@@ -69,10 +69,11 @@ Project
|
|
|
69
69
|
draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
|
|
70
70
|
First plan + read-only production scenario for public pages, drafted from the
|
|
71
71
|
pages (title, description, H1) with the seo, a11y, perf and console packs
|
|
72
|
-
init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
|
|
72
|
+
init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario] [--docs=off|changelog|full]
|
|
73
73
|
Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
|
|
74
74
|
framework, dev server URL, locales and main routes, and drafts a first scenario
|
|
75
|
-
(qa/flows/main-pages.yml) when the project has none
|
|
75
|
+
(qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
|
|
76
|
+
(--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders)
|
|
76
77
|
ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
|
|
77
78
|
[--out file] [--print] [--force]
|
|
78
79
|
Write a workflow that walks what each pull request affects on its preview
|
|
@@ -231,6 +232,18 @@ Results
|
|
|
231
232
|
Text diff of two runs: step statuses, criterion verdicts, scores and evidence
|
|
232
233
|
(default: current run against the scenario baseline, else the previous run)
|
|
233
234
|
|
|
235
|
+
Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
|
|
236
|
+
changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
|
|
237
|
+
The Unreleased entry since the last release: merged pull requests and commits
|
|
238
|
+
(Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
|
|
239
|
+
Removed, plus before/after pictures of the screens accepted runs show differently;
|
|
240
|
+
printed, or --apply writes it into CHANGELOG.md (not when a release tool owns it)
|
|
241
|
+
release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--dry-run] [--json]
|
|
242
|
+
Record a release on Kaloko: the version (default: from git tags, package.json,
|
|
243
|
+
release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
|
|
244
|
+
version unless --no-write) and the accepted runs that are its pictures; prints the
|
|
245
|
+
illustrated changelog page
|
|
246
|
+
|
|
234
247
|
Design (environments with kind: design; live HTML steps in a folder)
|
|
235
248
|
start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
|
|
236
249
|
walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
|
|
@@ -773,6 +786,38 @@ const commands = {
|
|
|
773
786
|
await writeFile(file, tpl)
|
|
774
787
|
say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
|
|
775
788
|
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
789
|
+
// the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
|
|
790
|
+
let docsMode = typeof args.docs === 'string' ? args.docs : null
|
|
791
|
+
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
|
|
792
|
+
if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
|
|
793
|
+
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
794
|
+
const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
|
|
795
|
+
docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
|
|
796
|
+
}
|
|
797
|
+
if (docsMode && docsMode !== 'off') {
|
|
798
|
+
const { docsConfigBlock, detectProducts, DOCS_SKELETON } = await import('./lib/changelog.mjs')
|
|
799
|
+
const { CHANGELOG_SKELETON } = await import('./lib/changelog-core.mjs')
|
|
800
|
+
const { mkdir: mkdirp } = await import('node:fs/promises')
|
|
801
|
+
const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
|
|
802
|
+
const cfg = await readFile(cfgFile, 'utf8')
|
|
803
|
+
const products = /^products:/m.test(cfg) ? [] : await detectProducts(cwd)
|
|
804
|
+
if (!/^docs:/m.test(cfg) && !/^products:/m.test(cfg)) { await writeFile(cfgFile, `${cfg.replace(/\n*$/, '\n')}${docsConfigBlock({ products })}`); say(`Added the changelog settings to ${relative(cwd, cfgFile)}${products.length ? ` with ${products.length} products (${products.map((p) => p.slug).join(', ')})` : ''}`) }
|
|
805
|
+
for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
|
|
806
|
+
if (existsSync(f)) continue
|
|
807
|
+
await mkdirp(dirname(f), { recursive: true })
|
|
808
|
+
await writeFile(f, CHANGELOG_SKELETON)
|
|
809
|
+
say(`Created ${relative(cwd, f)} (Keep a Changelog; kaloko changelog draft fills Unreleased, kaloko release records a version)`)
|
|
810
|
+
}
|
|
811
|
+
if (docsMode === 'full') {
|
|
812
|
+
for (const [rel, text] of Object.entries(DOCS_SKELETON)) {
|
|
813
|
+
const f = join(cwd, rel)
|
|
814
|
+
if (existsSync(f)) continue
|
|
815
|
+
await mkdirp(dirname(f), { recursive: true })
|
|
816
|
+
await writeFile(f, text)
|
|
817
|
+
}
|
|
818
|
+
say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
|
|
819
|
+
}
|
|
820
|
+
} else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
|
|
776
821
|
const agent = typeof args.agent === 'string' ? args.agent : 'none'
|
|
777
822
|
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
|
|
778
823
|
const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
|
|
@@ -877,7 +922,9 @@ const commands = {
|
|
|
877
922
|
const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
|
|
878
923
|
const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
|
|
879
924
|
const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
|
|
880
|
-
|
|
925
|
+
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
926
|
+
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
927
|
+
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
|
|
881
928
|
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
882
929
|
const addr = recipient(env.mail, meta.short)
|
|
883
930
|
if (addr) say(` test e-mail: ${addr}`)
|
|
@@ -2019,12 +2066,56 @@ const commands = {
|
|
|
2019
2066
|
const { config, root, file } = await context(args)
|
|
2020
2067
|
// engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
|
|
2021
2068
|
const engines = []
|
|
2022
|
-
|
|
2023
|
-
const
|
|
2069
|
+
const scenarios = []
|
|
2070
|
+
for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; engines.push(...(s.browsers || [])); scenarios.push({ file: relative(root, f), product: s.product || null, module: s.module || null }) } catch {} }
|
|
2071
|
+
const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
|
|
2024
2072
|
say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
|
|
2025
2073
|
if (!r.ok) process.exitCode = 1
|
|
2026
2074
|
},
|
|
2027
2075
|
|
|
2076
|
+
/** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
|
|
2077
|
+
async changelog (args) {
|
|
2078
|
+
const sub = args._[0]
|
|
2079
|
+
if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
|
|
2080
|
+
const { config, root } = await context(args)
|
|
2081
|
+
const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
|
|
2082
|
+
const product = pickProduct(config, root, args.product)
|
|
2083
|
+
let client = null
|
|
2084
|
+
if (!args.offline) { try { client = service(config) } catch {} }
|
|
2085
|
+
const d = await draftChangelog({ root, config, product, since: typeof args.since === 'string' ? args.since : null, client })
|
|
2086
|
+
if (args.json) { say(JSON.stringify(d, null, 2)); return }
|
|
2087
|
+
const pictures = d.changes.reduce((n, s) => n + (s.steps?.length || 0), 0)
|
|
2088
|
+
const head = `${product.slug}: ${d.commits} change(s) since ${d.since.ref || 'the first commit'}${d.service === 'ok' ? `, ${pictures} changed screen(s) in accepted runs since ${d.previous ? `release ${d.previous.version}` : 'the first accepted runs'}` : d.service === 'offline' ? ' (no service: pictures of changed screens need a token)' : ` (service: ${d.service})`}; ${d.internal.length} internal left out.`
|
|
2089
|
+
if (args.apply) {
|
|
2090
|
+
const r = await applyToChangelog(d)
|
|
2091
|
+
say(`${head}\n${r.created ? 'Created' : 'Updated'} ${relative(process.cwd(), d.file) || d.file}: ${r.added} new item(s) in Unreleased. Edit them for people, then kaloko release <version>.`)
|
|
2092
|
+
return
|
|
2093
|
+
}
|
|
2094
|
+
console.error(head)
|
|
2095
|
+
say(draftText(d))
|
|
2096
|
+
},
|
|
2097
|
+
|
|
2098
|
+
/** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
|
|
2099
|
+
async release (args) {
|
|
2100
|
+
const { config, root } = await context(args)
|
|
2101
|
+
if (!config.org) throw new UserError('Set org: in the config first.')
|
|
2102
|
+
const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
|
|
2103
|
+
const product = pickProduct(config, root, args.product)
|
|
2104
|
+
const client = args['dry-run'] ? null : service(config)
|
|
2105
|
+
const r = await releaseProduct({ root, config, product, version: args._[0] || null, date: typeof args.date === 'string' ? args.date : null, client, write: !args['no-write'], runs: list(args.runs), dryRun: !!args['dry-run'] })
|
|
2106
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2107
|
+
const file = relative(process.cwd(), r.file) || r.file
|
|
2108
|
+
const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
|
|
2109
|
+
lines.push(r.section ? ` ${file}: ${r.finalized ? 'Unreleased became' : 'entry'} ${r.version}, ${r.items} item(s), ${r.refs} picture reference(s)` : ` ${file}: no entry for ${r.version}`)
|
|
2110
|
+
if (r.release) {
|
|
2111
|
+
lines.push(` ${r.release.created ? 'Recorded' : 'Updated'} on the service with ${r.release.runs.length} reference run(s)${r.release.versioned_runs ? `; ${r.release.versioned_runs} accepted run(s) now carry ${r.version}` : ''}`)
|
|
2112
|
+
lines.push(` ${r.release.url}`)
|
|
2113
|
+
} else lines.push(' Dry run: nothing sent.')
|
|
2114
|
+
for (const n of r.notes) lines.push(` ! ${n}`)
|
|
2115
|
+
if (r.finalized) lines.push(` Commit ${file} with the release.`)
|
|
2116
|
+
say(lines.join('\n'))
|
|
2117
|
+
},
|
|
2118
|
+
|
|
2028
2119
|
async help () { say(HELP) }
|
|
2029
2120
|
}
|
|
2030
2121
|
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
|
|
2
|
+
// in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
|
|
3
|
+
// worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
|
|
4
|
+
|
|
5
|
+
// ---------- step references ----------
|
|
6
|
+
|
|
7
|
+
/** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
|
|
8
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
|
|
9
|
+
const PARAMS = {
|
|
10
|
+
locale: /^[A-Za-z0-9_-]{1,20}$/,
|
|
11
|
+
viewport: /^[A-Za-z0-9._-]{1,80}$/,
|
|
12
|
+
version: /^[A-Za-z0-9._+-]{1,80}$/,
|
|
13
|
+
state: /^[A-Za-z0-9._-]{1,60}$/
|
|
14
|
+
}
|
|
15
|
+
export const REF_SCHEME = 'kaloko:'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
|
|
19
|
+
* means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
|
|
20
|
+
* release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
|
|
21
|
+
* dimension values in the capture key). Returns null for anything else.
|
|
22
|
+
*/
|
|
23
|
+
export function parseStepRef (ref) {
|
|
24
|
+
const s = String(ref || '').trim()
|
|
25
|
+
if (!s.startsWith(REF_SCHEME)) return null
|
|
26
|
+
const rest = s.slice(REF_SCHEME.length)
|
|
27
|
+
const qi = rest.indexOf('?')
|
|
28
|
+
const path = qi < 0 ? rest : rest.slice(0, qi)
|
|
29
|
+
let parts
|
|
30
|
+
try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
|
|
31
|
+
if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
|
|
32
|
+
const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
|
|
33
|
+
const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
|
|
34
|
+
for (const [k, v] of q) {
|
|
35
|
+
if (!(k in PARAMS)) return null
|
|
36
|
+
if (!PARAMS[k].test(v)) return null
|
|
37
|
+
out[k] = v
|
|
38
|
+
}
|
|
39
|
+
if (out.version === 'latest') out.version = null
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
|
|
44
|
+
export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
|
|
45
|
+
const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
|
|
46
|
+
const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
|
|
47
|
+
return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
|
|
51
|
+
export function findStepRefs (markdown) {
|
|
52
|
+
const out = []
|
|
53
|
+
const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
|
|
54
|
+
let m
|
|
55
|
+
while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- versions ----------
|
|
60
|
+
|
|
61
|
+
/** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
|
|
62
|
+
export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
|
|
63
|
+
|
|
64
|
+
/** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
|
|
65
|
+
export function compareVersions (a, b) {
|
|
66
|
+
const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
|
|
67
|
+
const x = p(a); const y = p(b)
|
|
68
|
+
if (!x || !y) return String(a).localeCompare(String(b))
|
|
69
|
+
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
|
|
70
|
+
if (x.pre === y.pre) return 0
|
|
71
|
+
if (!x.pre) return 1
|
|
72
|
+
if (!y.pre) return -1
|
|
73
|
+
return x.pre.localeCompare(y.pre, 'en', { numeric: true })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
|
|
77
|
+
export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
|
|
78
|
+
/** The version a tag names under a pattern, or null when the tag does not match it. */
|
|
79
|
+
export function versionOfTag (pattern, tag) {
|
|
80
|
+
const [pre, post = ''] = String(pattern || 'v*').split('*')
|
|
81
|
+
const t = String(tag || '')
|
|
82
|
+
if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
|
|
83
|
+
return cleanVersion(t.slice(pre.length, t.length - post.length))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- Keep a Changelog ----------
|
|
87
|
+
|
|
88
|
+
export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
|
|
89
|
+
const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
|
|
90
|
+
/** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
|
|
91
|
+
export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
|
|
92
|
+
|
|
93
|
+
export const CHANGELOG_HEAD = `# Changelog
|
|
94
|
+
|
|
95
|
+
All notable changes for the people who use this product. The format follows
|
|
96
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
|
|
97
|
+
[Semantic Versioning](https://semver.org/). Pictures are step references
|
|
98
|
+
(\`\`) that Kaloko shows from the accepted runs.
|
|
99
|
+
`
|
|
100
|
+
export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
|
|
101
|
+
## [Unreleased]
|
|
102
|
+
`
|
|
103
|
+
|
|
104
|
+
const RELEASE_HEADING = /^##\s+(.+?)\s*$/
|
|
105
|
+
const DATE = /(\d{4}-\d{2}-\d{2})/
|
|
106
|
+
|
|
107
|
+
/** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
|
|
108
|
+
export function parseHeading (line) {
|
|
109
|
+
const m = String(line).match(RELEASE_HEADING)
|
|
110
|
+
if (!m || /^#/.test(m[1])) return null
|
|
111
|
+
const text = m[1]
|
|
112
|
+
const bracket = text.match(/^\[([^\]]+)\]/)
|
|
113
|
+
const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
|
|
114
|
+
if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
|
|
115
|
+
const version = cleanVersion(label)
|
|
116
|
+
if (!/^\d/.test(version)) return null
|
|
117
|
+
const rest = text.slice(bracket ? bracket[0].length : label.length)
|
|
118
|
+
return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
|
|
122
|
+
export function listItems (body) {
|
|
123
|
+
const items = []
|
|
124
|
+
let cur = null
|
|
125
|
+
for (const line of String(body || '').split('\n')) {
|
|
126
|
+
const m = line.match(/^[-*+]\s+(.*)$/)
|
|
127
|
+
if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
|
|
128
|
+
if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
|
|
129
|
+
if (cur != null) { items.push(cur.trimEnd()); cur = null }
|
|
130
|
+
}
|
|
131
|
+
if (cur) items.push(cur.trimEnd())
|
|
132
|
+
return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
|
|
136
|
+
export function parseEntry (body) {
|
|
137
|
+
const groups = []; const notes = []
|
|
138
|
+
let cur = null
|
|
139
|
+
for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
|
|
140
|
+
const h = line.match(/^###\s+(.+?)\s*$/)
|
|
141
|
+
if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
|
|
142
|
+
if (cur) cur.lines.push(line); else notes.push(line)
|
|
143
|
+
}
|
|
144
|
+
return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
|
|
149
|
+
* body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
|
|
150
|
+
* label), linksStart }.
|
|
151
|
+
*/
|
|
152
|
+
export function parseChangelog (text) {
|
|
153
|
+
const lines = String(text || '').replace(/\r/g, '').split('\n')
|
|
154
|
+
let linksStart = lines.length
|
|
155
|
+
while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
|
|
156
|
+
if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
|
|
157
|
+
const links = {}
|
|
158
|
+
for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
|
|
159
|
+
const sections = []
|
|
160
|
+
for (let i = 0; i < linksStart; i++) {
|
|
161
|
+
const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
|
|
162
|
+
if (!h) continue
|
|
163
|
+
if (sections.length) sections[sections.length - 1].end = i
|
|
164
|
+
sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
|
|
165
|
+
}
|
|
166
|
+
for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
|
|
167
|
+
const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
|
|
168
|
+
return { head, sections, links, linksStart, lines }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The section of a version (or the Unreleased one for null), or null. */
|
|
172
|
+
export function sectionOf (text, version) {
|
|
173
|
+
const { sections } = parseChangelog(text)
|
|
174
|
+
const v = version == null ? null : cleanVersion(version)
|
|
175
|
+
return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The newest released version written in the file (null when none). */
|
|
179
|
+
export function latestVersionIn (text) {
|
|
180
|
+
const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
|
|
181
|
+
return v.sort(compareVersions).pop() || null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
185
|
+
|
|
186
|
+
/** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
|
|
187
|
+
export function groupsMarkdown (groups) {
|
|
188
|
+
const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
|
|
189
|
+
return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
|
|
194
|
+
* out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
|
|
195
|
+
* without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
|
|
196
|
+
* Returns { text, added } (the number of new items).
|
|
197
|
+
*/
|
|
198
|
+
export function applyDraft (text, groups) {
|
|
199
|
+
let src = String(text || '')
|
|
200
|
+
if (!src.trim()) src = CHANGELOG_SKELETON
|
|
201
|
+
let parsed = parseChangelog(src)
|
|
202
|
+
let un = parsed.sections.find((s) => s.unreleased)
|
|
203
|
+
if (!un) {
|
|
204
|
+
const lines = parsed.lines
|
|
205
|
+
const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
|
|
206
|
+
const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
|
|
207
|
+
src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
|
|
208
|
+
parsed = parseChangelog(src)
|
|
209
|
+
un = parsed.sections.find((s) => s.unreleased)
|
|
210
|
+
}
|
|
211
|
+
const existing = parseEntry(un.body)
|
|
212
|
+
const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
|
|
213
|
+
const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
|
|
214
|
+
let added = 0
|
|
215
|
+
for (const [name, items] of Object.entries(groups)) {
|
|
216
|
+
for (const it of items || []) {
|
|
217
|
+
if (seen.has(norm(it))) continue
|
|
218
|
+
seen.add(norm(it))
|
|
219
|
+
if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
|
|
220
|
+
merged.get(name).items.push(it); added++
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
|
|
224
|
+
const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
|
|
225
|
+
const lines = parsed.lines
|
|
226
|
+
const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
|
|
227
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
|
|
232
|
+
* it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
|
|
233
|
+
*/
|
|
234
|
+
export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
|
|
235
|
+
const v = cleanVersion(version)
|
|
236
|
+
const parsed = parseChangelog(text)
|
|
237
|
+
if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
|
|
238
|
+
const un = parsed.sections.find((s) => s.unreleased)
|
|
239
|
+
if (!un) return { text, changed: false, empty: true }
|
|
240
|
+
const lines = [...parsed.lines]
|
|
241
|
+
lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
|
|
242
|
+
let out = lines.join('\n')
|
|
243
|
+
const link = parsed.links.unreleased
|
|
244
|
+
const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
|
|
245
|
+
if (m) {
|
|
246
|
+
const tag = tagFor(tagPattern, v)
|
|
247
|
+
out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
|
|
248
|
+
}
|
|
249
|
+
return { text: out, changed: true, empty: !un.body.trim() }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- commits and the draft ----------
|
|
253
|
+
|
|
254
|
+
const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
|
|
255
|
+
const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
|
|
256
|
+
const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
|
|
260
|
+
* title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
|
|
261
|
+
* Documentation, tests, chores, CI, refactors, merges and releases are internal.
|
|
262
|
+
*/
|
|
263
|
+
export function classifyCommit (subject, body = '') {
|
|
264
|
+
let s = String(subject || '').trim()
|
|
265
|
+
let pr = null
|
|
266
|
+
const sq = s.match(/\s*\(#(\d+)\)\s*$/)
|
|
267
|
+
if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
|
|
268
|
+
const breaking = /BREAKING[ -]CHANGE/.test(body)
|
|
269
|
+
if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
|
|
270
|
+
const m = s.match(CONVENTIONAL)
|
|
271
|
+
if (m) {
|
|
272
|
+
const type = m[1].toLowerCase()
|
|
273
|
+
const text = sentence(m[4])
|
|
274
|
+
if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
|
|
275
|
+
const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
|
|
276
|
+
return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
|
|
277
|
+
}
|
|
278
|
+
const verb = s.toLowerCase()
|
|
279
|
+
const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
|
|
280
|
+
: /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
|
|
281
|
+
: /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
|
|
282
|
+
: /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
|
|
283
|
+
return { group, text: sentence(s), internal: false, breaking, pr }
|
|
284
|
+
}
|
|
285
|
+
const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
|
|
289
|
+
*
|
|
290
|
+
* commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
|
|
291
|
+
* changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
|
|
292
|
+
* accepted runs since the last release show differently (from the service)
|
|
293
|
+
* Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
|
|
294
|
+
* new step one picture. A step whose run came from a pull request goes under that pull request's item.
|
|
295
|
+
* Returns { groups, internal (titles left out), markdown }.
|
|
296
|
+
*/
|
|
297
|
+
export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
|
|
298
|
+
const groups = {}
|
|
299
|
+
const internal = []
|
|
300
|
+
const byPr = new Map()
|
|
301
|
+
const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
|
|
302
|
+
for (const c of commits) {
|
|
303
|
+
const k = classifyCommit(c.subject, c.body)
|
|
304
|
+
if (k.internal) { internal.push(c.subject); continue }
|
|
305
|
+
const number = c.pr?.number || k.pr
|
|
306
|
+
const url = c.pr?.url || null
|
|
307
|
+
const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
|
|
308
|
+
const i = add(k.group, text)
|
|
309
|
+
if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
|
|
310
|
+
}
|
|
311
|
+
for (const sc of changes) {
|
|
312
|
+
for (const st of sc.steps || []) {
|
|
313
|
+
const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
|
|
314
|
+
const title = st.title || st.step
|
|
315
|
+
const pics = st.reason === 'new' ? `})` : st.reason === 'removed' ? `})` : `}) })`
|
|
316
|
+
const home = st.pr && byPr.get(st.pr)
|
|
317
|
+
if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
|
|
318
|
+
const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
|
|
319
|
+
add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { groups, internal, markdown: groupsMarkdown(groups) }
|
|
323
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// `kaloko changelog draft` and `kaloko release` (docs/DOCS-CHANGELOG.md, phase 1): products from kaloko.config.yml,
|
|
2
|
+
// the version source the repository already uses (never fighting a release tool), merged pull requests and commits
|
|
3
|
+
// since the last release, the steps the accepted runs show differently (from the service), the draft entry and the
|
|
4
|
+
// release record. Parsing and the draft itself are in changelog-core.mjs, shared with the service.
|
|
5
|
+
import { readFile, writeFile, access, readdir } from 'node:fs/promises'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { join, resolve, relative, dirname, basename } from 'node:path'
|
|
9
|
+
import { UserError, sha256 } from './util.mjs'
|
|
10
|
+
import { repoSlug } from './git.mjs'
|
|
11
|
+
import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile)
|
|
14
|
+
async function out (cmd, args, cwd, timeout = 20000) {
|
|
15
|
+
try { return (await run(cmd, args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024 })).stdout } catch { return null }
|
|
16
|
+
}
|
|
17
|
+
const exists = async (f) => { try { await access(f); return true } catch { return false } }
|
|
18
|
+
const readJson = async (f) => { try { return JSON.parse(await readFile(f, 'utf8')) } catch { return null } }
|
|
19
|
+
const readText = async (f) => { try { return await readFile(f, 'utf8') } catch { return null } }
|
|
20
|
+
export const today = () => new Date().toISOString().slice(0, 10)
|
|
21
|
+
|
|
22
|
+
// ---------- products ----------
|
|
23
|
+
|
|
24
|
+
const slugOf = (s) => String(s || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Products of the configuration, normalized. Without `products:` the project is the product (implicit): its slug is
|
|
28
|
+
* the config's project, else the directory name.
|
|
29
|
+
*/
|
|
30
|
+
export function productsOf (config, root = process.cwd()) {
|
|
31
|
+
const docs = config.docs || {}
|
|
32
|
+
const list = Object.entries(config.products || {}).map(([slug, p]) => ({
|
|
33
|
+
slug, implicit: false, name: p.name || slug,
|
|
34
|
+
projects: [...new Set([p.project, ...(p.projects || [])].filter(Boolean))],
|
|
35
|
+
modules: p.modules || [], reference_env: p.reference_env || null, default_scenario: p.default_scenario || null,
|
|
36
|
+
version: p.version || null, docs: { ...docs, ...(p.docs || {}) }, publish: p.publish || docs.publish || null
|
|
37
|
+
}))
|
|
38
|
+
if (list.length) return list
|
|
39
|
+
const slug = slugOf(config.project || basename(root)) || 'app'
|
|
40
|
+
return [{ slug, implicit: true, name: config.project || slug, projects: [slug], modules: [], reference_env: null, default_scenario: null, version: null, docs, publish: docs.publish || null }]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The product a command works on: --product, else the only one. */
|
|
44
|
+
export function pickProduct (config, root, name) {
|
|
45
|
+
const all = productsOf(config, root)
|
|
46
|
+
if (typeof name === 'string' && name) {
|
|
47
|
+
const p = all.find((x) => x.slug === name)
|
|
48
|
+
if (!p) throw new UserError(`No product ${name} in the config (${all.map((x) => x.slug).join(', ')}).`)
|
|
49
|
+
return p
|
|
50
|
+
}
|
|
51
|
+
if (all.length > 1) throw new UserError(`The config has several products; pass --product (${all.map((x) => x.slug).join(', ')}).`)
|
|
52
|
+
return all[0]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const changelogFile = (root, product) => resolve(root, product.docs?.changelog || 'CHANGELOG.md')
|
|
56
|
+
|
|
57
|
+
// ---------- the version source ----------
|
|
58
|
+
|
|
59
|
+
const SEMREL = ['.releaserc', '.releaserc.json', '.releaserc.yml', '.releaserc.yaml', '.releaserc.js', '.releaserc.cjs', 'release.config.js', 'release.config.cjs', 'release.config.mjs']
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where the product's version comes from, as the repository already decides it: { source, path, pattern, version,
|
|
63
|
+
* owns_changelog, tool }. The config wins (products.<p>.version or docs.version_source); otherwise release-please,
|
|
64
|
+
* changesets, semantic-release, package.json, pyproject.toml, Cargo.toml, a gemspec, else git tags (v*).
|
|
65
|
+
*/
|
|
66
|
+
export async function detectVersionSource (root, product = {}) {
|
|
67
|
+
const v = product.version || {}
|
|
68
|
+
const wanted = v.source && v.source !== 'auto' ? v.source : product.docs?.version_source && product.docs.version_source !== 'auto' ? product.docs.version_source : null
|
|
69
|
+
const pattern = v.pattern || 'v*'
|
|
70
|
+
const pkgPath = v.path && /package\.json$/.test(v.path) ? v.path : 'package.json'
|
|
71
|
+
const dir = v.path ? dirname(v.path) : '.'
|
|
72
|
+
const found = async (source) => {
|
|
73
|
+
if (source === 'release-please') {
|
|
74
|
+
const manifest = await readJson(join(root, '.release-please-manifest.json'))
|
|
75
|
+
const key = dir === '.' ? '.' : dir
|
|
76
|
+
return { source, path: '.release-please-manifest.json', pattern, version: manifest ? cleanVersion(manifest[key] ?? manifest['.'] ?? '') || null : null, owns_changelog: true, tool: 'release-please' }
|
|
77
|
+
}
|
|
78
|
+
if (source === 'changesets') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: true, tool: 'changesets' }
|
|
79
|
+
if (source === 'semantic-release') {
|
|
80
|
+
let conf = ''
|
|
81
|
+
for (const f of SEMREL) conf += (await readText(join(root, f))) || ''
|
|
82
|
+
conf += JSON.stringify((await readJson(join(root, 'package.json')))?.release || '')
|
|
83
|
+
return { source, path: null, pattern, version: await latestTag(root, pattern), owns_changelog: conf.includes('@semantic-release/changelog'), tool: 'semantic-release' }
|
|
84
|
+
}
|
|
85
|
+
if (source === 'package.json') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: false }
|
|
86
|
+
if (source === 'pyproject') { const t = await readText(join(root, v.path || 'pyproject.toml')); return { source, path: v.path || 'pyproject.toml', pattern, version: (t?.match(/^version\s*=\s*["']([^"']+)["']/m) || [])[1] || null, owns_changelog: false } }
|
|
87
|
+
if (source === 'cargo') { const t = await readText(join(root, v.path || 'Cargo.toml')); return { source, path: v.path || 'Cargo.toml', pattern, version: (t?.match(/^\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m) || [])[1] || null, owns_changelog: false } }
|
|
88
|
+
if (source === 'gemspec') {
|
|
89
|
+
const f = v.path || (await readdir(root).catch(() => [])).find((x) => x.endsWith('.gemspec'))
|
|
90
|
+
const t = f ? await readText(join(root, f)) : null
|
|
91
|
+
return { source, path: f || null, pattern, version: (t?.match(/\.version\s*=\s*["']([^"']+)["']/) || [])[1] || null, owns_changelog: false }
|
|
92
|
+
}
|
|
93
|
+
if (source === 'manual') return { source, path: null, pattern, version: null, owns_changelog: false }
|
|
94
|
+
return { source: 'git-tag', path: null, pattern, version: await latestTag(root, pattern), owns_changelog: false }
|
|
95
|
+
}
|
|
96
|
+
if (wanted) return found(wanted)
|
|
97
|
+
if (await exists(join(root, '.release-please-manifest.json')) || await exists(join(root, 'release-please-config.json'))) return found('release-please')
|
|
98
|
+
if (await exists(join(root, '.changeset', 'config.json'))) return found('changesets')
|
|
99
|
+
for (const f of SEMREL) if (await exists(join(root, f))) return found('semantic-release')
|
|
100
|
+
const pkg = await readJson(join(root, pkgPath))
|
|
101
|
+
if (pkg?.release) return found('semantic-release')
|
|
102
|
+
if (pkg?.version && !pkg.private) return found('package.json')
|
|
103
|
+
if (pkg?.version && pkg.version !== '0.0.0') return found('package.json')
|
|
104
|
+
if (await exists(join(root, 'pyproject.toml')) && /^version\s*=/m.test(await readText(join(root, 'pyproject.toml')) || '')) return found('pyproject')
|
|
105
|
+
if (await exists(join(root, 'Cargo.toml'))) return found('cargo')
|
|
106
|
+
if ((await readdir(root).catch(() => [])).some((x) => x.endsWith('.gemspec'))) return found('gemspec')
|
|
107
|
+
return found('git-tag')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The newest tag of the pattern (version order), or null. */
|
|
111
|
+
async function latestTag (root, pattern) {
|
|
112
|
+
const tags = ((await out('git', ['tag', '--list', pattern], root)) || '').split('\n').map((t) => t.trim()).filter(Boolean)
|
|
113
|
+
const versions = tags.map((t) => versionOfTag(pattern, t)).filter(Boolean)
|
|
114
|
+
return versions.sort(compareVersions).pop() || null
|
|
115
|
+
}
|
|
116
|
+
const tagExists = async (root, tag) => !!(await out('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], root))
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where the draft starts: --since (a tag, or a version that has a tag), else the newest tag of the pattern reachable
|
|
120
|
+
* from HEAD, else the newest release written in the changelog when it has a tag; null = the whole history.
|
|
121
|
+
*/
|
|
122
|
+
export async function sinceRef (root, product, src, since, changelogText = '') {
|
|
123
|
+
const pattern = src.pattern || 'v*'
|
|
124
|
+
if (typeof since === 'string' && since) {
|
|
125
|
+
if (await tagExists(root, since)) return { ref: since, version: versionOfTag(pattern, since) || cleanVersion(since) }
|
|
126
|
+
const tag = tagFor(pattern, since)
|
|
127
|
+
if (await tagExists(root, tag)) return { ref: tag, version: cleanVersion(since) }
|
|
128
|
+
if (await out('git', ['rev-parse', '--verify', '--quiet', `${since}^{commit}`], root)) return { ref: since, version: null }
|
|
129
|
+
throw new UserError(`--since ${since}: no tag ${since} or ${tag} and no such commit.`)
|
|
130
|
+
}
|
|
131
|
+
const described = ((await out('git', ['describe', '--tags', '--abbrev=0', '--match', pattern, 'HEAD'], root)) || '').trim()
|
|
132
|
+
if (described) return { ref: described, version: versionOfTag(pattern, described) }
|
|
133
|
+
const written = latestVersionIn(changelogText)
|
|
134
|
+
if (written && await tagExists(root, tagFor(pattern, written))) return { ref: tagFor(pattern, written), version: written }
|
|
135
|
+
return { ref: null, version: written || null }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------- pull requests and commits ----------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merged work since `ref` along the first-parent history: a merge of a pull request is one item (its title), other
|
|
142
|
+
* merges are opened up into their commits, plain commits are items. PR numbers come from merge subjects and squash
|
|
143
|
+
* suffixes ("(#12)"); titles and links from gh when it is there (KALOKO_NO_GH=1 skips it). `dir` limits the history
|
|
144
|
+
* to a product's folder in a monorepo.
|
|
145
|
+
*/
|
|
146
|
+
export async function collectCommits (root, ref, { dir = null, limit = 400 } = {}) {
|
|
147
|
+
const range = ref ? `${ref}..HEAD` : 'HEAD'
|
|
148
|
+
const SEP = '\u001f'; const END = '\u001e'
|
|
149
|
+
const fmt = `--format=%H${SEP}%P${SEP}%s${SEP}%b${END}`
|
|
150
|
+
const log = async (args) => ((await out('git', ['log', fmt, ...args], root)) || '').split(END).map((x) => x.trim()).filter(Boolean).map((x) => { const [sha, parents, subject, body = ''] = x.split(SEP); return { sha, parents: parents.split(' ').filter(Boolean), subject, body: body.trim() } })
|
|
151
|
+
const scope = dir && dir !== '.' ? ['--', dir] : []
|
|
152
|
+
const first = await log(['--first-parent', `-n${limit}`, range, ...scope])
|
|
153
|
+
const items = []
|
|
154
|
+
for (const c of first) {
|
|
155
|
+
if (c.parents.length > 1) {
|
|
156
|
+
const pr = c.subject.match(/^Merge pull request #(\d+)/) || c.subject.match(/^Merge branch .* into .*\(#(\d+)\)/) || c.subject.match(/\(#(\d+)\)\s*$/)
|
|
157
|
+
if (pr) { items.push({ sha: c.sha, subject: c.body.split('\n')[0] || c.subject, body: c.body.split('\n').slice(1).join('\n'), pr: { number: Number(pr[1]), url: null } }); continue }
|
|
158
|
+
for (const inner of await log(['--no-merges', `-n${limit}`, `${c.parents[0]}..${c.sha}`, ...scope])) items.push({ sha: inner.sha, subject: inner.subject, body: inner.body, pr: null })
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
const sq = c.subject.match(/\(#(\d+)\)\s*$/)
|
|
162
|
+
items.push({ sha: c.sha, subject: c.subject, body: c.body, pr: sq ? { number: Number(sq[1]), url: null } : null })
|
|
163
|
+
}
|
|
164
|
+
// links and titles of pull requests (GitHub)
|
|
165
|
+
const repo = await repoSlug(root)
|
|
166
|
+
const numbers = [...new Set(items.map((i) => i.pr?.number).filter(Boolean))]
|
|
167
|
+
if (repo?.host === 'github') {
|
|
168
|
+
let titles = new Map()
|
|
169
|
+
if (numbers.length && process.env.KALOKO_NO_GH !== '1') {
|
|
170
|
+
const json = await out('gh', ['pr', 'list', '--state', 'merged', '--limit', '200', '--json', 'number,title,url'], root, 15000)
|
|
171
|
+
try { titles = new Map(JSON.parse(json || '[]').map((p) => [p.number, p])) } catch {}
|
|
172
|
+
}
|
|
173
|
+
for (const i of items) {
|
|
174
|
+
if (!i.pr) continue
|
|
175
|
+
const p = titles.get(i.pr.number)
|
|
176
|
+
i.pr.url = p?.url || `https://github.com/${repo.slug}/pull/${i.pr.number}`
|
|
177
|
+
// a squash commit or merge keeps its own title unless it is GitHub's default "Merge pull request" line
|
|
178
|
+
if (p?.title && /^Merge pull request/.test(i.subject)) i.subject = p.title
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return items
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- the draft ----------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The draft entry of a product: commits and pull requests since the last release plus the steps that accepted runs
|
|
188
|
+
* show differently (when a service client is given). Returns { product, since, source, commits, changes, groups,
|
|
189
|
+
* markdown, internal, service, file }.
|
|
190
|
+
*/
|
|
191
|
+
export async function draftChangelog ({ root, config, product, since = null, client = null }) {
|
|
192
|
+
const file = changelogFile(root, product)
|
|
193
|
+
const text = (await readText(file)) || ''
|
|
194
|
+
const src = await detectVersionSource(root, product)
|
|
195
|
+
const from = await sinceRef(root, product, src, since, text)
|
|
196
|
+
const dir = product.version?.path ? dirname(product.version.path) : null
|
|
197
|
+
const commits = await collectCommits(root, from.ref, { dir })
|
|
198
|
+
let changes = []; let service = 'offline'; let previous = null
|
|
199
|
+
if (client && config.org) {
|
|
200
|
+
try {
|
|
201
|
+
const q = new URLSearchParams({ product: product.slug, ...(from.version && since ? { since: from.version } : {}) })
|
|
202
|
+
const r = await client.request('GET', `/orgs/${config.org}/changelog/changes?${q}`)
|
|
203
|
+
changes = (r.scenarios || []).map((s) => ({ scenario: s.scenario, title: s.title, steps: (s.steps || []).map((st) => ({ ...st, pr: st.pr ?? s.run?.pr?.number ?? null })) }))
|
|
204
|
+
previous = r.previous || null
|
|
205
|
+
service = 'ok'
|
|
206
|
+
} catch (e) { service = `error: ${String(e.message || e).split('\n')[0].slice(0, 200)}` }
|
|
207
|
+
}
|
|
208
|
+
const d = draftEntry({ commits, changes })
|
|
209
|
+
return { product: product.slug, since: from, previous, source: src, commits: commits.length, changes, groups: d.groups, markdown: d.markdown, internal: d.internal, service, file }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The draft as an Unreleased section, with a note for whoever edits it. */
|
|
213
|
+
export function draftText (d) {
|
|
214
|
+
if (!d.markdown) return '## [Unreleased]\n\n<!-- Nothing user-facing since the last release: no merged changes and no changed screens. -->\n'
|
|
215
|
+
return `## [Unreleased]\n\n<!-- Draft by kaloko changelog draft: write each line for the people who use the product (what they can do now,\n what looks different), one change per line; delete what is internal; keep the step references for the pictures. -->\n\n${d.markdown}\n`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Writes the draft into the changelog's Unreleased section (creating the file with the skeleton when missing). */
|
|
219
|
+
export async function applyToChangelog (d) {
|
|
220
|
+
if (d.source.owns_changelog) throw new UserError(`${d.source.tool} writes ${relative(process.cwd(), d.file) || 'CHANGELOG.md'}; kaloko leaves it to it. Use the printed draft in your pull request or release notes, and kaloko release reads the entry ${d.source.tool} writes.`)
|
|
221
|
+
const before = (await readText(d.file)) || ''
|
|
222
|
+
const { text, added } = applyDraft(before || CHANGELOG_SKELETON, d.groups)
|
|
223
|
+
if (text !== before) await writeFile(d.file, text)
|
|
224
|
+
return { added, created: !before }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------- the release ----------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Records a release on the service: the product (from the config), the version (given, else the version source's),
|
|
231
|
+
* the entry of that version in the changelog (an Unreleased section becomes the release unless a release tool owns
|
|
232
|
+
* the file; --no-write leaves the file alone) and the runs, which the service picks (newest accepted run per flow)
|
|
233
|
+
* unless `runs` names them. Returns what happened, for the CLI to say.
|
|
234
|
+
*/
|
|
235
|
+
export async function releaseProduct ({ root, config, product, version = null, date = null, client, write = true, runs = null, dryRun = false }) {
|
|
236
|
+
const src = await detectVersionSource(root, product)
|
|
237
|
+
const v = cleanVersion(version || src.version || '')
|
|
238
|
+
if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
|
|
239
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`Not a version: ${v}`)
|
|
240
|
+
const notes = []
|
|
241
|
+
if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
|
|
242
|
+
const file = changelogFile(root, product)
|
|
243
|
+
let text = (await readText(file)) || ''
|
|
244
|
+
let section = sectionOf(text, v)
|
|
245
|
+
let finalized = false
|
|
246
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(date || '') ? date : section?.date || today()
|
|
247
|
+
if (!section && write && !src.owns_changelog && sectionOf(text, null)?.body?.trim()) {
|
|
248
|
+
const f = finalizeRelease(text, v, day, { tagPattern: src.pattern })
|
|
249
|
+
if (f.changed) {
|
|
250
|
+
if (!dryRun) await writeFile(file, f.text)
|
|
251
|
+
text = f.text; section = sectionOf(text, v); finalized = true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!section) notes.push(`${relative(root, file) || 'CHANGELOG.md'} has no entry for ${v}${src.owns_changelog ? ` (${src.tool} writes it)` : ''}; the release is recorded without text.`)
|
|
255
|
+
const md = section?.body || ''
|
|
256
|
+
const refs = findStepRefs(md)
|
|
257
|
+
const broken = refs.filter((r) => !r.parsed).map((r) => r.ref)
|
|
258
|
+
if (broken.length) notes.push(`Broken step references: ${broken.join(', ')}`)
|
|
259
|
+
const tag = tagFor(src.pattern, v)
|
|
260
|
+
const commit = (await tagExists(root, tag)) ? ((await out('git', ['rev-list', '-n', '1', tag], root)) || '').trim() : ((await out('git', ['rev-parse', 'HEAD'], root)) || '').trim()
|
|
261
|
+
const payload = {
|
|
262
|
+
product: { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) },
|
|
263
|
+
release: { product: product.slug, version: v, date: section?.date || day, changelog: md, source_hash: sha256(md), tag: (await tagExists(root, tag)) ? tag : null, commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, ...(runs ? { runs } : {}) }
|
|
264
|
+
}
|
|
265
|
+
if (dryRun) return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, payload, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
|
|
266
|
+
const saved = await client.request('POST', `/orgs/${config.org}/products`, payload.product)
|
|
267
|
+
notes.push(...(saved.notes || []))
|
|
268
|
+
const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
|
|
269
|
+
return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, release: rec, product: saved.product, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
|
|
270
|
+
}
|
|
271
|
+
async function repoUrl (root) {
|
|
272
|
+
const r = await repoSlug(root)
|
|
273
|
+
return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------- init and doctor ----------
|
|
277
|
+
|
|
278
|
+
/** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
|
|
279
|
+
export function docsConfigBlock ({ products = null } = {}) {
|
|
280
|
+
const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
|
|
281
|
+
if (products?.length) {
|
|
282
|
+
lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
|
|
283
|
+
for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
|
|
284
|
+
}
|
|
285
|
+
return `${lines.join('\n')}\n`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
|
|
289
|
+
export async function detectProducts (root) {
|
|
290
|
+
const out = []
|
|
291
|
+
for (const base of ['apps']) {
|
|
292
|
+
const names = await readdir(join(root, base)).catch(() => [])
|
|
293
|
+
for (const n of names.sort()) {
|
|
294
|
+
const pkg = await readJson(join(root, base, n, 'package.json'))
|
|
295
|
+
if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return out.length > 1 ? out : []
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). */
|
|
302
|
+
export const DOCS_SKELETON = {
|
|
303
|
+
'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(``) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/) — learn by doing, start to finish\n- [How-to guides](how-to/) — one task each\n- [Reference](reference/) — the facts to look up\n- [Explanation](explanation/) — why it works this way\n',
|
|
304
|
+
'docs/tutorials/README.md': '# Tutorials\n',
|
|
305
|
+
'docs/how-to/README.md': '# How-to guides\n',
|
|
306
|
+
'docs/reference/README.md': '# Reference\n',
|
|
307
|
+
'docs/explanation/README.md': '# Explanation\n'
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Doctor checks for products: each product's version resolves from its source, scenarios name a known product
|
|
312
|
+
* (and module), and with several products every scenario says which one it shows. [{ name, ok, detail, hint }]
|
|
313
|
+
*/
|
|
314
|
+
export async function productChecks ({ config, root, scenarios = [] }) {
|
|
315
|
+
const checks = []
|
|
316
|
+
const products = Object.keys(config.products || {})
|
|
317
|
+
if (!products.length) return checks
|
|
318
|
+
for (const p of productsOf(config, root)) {
|
|
319
|
+
const src = await detectVersionSource(root, p)
|
|
320
|
+
const ok = !!src.version || src.source === 'manual'
|
|
321
|
+
checks.push({ name: `product ${p.slug}`, ok, detail: ok ? `version ${src.version || '(set by kaloko release)'} from ${src.source}${src.path ? ` (${src.path})` : src.source === 'git-tag' ? ` (${src.pattern})` : ''}` : `no version from ${src.source}${src.path ? ` (${src.path})` : ` (${src.pattern})`}`, hint: ok ? '' : src.source === 'git-tag' ? `Tag a release (git tag ${tagFor(src.pattern, '1.0.0')}) or set products.${p.slug}.version.` : `Set the version in ${src.path || src.source}, or products.${p.slug}.version in the config.` })
|
|
322
|
+
}
|
|
323
|
+
const untagged = []; const unknown = []
|
|
324
|
+
for (const s of scenarios) {
|
|
325
|
+
if (!s.product) { untagged.push(s.file); continue }
|
|
326
|
+
if (!products.includes(s.product)) unknown.push(`${s.file}: product ${s.product}`)
|
|
327
|
+
else if (s.module && !(config.products[s.product].modules || []).includes(s.module)) unknown.push(`${s.file}: module ${s.module} of ${s.product}`)
|
|
328
|
+
}
|
|
329
|
+
if (unknown.length) checks.push({ name: 'scenario products', ok: false, detail: unknown.slice(0, 5).join('; '), hint: 'Use a key of products: (and one of its modules:) in the scenario.' })
|
|
330
|
+
if (untagged.length && products.length > 1) checks.push({ name: 'scenario products', ok: false, detail: `${untagged.length} scenario(s) without product: ${untagged.slice(0, 4).join(', ')}${untagged.length > 4 ? ' …' : ''}`, hint: `Add product: <${products.join('|')}> to each, so their pictures go to the right changelog.` })
|
|
331
|
+
else if (!unknown.length) checks.push({ name: 'scenario products', ok: true, detail: products.length === 1 ? `untagged scenarios belong to ${products[0]}` : 'every scenario names its product' })
|
|
332
|
+
return checks
|
|
333
|
+
}
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { DEFAULTS as JEV } from './jev.mjs'
|
|
|
12
12
|
import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
|
|
13
13
|
import { serviceUrl } from './service.mjs'
|
|
14
14
|
import { videoTools, recordingChecks } from './video.mjs'
|
|
15
|
+
import { productChecks } from './changelog.mjs'
|
|
15
16
|
|
|
16
17
|
const portFree = (port) => new Promise((done) => {
|
|
17
18
|
const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
|
|
@@ -36,8 +37,9 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
|
36
37
|
* @param {string[]} [o.engines] browser engines the scenarios and config.walk ask for (webkit, firefox)
|
|
37
38
|
* @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
|
|
38
39
|
* @param {object} [o.tools] app tools found (default: looked up on PATH)
|
|
40
|
+
* @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
|
|
39
41
|
*/
|
|
40
|
-
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null }) {
|
|
42
|
+
export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
|
|
41
43
|
const checks = []
|
|
42
44
|
const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
|
|
43
45
|
|
|
@@ -165,6 +167,9 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
165
167
|
// recordings (batch D): ffmpeg and the device recorders, with install hints per OS
|
|
166
168
|
checks.push(...recordingChecks({ config, tools: await (videoToolsImpl || videoTools)() }))
|
|
167
169
|
|
|
170
|
+
// the visual changelog: each product's version resolves and scenarios say which product they show
|
|
171
|
+
checks.push(...await productChecks({ config, root, scenarios }))
|
|
172
|
+
|
|
168
173
|
return { ok: checks.every((c) => c.ok), checks }
|
|
169
174
|
}
|
|
170
175
|
|
package/src/lib/run.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function newRunId (scenario, date = new Date()) {
|
|
|
28
28
|
return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null }) {
|
|
31
|
+
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null, product = null }) {
|
|
32
32
|
const { id, short } = newRunId(scenario)
|
|
33
33
|
const dir = resolve(root, output, id)
|
|
34
34
|
await mkdir(dir, { recursive: true })
|
|
@@ -36,7 +36,7 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
|
|
|
36
36
|
id,
|
|
37
37
|
short,
|
|
38
38
|
scenario_file: relative(root, scenarioFile),
|
|
39
|
-
scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
|
|
39
|
+
scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}), ...(product || scenario.product ? { product: product || scenario.product } : {}), ...(scenario.module ? { module: scenario.module } : {}) },
|
|
40
40
|
environment: { name: env.name, base_url: env.base_url, readonly: env.readonly, ...(env.kind === 'design' ? { kind: 'design' } : {}) },
|
|
41
41
|
scope,
|
|
42
42
|
git,
|