changeledger 0.4.0 → 0.6.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.
- package/AGENTS.md +13 -7
- package/README.md +35 -13
- package/bin/changeledger.mjs +39 -4
- package/package.json +1 -1
- package/src/commands/agent.mjs +1 -1
- package/src/commands/check.mjs +11 -1
- package/src/commands/context.mjs +65 -0
- package/src/commands/init.mjs +3 -6
- package/src/commands/new.mjs +1 -1
- package/src/commands/register.mjs +19 -7
- package/src/commands/view.mjs +4 -0
- package/src/config-migration.mjs +213 -0
- package/src/contract.mjs +86 -50
- package/src/paths.mjs +1 -1
- package/src/viewer/domain.mjs +280 -0
- package/src/viewer/public/api.js +28 -0
- package/src/viewer/public/app.js +561 -31
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +283 -0
- package/src/viewer/server/router.mjs +40 -14
- package/templates/config.yml +8 -3
- package/templates/contract/blocked.md +6 -0
- package/templates/contract/close.md +18 -0
- package/templates/contract/core.md +71 -0
- package/templates/contract/discarded.md +5 -0
- package/templates/contract/implement.md +52 -0
- package/templates/contract/readiness.md +19 -0
- package/templates/contract/release.md +25 -0
- package/templates/contract/review.md +19 -0
- package/templates/contract/spec.md +94 -0
- package/templates/contract/validation.md +9 -0
- package/templates/AGENTS.md +0 -540
package/src/contract.mjs
CHANGED
|
@@ -1,29 +1,49 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { writeFileAtomic } from './atomic-write.mjs';
|
|
4
|
-
import { agentsTemplate } from './paths.mjs';
|
|
5
5
|
|
|
6
|
-
// The tool's contract (templates/AGENTS.md) is a tool artifact, not a project
|
|
7
|
-
// artifact. Each repo links to the installed copy via `.changeledger/AGENTS.md` (a
|
|
8
|
-
// per-machine, gitignored symlink) and points its own contract files at that
|
|
9
|
-
// link. We never copy the contract (it would drift) nor commit the symlink (it
|
|
10
|
-
// would dangle on another machine). See change #20260614-151759.
|
|
11
|
-
|
|
12
|
-
// Project-owned contract files that should reference the linked contract. The
|
|
13
|
-
// root AGENTS.md is required by `init`; CLAUDE.md is referenced when present.
|
|
14
6
|
const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
15
|
-
|
|
16
7
|
const MARKER = '<!-- changeledger -->';
|
|
17
|
-
const
|
|
8
|
+
const LEGACY_ENTRY = '.changeledger/AGENTS.md';
|
|
9
|
+
// Exact SHA-256 digests of every historical templates/AGENTS.md payload. A
|
|
10
|
+
// regular file is removed only when it is byte-identical to a contract version
|
|
11
|
+
// ChangeLedger actually shipped; headings or other heuristics are not enough.
|
|
12
|
+
const LEGACY_CONTRACT_HASHES = new Set([
|
|
13
|
+
'92a6d22c9985dce2d15724d20e2d5c0f1178d82dc56d28e339b72c7a27574c6f',
|
|
14
|
+
'4701b260d0cc39098f3b942ed2b1969f00a84e7b77a4e107070fedef0fda7d0d',
|
|
15
|
+
'30b3ce1768655ebee2be2b88328ba860293d8bbbe55e12a5aacc8177d7d02c9b',
|
|
16
|
+
'd36d17f99505f2b47a7ceea8f078618f652807463d5d60a83ca95f940d5a9c35',
|
|
17
|
+
'1175cc4bcc884d792cdde2338b3c72b336bcb66b47555d6e5938d756acdea635',
|
|
18
|
+
'f605adad9192286bc2cda45aebb5a12dab5b3c199924789398684b79b55811',
|
|
19
|
+
'05e06c8e9dc45ac41ff38b47075b22492c30f3f1a7019371293be10351aada77',
|
|
20
|
+
'9816e4d14f70dad268f9b89ad49094665997bf62f0b773a05fe8eed17eebc9de',
|
|
21
|
+
'1b5439055e52a1bb72f72b99cbd2842b2118776fb80e262175d584a335e07e5f',
|
|
22
|
+
'6fe7a03f699ffa875c1ef4bc2d59b451058d6ab4c3c8824cc220ae031d068480',
|
|
23
|
+
'd56ec9ef5c1bd71464d9d9bc2a772c42cce103f336ebba61106855c8011bca6f',
|
|
24
|
+
'071ed83e79589ec0362e4e99ee80cb4f7a839f2a810cf59dbf056c46e342a09c',
|
|
25
|
+
'1148a1b69ce035a695c64f0df1aaaedcbeeed669b5b774c52f7b136e9f5fe4f9',
|
|
26
|
+
'25d4508b0b60aa939e81d1e83383f90e7a41feacb6cb2f12fab6548cebb77ca1',
|
|
27
|
+
'37e1ffe059c0d26b07566597607c41d42df3e9548830d42413dd8d2afd62aa86',
|
|
28
|
+
'60519091c785977299725e4f2f3207b83828bfc56d94a989adf13bd0fc9bb174',
|
|
29
|
+
'6e201bb60337c9b0869c6c769272c82abc466330f82c662bb023a74ad0b8fa92',
|
|
30
|
+
'80e2560e01cfae87455177b54f77845b657764338d3bf3507119e3e1ff289bcd',
|
|
31
|
+
'7a126595a0b5de0b58eec9db38ee6d1654caf0b42df29d181692c382abe213c6',
|
|
32
|
+
'2e839be3ad9458feebcfd8ca979f9173b1f63b514939903ea8f9f03517528f88',
|
|
33
|
+
'f10407e809fc679d11e442d57c92a4b82d0647f06946bb42c7daccedf76873ec',
|
|
34
|
+
'99a42380f3835e1f706b986b86e58e33f12d68de4a69d94eb50845e790e05f71',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const REFERENCE = `${MARKER}
|
|
18
38
|
> [!IMPORTANT]
|
|
19
|
-
> This repo uses **ChangeLedger**.
|
|
20
|
-
>
|
|
39
|
+
> This repo uses **ChangeLedger**. Before creating or modifying files, run
|
|
40
|
+
> \`changeledger context\` (or \`changeledger context <change-id>\`) and follow its output.
|
|
41
|
+
> If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
|
|
21
42
|
`;
|
|
22
43
|
|
|
23
44
|
export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
|
|
24
45
|
export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
|
|
25
46
|
|
|
26
|
-
// A real, non-symlink file we may safely append to.
|
|
27
47
|
function isPlainFile(file) {
|
|
28
48
|
try {
|
|
29
49
|
return fs.lstatSync(file).isFile();
|
|
@@ -32,69 +52,85 @@ function isPlainFile(file) {
|
|
|
32
52
|
}
|
|
33
53
|
}
|
|
34
54
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
fs.symlinkSync(agentsTemplate, link);
|
|
47
|
-
} catch {
|
|
48
|
-
// Windows without Developer Mode/admin cannot create symlinks. Fall back to
|
|
49
|
-
// a copy so the contract is still present; `changeledger register` refreshes it if the
|
|
50
|
-
// installed contract changes.
|
|
51
|
-
writeFileAtomic(link, fs.readFileSync(agentsTemplate, 'utf8'));
|
|
52
|
-
}
|
|
53
|
-
return link;
|
|
55
|
+
function replaceReference(text) {
|
|
56
|
+
const start = text.indexOf(MARKER);
|
|
57
|
+
if (start === -1) return `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`;
|
|
58
|
+
const tail = text.slice(start).split('\n');
|
|
59
|
+
let consumed = 1;
|
|
60
|
+
while (consumed < tail.length && tail[consumed].startsWith('>')) consumed += 1;
|
|
61
|
+
const before = text.slice(0, start);
|
|
62
|
+
const after = tail.slice(consumed).join('\n').replace(/^\n+/, '');
|
|
63
|
+
return `${before}${REFERENCE}${after ? `\n${after}` : ''}`;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
//
|
|
57
|
-
// already there. Symlinks are skipped (appending would write into their target).
|
|
66
|
+
// Add or replace the managed bootstrap block in project-owned agent files.
|
|
58
67
|
export function ensureReference(repoRoot) {
|
|
59
68
|
const touched = [];
|
|
60
69
|
for (const name of CONTRACT_FILES) {
|
|
61
70
|
const file = path.join(repoRoot, name);
|
|
62
71
|
if (!isPlainFile(file)) continue;
|
|
63
72
|
const text = fs.readFileSync(file, 'utf8');
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const updated = replaceReference(text);
|
|
74
|
+
if (updated === text) continue;
|
|
75
|
+
writeFileAtomic(file, updated);
|
|
66
76
|
touched.push(name);
|
|
67
77
|
}
|
|
68
78
|
return touched;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
//
|
|
72
|
-
|
|
81
|
+
// Remove only artifacts known to be managed by legacy ChangeLedger versions.
|
|
82
|
+
// Unknown regular files fail closed instead of being deleted.
|
|
83
|
+
export function removeLegacyContract(changeledgerDir, knownHashes = LEGACY_CONTRACT_HASHES) {
|
|
84
|
+
const file = contractLink(changeledgerDir);
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.lstatSync(file);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === 'ENOENT') return false;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (stat.isSymbolicLink()) {
|
|
93
|
+
fs.unlinkSync(file);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (stat.isFile()) {
|
|
97
|
+
const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
98
|
+
if (knownHashes.has(digest)) {
|
|
99
|
+
fs.unlinkSync(file);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(
|
|
104
|
+
'`.changeledger/AGENTS.md` is not a recognized legacy ChangeLedger contract; move or remove it manually, then run `changeledger register` again',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function removeLegacyGitignore(repoRoot) {
|
|
73
109
|
const file = path.join(repoRoot, '.gitignore');
|
|
74
|
-
|
|
75
|
-
const text = fs.
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
if (!fs.existsSync(file)) return false;
|
|
111
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const kept = lines.filter((line) => line !== LEGACY_ENTRY);
|
|
114
|
+
if (kept.length === lines.length) return false;
|
|
115
|
+
writeFileAtomic(file, kept.join('\n'));
|
|
78
116
|
return true;
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
|
|
82
|
-
export function checkContract(repoRoot, changeledgerDir) {
|
|
119
|
+
export function checkContract(repoRoot) {
|
|
83
120
|
const errors = [];
|
|
84
121
|
const root = rootContract(repoRoot);
|
|
85
122
|
if (!fs.existsSync(root)) {
|
|
86
123
|
errors.push('missing AGENTS.md at the repo root (ChangeLedger contract reference lives here)');
|
|
87
124
|
}
|
|
88
|
-
// Every present, non-symlink contract file must carry the reference.
|
|
89
125
|
for (const name of CONTRACT_FILES) {
|
|
90
126
|
const file = path.join(repoRoot, name);
|
|
91
127
|
if (!isPlainFile(file)) continue;
|
|
92
|
-
|
|
128
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
129
|
+
if (!text.includes(MARKER)) {
|
|
93
130
|
errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
|
|
131
|
+
} else if (!text.includes(REFERENCE.trim())) {
|
|
132
|
+
errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
|
|
94
133
|
}
|
|
95
134
|
}
|
|
96
|
-
if (!fs.existsSync(contractLink(changeledgerDir))) {
|
|
97
|
-
errors.push('`.changeledger/AGENTS.md` is missing or dangling — run `changeledger register`');
|
|
98
|
-
}
|
|
99
135
|
return errors;
|
|
100
136
|
}
|
package/src/paths.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
5
5
|
export const templatesDir = path.join(packageRoot, 'templates');
|
|
6
|
-
export const
|
|
6
|
+
export const contractTemplatesDir = path.join(templatesDir, 'contract');
|
|
7
7
|
export const publicDir = path.join(packageRoot, 'src', 'viewer', 'public');
|
|
8
8
|
|
|
9
9
|
// ISO 8601 UTC at second precision, matching the change `created`/task convention.
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
4
5
|
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
5
6
|
import { checkRepo } from '../check.mjs';
|
|
6
7
|
import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
|
|
7
8
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
9
|
+
import {
|
|
10
|
+
buildMigration,
|
|
11
|
+
getSchemaVersion,
|
|
12
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
13
|
+
} from '../config-migration.mjs';
|
|
8
14
|
import { computeMetrics } from '../metrics.mjs';
|
|
9
15
|
import { nowUtc } from '../paths.mjs';
|
|
10
16
|
import { listProjects, remove, update } from '../registry.mjs';
|
|
@@ -209,12 +215,21 @@ export function saveProjectConfig(projects, payload, { mutateConfig = mutateFile
|
|
|
209
215
|
if (revision(before) !== payload.revision) {
|
|
210
216
|
throw new Error('configuration changed on disk; reload before saving');
|
|
211
217
|
}
|
|
218
|
+
const currentSchema = getSchemaVersion(parseYaml(before));
|
|
219
|
+
if (currentSchema > SUPPORTED_SCHEMA_VERSION) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`config schema ${currentSchema} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
212
224
|
return payload.content;
|
|
213
225
|
});
|
|
214
226
|
} catch (error) {
|
|
215
227
|
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
216
228
|
return { code: 409, body: { error: error.message } };
|
|
217
229
|
}
|
|
230
|
+
if (/^config schema \d+ is newer than supported schema \d+$/.test(error.message)) {
|
|
231
|
+
return { code: 400, body: { error: error.message } };
|
|
232
|
+
}
|
|
218
233
|
return { code: 400, body: { error: 'unable to save project configuration' } };
|
|
219
234
|
}
|
|
220
235
|
return {
|
|
@@ -264,3 +279,268 @@ export function unregisterProject(projects, payload, { localOnly = false } = {})
|
|
|
264
279
|
}
|
|
265
280
|
return { code: 200, body: { ok: true } };
|
|
266
281
|
}
|
|
282
|
+
|
|
283
|
+
// Returns config content + schema metadata without mutating anything.
|
|
284
|
+
export function readProjectConfigStructured(projects, id) {
|
|
285
|
+
const found = projectFor(projects, id);
|
|
286
|
+
if (!found.project) return found;
|
|
287
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
288
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
289
|
+
const config = parseYaml(content);
|
|
290
|
+
const schemaVersion = getSchemaVersion(config);
|
|
291
|
+
return {
|
|
292
|
+
code: 200,
|
|
293
|
+
body: {
|
|
294
|
+
content,
|
|
295
|
+
revision: revision(content),
|
|
296
|
+
schemaVersion,
|
|
297
|
+
supported: SUPPORTED_SCHEMA_VERSION,
|
|
298
|
+
config,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Applies a semantic patch (allowlisted fields only) to the YAML AST, preserving
|
|
304
|
+
// comments, unknown keys and fields the form does not represent.
|
|
305
|
+
export function patchProjectConfig(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
|
|
306
|
+
const found = projectFor(projects, payload.project);
|
|
307
|
+
if (!found.project) return found;
|
|
308
|
+
if (!payload.patch || typeof payload.patch !== 'object' || Array.isArray(payload.patch)) {
|
|
309
|
+
return { code: 400, body: { error: 'patch must be an object' } };
|
|
310
|
+
}
|
|
311
|
+
if (typeof payload.revision !== 'string') {
|
|
312
|
+
return { code: 400, body: { error: 'revision is required' } };
|
|
313
|
+
}
|
|
314
|
+
// Explicitly reject attempts to change identity fields via patch.
|
|
315
|
+
if ('project_id' in payload.patch) {
|
|
316
|
+
return { code: 400, body: { error: 'project_id cannot be changed from the viewer' } };
|
|
317
|
+
}
|
|
318
|
+
if ('schema_version' in payload.patch) {
|
|
319
|
+
return { code: 400, body: { error: 'schema_version cannot be changed via patch' } };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
323
|
+
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
mutateConfig(file, (before) => {
|
|
327
|
+
if (revision(before) !== payload.revision) {
|
|
328
|
+
throw new Error('configuration changed on disk; reload before saving');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const doc = parseDocument(before, { merge: false });
|
|
332
|
+
const config = doc.toJS() ?? {};
|
|
333
|
+
|
|
334
|
+
// Fail closed for future schema
|
|
335
|
+
const schemaVersion = getSchemaVersion(config);
|
|
336
|
+
if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`config schema ${schemaVersion} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
applyPatch(doc, payload.patch, config);
|
|
343
|
+
|
|
344
|
+
const patched = doc.toString();
|
|
345
|
+
const candidate = parseYaml(patched);
|
|
346
|
+
|
|
347
|
+
// Identity guard
|
|
348
|
+
if (String(candidate.project_id ?? '') !== String(found.project.id)) {
|
|
349
|
+
throw new Error('project_id cannot be changed from the viewer');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Structural validation
|
|
353
|
+
const repo = loadRepo(found.project.path);
|
|
354
|
+
resolveRepoPath(repo.repoRoot, candidate.changes_dir, 'changes_dir');
|
|
355
|
+
resolveSpecsDir(repo.repoRoot, candidate);
|
|
356
|
+
const candidateRepo = loadRepoWithConfig(repo.repoRoot, repo.changeledgerDir, candidate);
|
|
357
|
+
const { errors } = checkRepo(candidateRepo);
|
|
358
|
+
if (errors.length) throw new Error(errors[0].message);
|
|
359
|
+
|
|
360
|
+
result = { content: patched, rev: revision(patched) };
|
|
361
|
+
return patched;
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
365
|
+
return { code: 409, body: { error: error.message } };
|
|
366
|
+
}
|
|
367
|
+
return { code: 400, body: { error: error.message } };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { code: 200, body: { ok: true, revision: result.rev } };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Preview the migration without writing. Returns summary + candidate YAML.
|
|
374
|
+
export function previewConfigMigration(projects, id, rev) {
|
|
375
|
+
const found = projectFor(projects, id);
|
|
376
|
+
if (!found.project) return found;
|
|
377
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
378
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
379
|
+
if (rev && revision(content) !== rev) {
|
|
380
|
+
return { code: 409, body: { error: 'configuration changed on disk; reload before saving' } };
|
|
381
|
+
}
|
|
382
|
+
let migrationResult;
|
|
383
|
+
try {
|
|
384
|
+
migrationResult = buildMigration(content);
|
|
385
|
+
} catch (e) {
|
|
386
|
+
return { code: 400, body: { error: e.message } };
|
|
387
|
+
}
|
|
388
|
+
if (!migrationResult) {
|
|
389
|
+
return {
|
|
390
|
+
code: 200,
|
|
391
|
+
body: {
|
|
392
|
+
already_current: true,
|
|
393
|
+
message: `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}`,
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
code: 200,
|
|
399
|
+
body: {
|
|
400
|
+
summary: `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
|
|
401
|
+
changes: migrationResult.changes,
|
|
402
|
+
yaml: migrationResult.yaml,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Apply the migration atomically. Uses the same engine as `changeledger config migrate`.
|
|
408
|
+
// Revision check and write are inside mutateFileAtomic to avoid TOCTOU races.
|
|
409
|
+
export function applyConfigMigration(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
|
|
410
|
+
const found = projectFor(projects, payload.project);
|
|
411
|
+
if (!found.project) return found;
|
|
412
|
+
if (typeof payload.revision !== 'string') {
|
|
413
|
+
return { code: 400, body: { error: 'revision is required' } };
|
|
414
|
+
}
|
|
415
|
+
const file = path.join(found.project.path, '.changeledger', 'config.yml');
|
|
416
|
+
|
|
417
|
+
let result;
|
|
418
|
+
try {
|
|
419
|
+
mutateConfig(file, (before) => {
|
|
420
|
+
if (revision(before) !== payload.revision) {
|
|
421
|
+
throw new Error('configuration changed on disk; reload before saving');
|
|
422
|
+
}
|
|
423
|
+
let migrationResult;
|
|
424
|
+
try {
|
|
425
|
+
migrationResult = buildMigration(before);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
throw new Error(e.message);
|
|
428
|
+
}
|
|
429
|
+
if (!migrationResult) {
|
|
430
|
+
result = { already_current: true, rev: payload.revision };
|
|
431
|
+
return undefined; // no write needed
|
|
432
|
+
}
|
|
433
|
+
result = { ok: true, rev: revision(migrationResult.yaml) };
|
|
434
|
+
return migrationResult.yaml;
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (error.message === 'configuration changed on disk; reload before saving') {
|
|
438
|
+
return { code: 409, body: { error: error.message } };
|
|
439
|
+
}
|
|
440
|
+
return { code: 400, body: { error: error.message } };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (result.already_current) {
|
|
444
|
+
return { code: 200, body: { already_current: true, revision: result.rev } };
|
|
445
|
+
}
|
|
446
|
+
return { code: 200, body: { ok: true, revision: result.rev } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Allowlisted fields the form patch may update.
|
|
450
|
+
const PATCH_ALLOWED = new Set([
|
|
451
|
+
'project_name',
|
|
452
|
+
'language',
|
|
453
|
+
'tdd',
|
|
454
|
+
'changes_dir',
|
|
455
|
+
'specs_dir',
|
|
456
|
+
'statuses',
|
|
457
|
+
'stages',
|
|
458
|
+
'readiness',
|
|
459
|
+
'types',
|
|
460
|
+
'release',
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
const CANONICAL_STATUSES_REQUIRED = new Set([
|
|
464
|
+
'draft',
|
|
465
|
+
'approved',
|
|
466
|
+
'in-progress',
|
|
467
|
+
'in-review',
|
|
468
|
+
'in-validation',
|
|
469
|
+
'blocked',
|
|
470
|
+
'done',
|
|
471
|
+
'discarded',
|
|
472
|
+
]);
|
|
473
|
+
|
|
474
|
+
const CANONICAL_STAGES_REQUIRED = new Set([
|
|
475
|
+
'request',
|
|
476
|
+
'investigation',
|
|
477
|
+
'proposal',
|
|
478
|
+
'specification',
|
|
479
|
+
'plan',
|
|
480
|
+
'log',
|
|
481
|
+
]);
|
|
482
|
+
|
|
483
|
+
function applyPatch(doc, patch, currentConfig) {
|
|
484
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
485
|
+
if (!PATCH_ALLOWED.has(key)) continue;
|
|
486
|
+
|
|
487
|
+
if (key === 'types') {
|
|
488
|
+
applyTypesPatch(doc, value, currentConfig.types ?? {});
|
|
489
|
+
} else if (key === 'release') {
|
|
490
|
+
applyReleasePatch(doc, value);
|
|
491
|
+
} else if (key === 'readiness') {
|
|
492
|
+
applyReadinessPatch(doc, value);
|
|
493
|
+
} else if (key === 'statuses') {
|
|
494
|
+
applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
|
|
495
|
+
} else if (key === 'stages') {
|
|
496
|
+
applyRequiredListPatch(doc, 'stages', value, CANONICAL_STAGES_REQUIRED);
|
|
497
|
+
} else {
|
|
498
|
+
doc.set(key, value);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function applyRequiredListPatch(doc, key, proposed, required) {
|
|
504
|
+
if (!Array.isArray(proposed) || proposed.some((value) => typeof value !== 'string')) {
|
|
505
|
+
throw new Error(`${key} must be a list of strings`);
|
|
506
|
+
}
|
|
507
|
+
const duplicate = proposed.find((value, index) => proposed.indexOf(value) !== index);
|
|
508
|
+
if (duplicate) throw new Error(`${key} contains duplicate value "${duplicate}"`);
|
|
509
|
+
const missing = [...required].find((value) => !proposed.includes(value));
|
|
510
|
+
if (missing) throw new Error(`${key} cannot remove required value "${missing}"`);
|
|
511
|
+
doc.set(key, proposed);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function applyTypesPatch(doc, typesPatch, currentTypes) {
|
|
515
|
+
for (const [typeName, typeDef] of Object.entries(typesPatch)) {
|
|
516
|
+
if (!Object.hasOwn(currentTypes, typeName)) continue; // don't add new types
|
|
517
|
+
if (!typeDef || typeof typeDef !== 'object') continue;
|
|
518
|
+
if (Array.isArray(typeDef.stages)) {
|
|
519
|
+
doc.setIn(['types', typeName, 'stages'], typeDef.stages);
|
|
520
|
+
}
|
|
521
|
+
if (typeof typeDef.review_required === 'boolean') {
|
|
522
|
+
doc.setIn(['types', typeName, 'review_required'], typeDef.review_required);
|
|
523
|
+
} else if (typeDef.review_required === null) {
|
|
524
|
+
doc.deleteIn(['types', typeName, 'review_required']);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function applyReleasePatch(doc, releasePatch) {
|
|
530
|
+
if (releasePatch.impacts && typeof releasePatch.impacts === 'object') {
|
|
531
|
+
for (const [type, impact] of Object.entries(releasePatch.impacts)) {
|
|
532
|
+
if (impact === null) doc.deleteIn(['release', 'impacts', type]);
|
|
533
|
+
else doc.setIn(['release', 'impacts', type], impact);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function applyReadinessPatch(doc, readinessPatch) {
|
|
539
|
+
if (!readinessPatch || typeof readinessPatch !== 'object') return;
|
|
540
|
+
if (Array.isArray(readinessPatch.target_patterns)) {
|
|
541
|
+
doc.setIn(['readiness', 'target_patterns'], readinessPatch.target_patterns);
|
|
542
|
+
}
|
|
543
|
+
if (Array.isArray(readinessPatch.verification_patterns)) {
|
|
544
|
+
doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
|
|
545
|
+
}
|
|
546
|
+
}
|
package/src/viewer/public/api.js
CHANGED
|
@@ -41,9 +41,37 @@ const postProject = (route, body) =>
|
|
|
41
41
|
body: JSON.stringify(body),
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
const jsonOrThrow = async (response) => {
|
|
45
|
+
const body = await response.json();
|
|
46
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
47
|
+
return body;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
export const postProjectConfig = (project, content, revision) =>
|
|
45
51
|
postProject('/api/project-config', { project, content, revision });
|
|
46
52
|
|
|
53
|
+
export const getProjectConfigStructured = (project) =>
|
|
54
|
+
fetch(`/api/project-config-structured?project=${encodeURIComponent(project)}`).then(async (r) => {
|
|
55
|
+
const body = await r.json();
|
|
56
|
+
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
|
57
|
+
return body;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const patchProjectConfigApi = (project, patch, revision) =>
|
|
61
|
+
postProject('/api/project-config-patch', { project, patch, revision });
|
|
62
|
+
|
|
63
|
+
export const getConfigMigrationPreview = (project, revision) =>
|
|
64
|
+
fetch(
|
|
65
|
+
`/api/project-config-migrate-preview?project=${encodeURIComponent(project)}&revision=${encodeURIComponent(revision ?? '')}`,
|
|
66
|
+
).then(async (r) => {
|
|
67
|
+
const body = await r.json();
|
|
68
|
+
if (!r.ok) throw new Error(body.error || `HTTP ${r.status}`);
|
|
69
|
+
return body;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const postConfigMigrationApply = (project, revision) =>
|
|
73
|
+
postProject('/api/project-config-migrate-apply', { project, revision }).then(jsonOrThrow);
|
|
74
|
+
|
|
47
75
|
export const postProjectPath = (project, path) =>
|
|
48
76
|
postProject('/api/project-path', { project, path });
|
|
49
77
|
|