@roomi-fields/grillhouse 0.0.0-stage → 0.23.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.
Files changed (166) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/METACADRE.md +41 -0
  3. package/agents/developpeur.md +10 -0
  4. package/agents/explorateur.md +9 -0
  5. package/agents/questionneur.md +11 -0
  6. package/agents/referent-technique.md +9 -0
  7. package/agents/regulateur.md +11 -0
  8. package/agents/relecteur.md +9 -0
  9. package/agents/testwriter.md +10 -0
  10. package/bin/grillhouse.mjs +125 -0
  11. package/cadre/templates/projet/.claude/settings.json +30 -0
  12. package/cadre/templates/projet/.editorconfig +47 -0
  13. package/cadre/templates/projet/.env.example +57 -0
  14. package/cadre/templates/projet/.eslintrc.cjs +56 -0
  15. package/cadre/templates/projet/.github/workflows/ci.yml +66 -0
  16. package/cadre/templates/projet/.mcp.json +13 -0
  17. package/cadre/templates/projet/.prettierrc +11 -0
  18. package/cadre/templates/projet/CHANGELOG.md +61 -0
  19. package/cadre/templates/projet/CLAUDE.md +46 -0
  20. package/cadre/templates/projet/CONTRIBUTING.md +243 -0
  21. package/cadre/templates/projet/CREDITS.md +41 -0
  22. package/cadre/templates/projet/README.md +152 -0
  23. package/cadre/templates/projet/_gitignore +91 -0
  24. package/cadre/templates/projet/_npmignore +46 -0
  25. package/cadre/templates/projet/deployment/README.md +98 -0
  26. package/cadre/templates/projet/deployment/docs/01-INSTALL.md +145 -0
  27. package/cadre/templates/projet/deployment/docs/02-CONFIGURATION.md +117 -0
  28. package/cadre/templates/projet/deployment/docs/03-API.md +264 -0
  29. package/cadre/templates/projet/deployment/docs/04-TROUBLESHOOTING.md +262 -0
  30. package/cadre/templates/projet/deployment/scripts/install.ps1 +100 -0
  31. package/cadre/templates/projet/deployment/scripts/start.ps1 +61 -0
  32. package/cadre/templates/projet/deployment/scripts/stop.ps1 +37 -0
  33. package/cadre/templates/projet/deployment/scripts/test.ps1 +118 -0
  34. package/cadre/templates/projet/docs/agents/issue-tracker.md +44 -0
  35. package/cadre/templates/projet/ecosystem.config.cjs +73 -0
  36. package/cadre/templates/projet/package.json +90 -0
  37. package/cadre/templates/projet/pitmaster/SUIVI.md +22 -0
  38. package/cadre/templates/projet/src/config.ts +68 -0
  39. package/cadre/templates/projet/src/errors.ts +102 -0
  40. package/cadre/templates/projet/src/index.ts +46 -0
  41. package/cadre/templates/projet/src/services/.gitkeep +8 -0
  42. package/cadre/templates/projet/src/types/index.ts +24 -0
  43. package/cadre/templates/projet/src/utils/logger.ts +106 -0
  44. package/cadre/templates/projet/tests/integration/.gitkeep +7 -0
  45. package/cadre/templates/projet/tests/unit/config.spec.ts +68 -0
  46. package/cadre/templates/projet/tests/unit/errors.spec.ts +132 -0
  47. package/cadre/templates/projet/tests/unit/types.spec.ts +73 -0
  48. package/cadre/templates/projet/tsconfig.eslint.json +9 -0
  49. package/cadre/templates/projet/tsconfig.json +41 -0
  50. package/cadre/templates/projet/tsdoc.json +6 -0
  51. package/cadre/templates/projet/vitest.config.ts +44 -0
  52. package/cadre/templates/turbo/paquets.json +29 -0
  53. package/cadre/templates/turbo/sans-paquets.json +28 -0
  54. package/charte.md +107 -0
  55. package/docs/ARCHITECTURE.md +90 -0
  56. package/hooks/crochet.mjs +33 -0
  57. package/hooks/hooks.json +58 -0
  58. package/hooks/register.tsx +558 -0
  59. package/package.json +75 -4
  60. package/scripts/avancement.mjs +244 -0
  61. package/scripts/bilan.mjs +523 -0
  62. package/scripts/branche-de-nuit.mjs +59 -0
  63. package/scripts/cadre.mjs +109 -0
  64. package/scripts/circuit.mjs +4375 -0
  65. package/scripts/code-mort.mjs +371 -0
  66. package/scripts/composants-affectes.mjs +598 -0
  67. package/scripts/consommateurs-arbitres.mjs +192 -0
  68. package/scripts/consommateurs.mjs +310 -0
  69. package/scripts/crochet.mjs +24 -0
  70. package/scripts/crochets.mjs +135 -0
  71. package/scripts/dernier-vert.mjs +208 -0
  72. package/scripts/dette.mjs +181 -0
  73. package/scripts/droits.mjs +354 -0
  74. package/scripts/enveloppe/codes.mjs +17 -0
  75. package/scripts/enveloppe/composant.mjs +11 -0
  76. package/scripts/enveloppe/enveloppe.mjs +628 -0
  77. package/scripts/enveloppe/lancer.sh +311 -0
  78. package/scripts/etapes.mjs +323 -0
  79. package/scripts/etat-du-circuit.mjs +104 -0
  80. package/scripts/frontieres.mjs +456 -0
  81. package/scripts/gardes-du-cadre.mjs +72 -0
  82. package/scripts/greffons.mjs +338 -0
  83. package/scripts/grillhouse-maj.mjs +514 -0
  84. package/scripts/index-interfaces.mjs +293 -0
  85. package/scripts/init.mjs +117 -0
  86. package/scripts/integration/file-attente.mjs +61 -0
  87. package/scripts/integration/file.mjs +494 -0
  88. package/scripts/integration/integrer.mjs +381 -0
  89. package/scripts/integration/rejeu.mjs +139 -0
  90. package/scripts/integration/verdict.mjs +13 -0
  91. package/scripts/interfaces-contre-code.mjs +547 -0
  92. package/scripts/ligne-etat/defaut.sh +110 -0
  93. package/scripts/ligne-etat/ligne-etat.mjs +93 -0
  94. package/scripts/migrations/0.23.0-charte-importee.mjs +47 -0
  95. package/scripts/migrations/0.23.0-config-vitest.mjs +105 -0
  96. package/scripts/migrations/0.23.0-crochets-de-seance.mjs +55 -0
  97. package/scripts/migrations/0.23.0-marche-du-projet.mjs +82 -0
  98. package/scripts/migrations.mjs +43 -0
  99. package/scripts/nuit.sh +269 -0
  100. package/scripts/paquets.mjs +79 -0
  101. package/scripts/poussee-cles.mjs +46 -0
  102. package/scripts/preparation.mjs +84 -0
  103. package/scripts/references-par-les-types.mjs +420 -0
  104. package/scripts/registre.mjs +336 -0
  105. package/scripts/resultats.mjs +580 -0
  106. package/scripts/resumes.mjs +57 -0
  107. package/scripts/retirer-copie.sh +127 -0
  108. package/scripts/session-start.sh +203 -0
  109. package/scripts/sortie.mjs +155 -0
  110. package/scripts/sous-modules.sh +316 -0
  111. package/scripts/structure-guard.sh +27 -0
  112. package/scripts/superviseur.mjs +102 -0
  113. package/scripts/tableau.mjs +1886 -0
  114. package/scripts/tests.mjs +236 -0
  115. package/scripts/ticket.sh +23 -0
  116. package/scripts/turbo-des-paquets.mjs +899 -0
  117. package/scripts/un-commit-par-ticket.mjs +212 -0
  118. package/scripts/verifier.mjs +469 -0
  119. package/scripts/verrou-de-fichier.mjs +130 -0
  120. package/scripts/verrou-de-machine.mjs +211 -0
  121. package/scripts/verrous/commande.mjs +180 -0
  122. package/scripts/verrous/ticket-du-sous-agent.mjs +39 -0
  123. package/scripts/verrous/verrou.mjs +165 -0
  124. package/scripts/vitest-config/avancement.mjs +39 -0
  125. package/scripts/vitest-config/avancement.reporter.mjs +56 -0
  126. package/scripts/vitest-config/base-de-passe.mjs +128 -0
  127. package/scripts/vitest-config/base.d.ts +6 -0
  128. package/scripts/vitest-config/base.mjs +87 -0
  129. package/scripts/vitest-config/changements.mjs +173 -0
  130. package/scripts/vitest-config/cibles.mjs +207 -0
  131. package/scripts/vitest-config/duree.reporter.mjs +98 -0
  132. package/scripts/vitest-config/machine.setup.mjs +14 -0
  133. package/scripts/vitest-config/mesure.d.ts +6 -0
  134. package/scripts/vitest-config/mesure.mjs +13 -0
  135. package/scripts/vitest-config/mesure.setup.mjs +12 -0
  136. package/scripts/vitest-config/pression.mjs +90 -0
  137. package/scripts/vitest-config/resultats.mjs +23 -0
  138. package/scripts/vitest-config/resultats.setup.mjs +19 -0
  139. package/scripts/vitest.nuit.config.mjs +26 -0
  140. package/skills/developpeur/SKILL.md +175 -0
  141. package/skills/explorateur/SKILL.md +84 -0
  142. package/skills/grill/SKILL.md +238 -0
  143. package/skills/grill/references/releve-structurel.md +38 -0
  144. package/skills/mesure/SKILL.md +168 -0
  145. package/skills/pitmaster/SKILL.md +382 -0
  146. package/skills/pitmaster/references/cadre.md +119 -0
  147. package/skills/pitmaster/references/consigne-agent.md +308 -0
  148. package/skills/pitmaster/references/erreurs.md +25 -0
  149. package/skills/pitmaster/references/tour.md +124 -0
  150. package/skills/questionneur/SKILL.md +64 -0
  151. package/skills/redacteur/SKILL.md +84 -0
  152. package/skills/redacteur/references/documents-d-un-composant.md +105 -0
  153. package/skills/referent-technique/SKILL.md +145 -0
  154. package/skills/regulateur/SKILL.md +220 -0
  155. package/skills/release/SKILL.md +134 -0
  156. package/skills/release/scripts/update-version.cjs +92 -0
  157. package/skills/relecteur/SKILL.md +152 -0
  158. package/skills/relecteur/references/LICENSE-MIT +30 -0
  159. package/skills/relecteur/references/SOURCE.md +8 -0
  160. package/skills/relecteur/references/claims-check.md +14 -0
  161. package/skills/relecteur/references/deletion-check.md +14 -0
  162. package/skills/relecteur/references/edge-case-hunter.md +80 -0
  163. package/skills/relecteur/references/verification-gap.md +113 -0
  164. package/skills/testwriter/SKILL.md +187 -0
  165. package/types/index.d.ts +56 -0
  166. package/README.md +0 -3
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+ // The queue: brings the branches `agent/<ticket>` the integration committed onto main, one at a time,
3
+ // in their order of arrival (scripts/integration/file-attente.mjs). Run from the main tree by the
4
+ // circuit of a ticket (scripts/circuit.mjs), once it has put the ticket's branch in the queue, and
5
+ // by the supervisor by hand.
6
+ //
7
+ // npx grillhouse file
8
+ //
9
+ // No argument (exit 2 and the usage otherwise). It takes the branches waiting one by one until none is
10
+ // left, then exits 0, whatever became of each branch. A queue launched while another runs exits 0 at
11
+ // once, refusing nothing: the running queue takes the branch that arrived (it reads the entries
12
+ // again before it lets go of its lock, `.git/file-integration.lock`).
13
+ //
14
+ // For each branch, against the main of the moment:
15
+ // - main is an ancestor of the branch (main has not moved since its base): main moves forward onto
16
+ // the branch (`merge --ff-only`), nothing replayed;
17
+ // - main has moved: the branch is replaced on main in the queue's own copy
18
+ // (`.claude/worktrees/file`, made ready by scripts/integration/rejeu.mjs), its dependencies made
19
+ // to agree with the lock file and the manifests of the branch once replaced (an install that
20
+ // fails makes the branch leave the queue, « Les dépendances de la copie ne s'installent pas. »,
21
+ // its tests not played), and the same command as the integration's replays it there:
22
+ // `npm run verifier` (Turborepo: the build, then the guards and every test of the day beside
23
+ // each other, each task replayed from the cache of the main tree when its inputs are
24
+ // unchanged), every command of the project run in the copy leaving its index up to date
25
+ // (rejeu.mjs); main moves forward onto the replaced branch when `npm run verifier` is green;
26
+ // - a branch with nothing ahead of main does not enter: it leaves the queue, its ticket loses
27
+ // `en-file` and takes a comment, and stays as it is;
28
+ // - a branch that does not replace (the files in conflict) or whose `npm run verifier` is red once
29
+ // replaced leaves the queue: its ticket loses `en-file`, reopens with a comment that
30
+ // names the files or ends with the output of the tests, and the branch stays where it is, for
31
+ // the relaunch of its ticket (scripts/enveloppe/lancer.sh);
32
+ // - a branch whose replay cannot be done (the copy does not get ready, main does not take the
33
+ // branch, the main tree holds a file of the lot), or whose `npm run verifier` is « à repasser »
34
+ // once replaced (75: a failure of duration under load, which found no rest in its bound,
35
+ // scripts/verifier.mjs; neither green nor red), stays in the queue, the reason written to its
36
+ // ticket, which is neither reopened nor closed, and the queue goes on; the next queue takes it
37
+ // again. Any other code than 0 and 75 is red.
38
+ // At its entry on main, the branch's ticket closes « intégré : <commit> », loses `en-file`, the
39
+ // working directory of its circuit (`.claude/worktrees/<ticket>.travail`, scripts/circuit.mjs),
40
+ // which lives as long as the ticket, is removed, and the branch is deleted; a branch git does not delete (its copy still in place) is written to the
41
+ // ticket, which closes all the same. Then main is pushed to origin, on the branch
42
+ // `grillhouse.poussee.commit` of main's package.json (`main` without the key); a refused push is
43
+ // written to the ticket and does not stop the queue.
44
+ // Once pushed, the queue reads the CI of the commit pushed, as a merge queue waits for its checks.
45
+ // As GitHub's required checks name what a merge waits for, a check that does not exist is not
46
+ // waited for: the queue first reads, in the commit pushed, the workflows that run on a push to the
47
+ // branch it pushes to (`fluxDeLaBranche`, the files `.github/workflows/*.yml` and `*.yaml`, their
48
+ // `on` and its filters as GitHub reads them); with none, the ticket is told in one line that
49
+ // there is no CI for that branch, and nothing is waited for. A workflow the queue cannot judge (a
50
+ // file it cannot read, a filter on the paths) counts as one that runs. With one, it looks for its
51
+ // run (`gh run list --commit <sha> --json databaseId,status,conclusion`) for 2 min at most
52
+ // (GRILLHOUSE_CI_RECHERCHE_MS replaces it, in milliseconds), the time the CI takes to create it,
53
+ // then reads it (`gh run view <id> --json status,conclusion,jobs`) every 10 s
54
+ // (GRILLHOUSE_CI_PAS_MS), as `gh run watch` does, until it completes, for 30 min at most
55
+ // (GRILLHOUSE_CI_ATTENTE_MS): green on the conclusion `success`, red on any other. Under the
56
+ // circuit's progress directory (GRILLHOUSE_AVANCEMENT, scripts/avancement.mjs), each reading writes
57
+ // there the steps of the run done over planned, which the board shows; the queue's own runs of
58
+ // `npm run verifier` play out of it. A red CI reopens the ticket (`bd reopen`), its comment giving the
59
+ // address of the run (`gh run view <id> --json url`) and the end of `gh run view <id>
60
+ // --log-failed`. Without `gh`, without a run found, with `gh` refusing or past the 30 min, the
61
+ // ticket is told that the CI was not read, and why, and stays closed.
62
+ // Every comment and change of the queue at a ticket is written under the author `file`, which bd
63
+ // reads in BEADS_ACTOR, as the circuit writes its own lines under `circuit`.
64
+ import { spawnSync } from 'node:child_process';
65
+ import { existsSync, rmSync } from 'node:fs';
66
+ import { createRequire } from 'node:module';
67
+ import path from 'node:path';
68
+ import { pathToFileURL } from 'node:url';
69
+ import { lireCommit } from '../poussee-cles.mjs';
70
+ import { attendreUnPeu, lacherVerrou, prendreVerrou } from '../verrou-de-fichier.mjs';
71
+ import { dossierCommun, lireEntrees, retirerEntree } from './file-attente.mjs';
72
+ import { ouvrirCopie } from './rejeu.mjs';
73
+ import { A_REPASSER } from '../vitest-config/pression.mjs';
74
+ import { AVANCEMENT, ecrireTache } from '../vitest-config/avancement.mjs';
75
+
76
+ const ESSAIS_PAR_BRANCHE = 5;
77
+ // The variable in which bd reads the author of a comment, and the queue's author.
78
+ const ACTEUR = 'BEADS_ACTOR';
79
+ export const AUTEUR_DE_LA_FILE = 'file';
80
+ // How long the queue looks for the run of the CI, then waits for it.
81
+ const RECHERCHE_CI_MS = 2 * 60 * 1000;
82
+ const ATTENTE_CI_MS = 30 * 60 * 1000;
83
+ // The gap between two readings of the run, as `gh run watch` reads it again.
84
+ const PAS_CI_MS = 10 * 1000;
85
+ // The steps done over planned of the view <vue> of a run (`gh run view --json jobs`), written to
86
+ // the progress directory <avancement> as the record `etapes-de-la-ci`; nothing without one.
87
+ function ecrireLesEtapes(avancement, vue) {
88
+ if (!avancement) return;
89
+ const etapes = (Array.isArray(vue?.jobs) ? vue.jobs : []).flatMap(j =>
90
+ Array.isArray(j?.steps) ? j.steps : []
91
+ );
92
+ try {
93
+ ecrireTache(avancement, 'etapes-de-la-ci', {
94
+ faits: etapes.filter(e => e?.status === 'completed').length,
95
+ prevus: etapes.length,
96
+ });
97
+ } catch {
98
+ // A directory that does not write leaves the queue going, without its progress.
99
+ }
100
+ }
101
+
102
+ const delai = (variable, defaut) => {
103
+ const v = Number(process.env[variable]);
104
+ return Number.isFinite(v) && process.env[variable] !== '' && v >= 0 ? v : defaut;
105
+ };
106
+
107
+ // The directory of the workflows GitHub reads, and the extensions it reads in it.
108
+ const DOSSIER_DES_FLUX = '.github/workflows';
109
+ const EXTENSIONS_DES_FLUX = /\.ya?ml$/;
110
+
111
+ // The regular expression of a filter pattern of GitHub (« Filter pattern cheat sheet »): `*` any
112
+ // characters but `/`, `**` any characters, `?` zero or one and `+` one or more of the preceding
113
+ // character, `[…]` one character of the set, `\` escaping the next.
114
+ function motifDeGitHub(motif) {
115
+ let re = '';
116
+ for (let i = 0; i < motif.length; i++) {
117
+ const c = motif[i];
118
+ if (c === '\\' && i + 1 < motif.length) {
119
+ re += motif[++i].replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
120
+ } else if (c === '*') {
121
+ if (motif[i + 1] === '*') {
122
+ re += '.*';
123
+ i++;
124
+ } else {
125
+ re += '[^/]*';
126
+ }
127
+ } else if (c === '?' || c === '+') {
128
+ re += c;
129
+ } else if (c === '[') {
130
+ const fin = motif.indexOf(']', i + 1);
131
+ if (fin < 0) throw new Error(`motif sans fin : ${motif}`);
132
+ re += motif.slice(i, fin + 1);
133
+ i = fin;
134
+ } else {
135
+ re += c.replace(/[.^${}()|\]\\/]/g, '\\$&');
136
+ }
137
+ }
138
+ return new RegExp(`^${re}$`);
139
+ }
140
+
141
+ // Whether the `on.push` of a workflow <push> runs on a push to <branche>, as GitHub reads its
142
+ // filters: none runs on every branch; `branches` in its order, a `!` pattern taking out what the
143
+ // ones before took in, the last match deciding; `branches-ignore` takes out what it matches;
144
+ // `tags` or `tags-ignore` alone run on no branch. A filter on the paths, or a form the queue does
145
+ // not judge, counts as one that runs.
146
+ function pousseeSurLaBranche(push, branche) {
147
+ if (push === null || push === undefined) return true;
148
+ if (typeof push !== 'object' || Array.isArray(push)) return true;
149
+ if (push.paths || push['paths-ignore']) return true;
150
+ const { branches, 'branches-ignore': ignorees } = push;
151
+ if (branches && ignorees) return true;
152
+ const motifs = l => (Array.isArray(l) ? l : [l]).map(String);
153
+ if (branches) {
154
+ let dedans = false;
155
+ for (const m of motifs(branches)) {
156
+ const negatif = m.startsWith('!');
157
+ if (motifDeGitHub(negatif ? m.slice(1) : m).test(branche)) dedans = !negatif;
158
+ }
159
+ return dedans;
160
+ }
161
+ if (ignorees) {
162
+ const l = motifs(ignorees);
163
+ if (l.some(m => m.startsWith('!'))) return true;
164
+ return !l.some(m => motifDeGitHub(m).test(branche));
165
+ }
166
+ return !(push.tags || push['tags-ignore']);
167
+ }
168
+
169
+ // The workflows of <fichiers> (`{ chemin, texte }`, `texte` null for a file git does not give)
170
+ // that run on a push to <branche>: their paths. A file the queue cannot read or judge counts as
171
+ // one that runs.
172
+ export function fluxDeLaBranche(fichiers, branche) {
173
+ return fichiers
174
+ .filter(({ texte }) => {
175
+ if (texte === null) return true;
176
+ try {
177
+ // Loaded only for a workflow to read: a project without one, or before its `npm install`,
178
+ // runs its queue without the library (which, absent, counts the workflow as one that runs).
179
+ const require = createRequire(import.meta.url);
180
+ const on = require('yaml').parse(texte)?.on;
181
+ if (typeof on === 'string') return on === 'push';
182
+ if (Array.isArray(on)) return on.includes('push');
183
+ if (on && typeof on === 'object')
184
+ return 'push' in on && pousseeSurLaBranche(on.push, branche);
185
+ return false;
186
+ } catch {
187
+ return true;
188
+ }
189
+ })
190
+ .map(({ chemin }) => chemin);
191
+ }
192
+
193
+ function main(argv) {
194
+ if (argv.length) {
195
+ process.stderr.write('usage: file.mjs (sans argument)\n');
196
+ return 2;
197
+ }
198
+ // The progress directory of the circuit's step (scripts/avancement.mjs), where the queue writes
199
+ // the steps of the CI; its own runs of `npm run verifier` play out of it, as measures of their
200
+ // own.
201
+ const avancement = process.env[AVANCEMENT] || null;
202
+ delete process.env[AVANCEMENT];
203
+ // The main tree, from git's common directory: launched from a copy, the queue still works on it.
204
+ const racine = path.dirname(
205
+ spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
206
+ encoding: 'utf8',
207
+ }).stdout.trim()
208
+ );
209
+ const git = (...args) => spawnSync('git', ['-C', racine, ...args], { encoding: 'utf8' });
210
+ // bd runs under the queue's author (BEADS_ACTOR), apart from the supervisor's and the owner's.
211
+ const bd = (...a) => {
212
+ const r = spawnSync('bd', a, {
213
+ cwd: racine,
214
+ encoding: 'utf8',
215
+ maxBuffer: 256 * 1024 * 1024,
216
+ env: { ...process.env, [ACTEUR]: AUTEUR_DE_LA_FILE },
217
+ });
218
+ if (r.status !== 0) process.stderr.write(`bd ${a[0]} ${a[1] ?? ''} : ${r.stderr}`);
219
+ return r;
220
+ };
221
+ const dire = s => process.stdout.write(`${s}\n`);
222
+ const principale = git('symbolic-ref', '--short', '-q', 'HEAD').stdout.trim();
223
+ if (!principale) {
224
+ process.stderr.write("⛔ L'arbre principal n'est pas sur une branche.\n");
225
+ return 1;
226
+ }
227
+ const refPrincipale = `refs/heads/${principale}`;
228
+ const copie = path.join(racine, '.claude', 'worktrees', 'file');
229
+ const verrou = path.join(dossierCommun(racine), 'file-integration.lock');
230
+
231
+ const commenter = (ticket, texte) => bd('comments', 'add', ticket, texte);
232
+ // The branch leaves the queue and stays in place; its ticket reopens.
233
+ const sortir = (entree, titre, detail) => {
234
+ const texte = `## Sortie de la file\n\nLa branche ${entree.branche} ne peut pas entrer sur ${principale} : ${titre}\n${detail}\n\nLa branche reste en place ; la relance du ticket remet son lot en travail local sur ${principale}.`;
235
+ bd('update', entree.ticket, '--status', 'open', '--remove-label', 'en-file');
236
+ bd('comments', 'add', entree.ticket, texte);
237
+ retirerEntree(entree);
238
+ dire(`✗ ${entree.branche} sort de la file : ${titre}`);
239
+ };
240
+ // The branch has nothing ahead of main: it leaves the queue, its ticket stays as it is and loses
241
+ // `en-file`.
242
+ const ecarter = entree => {
243
+ const texte = `## Sortie de la file\n\nLa branche ${entree.branche} n'a rien en avance sur ${principale} : elle n'entre pas, et le ticket reste ouvert.`;
244
+ bd('update', entree.ticket, '--remove-label', 'en-file');
245
+ bd('comments', 'add', entree.ticket, texte);
246
+ retirerEntree(entree);
247
+ dire(`⚠ ${entree.branche} n'a rien en avance sur ${principale} : elle sort de la file`);
248
+ };
249
+ // The branch stays in the queue, the reason written to its ticket.
250
+ const garder = (entree, raison) => {
251
+ commenter(
252
+ entree.ticket,
253
+ `## La file garde la branche\n\nLa branche ${entree.branche} reste en file : ${raison}`
254
+ );
255
+ dire(`⚠ ${entree.branche} reste en file : ${raison}`);
256
+ };
257
+
258
+ // The branch replaced on main, replayed: { nouveau } (the commit to move main onto), or
259
+ // { sortie: [titre, détail] } (it leaves the queue), or { garde } (it stays, the reason).
260
+ const rejouer = (tip, tete) => {
261
+ if (existsSync(copie))
262
+ spawnSync('git', ['-C', copie, 'rebase', '--abort'], { encoding: 'utf8' });
263
+ const ouverte = ouvrirCopie({ racine, copie, ref: tete, dire });
264
+ if (ouverte.refus) return { garde: ouverte.refus };
265
+ const { dansCopie, installer, tester } = ouverte;
266
+ if (dansCopie('checkout', '-q', '--detach', tip).status !== 0)
267
+ return { garde: `la copie ne prend pas ${tip.slice(0, 7)}` };
268
+ const rebase = dansCopie('rebase', tete);
269
+ if (rebase.status !== 0) {
270
+ const conflits = dansCopie('diff', '--name-only', '--diff-filter=U')
271
+ .stdout.split('\n')
272
+ .filter(Boolean);
273
+ dansCopie('rebase', '--abort');
274
+ return {
275
+ sortie: [
276
+ 'elle ne se replace pas sur main.',
277
+ conflits.length
278
+ ? `Fichiers en conflit :\n${conflits.map(f => ` ${f}`).join('\n')}`
279
+ : `Sortie de git :\n${rebase.stdout}${rebase.stderr}`,
280
+ ],
281
+ };
282
+ }
283
+ const installation = installer();
284
+ if (installation) {
285
+ return { sortie: [installation, "Le verrou de la branche replacée ne s'installe pas."] };
286
+ }
287
+ dire('— npm run verifier (construction, gardes, tests)');
288
+ const t = tester();
289
+ if (t.status === A_REPASSER) {
290
+ return {
291
+ garde: `son \`npm run verifier\` est à repasser (code ${A_REPASSER}) une fois replacée : un échec de durée sous charge, sans repos dans la borne. Ni vert ni rouge ; la prochaine file la rejoue.`,
292
+ };
293
+ }
294
+ if (t.status !== 0) {
295
+ const fin = `${t.stdout}${t.stderr}`.trim().split('\n').slice(-40).join('\n');
296
+ return { sortie: ['ses tests sont rouges une fois la branche replacée.', fin] };
297
+ }
298
+ return { nouveau: dansCopie('rev-parse', 'HEAD').stdout.trim() };
299
+ };
300
+
301
+ // Main is at <nouveau>: the ticket closes, the branch is deleted, main is pushed.
302
+ const entrer = (entree, nouveau) => {
303
+ const court = nouveau.slice(0, 7);
304
+ bd('update', entree.ticket, '--remove-label', 'en-file');
305
+ bd('close', entree.ticket, '--reason', `intégré : ${court}`);
306
+ rmSync(path.join(racine, '.claude', 'worktrees', `${entree.ticket}.travail`), {
307
+ recursive: true,
308
+ force: true,
309
+ });
310
+ retirerEntree(entree);
311
+ dire(`✓ ${entree.branche} entre sur ${principale} : ${court}`);
312
+ const efface = git('branch', '-D', entree.branche);
313
+ if (efface.status !== 0) {
314
+ commenter(
315
+ entree.ticket,
316
+ `La branche ${entree.branche} reste en place après son entrée sur ${principale} (${efface.stderr.trim()}) : sa copie est encore en place. Le ticket est fermé.`
317
+ );
318
+ dire(`⚠ ${entree.branche} n'est pas effacée`);
319
+ }
320
+ const paquet = git('show', `${refPrincipale}:package.json`);
321
+ const { commit, faute } = lireCommit(paquet.status === 0 ? JSON.parse(paquet.stdout) : null);
322
+ if (faute) {
323
+ commenter(
324
+ entree.ticket,
325
+ `Poussée non faite : grillhouse.poussee.commit vaut ${faute}, pas un nom de branche.`
326
+ );
327
+ return;
328
+ }
329
+ const pousse = spawnSync(
330
+ 'git',
331
+ ['-C', racine, 'push', 'origin', `${nouveau}:refs/heads/${commit}`],
332
+ { encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }
333
+ );
334
+ if (pousse.status !== 0) {
335
+ commenter(
336
+ entree.ticket,
337
+ `La poussée de ${court} vers origin/${commit} (grillhouse.poussee.commit) est refusée :\n${pousse.stderr.trim()}`
338
+ );
339
+ dire(`⚠ poussée de ${court} refusée`);
340
+ } else {
341
+ dire(`✓ poussée : ${court} est sur origin/${commit}`);
342
+ lireCI(entree, nouveau, commit);
343
+ }
344
+ };
345
+
346
+ // The workflows of the commit <sha> that run on a push to <branche>: their paths.
347
+ const fluxDuCommit = (sha, branche) => {
348
+ const l = git('ls-tree', '--name-only', `${sha}:${DOSSIER_DES_FLUX}`);
349
+ const noms =
350
+ l.status === 0 ? l.stdout.split('\n').filter(n => EXTENSIONS_DES_FLUX.test(n)) : [];
351
+ const fichiers = noms.map(n => {
352
+ const chemin = `${DOSSIER_DES_FLUX}/${n}`;
353
+ const f = git('show', `${sha}:${chemin}`);
354
+ return { chemin, texte: f.status === 0 ? f.stdout : null };
355
+ });
356
+ return fluxDeLaBranche(fichiers, branche);
357
+ };
358
+
359
+ // The CI of the commit <sha> pushed to <branche>: none, the ticket is told so in one line; green,
360
+ // the ticket stays closed; red, it reopens; not read, the ticket is told why.
361
+ const lireCI = (entree, sha, branche) => {
362
+ const court = sha.slice(0, 7);
363
+ if (fluxDuCommit(sha, branche).length === 0) {
364
+ const texte = `Aucune CI pour origin/${branche} : aucun flux de ${DOSSIER_DES_FLUX} de ${court} ne se lance sur la branche ${branche}, la file n'en attend pas.`;
365
+ commenter(entree.ticket, texte);
366
+ dire(`✓ ${texte}`);
367
+ return;
368
+ }
369
+ const gh = (args, options = {}) =>
370
+ spawnSync('gh', args, {
371
+ cwd: racine,
372
+ encoding: 'utf8',
373
+ maxBuffer: 64 * 1024 * 1024,
374
+ ...options,
375
+ });
376
+ const nonLue = raison => {
377
+ commenter(entree.ticket, `## CI non lue\n\nLa CI de ${court} n'a pas été lue : ${raison}`);
378
+ dire(`⚠ CI de ${court} non lue : ${raison}`);
379
+ };
380
+ const recherche = delai('GRILLHOUSE_CI_RECHERCHE_MS', RECHERCHE_CI_MS);
381
+ const debut = Date.now();
382
+ let passage = null;
383
+ for (;;) {
384
+ const r = gh(['run', 'list', '--commit', sha, '--json', 'databaseId,status,conclusion']);
385
+ if (r.error) return nonLue(`gh ne se lance pas (${r.error.code ?? r.error.message}).`);
386
+ if (r.status !== 0) return nonLue(`gh run list refuse :\n${`${r.stderr}${r.stdout}`.trim()}`);
387
+ let passages = [];
388
+ try {
389
+ passages = JSON.parse(r.stdout || '[]');
390
+ } catch {
391
+ return nonLue(`gh run list rend une sortie illisible :\n${r.stdout.trim()}`);
392
+ }
393
+ passage = passages.find(x => x?.databaseId);
394
+ const reste = recherche - (Date.now() - debut);
395
+ if (passage || reste <= 0) break;
396
+ attendreUnPeu(Math.min(10_000, reste));
397
+ }
398
+ if (!passage) {
399
+ return nonLue(`aucun passage de la CI pour ce commit en ${Math.round(recherche / 1000)} s.`);
400
+ }
401
+ const id = String(passage.databaseId);
402
+ const attente = delai('GRILLHOUSE_CI_ATTENTE_MS', ATTENTE_CI_MS);
403
+ const pas = delai('GRILLHOUSE_CI_PAS_MS', PAS_CI_MS);
404
+ const limite = Date.now() + attente;
405
+ let vue = null;
406
+ for (;;) {
407
+ const v = gh(['run', 'view', id, '--json', 'status,conclusion,jobs'], {
408
+ timeout: 60_000,
409
+ killSignal: 'SIGKILL',
410
+ });
411
+ if (v.error) return nonLue(`gh ne se lance pas (${v.error.code ?? v.error.message}).`);
412
+ if (v.status !== 0) return nonLue(`gh run view refuse :\n${`${v.stderr}${v.stdout}`.trim()}`);
413
+ try {
414
+ vue = JSON.parse(v.stdout || '{}');
415
+ } catch {
416
+ return nonLue(`gh run view rend une sortie illisible :\n${v.stdout.trim()}`);
417
+ }
418
+ ecrireLesEtapes(avancement, vue);
419
+ if (vue?.status === 'completed') break;
420
+ const reste = limite - Date.now();
421
+ if (reste <= 0) {
422
+ return nonLue(`le passage ${id} n'a pas fini en ${Math.round(attente / 60_000)} min.`);
423
+ }
424
+ attendreUnPeu(Math.min(pas, reste));
425
+ }
426
+ if (vue.conclusion === 'success') {
427
+ dire(`✓ CI de ${court} verte (passage ${id})`);
428
+ return;
429
+ }
430
+ const url = gh(['run', 'view', id, '--json', 'url', '--jq', '.url']).stdout?.trim() || id;
431
+ const journal = `${gh(['run', 'view', id, '--log-failed']).stdout ?? ''}`
432
+ .trim()
433
+ .split('\n')
434
+ .slice(-60)
435
+ .join('\n');
436
+ bd('reopen', entree.ticket);
437
+ commenter(
438
+ entree.ticket,
439
+ `## CI rouge\n\nLa CI de ${court} sur ${principale} est rouge : ${url}\n\nLa fin de gh run view ${id} --log-failed :\n\n${journal}\n\nLe ticket est rouvert : sa correction entre sous son numéro.`
440
+ );
441
+ dire(`✗ CI de ${court} rouge (passage ${id}) : ${entree.ticket} rouvert`);
442
+ };
443
+
444
+ // One branch of the queue, against the main of the moment.
445
+ const traiter = entree => {
446
+ const tip = git('rev-parse', '--verify', '-q', `refs/heads/${entree.branche}`).stdout.trim();
447
+ if (!tip) return sortir(entree, "la branche n'existe plus.", '');
448
+ if (Number(git('rev-list', '--count', `${refPrincipale}..${tip}`).stdout.trim()) === 0)
449
+ return ecarter(entree);
450
+ for (let essai = 0; essai < ESSAIS_PAR_BRANCHE; essai++) {
451
+ const tete = git('rev-parse', refPrincipale).stdout.trim();
452
+ let nouveau = tip;
453
+ if (git('merge-base', '--is-ancestor', tete, tip).status !== 0) {
454
+ const r = rejouer(tip, tete);
455
+ if (r.sortie) return sortir(entree, ...r.sortie);
456
+ if (r.garde) return garder(entree, r.garde);
457
+ nouveau = r.nouveau;
458
+ }
459
+ if (nouveau !== tete) {
460
+ const avance = git('merge', '-q', '--ff-only', nouveau);
461
+ if (avance.status !== 0) {
462
+ if (git('rev-parse', refPrincipale).stdout.trim() !== tete) continue;
463
+ return garder(
464
+ entree,
465
+ `${principale} n'avance pas sur ${nouveau.slice(0, 7)} :\n${avance.stdout}${avance.stderr}`.trim()
466
+ );
467
+ }
468
+ }
469
+ return entrer(entree, nouveau);
470
+ }
471
+ return garder(entree, `${principale} bouge sans cesse : cinq essais n'ont pas suffi.`);
472
+ };
473
+
474
+ const vues = new Set();
475
+ for (;;) {
476
+ if (!prendreVerrou(verrou, { attendre: false })) return 0;
477
+ try {
478
+ for (;;) {
479
+ const entree = lireEntrees(racine).find(e => !vues.has(e.fichier));
480
+ if (!entree) break;
481
+ vues.add(entree.fichier);
482
+ traiter(entree);
483
+ }
484
+ } finally {
485
+ lacherVerrou(verrou);
486
+ }
487
+ // An entry written while the lock was let go has no queue running to take it.
488
+ if (!lireEntrees(racine).some(e => !vues.has(e.fichier))) return 0;
489
+ }
490
+ }
491
+
492
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
493
+ process.exitCode = main(process.argv.slice(2));
494
+ }