changeledger 0.3.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/changeledger.mjs +375 -0
- package/package.json +72 -0
- package/src/atomic-write.mjs +134 -0
- package/src/change.mjs +102 -0
- package/src/check.mjs +536 -0
- package/src/commands/agent.mjs +256 -0
- package/src/commands/check.mjs +48 -0
- package/src/commands/graduate.mjs +105 -0
- package/src/commands/init.mjs +43 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/register.mjs +28 -0
- package/src/commands/release.mjs +138 -0
- package/src/commands/view.mjs +52 -0
- package/src/config.mjs +76 -0
- package/src/contract.mjs +100 -0
- package/src/git.mjs +73 -0
- package/src/lifecycle.mjs +57 -0
- package/src/metrics.mjs +143 -0
- package/src/paths.mjs +12 -0
- package/src/registry.mjs +55 -0
- package/src/release.mjs +71 -0
- package/src/repo.mjs +122 -0
- package/src/slug.mjs +12 -0
- package/src/spec.mjs +12 -0
- package/src/viewer/domain.mjs +133 -0
- package/src/viewer/public/api.js +25 -0
- package/src/viewer/public/app-state.js +87 -0
- package/src/viewer/public/app.js +717 -0
- package/src/viewer/public/index.html +64 -0
- package/src/viewer/public/security.js +65 -0
- package/src/viewer/public/state.js +31 -0
- package/src/viewer/public/styles.css +1062 -0
- package/src/viewer/public/templates.js +9 -0
- package/src/viewer/public/view-parts.js +162 -0
- package/src/viewer/public/view-renderers.js +191 -0
- package/src/viewer/server/router.mjs +200 -0
- package/src/viewer/server/security.mjs +27 -0
- package/src/writer.mjs +151 -0
- package/src/yaml.mjs +75 -0
- package/templates/AGENTS.md +540 -0
- package/templates/config.yml +55 -0
package/src/registry.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Global project registry: maps a stable project_id to its absolute path on this
|
|
2
|
+
// machine. Identity lives in the repo's config (committed); the path is local,
|
|
3
|
+
// so moved/cloned repos just re-register. Override the home with
|
|
4
|
+
// CHANGELEDGER_HOME (used by tests).
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { withFileLock, writeFileAtomic } from './atomic-write.mjs';
|
|
10
|
+
|
|
11
|
+
export function registryDir() {
|
|
12
|
+
return path.join(process.env.CHANGELEDGER_HOME || os.homedir(), '.changeledger');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function registryPath() {
|
|
16
|
+
return path.join(registryDir(), '.registry.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readRegistry() {
|
|
20
|
+
const file = registryPath();
|
|
21
|
+
if (!fs.existsSync(file)) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error('.registry.json is not valid JSON');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeRegistry(reg) {
|
|
30
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
31
|
+
writeFileAtomic(registryPath(), `${JSON.stringify(reg, null, 2)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function register({ id, name, path: repoPath }) {
|
|
35
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
36
|
+
return withFileLock(registryPath(), () => {
|
|
37
|
+
const reg = readRegistry();
|
|
38
|
+
reg[id] = { name, path: repoPath };
|
|
39
|
+
writeRegistry(reg);
|
|
40
|
+
return reg;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listProjects() {
|
|
45
|
+
return Object.entries(readRegistry()).map(([id, v]) => ({ id, name: v.name, path: v.path }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function remove(id) {
|
|
49
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
50
|
+
withFileLock(registryPath(), () => {
|
|
51
|
+
const reg = readRegistry();
|
|
52
|
+
delete reg[id];
|
|
53
|
+
writeRegistry(reg);
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/release.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveRepoPath } from './config.mjs';
|
|
4
|
+
import { parseYaml } from './yaml.mjs';
|
|
5
|
+
|
|
6
|
+
export const RELEASE_IMPACTS = ['none', 'patch', 'minor', 'major'];
|
|
7
|
+
export const DEFAULT_RELEASES_DIR = '.changeledger/releases';
|
|
8
|
+
|
|
9
|
+
const STABLE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
10
|
+
|
|
11
|
+
export function parseVersion(value) {
|
|
12
|
+
const match = String(value ?? '').match(STABLE_SEMVER);
|
|
13
|
+
if (!match) throw new Error(`Invalid stable SemVer "${value}" (expected X.Y.Z)`);
|
|
14
|
+
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const av = parseVersion(a);
|
|
19
|
+
const bv = parseVersion(b);
|
|
20
|
+
return av.major - bv.major || av.minor - bv.minor || av.patch - bv.patch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function bumpVersion(version, impact) {
|
|
24
|
+
const current = parseVersion(version);
|
|
25
|
+
if (!RELEASE_IMPACTS.includes(impact) || impact === 'none') {
|
|
26
|
+
throw new Error(`Cannot bump version with release impact "${impact}"`);
|
|
27
|
+
}
|
|
28
|
+
if (impact === 'major') return `${current.major + 1}.0.0`;
|
|
29
|
+
if (impact === 'minor') return `${current.major}.${current.minor + 1}.0`;
|
|
30
|
+
return `${current.major}.${current.minor}.${current.patch + 1}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveReleasesDir(repoRoot) {
|
|
34
|
+
return resolveRepoPath(repoRoot, DEFAULT_RELEASES_DIR, 'releases_dir');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadReleases(repoRoot) {
|
|
38
|
+
const releasesDir = resolveReleasesDir(repoRoot);
|
|
39
|
+
const releases = [];
|
|
40
|
+
if (!fs.existsSync(releasesDir)) return releases;
|
|
41
|
+
for (const name of fs.readdirSync(releasesDir).sort()) {
|
|
42
|
+
if (!name.endsWith('.yml')) continue;
|
|
43
|
+
const file = path.join(releasesDir, name);
|
|
44
|
+
try {
|
|
45
|
+
releases.push({ file, name, ...parseYaml(fs.readFileSync(file, 'utf8')) });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`Invalid release manifest "${name}": ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return releases;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function loadReleasesAsync(repoRoot) {
|
|
54
|
+
const releasesDir = resolveReleasesDir(repoRoot);
|
|
55
|
+
const releases = [];
|
|
56
|
+
try {
|
|
57
|
+
const names = (await fs.promises.readdir(releasesDir)).sort();
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
if (!name.endsWith('.yml')) continue;
|
|
60
|
+
const file = path.join(releasesDir, name);
|
|
61
|
+
try {
|
|
62
|
+
releases.push({ file, name, ...parseYaml(await fs.promises.readFile(file, 'utf8')) });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`Invalid release manifest "${name}": ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code !== 'ENOENT') throw error;
|
|
69
|
+
}
|
|
70
|
+
return releases;
|
|
71
|
+
}
|
package/src/repo.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseChange } from './change.mjs';
|
|
4
|
+
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from './config.mjs';
|
|
5
|
+
import { loadReleases, loadReleasesAsync } from './release.mjs';
|
|
6
|
+
import { parseSpec } from './spec.mjs';
|
|
7
|
+
|
|
8
|
+
// Single authority for resolving a change id to its file. Matches by EXACT
|
|
9
|
+
// frontmatter.id equality — never by filename prefix — so a partial or ambiguous
|
|
10
|
+
// id (timestamp ids share prefixes) cannot silently target the first file that
|
|
11
|
+
// happens to share it, and a misleading filename cannot stand in for a change
|
|
12
|
+
// whose frontmatter id differs. A file that fails to parse cannot be the exact
|
|
13
|
+
// match, so it is skipped rather than aborting the search. Shared by every
|
|
14
|
+
// mutating and locating command.
|
|
15
|
+
export function resolveChange(start, id) {
|
|
16
|
+
const changeledgerDir = findChangeledgerDir(start);
|
|
17
|
+
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
18
|
+
const config = loadConfig(changeledgerDir);
|
|
19
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
20
|
+
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
21
|
+
if (fs.existsSync(changesDir)) {
|
|
22
|
+
for (const name of fs.readdirSync(changesDir).sort()) {
|
|
23
|
+
if (!name.endsWith('.md')) continue;
|
|
24
|
+
const file = path.join(changesDir, name);
|
|
25
|
+
let frontmatter;
|
|
26
|
+
try {
|
|
27
|
+
({ frontmatter } = parseChange(fs.readFileSync(file, 'utf8')));
|
|
28
|
+
} catch {
|
|
29
|
+
continue; // unparseable file can't be the exact match
|
|
30
|
+
}
|
|
31
|
+
if (String(frontmatter.id) === String(id)) return { config, repoRoot, changesDir, file };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(
|
|
35
|
+
`No change with id "${id}" (use the exact id; run \`changeledger check\` if a filename's id looks wrong)`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Loads a ChangeLedger repo: locates .changeledger/, reads config and every change file.
|
|
40
|
+
// Shared by `changeledger view` and `changeledger check`.
|
|
41
|
+
export function loadRepo(start = process.cwd()) {
|
|
42
|
+
const changeledgerDir = findChangeledgerDir(start);
|
|
43
|
+
if (!changeledgerDir) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Not a ChangeLedger repo (no .changeledger/ found). Run `changeledger init` first.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
49
|
+
const config = loadConfig(changeledgerDir);
|
|
50
|
+
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
51
|
+
|
|
52
|
+
const changes = [];
|
|
53
|
+
if (fs.existsSync(changesDir)) {
|
|
54
|
+
for (const name of fs.readdirSync(changesDir).sort()) {
|
|
55
|
+
if (!name.endsWith('.md')) continue;
|
|
56
|
+
const file = path.join(changesDir, name);
|
|
57
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
58
|
+
changes.push({ file, name, text, ...parseChange(text) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
changes.sort((a, b) => String(a.frontmatter.id).localeCompare(String(b.frontmatter.id)));
|
|
62
|
+
|
|
63
|
+
const specs = [];
|
|
64
|
+
const specsDir = resolveSpecsDir(repoRoot, config);
|
|
65
|
+
if (fs.existsSync(specsDir)) {
|
|
66
|
+
for (const name of fs.readdirSync(specsDir).sort()) {
|
|
67
|
+
if (!name.endsWith('.md')) continue;
|
|
68
|
+
const file = path.join(specsDir, name);
|
|
69
|
+
specs.push({ file, name, ...parseSpec(fs.readFileSync(file, 'utf8')) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const releases = loadReleases(repoRoot);
|
|
74
|
+
|
|
75
|
+
return { changeledgerDir, repoRoot, config, changes, specs, releases };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Async equivalent for HTTP paths that should not monopolize the Node event
|
|
79
|
+
// loop while reading large change/spec histories. The synchronous loader remains
|
|
80
|
+
// the command API for CLI code.
|
|
81
|
+
export async function loadRepoAsync(start = process.cwd()) {
|
|
82
|
+
const changeledgerDir = findChangeledgerDir(start);
|
|
83
|
+
if (!changeledgerDir) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'Not a ChangeLedger repo (no .changeledger/ found). Run `changeledger init` first.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
89
|
+
const config = loadConfig(changeledgerDir);
|
|
90
|
+
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
91
|
+
|
|
92
|
+
const changes = [];
|
|
93
|
+
try {
|
|
94
|
+
const names = (await fs.promises.readdir(changesDir)).sort();
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (!name.endsWith('.md')) continue;
|
|
97
|
+
const file = path.join(changesDir, name);
|
|
98
|
+
const text = await fs.promises.readFile(file, 'utf8');
|
|
99
|
+
changes.push({ file, name, text, ...parseChange(text) });
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code !== 'ENOENT') throw e;
|
|
103
|
+
}
|
|
104
|
+
changes.sort((a, b) => String(a.frontmatter.id).localeCompare(String(b.frontmatter.id)));
|
|
105
|
+
|
|
106
|
+
const specs = [];
|
|
107
|
+
const specsDir = resolveSpecsDir(repoRoot, config);
|
|
108
|
+
try {
|
|
109
|
+
const names = (await fs.promises.readdir(specsDir)).sort();
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!name.endsWith('.md')) continue;
|
|
112
|
+
const file = path.join(specsDir, name);
|
|
113
|
+
specs.push({ file, name, ...parseSpec(await fs.promises.readFile(file, 'utf8')) });
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (e.code !== 'ENOENT') throw e;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const releases = await loadReleasesAsync(repoRoot);
|
|
120
|
+
|
|
121
|
+
return { changeledgerDir, repoRoot, config, changes, specs, releases };
|
|
122
|
+
}
|
package/src/slug.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const EMPTY_SLUG = 'slug must contain at least one ASCII letter or number';
|
|
2
|
+
|
|
3
|
+
export function slugify(value) {
|
|
4
|
+
const slug = String(value ?? '')
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.normalize('NFD')
|
|
7
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
8
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
9
|
+
.replace(/^-+|-+$/g, '');
|
|
10
|
+
if (!slug) throw new Error(EMPTY_SLUG);
|
|
11
|
+
return slug;
|
|
12
|
+
}
|
package/src/spec.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Parses a spec file: the persistent-truth layer. Unlike changes, specs have no
|
|
2
|
+
// lifecycle — just frontmatter (title, updated, tags) plus a free markdown body.
|
|
3
|
+
|
|
4
|
+
import { parseYaml } from './yaml.mjs';
|
|
5
|
+
|
|
6
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
|
|
7
|
+
|
|
8
|
+
export function parseSpec(text) {
|
|
9
|
+
const fm = text.match(FRONTMATTER);
|
|
10
|
+
if (!fm) throw new Error('Spec is missing its frontmatter block');
|
|
11
|
+
return { frontmatter: parseYaml(fm[1]), body: text.slice(fm[0].length).trim() };
|
|
12
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
|
|
4
|
+
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
5
|
+
import { computeMetrics } from '../metrics.mjs';
|
|
6
|
+
import { nowUtc } from '../paths.mjs';
|
|
7
|
+
import { listProjects } from '../registry.mjs';
|
|
8
|
+
import { loadRepo } from '../repo.mjs';
|
|
9
|
+
|
|
10
|
+
// Serializes a loaded repo into the flat shape the UI consumes.
|
|
11
|
+
export function serialize(repo) {
|
|
12
|
+
return {
|
|
13
|
+
language: repo.config.language ?? 'en',
|
|
14
|
+
statuses: repo.config.statuses ?? [],
|
|
15
|
+
types: Object.keys(repo.config.types ?? {}),
|
|
16
|
+
metrics: computeMetrics(repo.changes, { now: nowUtc() }),
|
|
17
|
+
changes: repo.changes.map((c) => ({
|
|
18
|
+
id: c.frontmatter.id,
|
|
19
|
+
title: c.frontmatter.title,
|
|
20
|
+
type: c.frontmatter.type,
|
|
21
|
+
status: c.frontmatter.status,
|
|
22
|
+
owner: c.frontmatter.owner ?? null,
|
|
23
|
+
archived: c.frontmatter.archived === true,
|
|
24
|
+
created: c.frontmatter.created,
|
|
25
|
+
depends_on: c.frontmatter.depends_on ?? [],
|
|
26
|
+
stages: c.stages,
|
|
27
|
+
tasks: c.tasks,
|
|
28
|
+
progress: c.progress,
|
|
29
|
+
})),
|
|
30
|
+
specs: (repo.specs ?? []).map((s) => ({
|
|
31
|
+
name: s.name,
|
|
32
|
+
title: s.frontmatter.title,
|
|
33
|
+
updated: s.frontmatter.updated,
|
|
34
|
+
tags: s.frontmatter.tags ?? [],
|
|
35
|
+
body: s.body,
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isAlive = (p) => fs.existsSync(path.join(p, '.changeledger', 'config.yml'));
|
|
41
|
+
|
|
42
|
+
// The project list and which one is "current" (the repo the command ran in).
|
|
43
|
+
export function resolveProjects(cwd, localOnly) {
|
|
44
|
+
const changeledgerDir = findChangeledgerDir(cwd);
|
|
45
|
+
const repoRoot = changeledgerDir ? path.dirname(changeledgerDir) : null;
|
|
46
|
+
|
|
47
|
+
if (localOnly) {
|
|
48
|
+
if (!repoRoot) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
49
|
+
const config = loadConfig(changeledgerDir);
|
|
50
|
+
const id = config.project_id ?? 'local';
|
|
51
|
+
const name = config.project_name ?? path.basename(repoRoot);
|
|
52
|
+
return { projects: [{ id, name, path: repoRoot, alive: true }], current: id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const projects = listProjects().map((p) => ({ ...p, alive: isAlive(p.path) }));
|
|
56
|
+
let current = null;
|
|
57
|
+
if (repoRoot) {
|
|
58
|
+
const match = projects.find((p) => path.resolve(p.path) === repoRoot);
|
|
59
|
+
if (match) current = match.id;
|
|
60
|
+
}
|
|
61
|
+
return { projects, current };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Full-text search across the given (alive) projects. `load` maps a project path
|
|
65
|
+
// to a loaded repo (loadRepo by default). Returns groups with at least one match.
|
|
66
|
+
export function searchProjects(projects, q, load = loadRepo) {
|
|
67
|
+
const needle = String(q ?? '')
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
if (!needle) return [];
|
|
71
|
+
const groups = [];
|
|
72
|
+
for (const p of projects) {
|
|
73
|
+
if (!p.alive) continue;
|
|
74
|
+
let repo;
|
|
75
|
+
try {
|
|
76
|
+
repo = load(p.path);
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const matches = repo.changes
|
|
81
|
+
.filter((c) => `${c.text ?? ''} ${c.frontmatter?.title ?? ''}`.toLowerCase().includes(needle))
|
|
82
|
+
.map((c) => ({
|
|
83
|
+
id: c.frontmatter.id,
|
|
84
|
+
title: c.frontmatter.title,
|
|
85
|
+
type: c.frontmatter.type,
|
|
86
|
+
status: c.frontmatter.status,
|
|
87
|
+
}));
|
|
88
|
+
if (matches.length) groups.push({ project: { id: p.id, name: p.name }, matches });
|
|
89
|
+
}
|
|
90
|
+
return groups;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Applies a status move requested from the viewer. Returns { code, body } so the
|
|
94
|
+
// HTTP handler stays thin and the logic is testable. Reuses the `status` command
|
|
95
|
+
// (enum validation + setStatus + appendLog).
|
|
96
|
+
export function changeStatus(projects, { project, id, status, reason }) {
|
|
97
|
+
// A write must target an exact project; never silently fall back to the first
|
|
98
|
+
// registered one.
|
|
99
|
+
const proj = projects.find((p) => p.id === project);
|
|
100
|
+
if (!proj) return { code: 404, body: { error: `no project "${project}"` } };
|
|
101
|
+
if (!proj.alive) return { code: 410, body: { error: 'project path is gone' } };
|
|
102
|
+
if (!id || !status) return { code: 400, body: { error: 'id and status are required' } };
|
|
103
|
+
|
|
104
|
+
// The viewer is the human's surface. Enforce the human/agent boundary here —
|
|
105
|
+
// the UI is bypassable.
|
|
106
|
+
let current;
|
|
107
|
+
try {
|
|
108
|
+
const change = loadRepo(proj.path).changes.find((c) => String(c.frontmatter.id) === String(id));
|
|
109
|
+
if (!change) return { code: 404, body: { error: `no change with id "${id}"` } };
|
|
110
|
+
current = change.frontmatter.status;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { code: 400, body: { error: e.message } };
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
if (current === 'draft' && status === 'approved') {
|
|
116
|
+
applyStatusCmd(id, status, proj.path);
|
|
117
|
+
} else if (current === 'in-validation' && status === 'done') {
|
|
118
|
+
applyValidation(id, 'pass', {}, proj.path);
|
|
119
|
+
} else if (current === 'in-validation' && status === 'in-progress') {
|
|
120
|
+
applyValidation(id, 'fail', { reason }, proj.path);
|
|
121
|
+
} else {
|
|
122
|
+
return {
|
|
123
|
+
code: 403,
|
|
124
|
+
body: {
|
|
125
|
+
error: 'the viewer only allows draft → approved and in-validation → done|in-progress',
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { code: 200, body: { ok: true, id, status } };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return { code: 400, body: { error: e.message } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const getProjects = () => fetch('/api/projects').then((r) => r.json());
|
|
2
|
+
|
|
3
|
+
export const getRepo = async (project) => {
|
|
4
|
+
const res = await fetch(`/api/repo?project=${encodeURIComponent(project)}`);
|
|
5
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6
|
+
return res.text();
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const getGitRefs = (project, id) =>
|
|
10
|
+
fetch(`/api/git?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`).then((r) =>
|
|
11
|
+
r.json(),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const searchAllProjects = (query) =>
|
|
15
|
+
fetch(`/api/search?q=${encodeURIComponent(query)}`).then((r) => r.json());
|
|
16
|
+
|
|
17
|
+
export const postStatus = (project, id, status, reason) =>
|
|
18
|
+
fetch('/api/status', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'x-changeledger-token': window.__CHANGELEDGER_TOKEN__,
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({ project, id, status, ...(reason ? { reason } : {}) }),
|
|
25
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const state = {
|
|
2
|
+
repo: null,
|
|
3
|
+
lastJson: '',
|
|
4
|
+
filters: {
|
|
5
|
+
text: '',
|
|
6
|
+
type: 'all',
|
|
7
|
+
owner: 'all',
|
|
8
|
+
statuses: new Set(),
|
|
9
|
+
showArchived: false,
|
|
10
|
+
showDiscarded: false,
|
|
11
|
+
},
|
|
12
|
+
currentView: 'board',
|
|
13
|
+
sortKey: 'id',
|
|
14
|
+
sortDir: 1,
|
|
15
|
+
currentProject: null,
|
|
16
|
+
projectsList: [],
|
|
17
|
+
globalMode: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function setRepo(json) {
|
|
21
|
+
state.lastJson = json;
|
|
22
|
+
state.repo = JSON.parse(json);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function invalidateCache() {
|
|
26
|
+
state.lastJson = '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setTextFilter(text) {
|
|
30
|
+
state.filters.text = text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setTypeFilter(type) {
|
|
34
|
+
state.filters.type = type;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setOwnerFilter(owner) {
|
|
38
|
+
state.filters.owner = owner;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toggleStatusFilter(status) {
|
|
42
|
+
if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
|
|
43
|
+
else state.filters.statuses.add(status);
|
|
44
|
+
return state.filters.statuses.has(status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearStatusFilters() {
|
|
48
|
+
state.filters.statuses.clear();
|
|
49
|
+
state.filters.showArchived = false;
|
|
50
|
+
state.filters.showDiscarded = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toggleShowArchived() {
|
|
54
|
+
state.filters.showArchived = !state.filters.showArchived;
|
|
55
|
+
return state.filters.showArchived;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toggleShowDiscarded() {
|
|
59
|
+
state.filters.showDiscarded = !state.filters.showDiscarded;
|
|
60
|
+
return state.filters.showDiscarded;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setView(v) {
|
|
64
|
+
state.currentView = v;
|
|
65
|
+
state.globalMode = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function selectProject(id) {
|
|
69
|
+
state.currentProject = id;
|
|
70
|
+
state.lastJson = '';
|
|
71
|
+
state.filters.type = 'all';
|
|
72
|
+
state.filters.owner = 'all';
|
|
73
|
+
state.filters.statuses.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function setSortKey(key) {
|
|
77
|
+
if (state.sortKey === key) state.sortDir = -state.sortDir;
|
|
78
|
+
else {
|
|
79
|
+
state.sortKey = key;
|
|
80
|
+
state.sortDir = 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function toggleGlobalMode() {
|
|
85
|
+
state.globalMode = !state.globalMode;
|
|
86
|
+
return state.globalMode;
|
|
87
|
+
}
|