@sylad/cadence 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +10 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/agents/ux-reviewer.md +49 -0
- package/bin/cadence.js +35 -0
- package/bin/raf.js +10 -0
- package/dist/audit.js +54 -0
- package/dist/check.js +60 -0
- package/dist/cli.js +384 -0
- package/dist/dates.js +63 -0
- package/dist/deliver.js +305 -0
- package/dist/gantt.js +176 -0
- package/dist/git.js +91 -0
- package/dist/hook.js +36 -0
- package/dist/link.js +32 -0
- package/dist/news.js +200 -0
- package/dist/plan.js +255 -0
- package/dist/schedule.js +55 -0
- package/dist/session.js +108 -0
- package/dist/skills.js +61 -0
- package/dist/state.js +126 -0
- package/package.json +28 -0
- package/skills/deliver/SKILL.md +41 -0
- package/skills/session-close/SKILL.md +33 -0
- package/skills/session-start/SKILL.md +38 -0
package/dist/deliver.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { headSha, isAncestor, onRemote, readCommits, repoStatus } from './git.js';
|
|
4
|
+
import { extractRefs, RafError } from './plan.js';
|
|
5
|
+
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
6
|
+
const POLL_CI = 15_000;
|
|
7
|
+
const CI_APPEAR = 300_000;
|
|
8
|
+
const POLL_VERIFY = 10_000;
|
|
9
|
+
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
10
|
+
const GH_TIMEOUT = 60_000;
|
|
11
|
+
export const TIMED_OUT = 124;
|
|
12
|
+
const codeText = (code) => (code === TIMED_OUT ? 'délai dépassé' : `code ${code}`);
|
|
13
|
+
export function parseDeliverConfig(text, file) {
|
|
14
|
+
let raw;
|
|
15
|
+
try {
|
|
16
|
+
raw = parse(text);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
20
|
+
}
|
|
21
|
+
const d = raw?.deliver;
|
|
22
|
+
if (!d || typeof d !== 'object' || Array.isArray(d))
|
|
23
|
+
throw new RafError(`${file} : clé deliver absente`);
|
|
24
|
+
const cfg = d;
|
|
25
|
+
const bad = (what) => new RafError(`${file} : deliver.${what}`);
|
|
26
|
+
let ci = 'none';
|
|
27
|
+
if (cfg.ci === 'github' || cfg.ci === 'none')
|
|
28
|
+
ci = cfg.ci;
|
|
29
|
+
else if (cfg.ci && typeof cfg.ci === 'object' && typeof cfg.ci.command === 'string') {
|
|
30
|
+
ci = { command: cfg.ci.command };
|
|
31
|
+
if (!ci.command.trim())
|
|
32
|
+
throw bad('ci.command : commande vide');
|
|
33
|
+
}
|
|
34
|
+
else if (cfg.ci !== undefined)
|
|
35
|
+
throw bad('ci : attendu github, none ou { command: "…" }');
|
|
36
|
+
const seconds = (key, dflt) => {
|
|
37
|
+
const v = cfg[key];
|
|
38
|
+
if (v === undefined)
|
|
39
|
+
return dflt;
|
|
40
|
+
if (typeof v !== 'number' || !(v > 0))
|
|
41
|
+
throw bad(`${key} : nombre de secondes positif attendu`);
|
|
42
|
+
return v;
|
|
43
|
+
};
|
|
44
|
+
const deploy = cfg.deploy === undefined ? [] : typeof cfg.deploy === 'string' ? [cfg.deploy] : cfg.deploy;
|
|
45
|
+
if (!Array.isArray(deploy) || !deploy.every((c) => typeof c === 'string' && c.trim()))
|
|
46
|
+
throw bad('deploy : liste de commandes non vides attendue');
|
|
47
|
+
const verify = cfg.verify ?? [];
|
|
48
|
+
if (!Array.isArray(verify) || verify.length === 0) {
|
|
49
|
+
throw bad('verify : au moins une vérification (une livraison se prouve par son effet)');
|
|
50
|
+
}
|
|
51
|
+
const checks = verify.map((v, i) => {
|
|
52
|
+
const where = `verify[${i + 1}]`;
|
|
53
|
+
if (!v || typeof v !== 'object')
|
|
54
|
+
throw bad(`${where} : { url } ou { command } attendu`);
|
|
55
|
+
const { url, status, contains, command } = v;
|
|
56
|
+
if ((url === undefined) === (command === undefined))
|
|
57
|
+
throw bad(`${where} : exactement un de url ou command`);
|
|
58
|
+
if (command !== undefined) {
|
|
59
|
+
if (typeof command !== 'string' || !command.trim())
|
|
60
|
+
throw bad(`${where}.command : commande non vide attendue`);
|
|
61
|
+
return { command };
|
|
62
|
+
}
|
|
63
|
+
if (typeof url !== 'string')
|
|
64
|
+
throw bad(`${where}.url : texte attendu`);
|
|
65
|
+
if (status !== undefined && !Number.isInteger(status))
|
|
66
|
+
throw bad(`${where}.status : entier attendu`);
|
|
67
|
+
if (contains !== undefined && typeof contains !== 'string')
|
|
68
|
+
throw bad(`${where}.contains : texte attendu`);
|
|
69
|
+
return { url, ...(status === undefined ? {} : { status: status }), ...(contains === undefined ? {} : { contains }) };
|
|
70
|
+
});
|
|
71
|
+
return { ci, ciTimeout: seconds('ciTimeout', 1800), deploy: deploy, verify: checks, verifyTimeout: seconds('verifyTimeout', 300), deployTimeout: seconds('deployTimeout', 1800) };
|
|
72
|
+
}
|
|
73
|
+
/** Dépendances réelles : sh, gh, fetch, horloge. */
|
|
74
|
+
export function realDeps(root) {
|
|
75
|
+
const gh = (args) => {
|
|
76
|
+
try {
|
|
77
|
+
return execFileSync('gh', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: GH_TIMEOUT });
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
const err = e;
|
|
81
|
+
if (err.code === 'ENOENT')
|
|
82
|
+
throw new Error('gh introuvable (https://cli.github.com)');
|
|
83
|
+
if (err.code === 'ETIMEDOUT')
|
|
84
|
+
throw new Error(`gh sans réponse après ${GH_TIMEOUT / 1000} s`);
|
|
85
|
+
throw new Error(String(err.stderr || err.message).trim().split('\n').slice(0, 3).join(' '));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
exec: (cmd, env, timeoutMs) => {
|
|
90
|
+
const r = spawnSync('sh', ['-c', cmd], {
|
|
91
|
+
cwd: root,
|
|
92
|
+
env: { ...process.env, ...env },
|
|
93
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
94
|
+
timeout: Math.max(1_000, timeoutMs),
|
|
95
|
+
killSignal: 'SIGKILL',
|
|
96
|
+
});
|
|
97
|
+
if (r.error?.code === 'ETIMEDOUT' || r.signal)
|
|
98
|
+
return TIMED_OUT;
|
|
99
|
+
return r.status ?? 1;
|
|
100
|
+
},
|
|
101
|
+
gh: (sha) => JSON.parse(gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion'])),
|
|
102
|
+
ghReady: () => {
|
|
103
|
+
try {
|
|
104
|
+
gh(['auth', 'status']);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
return e.message;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
fetch: async (url) => {
|
|
112
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
113
|
+
return { status: res.status, text: await res.text() };
|
|
114
|
+
},
|
|
115
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
116
|
+
now: () => Date.now(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function substitute(text, sha) {
|
|
120
|
+
return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
|
|
121
|
+
}
|
|
122
|
+
function describeCheck(c, sha) {
|
|
123
|
+
if (c.command !== undefined)
|
|
124
|
+
return c.command;
|
|
125
|
+
return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
|
|
126
|
+
}
|
|
127
|
+
/** Livre HEAD. 0 livré, 1 étape en échec, 2 refus avant d'agir. */
|
|
128
|
+
export async function deliver(ctx, deps) {
|
|
129
|
+
const { config, out, err } = ctx;
|
|
130
|
+
const refuse = (msg) => {
|
|
131
|
+
err(`deliver : ${msg}`);
|
|
132
|
+
return 2;
|
|
133
|
+
};
|
|
134
|
+
const sha = headSha(ctx.root);
|
|
135
|
+
if (!sha)
|
|
136
|
+
return refuse('aucun commit à livrer');
|
|
137
|
+
const repo = repoStatus(ctx.root);
|
|
138
|
+
if (repo.dirty)
|
|
139
|
+
return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
|
|
140
|
+
if (!onRemote(ctx.root, sha))
|
|
141
|
+
return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
|
|
142
|
+
const lock = readLock(ctx.state);
|
|
143
|
+
if (lock && lockAlive(lock)) {
|
|
144
|
+
return refuse(`livraison déjà en cours (${lock.sha.slice(0, 7)}, pid ${lock.pid}, depuis ${lock.started}) — jamais deux à la fois ; ` +
|
|
145
|
+
`si ce processus n'est plus une livraison : rm ${lockPath(ctx.state)}`);
|
|
146
|
+
}
|
|
147
|
+
if (config.ci === 'github' && !ctx.dryRun) {
|
|
148
|
+
const why = deps.ghReady();
|
|
149
|
+
if (why)
|
|
150
|
+
return refuse(`ci: github exige gh authentifié — ${why}`);
|
|
151
|
+
}
|
|
152
|
+
const branch = repo.branch ?? 'HEAD détachée';
|
|
153
|
+
const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: repo.branch ?? '' };
|
|
154
|
+
if (ctx.dryRun) {
|
|
155
|
+
out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
|
|
156
|
+
out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
|
|
157
|
+
out(' Déploiement :');
|
|
158
|
+
if (config.deploy.length === 0)
|
|
159
|
+
out(' (aucune commande)');
|
|
160
|
+
config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
|
|
161
|
+
out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
|
|
162
|
+
config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
|
|
163
|
+
out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
if (lock && removeStaleLock(ctx.state, lock)) {
|
|
167
|
+
err(`deliver : verrou périmé retiré (pid ${lock.pid} mort, ${lock.sha.slice(0, 7)})`);
|
|
168
|
+
}
|
|
169
|
+
if (!writeLock(ctx.state, { pid: process.pid, sha, started: new Date(deps.now()).toISOString() })) {
|
|
170
|
+
return refuse('une autre livraison vient de démarrer');
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const fail = (msg) => {
|
|
174
|
+
err(`deliver : ${msg}`);
|
|
175
|
+
return 1;
|
|
176
|
+
};
|
|
177
|
+
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
178
|
+
const ci = await waitCi(ctx, deps, sha, env);
|
|
179
|
+
if (ci)
|
|
180
|
+
return fail(ci);
|
|
181
|
+
for (const [i, cmd] of config.deploy.entries()) {
|
|
182
|
+
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
183
|
+
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
184
|
+
if (code !== 0)
|
|
185
|
+
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
|
|
186
|
+
}
|
|
187
|
+
const failed = await verifyAll(ctx, deps, sha, env);
|
|
188
|
+
if (failed)
|
|
189
|
+
return fail(failed);
|
|
190
|
+
const delivered = deliveredLots(ctx, lastDelivery(ctx.state), sha);
|
|
191
|
+
appendDelivery(ctx.state, ctx.today, sha);
|
|
192
|
+
out(`✓ livré et vérifié : ${env.CADENCE_SHORT}`);
|
|
193
|
+
if (delivered)
|
|
194
|
+
out(delivered);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
releaseLock(ctx.state, process.pid);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Message d'échec, ou null quand la CI est verte. */
|
|
202
|
+
async function waitCi(ctx, deps, sha, env) {
|
|
203
|
+
const { ci, ciTimeout } = ctx.config;
|
|
204
|
+
if (ci === 'none')
|
|
205
|
+
return null;
|
|
206
|
+
if (typeof ci === 'object') {
|
|
207
|
+
ctx.out(`→ CI : ${ci.command}`);
|
|
208
|
+
const code = deps.exec(ci.command, env, ciTimeout * 1000);
|
|
209
|
+
return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
|
|
210
|
+
}
|
|
211
|
+
ctx.out(`→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`);
|
|
212
|
+
const start = deps.now();
|
|
213
|
+
let last = '';
|
|
214
|
+
let failingSince = null;
|
|
215
|
+
for (;;) {
|
|
216
|
+
let runs = [];
|
|
217
|
+
let error = null;
|
|
218
|
+
try {
|
|
219
|
+
runs = deps.gh(sha);
|
|
220
|
+
failingSince = null;
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
// Erreur passagère (réseau, quota d'API) : on réessaie ; persistante, on abandonne avec sa cause.
|
|
224
|
+
error = e.message;
|
|
225
|
+
failingSince ??= deps.now();
|
|
226
|
+
}
|
|
227
|
+
const elapsed = deps.now() - start;
|
|
228
|
+
if (error !== null) {
|
|
229
|
+
if (deps.now() - failingSince >= CI_APPEAR || elapsed >= ciTimeout * 1000)
|
|
230
|
+
return `gh run list en échec : ${error}`;
|
|
231
|
+
ctx.err(` gh run list en échec, nouvel essai : ${error}`);
|
|
232
|
+
}
|
|
233
|
+
else if (runs.length === 0) {
|
|
234
|
+
if (elapsed >= CI_APPEAR)
|
|
235
|
+
return `aucun run CI pour ce sha après ${CI_APPEAR / 60_000} min (le commit poussé est-il celui que la CI construit ?)`;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
const pending = runs.filter((r) => r.status !== 'completed');
|
|
239
|
+
const summary = `${runs.length} run(s), ${runs.length - pending.length} terminé(s)`;
|
|
240
|
+
if (summary !== last)
|
|
241
|
+
ctx.out(` ${summary}`);
|
|
242
|
+
last = summary;
|
|
243
|
+
if (pending.length === 0) {
|
|
244
|
+
const bad = runs.filter((r) => !CI_OK.has(r.conclusion));
|
|
245
|
+
return bad.length ? `CI en échec : ${bad.map((r) => `${r.name} (${r.conclusion})`).join(', ')}` : null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (elapsed >= ciTimeout * 1000)
|
|
249
|
+
return `CI non terminée après ${ciTimeout} s`;
|
|
250
|
+
await deps.sleep(POLL_CI);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
254
|
+
if (c.command !== undefined) {
|
|
255
|
+
const code = deps.exec(c.command, env, budgetMs);
|
|
256
|
+
return code === 0 ? null : codeText(code);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const res = await deps.fetch(substitute(c.url, sha));
|
|
260
|
+
const want = c.status ?? 200;
|
|
261
|
+
if (res.status !== want)
|
|
262
|
+
return `statut ${res.status} (attendu ${want})`;
|
|
263
|
+
if (c.contains !== undefined && !res.text.includes(substitute(c.contains, sha)))
|
|
264
|
+
return `« ${substitute(c.contains, sha)} » absent de la réponse`;
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
return `erreur réseau : ${e.message}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
|
|
272
|
+
async function verifyAll(ctx, deps, sha, env) {
|
|
273
|
+
const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
|
|
274
|
+
for (const [i, c] of ctx.config.verify.entries()) {
|
|
275
|
+
const label = describeCheck(c, sha);
|
|
276
|
+
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
277
|
+
for (;;) {
|
|
278
|
+
const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
|
|
279
|
+
if (reason === null)
|
|
280
|
+
break;
|
|
281
|
+
if (deps.now() >= deadline)
|
|
282
|
+
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
283
|
+
await deps.sleep(POLL_VERIFY);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** Ligne des lots cités depuis la livraison précédente, ou null (première livraison, rien de cité). */
|
|
289
|
+
function deliveredLots(ctx, prev, sha) {
|
|
290
|
+
if (!ctx.plan || !prev || prev === sha)
|
|
291
|
+
return null;
|
|
292
|
+
if (!isAncestor(ctx.root, prev, sha)) {
|
|
293
|
+
return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
|
|
294
|
+
}
|
|
295
|
+
const known = new Set(ctx.plan.lots().map((l) => l.id));
|
|
296
|
+
const ids = new Set();
|
|
297
|
+
for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
|
|
298
|
+
for (const r of extractRefs(`${c.subject}\n${c.body}`, ctx.plan.prefix))
|
|
299
|
+
if (known.has(r.lot))
|
|
300
|
+
ids.add(r.lot);
|
|
301
|
+
}
|
|
302
|
+
if (ids.size === 0)
|
|
303
|
+
return null;
|
|
304
|
+
return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — raf done si l'effet est celui attendu`;
|
|
305
|
+
}
|
package/dist/gantt.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export function ganttData(project, bars, today, generated) {
|
|
2
|
+
return {
|
|
3
|
+
project,
|
|
4
|
+
today,
|
|
5
|
+
generated,
|
|
6
|
+
bars: bars.map((b) => ({
|
|
7
|
+
id: b.lot.id,
|
|
8
|
+
title: b.lot.title,
|
|
9
|
+
status: b.lot.status,
|
|
10
|
+
quickwin: b.lot.quickwin,
|
|
11
|
+
start: b.start,
|
|
12
|
+
end: b.end,
|
|
13
|
+
projected: b.projected,
|
|
14
|
+
commits: b.commits,
|
|
15
|
+
estimate: b.lot.estimate,
|
|
16
|
+
after: b.lot.after,
|
|
17
|
+
tasks: { done: b.lot.tasks.filter((t) => t.status === 'done').length, total: b.lot.tasks.length },
|
|
18
|
+
notes: b.lot.notes,
|
|
19
|
+
})),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** A single self-contained HTML file: data, style and script inline, no network access. */
|
|
23
|
+
export function renderGantt(data) {
|
|
24
|
+
// `</` fermerait la balise script : on l'échappe dans le JSON embarqué.
|
|
25
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
26
|
+
const title = escapeHtml(`${data.project} — reste à faire`);
|
|
27
|
+
return `<!doctype html>
|
|
28
|
+
<html lang="fr">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<title>${title}</title>
|
|
33
|
+
<style>${STYLE}</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<header>
|
|
37
|
+
<h1>${title}</h1>
|
|
38
|
+
<p class="meta">Généré le <span id="generated"></span> par raf · aujourd'hui <span id="today"></span></p>
|
|
39
|
+
<div class="controls">
|
|
40
|
+
<label><input type="checkbox" id="show-done" checked> terminés</label>
|
|
41
|
+
<label><input type="checkbox" id="show-dropped"> abandonnés</label>
|
|
42
|
+
<span class="legend">
|
|
43
|
+
<i class="sw todo"></i>à faire <i class="sw doing"></i>en cours <i class="sw done"></i>terminé <i class="sw dropped"></i>abandonné <b>⚡</b> quickwin
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
<main>
|
|
48
|
+
<div id="chart" role="img" aria-label="Diagramme de Gantt du plan"></div>
|
|
49
|
+
<div id="tip" hidden></div>
|
|
50
|
+
<table>
|
|
51
|
+
<thead><tr><th>Lot</th><th>Titre</th><th>Statut</th><th>Est. (j)</th><th>Début</th><th>Fin</th><th>Commits</th><th>Dernière note</th></tr></thead>
|
|
52
|
+
<tbody id="rows"></tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</main>
|
|
55
|
+
<script type="application/json" id="raf-data">${json}</script>
|
|
56
|
+
<script>${SCRIPT}</script>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
function escapeHtml(s) {
|
|
62
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
63
|
+
}
|
|
64
|
+
const STYLE = `
|
|
65
|
+
:root { --bg:#fff; --fg:#1b1f24; --muted:#636c76; --grid:#e3e6ea; --todo:#8c959f; --doing:#0969da; --done:#1a7f37; --dropped:#cf222e; --today:#bf8700; }
|
|
66
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0d1117; --fg:#e6edf3; --muted:#8d96a0; --grid:#262c36; --todo:#6e7781; --doing:#4493f8; --done:#3fb950; --dropped:#f85149; --today:#d29922; } }
|
|
67
|
+
* { box-sizing: border-box; }
|
|
68
|
+
body { margin:0; font:14px/1.45 system-ui, sans-serif; background:var(--bg); color:var(--fg); }
|
|
69
|
+
header, main { padding: 16px 24px; }
|
|
70
|
+
h1 { margin:0 0 4px; font-size:20px; }
|
|
71
|
+
.meta { margin:0 0 8px; color:var(--muted); }
|
|
72
|
+
.controls { display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
|
|
73
|
+
.legend { color:var(--muted); display:flex; gap:6px; align-items:center; }
|
|
74
|
+
.sw { display:inline-block; width:12px; height:12px; border-radius:2px; margin-left:8px; }
|
|
75
|
+
.sw.todo { background:var(--todo); } .sw.doing { background:var(--doing); } .sw.done { background:var(--done); } .sw.dropped { background:var(--dropped); }
|
|
76
|
+
#chart { overflow-x:auto; border:1px solid var(--grid); border-radius:6px; }
|
|
77
|
+
svg text { fill:var(--fg); font-size:12px; }
|
|
78
|
+
svg .axis { fill:var(--muted); }
|
|
79
|
+
svg .grid { stroke:var(--grid); }
|
|
80
|
+
svg .today { stroke:var(--today); stroke-width:2; }
|
|
81
|
+
svg .bar { rx:3; ry:3; }
|
|
82
|
+
svg .bar.todo { fill:var(--todo); } svg .bar.doing { fill:var(--doing); } svg .bar.done { fill:var(--done); } svg .bar.dropped { fill:var(--dropped); }
|
|
83
|
+
svg .bar.projected { fill-opacity:.45; stroke:currentColor; stroke-width:1; }
|
|
84
|
+
svg .bar.projected.todo { color:var(--todo); }
|
|
85
|
+
svg .weekend { fill:var(--grid); opacity:.45; }
|
|
86
|
+
svg .row:hover .bar { stroke:var(--fg); stroke-opacity:.8; }
|
|
87
|
+
#tip { position:fixed; max-width:360px; padding:8px 10px; border-radius:6px; background:var(--fg); color:var(--bg); font-size:12px; pointer-events:none; z-index:2; }
|
|
88
|
+
#tip p { margin:2px 0; }
|
|
89
|
+
table { width:100%; border-collapse:collapse; margin-top:20px; }
|
|
90
|
+
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--grid); vertical-align:top; }
|
|
91
|
+
th { color:var(--muted); font-weight:600; }
|
|
92
|
+
td.date { white-space:nowrap; }
|
|
93
|
+
td.st-doing { color:var(--doing); } td.st-done { color:var(--done); } td.st-dropped { color:var(--dropped); }
|
|
94
|
+
`;
|
|
95
|
+
// Script du navigateur : construit le SVG avec l'API DOM (jamais innerHTML sur les données).
|
|
96
|
+
const SCRIPT = `
|
|
97
|
+
(function () {
|
|
98
|
+
var data = JSON.parse(document.getElementById('raf-data').textContent);
|
|
99
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
100
|
+
var DAY = 86400000, COL = 18, ROW = 26, LABEL = 280, HEAD = 34;
|
|
101
|
+
var LABELS = { todo: 'à faire', doing: 'en cours', done: 'terminé', dropped: 'abandonné' };
|
|
102
|
+
document.getElementById('generated').textContent = data.generated;
|
|
103
|
+
document.getElementById('today').textContent = data.today;
|
|
104
|
+
var ms = function (d) { return Date.parse(d + 'T00:00:00Z'); };
|
|
105
|
+
var el = function (name, attrs, text) {
|
|
106
|
+
var n = document.createElementNS(NS, name);
|
|
107
|
+
for (var k in attrs) n.setAttribute(k, attrs[k]);
|
|
108
|
+
if (text != null) n.textContent = text;
|
|
109
|
+
return n;
|
|
110
|
+
};
|
|
111
|
+
var tip = document.getElementById('tip');
|
|
112
|
+
function showTip(ev, b) {
|
|
113
|
+
tip.replaceChildren();
|
|
114
|
+
var lines = [b.id + ' — ' + b.title, LABELS[b.status] + (b.projected ? ' (prévision)' : '') + ' · ' + b.start + ' → ' + b.end,
|
|
115
|
+
'estimation ' + b.estimate + ' j · ' + b.commits + ' commit(s)' + (b.tasks.total ? ' · sous-tâches ' + b.tasks.done + '/' + b.tasks.total : '')];
|
|
116
|
+
if (b.after.length) lines.push('après ' + b.after.join(', '));
|
|
117
|
+
b.notes.slice(-3).forEach(function (n) { lines.push(n.date + ' : ' + n.text); });
|
|
118
|
+
lines.forEach(function (t, i) { var p = document.createElement('p'); p.textContent = t; if (i === 0) p.style.fontWeight = '600'; tip.appendChild(p); });
|
|
119
|
+
tip.hidden = false;
|
|
120
|
+
tip.style.left = Math.min(ev.clientX + 12, window.innerWidth - 380) + 'px';
|
|
121
|
+
tip.style.top = (ev.clientY + 14) + 'px';
|
|
122
|
+
}
|
|
123
|
+
function render() {
|
|
124
|
+
var showDone = document.getElementById('show-done').checked;
|
|
125
|
+
var showDropped = document.getElementById('show-dropped').checked;
|
|
126
|
+
var bars = data.bars.filter(function (b) { return (showDone || b.status !== 'done') && (showDropped || b.status !== 'dropped'); });
|
|
127
|
+
var chart = document.getElementById('chart');
|
|
128
|
+
chart.replaceChildren();
|
|
129
|
+
if (!bars.length) { chart.textContent = 'Rien à afficher.'; return; }
|
|
130
|
+
var min = Math.min.apply(null, bars.map(function (b) { return ms(b.start); }).concat([ms(data.today)])) - 2 * DAY;
|
|
131
|
+
var max = Math.max.apply(null, bars.map(function (b) { return ms(b.end); }).concat([ms(data.today)])) + 3 * DAY;
|
|
132
|
+
var days = Math.round((max - min) / DAY);
|
|
133
|
+
var x = function (d) { return LABEL + Math.round((ms(d) - min) / DAY) * COL; };
|
|
134
|
+
var svg = el('svg', { width: LABEL + days * COL + 48, height: HEAD + bars.length * ROW + 10 });
|
|
135
|
+
for (var i = 0; i <= days; i++) {
|
|
136
|
+
var t = new Date(min + i * DAY);
|
|
137
|
+
if (t.getUTCDay() === 0 || t.getUTCDay() === 6) {
|
|
138
|
+
svg.appendChild(el('rect', { x: LABEL + i * COL, y: HEAD - 6, width: COL, height: bars.length * ROW + 6, class: 'weekend' }));
|
|
139
|
+
}
|
|
140
|
+
if (t.getUTCDay() === 1) {
|
|
141
|
+
var gx = LABEL + i * COL;
|
|
142
|
+
svg.appendChild(el('line', { x1: gx, x2: gx, y1: HEAD - 6, y2: HEAD + bars.length * ROW, class: 'grid' }));
|
|
143
|
+
svg.appendChild(el('text', { x: gx + 3, y: 18, class: 'axis' }, t.toISOString().slice(5, 10)));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
bars.forEach(function (b, r) {
|
|
147
|
+
var y = HEAD + r * ROW;
|
|
148
|
+
var g = el('g', { class: 'row', tabindex: 0 });
|
|
149
|
+
g.appendChild(el('text', { x: 8, y: y + 17 }, (b.quickwin ? '⚡ ' : '') + b.id + ' ' + (b.title.length > 34 ? b.title.slice(0, 33) + '…' : b.title)));
|
|
150
|
+
var bx = x(b.start), bw = Math.max(COL - 2, x(b.end) + COL - bx - 2);
|
|
151
|
+
g.appendChild(el('rect', { x: bx, y: y + 5, width: bw, height: ROW - 10, class: 'bar ' + b.status + (b.projected && b.status !== 'doing' ? ' projected' : '') }));
|
|
152
|
+
g.addEventListener('mousemove', function (ev) { showTip(ev, b); });
|
|
153
|
+
g.addEventListener('mouseleave', function () { tip.hidden = true; });
|
|
154
|
+
svg.appendChild(g);
|
|
155
|
+
});
|
|
156
|
+
var tx = x(data.today) + COL / 2;
|
|
157
|
+
svg.appendChild(el('line', { x1: tx, x2: tx, y1: HEAD - 10, y2: HEAD + bars.length * ROW, class: 'today' }));
|
|
158
|
+
chart.appendChild(svg);
|
|
159
|
+
var today = x(data.today) - LABEL;
|
|
160
|
+
chart.scrollLeft = Math.max(0, today - chart.clientWidth / 3);
|
|
161
|
+
|
|
162
|
+
var rows = document.getElementById('rows');
|
|
163
|
+
rows.replaceChildren();
|
|
164
|
+
bars.forEach(function (b) {
|
|
165
|
+
var tr = document.createElement('tr');
|
|
166
|
+
var last = b.notes.length ? b.notes[b.notes.length - 1] : null;
|
|
167
|
+
[b.id, (b.quickwin ? '⚡ ' : '') + b.title, LABELS[b.status], String(b.estimate), b.start, b.end, String(b.commits), last ? last.date + ' : ' + last.text : '']
|
|
168
|
+
.forEach(function (v, i) { var td = document.createElement('td'); td.textContent = v; if (i === 2) td.className = 'st-' + b.status; if (i === 4 || i === 5) td.className = 'date'; tr.appendChild(td); });
|
|
169
|
+
rows.appendChild(tr);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
document.getElementById('show-done').addEventListener('change', render);
|
|
173
|
+
document.getElementById('show-dropped').addEventListener('change', render);
|
|
174
|
+
render();
|
|
175
|
+
})();
|
|
176
|
+
`;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
function git(cwd, args) {
|
|
4
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
5
|
+
}
|
|
6
|
+
export function gitRoot(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
return git(cwd, ['rev-parse', '--show-toplevel']).trim();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const FIELD = '\x1f';
|
|
15
|
+
const RECORD = '\x1e';
|
|
16
|
+
/** Commits reachable from HEAD, newest first. Empty when the repo has no commit yet. */
|
|
17
|
+
export function readCommits(cwd, opts = {}) {
|
|
18
|
+
const args = ['log', `--format=%H${FIELD}%cs${FIELD}%s${FIELD}%b${RECORD}`];
|
|
19
|
+
if (opts.since)
|
|
20
|
+
args.push(`--since=${opts.since}`);
|
|
21
|
+
if (opts.range)
|
|
22
|
+
args.push(opts.range);
|
|
23
|
+
let out;
|
|
24
|
+
try {
|
|
25
|
+
out = git(cwd, args);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
.split(RECORD)
|
|
32
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
33
|
+
.filter((r) => r.length > 0)
|
|
34
|
+
.map((r) => {
|
|
35
|
+
const [sha, day, subject, body = ''] = r.split(FIELD);
|
|
36
|
+
return { sha, day, subject, body: body.trim() };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Dossier où git exécute réellement les hooks : gère worktrees, core.hooksPath
|
|
41
|
+
* (y compris « ~/… ») et Husky v9, dont `.husky/_` est généré — les hooks à la main vont dans `.husky/`.
|
|
42
|
+
*/
|
|
43
|
+
export function hooksDir(cwd) {
|
|
44
|
+
const dir = resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'hooks']).trim());
|
|
45
|
+
return basename(dir) === '_' && basename(dirname(dir)) === '.husky' ? dirname(dir) : dir;
|
|
46
|
+
}
|
|
47
|
+
/** Fichiers modifiés par un commit, relatifs à la racine du dépôt. */
|
|
48
|
+
export function changedFiles(cwd, sha) {
|
|
49
|
+
return git(cwd, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha]).split('\n').filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
function tryGit(cwd, args) {
|
|
52
|
+
try {
|
|
53
|
+
return git(cwd, args).trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Sha complet de HEAD, null tant que le dépôt n'a aucun commit. */
|
|
60
|
+
export function headSha(cwd) {
|
|
61
|
+
return tryGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD']);
|
|
62
|
+
}
|
|
63
|
+
export function repoStatus(cwd) {
|
|
64
|
+
const lines = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean);
|
|
65
|
+
const untracked = lines.filter((l) => l.startsWith('??')).length;
|
|
66
|
+
const upstream = tryGit(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
67
|
+
const ahead = upstream && headSha(cwd) ? Number(tryGit(cwd, ['rev-list', '--count', '@{u}..HEAD']) ?? 0) : 0;
|
|
68
|
+
return {
|
|
69
|
+
branch: tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD']),
|
|
70
|
+
dirty: lines.length - untracked,
|
|
71
|
+
untracked,
|
|
72
|
+
upstream,
|
|
73
|
+
ahead,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Le commit est-il contenu dans une branche distante connue localement ? */
|
|
77
|
+
export function onRemote(cwd, sha) {
|
|
78
|
+
return (tryGit(cwd, ['branch', '-r', '--contains', sha]) ?? '').length > 0;
|
|
79
|
+
}
|
|
80
|
+
/** Chemin d'un fichier propre au dépôt local, dans le dossier git (jamais commité, un par worktree). */
|
|
81
|
+
export function gitPath(cwd, name) {
|
|
82
|
+
return resolve(cwd, git(cwd, ['rev-parse', '--git-path', name]).trim());
|
|
83
|
+
}
|
|
84
|
+
/** Dossier git commun à tous les worktrees du dépôt. */
|
|
85
|
+
export function gitCommonDir(cwd) {
|
|
86
|
+
return resolve(cwd, git(cwd, ['rev-parse', '--git-common-dir']).trim());
|
|
87
|
+
}
|
|
88
|
+
/** `a` est-il un ancêtre de `b` (objets présents) ? */
|
|
89
|
+
export function isAncestor(cwd, a, b) {
|
|
90
|
+
return tryGit(cwd, ['merge-base', '--is-ancestor', a, b]) !== null;
|
|
91
|
+
}
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { hooksDir } from './git.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
const BEGIN = '# >>> raf (cadence)';
|
|
6
|
+
const END = '# <<< raf (cadence)';
|
|
7
|
+
const BLOCK = `${BEGIN}\ncommand -v raf >/dev/null 2>&1 && raf hook post-commit || true\n${END}\n`;
|
|
8
|
+
/** Adds the raf block to post-commit without touching anything else already there. */
|
|
9
|
+
export function installHook(cwd) {
|
|
10
|
+
const dir = hooksDir(cwd);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const path = join(dir, 'post-commit');
|
|
13
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
14
|
+
if (current.includes(BEGIN))
|
|
15
|
+
return { path, changed: false };
|
|
16
|
+
if (current !== '' && !/^#!.*\b(sh|bash|zsh|dash)\b/.test(current)) {
|
|
17
|
+
throw new RafError(`${path} n'est pas un script shell : y ajouter à la main « raf hook post-commit »`);
|
|
18
|
+
}
|
|
19
|
+
let next;
|
|
20
|
+
if (current === '') {
|
|
21
|
+
next = `#!/bin/sh\n${BLOCK}`;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const lines = current.replace(/\n*$/, '').split('\n');
|
|
25
|
+
// Un « exit » final empêcherait le bloc de s'exécuter : on l'insère juste avant.
|
|
26
|
+
const last = lines.length - 1;
|
|
27
|
+
if (/^\s*exit\b/.test(lines[last]))
|
|
28
|
+
lines.splice(last, 0, BLOCK.trimEnd());
|
|
29
|
+
else
|
|
30
|
+
lines.push(BLOCK.trimEnd());
|
|
31
|
+
next = `${lines.join('\n')}\n`;
|
|
32
|
+
}
|
|
33
|
+
writeFileSync(path, next);
|
|
34
|
+
chmodSync(path, 0o755);
|
|
35
|
+
return { path, changed: true };
|
|
36
|
+
}
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { extractRefs } from './plan.js';
|
|
2
|
+
export function isMerge(c) {
|
|
3
|
+
return /^Merge\b/.test(c.subject);
|
|
4
|
+
}
|
|
5
|
+
export function linkCommits(lots, commits, prefix) {
|
|
6
|
+
const ids = new Set(lots.map((l) => l.id));
|
|
7
|
+
const tasks = new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`)));
|
|
8
|
+
const byLot = new Map();
|
|
9
|
+
const orphans = [];
|
|
10
|
+
const unknown = [];
|
|
11
|
+
for (const c of commits) {
|
|
12
|
+
const refs = extractRefs(`${c.subject}\n${c.body}`, prefix);
|
|
13
|
+
if (refs.length === 0) {
|
|
14
|
+
if (!isMerge(c))
|
|
15
|
+
orphans.push(c);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const r of refs) {
|
|
20
|
+
const full = r.task ? `${r.lot}/${r.task}` : r.lot;
|
|
21
|
+
if (!ids.has(r.lot) || (r.task && !tasks.has(full))) {
|
|
22
|
+
unknown.push({ commit: c, ref: full });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (seen.has(r.lot))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(r.lot);
|
|
28
|
+
byLot.set(r.lot, [...(byLot.get(r.lot) ?? []), c]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { byLot, orphans, unknown };
|
|
32
|
+
}
|