MMOframework
# Release checklists
What to look at before a version is committed or shipped, one box per item -- or one per item and per member when
a team tests it together -- saved as it is checked. A checklist may follow a `` board: its items then
name the tickets they verify, a ticket all of whose items are checked is labelled verified on the board, and can be
moved to done from the checklist, which comments in its discussion what was checked.
| | |
|---|---|
| `MMO\admin\helpers\Checklist` | a checklist: its items, its checks, its progress, its tickets' progress |
| `MMO\admin\helpers\ChecklistBuilder` | builds the items from a recipe: `git status` of repositories, the board's tickets, items written by hand |
| `MMO\admin\helpers\KanbanBoard` | a board's file: tickets, discussions, labels, moves, and its lock |
| `MMO\admin\helpers\BoardFileRepository` | a board's file as mmo.client's kanban entities (mmo.git's `BoardSync` with GitHub) |
| `MMO\admin\controllers\ChecklistController` | the checklists' JSON API, and the items' actions: the diffs, the commands (▶), the decisions |
| `MMO\admin\controllers\ChecklistDiffController` | mmo.git's `DiffController` on a recipe's repositories, untracked files included (never routed by itself) |
| `MMO\admin\controllers\BoardController` | the boards' JSON API: a board, its discussions, its tickets' AI prompts, a new board with its checklist, the board each user works on |
| `MMO\admin\helpers\BoardSelection` | the board each user picked (`boards/.current.json`, this machine's: git-ignore it) |
| `{MMO\admin}/templates/checklists.phtml` | the page of the checklists: one (`?name=`), the default one, or every one (`?list=1`), a selector between them |
| `{MMO\admin}/templates/board.phtml` | the page of a board (`?board=`, `?ticket=12` opens a ticket) |
| `{MMO\admin}/pages/checklists`, `pages/boards` | the two templates as pages, in English |
| `{MMO\admin}/pages/developer/diff.phtml` | Developer > Diff: an item's changes in mmo.git's `` (`?checklist=&repository=&path=&from=&to=`) |
| `MMO\admin\helpers\TestAccounts` | the recipe's test accounts (`accounts`): the profiles, the conditions they recognize, where they may be made and opened |
| `MMO\admin\helpers\TestData`, `TestDataStore` | the recipe's test records (`testData`): what each test account must see and must not, made through the application's entities |
The `mmo-change-tracking` agent skill puts a change on a board, in « Backlog ». The `mmo-release-bugs` agent skill (`.claude/skills/`, also a prompt of the MCP server) is the whole method around
it: a bug report becomes tickets with a fix prompt each, agents fix them and report in the tickets, the checklist
proves them.
## Adding them to an application
```jsonc
// configs/config.json
"MMO\\core\\Application": {
"routes": {
"MMO\\admin\\controllers\\ChecklistController": {},
"MMO\\admin\\controllers\\BoardController": {},
"MMO\\client\\controllers\\PageController": {} // after them: its `**` route catches everything
}
},
"MMO\\admin\\controllers\\ChecklistController": {
"folder": "{root}/checklists", // one JSON file per checklist
"boards": "{root}/boards", // the boards a checklist may follow: {boards}/{board}.json; null for none
"scope": "admin", // mmo.oauth: who may read and check; null on a local tool
"anonymous": false, // true: the routes answer whoever calls, logged in or not
"checklists": { // the checklists the application declares (see below)
"general": {"title": "Release checklist", "recipe": {"itemsFile": "{root}/src/checklists/general.json"}}
},
"default": "general", // the one the page opens when it is given none
"verifiedLabel": {"id": "verified", "label": "verified", "color": "green", "icon": "check"},
"doneComment": "**Release checklist**: verified, moved to **done**.",
"autoDone": true, // a ticket of an active status every checklist of the board verifies moves to done by itself; false: by hand
"supervisors": null, // ["marc"]: only their ticks move a ticket (their member box, else the tick's author); null: anyone's
"autoDoneComment": "**Checklists**: every item checked, moved to **done** automatically.",
"diffScope": "dev.checklists", // who reads the recipe's diffs -- from this machine only
"openAsScope": "sudo.switchAccount", // with dev.checklists: who opens a check as its test account -- this machine only
"testAccountsCommand": "php admin.php developer test-accounts --checklist={name}", // what a missing test account's item says to run
"testDataCommand": "php admin.php developer test-data --checklist={name}", // what makes the test records (the expectations' tooltip)
"runScope": "dev.checklists#run", // who runs an item's command (▶) -- from this machine only
"commands": {"mmo": {"pattern": "#^php tools/mmo\\.php (verify|i18n)( \\S+)*$#"}, ...}, // what ▶ runs (below)
"readOnly": ["cpam-orphans"], "readOnlyForbids": ["--fix"], // the commands of admin.php that only read (below)
"runTimeout": 600, "php": "php", // how long a command may run (s); the php it runs with
"decisionComment": "**Decision of {by} ({date})**: {option}\n\n> {question}", // what a decision posts on its ticket
"decisionDate": "d/m/Y"
},
"MMO\\admin\\controllers\\BoardController": {
"folder": "{root}/boards", // one JSON file per board; null: no boards (every route 404)
"scope": "admin",
"anonymous": false,
"default": null // the board proposed to whoever picked none; null: the developer layer's, else the first
},
"MMO\\client\\Theme": {"pages": ["{root}/src/pages", "{MMO\\admin}/pages"]}
```
## Several boards, each with its checklist
A project has several boards, one per feature or deployment objective, and each board its checklist: the board names
it (`"checklist"` at the top of its file, beside `title` and `description`, changed by `PUT api/boards/{board}.json`), the
checklist follows it (its `board`). Other checklists may follow the same board (a release's).
- **A new board** is made with its checklist: `POST api/boards/{board}/create.json {title, description, checklist, from,
refPrefix}` -- the columns Backlog, À faire, En cours, Recette, Terminé (or `from`'s), `from`'s labels, categories and
people, and a checklist (`checklist`, the board's name by default) following it with the recipe of `from`'s. The
board page's « New board » does it, copying the board shown.
- **The board each user works on**: `GET|PUT api/current-board.json {board}` (@see `BoardSelection`). Picking a board in
the board page's selector makes it the user's: the page then opens on it, the checklists page on its checklist, and
the developer layer files its tickets there (`DeveloperController::getBoard()`, a development machine only). The
agents' skills (`mmo-change-tracking`, `mmo-release-bugs`) propose it first when they ask which board to use.
- `GET api/boards.json` says, for each board, its `title`, `description`, `checklist` and how many tickets each column
holds (`columns`).
## A ticket's life
Backlog → À faire → En cours → Recette → Terminé: each column plays a role (`KanbanBoard::roles()`: `intake`, `ready`,
`doing`, `verify`, `done`), found from the statuses' types or declared by the board (`roles` at the top of its file).
- `POST api/boards/{board}/{ticket}/move.json {status, comment}` moves a ticket only when it is ready for the column --
À faire wants its prompt (a ```` ```claude ```` block), Recette a ```` ```check ```` block and items of the board's
checklist naming it, Terminé every one of them checked -- else 409 with the `reasons`. The agents move tickets so;
the board's page still drops them anywhere.
- `PUT|GET api/boards/{board}/plan.json`: the plan of the À faire tickets (groups, their tickets in order, what is left
out and why), kept as the board's `plan`, shown folded above it; each ticket planned gets a comment with its rank.
- `ChecklistController::$autoDone` is on by default: a ticket whose items are all checked (by `supervisors`, when set)
goes to Terminé by itself.
Rather than mmo.admin's pages (in English, a menu group of their own), a page of the application imports the
templates, in its words (every key optional; `texts` lists each template's words at its top):
```php
= $this->import(\MMO\filename('{MMO\admin}/templates/checklists.phtml'), ['locale' => 'fr-FR', 'texts' => ['title' => 'Checklists', ...]], false) ?>
= $this->import(\MMO\filename('{MMO\admin}/templates/board.phtml'), ['board' => 'acme', 'checklists' => \MMO\url('admin/checklists')], false) ?>
```
The web server writes the files: the folders must be writable by it. Without `scope`, whoever reaches the routes
writes the checklists and the boards: keep that to localhost, as `anonymous` (the agents' `curl` on a development
machine, `configs/dev.json`). my.oviepro has both: its general checklist in production (`{db}/checklists`, `sudo`),
the board and the review checklists in dev only.
### Declared checklists
A checklist of `checklists` is made the first time it is asked for, and kept up to its declaration (`title`,
`description`, `members`, `types`, `groups`, `board`, `boardUrl`, `recipe`). Its items are built from its recipe when it
is made, and again whenever what they come from changes -- the recipe, or its `itemsFile` (a new version of the
application asking other checks) -- for a recipe of `items` and `itemsFile` alone; a recipe reading repositories or a
board is built by `build.json` only. What was checked stays checked while its item is there. Such a checklist is the
general one of an application: what is tested before each version, round after round (`reset.json`).
Its items file says, with the release that brings it, what to check again on the server:
- `"round": "4.2.9"` at its top: the release the checklist is checked for. Once deployed, the first look at the
checklist starts that round -- every box unchecked, the last round in the history -- unless it is at it already
(it is not started twice). Change it when a release needs the whole checklist checked again.
- `"recheck": "4.2.9"` on a check, or on a page or a section for all of theirs (a check's own wins): a release that
touched a page asks its checks again. A value the item did not have unchecks it, every member's box, once; the other
items stay checked. The page marks it « to check again (4.2.9) » until it is done again. Taking the marker away
unchecks nothing: leave it, or remove it, after the round.
## The file
`{folder}/{name}.json`:
```jsonc
{
"version": 4, "updatedAt": "2026-10-05T14:17:00+02:00",
"title": "my.oviepro 4.1: review before commit", "description": "...",
"board": "my.oviepro-deploy", // optional: the board of the tickets
"boardUrl": "boards/oviepro-deploy", // optional: its page, where `?ticket=12` opens a ticket
"members": [], // optional: one box per member
"recipe": {...}, // optional: how `build.json` makes the items
"refPrefix": "R421-", // optional: what the items' refs start with (`C` by default)
"lastRef": 224, // the last ref number given: never given again
"types": {"pdf": {"label": "PDF", "icon": "file-pdf"}}, // optional: what each `kind` is called, and its icon
"groups": {"General": {"icon": "globe"}}, // optional: a group's icon
"items": [{"id": "3d332c914651", "ref": "R421-12", "group": "mmo.core", "section": "src/entities", "kind": "code",
"title": "src/entities/Entity.php (modified)", "detail": "Review the diff: `git -C ... diff -- ...`",
"url": null, "note": "", "tickets": ["80"], "recheck": ""}], // recheck: what it is to be checked again for
"checks": {"3d332c914651": {"*": {"at": "2026-10-05T13:34:55+02:00", "by": "marc"}}},
"release": {"name": "4.1", "startedAt": "...", "startedBy": "marc"}, // the round being checked
"history": [{"name": "4.0", "startedAt": "...", "startedBy": "marc", "closedAt": "...", "closedBy": "marc", "done": 700, "total": 760}]
}
```
- An item's `id` is `sha1(group|section|title)`, 12 characters: rebuilt items keep their checks, a reworded item
starts again unchecked.
- An item's `ref` (`R421-12`) is what people call it by: `refPrefix` and a number, unique in the checklist, shown
as a badge before its title. Give each checklist its own prefix (`G` the general one, `F` the front one, `R421-`
release 4.2.1's) and a ref names one item of them all. The ref stays the item's while it is there -- rebuilt items
keep theirs, a new one gets the number after `lastRef`, a removed item's is never given again; a reworded item is a
new item, with a new ref. Items an items file brings with their `ref` keep it (a release carries its refs to the
server). The filter finds an item by its ref, `#R421-12` in the page's url scrolls to it, clicking the badge copies
it, the routes take it in place of the id, and the comment of a ticket moved to done lists its items' refs.
Changing `refPrefix` (`PUT`, or a declaration's) renames the refs, their numbers kept.
- `group` is what the page folds (a repository, the pages, "before committing"), `section` a heading in it (a
folder, a page), `kind` what it is (`code`, `page`, `global`, a test's type), `url` where to look, `detail` how
(`` `code` `` shows as code, nothing else is markup).
- `checks[item][member]`, `*` without members. An item is done once every box is checked.
- `note` is a word on the item's section (a page's), shown by its heading; a section whose items share an `url` links it.
- The first check starts the round (`release`); `reset.json` closes it -- every box unchecked, a line of `history`
saying how far it got -- and names the next. An items file asking for a new `round` does the same when it is built.
- A rebuild bringing an item a `recheck` it did not have unchecks that item alone.
- `action` is how the item is checked in one click (below); `runs[item]` its last ▶ (`{at, by, exitCode, passed,
duration}`, never its output), `decisions[item]` the option its buttons posted (`{at, by, option}`).
## One click per item: `action`
Each item may say how it is checked, and the page gives it a button. `Checklist::NormalizeAction` checks it: an
action that is not valid is dropped (its item stays), a `PUT` bringing one is refused (400).
| `type` | keys | the page |
|---|---|---|
| `diff` | `repository` (a label of the recipe's `repositories`), `path?`, `from?` (`HEAD`), `to?` (none: the working tree) | « See the diff »: Developer > Diff, in `` |
| `page` | `url`, `as?` (a profile of the recipe's `accounts`, else the check's words), `steps: [...]` | « Open » (a new tab), the steps numbered; with a profile, « Open as test.… » first (*The test accounts*) |
| `run` | `command`, `expect?` (what a person reads), `match?` / `notMatch?` (what the machine finds, or must not: a text, or a `/regular expression/`), `cwd?` (a repository's label) | ▶: the server runs it; its output in the item, folded; checked when it passes (below) |
| `decision` | `ticket`, `question`, `options: [...]` (two or more), `asked?` (the date it was asked) | a button per option; « Decided on … » once decided |
| `manual` | `command?`, `steps?` (one of them), `as?` (a request sent as a profile) | « Copy » the command; the steps: what only a person can do (a server, a console); « Open as » the site's home |
```jsonc
{"group": "before committing", "section": "checks", "title": "`php tools/mmo.php verify --lib=admin` passes",
"action": {"type": "run", "command": "php tools/mmo.php verify --lib=admin", "match": "/^ok\\.$/m", "cwd": "hub (MMO/master)"}}
```
**What the builder gives** (`build.json`, a declared checklist's items file):
- a changed file (`git status`) → `diff` from `HEAD` to the working tree: what is about to be committed;
- a page's check (```` ```check ```` `page | url | what to check`) → `page`: its url, as whom when the text says it
(`connecté en compte limité à un groupe`, `as an account limited to a group`: the recipe's `account`, a regular
expression, replaces the default), its steps -- the text, `;` between them; a page of an items file → `page`;
- a page's check whose second cell is no address to open as it is → still an action (`ChecklistBuilder::screenAction`):
- a command (`php admin.php x --dry-run`, `(ligne de commande) php ...`, `cd folder && ...`, `~/folder (command)`; a
quote or a pipeline the block's `|` cut is put back) → a `run` when the server runs it, its group runs commands
(below) and nothing is said besides it but `(ligne de commande)` (the builder's `localNote`; `(sur l'instance, après
une sauvegarde)`, `dev.oviepro.com`... keep it copied), else a `manual` to copy (`cd folder && command`), its
parentheses and `puis ...` as steps;
- a delivery (`Livraison stg | php admin.php ...`, the recipe's `delivery`, a regular expression) → `manual`: a server
is a person's;
- a request (`POST x.pdf`, `(outils réseau) POST ...`, an `.json` / `.pdf` address with a placeholder) → `manual`;
- an address with a placeholder (`declaration/AccidentTravail/documents?id=`, `{id}`, `…`) → `page`: the same
address with a record the ticket names (`?id=6979c45f...` in its discussion or description), else the recipe's
`screens` for its page or its path (the closest parent: a list to pick the record from), its first step « Open
un AT from this list (...) » (`texts.openRecord`, `texts.aRecord`); else `manual`;
- `{uid}` in an address is the connected user (`{uid de l'utilisateur connecté}` becomes it): the page puts in
`MMO.user.uid` when it opens it;
- a screen named without its address (`(menu du compte) Profil`) → `page`: the address another check of the ticket
gives it, else `screens[name]`; else `manual`, the cells as steps;
- the other lines of a ```` ```check ```` block, by their first cell:
````markdown
```check
diff | mmo.admin:src/helpers | the guard comes first, before any write
run | php tools/mmo.php verify --lib=admin | ok. | hub (MMO/master)
decision | Keep the old export? | keep it / drop it
manual | `php admin.php decode-html` | on each instance, after the release
```
````
`diff | repository:path | what to read` reads the changes since the last release: its most recent `v*` tag the
branch holds, or its last `version X.Y.Z` commit of `mmo.json` when it is later (mmo.admin's release tags nothing; an
old `v16.0.0` of a library its releases no longer tag gives way), to the branch.
`run | command | expected | repository` is a `run` when the command is on the list and its group runs commands, else
a `manual` to copy; a cell `match=` or `notMatch=...` (4th, 5th...) is what the machine finds,
`expected` stays the sentence to read -- in a page's line too (`page | php admin.php x --dry-run | what to see |
match=/0 error/`).
`decision | question | option 1 / option 2` is asked on the ticket (`asked`: the date of the comment holding it).
`manual | command or step | what to do`. They make a group of their own (`texts.actions`), a section per type
(`texts.diffSection`, `runSection`, `decisionSection`, `manualSection`), a kind per type (named and iconed);
- an item of the recipe keeps its `action`; without one, its first command in `code` (title, then detail; `cd folder
&& command` runs it in that repository) is a `run` when it is on the list and its group runs commands, else a
`manual`; a `diff` without revisions reads the working tree in the `beforeCommit` group (`texts.beforeCommit`,
« before committing »), the last release's changes elsewhere.
- **where a command gets a ▶**: in every group but the recipe's `noRunGroups` -- a release's groups, whose commands are
a server's (`"noRunGroups": ["release dev.oviepro.com"]`): copied there --, or only in its `runGroups` when it gives
them (`ChecklistBuilder::runsIn`). Without either, every group runs (before 22/10/2026: only `beforeCommit`).
- **the same check twice is one item** (`ChecklistBuilder::merge`, since 23/10/2026; `"merge": false` in the recipe
keeps them apart): items with the same action -- a diff of the same repository and path, the same command
(where it runs, what it must print), the same page with the same steps and account, the same decision -- become the
first of them, with the tickets of all. Its title gets `texts.merged` (`{title} (+ {count} more)`): a new title, so a
new id and ref, unticked -- a box ticked for one of them was not ticked for all. Its detail says what each other one
was for (`texts.mergedItem`). A merged diff reads commits: `from` the oldest revision of its items (the last release
for `HEAD`), `to` the branch -- the working tree only while its path has changes not committed --: `` shows
who changed each line, and the detail lists the commits touching its path (`texts.commits`, the newest 12, then
`texts.moreCommits`).
- a page's check -- or a request sent by hand -- gets the profile of `recipe.accounts` its line names (`as=`, a
last cell: `page | url | what to check | as=limited`), else the one whose `match` is found first in its text (the
check, then the page's name; an item written by hand: its title and detail); a command to copy is no one's. Without
profiles, the words `account` finds stay. `build.json` lists in `build.unmatchedAccounts` (`{ref, id, title, text,
as}`) the checks speaking of an account (`TestAccounts::MENTION`: `en compte …`, `avec un compte …`, `en
administrateur`, `as an editor`...) or naming one (`as=`) that no profile knows: complete the profiles until it is empty.
### The test accounts
`recipe.accounts` says as whom a check is done, and which account of this machine's database fills the condition:
```jsonc
"accounts": {
"limited": {"username": "test.groupe.nord", "label": "Account limited to a group (North)",
"match": "/\\bcompte limité\\b|limited to a group/iu", "roles": ["dev-user"], "groups": ["North"],
"description": "sees the establishments of the North group only"},
"nonAdmin": {"username": "test.user", "match": "/compte (?:non administrateur|sans admin)/iu", "roles": ["dev-user"]},
"self": {"self": true, "label": "Your account (god)", "match": "/connecté en god/iu"} // the account logged in: nothing to switch to
}
```
- A profile's `username` starts with `test.` (`TestAccounts::USERNAME`): never an operator's (`god`) nor a real
person's -- any other is left out. Several profiles may name one account (with the same roles and groups).
- **They exist**: `php admin.php developer test-accounts [--checklist=] [--dry-run]` makes the accounts of the
profiles the database lacks, with their roles and groups (`groups` by label, joined through the account's
`setGroupes()`; `DeveloperShellController::$groupsCollection`), a password nobody knows (never shown): they open
through « Switch account » only. Only on a development instance (`MMO_MODE` `dev`), in a database of this machine,
the one `configs/config.json` declares -- never a copy of a client's that `local.json` points to, never `--database=`.
An account that exists is left as it is (its differences from its profile reported), unless the command made it
(`{db}/developer/test-accounts.json`, by database): its roles and groups are then brought back to its profile.
- **The button**: a page's check with a profile shows « Open as test.… » (its label beside): `GET
developer/open-as?checklist=&item=` (or `&account=&url=`) opens the page in that account's session as mmo.oauth's
« Switch account » does -- a token whose actor is the account logged in: journaled, the banner « Logged in as … (by
…) », `login/restore` back --, without the password, so only when:
1. the request comes from this machine (`LocalRefusal`, as ▶);
2. the account really logged in (the actor of a switched session) holds `dev.checklists` (the developer layer) and
`openAsScope` (`sudo.switchAccount`);
3. the profile is one of the checklist's, a `test.` account this database has, active, not the one logged in;
4. the address is one of the site (`TestAccounts::SiteRelative`: the recipe's `site` or the application's url, made
relative; another host, a scheme, `//`, `..`, a backslash are refused): never a redirection elsewhere. `{uid}` is
the test account's.
A profile whose account is missing shows « No test account … » and the command to copy (`accountsCommand`).
### The test records
An account limited to a group has nothing to check when its group has no record: `recipe.testData` gives each profile
records in its perimeter and records outside it, and says what each check opened as a test account must show
(`TestData`):
```jsonc
"testData": {
"prefix": "TEST CLAUDE", // every record's label starts with it (the default): they are found, and archived, by it
"by": "admin", // the profile whose account writes them: its rights, the application's rules
"records": {
"employeeNorth": {"collection": "salaries", "label": "TEST CLAUDE Salarié Nord", "fields": {"nom": "TEST CLAUDE", "prenom": "Salarié Nord"}},
"contractNorth": {"collection": "contrats", "label": "TEST CLAUDE Contrat Nord", "fields": {"salarie": "@employeeNorth",
"entreprise": {"$find": "groupes", "where": {"label": "North"}, "path": "entreprises/0"}, "debut": {"$date": "2024-01-02"}}},
"atNorth": {"collection": "accidentTravails", "label": "TEST CLAUDE AT Nord", "status": "draft",
"fields": {"contrat": "@contractNorth", "date": {"$date": "-10 days"}}},
"mpNorth": {"collection": "maladiePros", "label": "TEST CLAUDE MP Nord", "status": "draft", "fields": {"contrat": "@contractNorth"},
"calls": [{"method": "addCommentaire", "args": ["$by", "TEST CLAUDE comment"]}]},
"certificateNorth": {"collection": "files", "label": "TEST CLAUDE certificat Nord.pdf", "file": {}, "labels": ["\"#certificat\""],
"fields": {"attachment": "@atNorth"}}
},
"expect": [
{"as": "limited", "match": "/sousTraitants/", "sees": ["astNorth"], "hides": ["astSouthEast"]},
{"as": "otherScope", "sees": ["atSouthEast"], "hides": ["atNorth", "@certificateNorth (its link: 403)"]},
{"items": ["R501-316"], "sees": ["@astDraftNorth (complete: becomes active)"]}
]
}
```
- a field's value: `"@key"` a record of the recipe (made before it), `"@key/path"` a value of it, `{"$find": collection,
"where": {...}, "sort": {...}, "path": "a/0"}` a record the database has -- read, never written --, `{"$date": "-10
days"}` a date; `"$by"` the account writing; `"@@x"` the text `@x`. A record naming one that is not, or itself, is
left out;
- `status` the status it is made with (`draft`: a declaration that is not complete is not submitted); `calls` the
entity's `add…()` methods called before it is saved (nothing else); `file` (`{name?, content?}`, a one-page PDF
saying its label by default) makes a file as mmo.edms' `FileManager::uploadFile()` does, `labels` its tokens, its
bytes under `test-data/` in the storage (`TestDataStore::$filesKey`: the month folders are the web server's), readable
by it -- the storage's `files` path must be the web's from the command line too (my.oviepro: `{root}/www/files`);
- a record's label, a file's name, starts with `prefix`, else it is left out (the command says why);
- `expect`: the first rule that fits an item -- its profile (`as`), its ref (`items`), its text (`match`: its address,
title, detail, steps) -- gives the line « must see: …; must not see: … » beside its « Open as » button; a record's key,
or `@key` in words, shows as its label. A rule with neither `as` nor `items` fits only the items done as a profile.
**They exist**: `php admin.php developer test-data [--checklist=] [--dry-run] [--archive]` (after `test-accounts`:
`by` is one of them) makes the records the database lacks, in the order they name each other, each committed alone as
the account `by` names (\MMO\oauth\user() while it is saved): the application's rules apply as to that account's
request -- a contract without its employee is refused (my.oviepro #329), a record outside the perimeter of a limited
writer too --, never a raw insert. It stops at the first record refused. The same guards as `test-accounts`: a
development instance, this machine's database, the one `configs/config.json` declares (every collection of the records,
and the accounts'), never `--database=`. `{db}/developer/test-data.json` (not versioned) keeps what it made, by
database and key:
- a record it made that is still there is not made again: a second run makes nothing (« exists »); one that is gone (a
database restored) is made again;
- a record labelled so that it did not make is left as it is, and not made (« skipped »): it never writes in a
record it did not make, and `$find` only reads;
- `--archive` archives what it made, the last made first (`archived` in the file); a run after it makes them again
(an archived record of that label is no longer in the way).
Several checklists may give one key: the same record (else the later one is left out). my.oviepro's front checklist
copies the release's `testData` with its `accounts` (`boards/my.oviepro-deploy.front-recette.py`).
### The commands ▶ runs
`POST api/checklists/{name}/items/{item}/run.json` runs **the command the item declares** -- never one the request
sends -- when:
1. the request comes from this machine: `REMOTE_ADDR` `127.0.0.1` or `::1`, no proxy header (`X-Forwarded-For`,
`Forwarded`, `X-Real-IP`...), a local host name (`localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, `*.test`). A
server never runs one, even behind its own proxy;
2. the asker holds `runScope` (`dev.checklists#run`, mmo.oauth's, or the developer mode's: every scope on an `open`
development machine; the role `developer` has it, `tester` not);
3. the command is on `commands`, a word of it holds only letters, digits and `.,/:=@~+%_-` (no quote, `;`, `|`, `$`,
`*`, backquote: nothing a shell reads) -- but `--eval 'var DRY_RUN = true'`, exactly, for `mongosh` --, it holds what
its rule `requires` and nothing its rule `forbids`:
| name | pattern | requires |
|---|---|---|
| `mmo` | `php tools/mmo.php verify …`, `php tools/mmo.php i18n …` | |
| `phpunit` | `phpunit …`, `vendor/bin/phpunit …` | |
| `npm` | `npm test …` | |
| `node` | `node --test …` | |
| `admin` | `php admin.php …` | `--dry-run`: a reprise writes in the database, only its dry run runs |
| `readOnly` | `php admin.php …` | never `--fix` (`readOnlyForbids`); the command is one `admin.php` declares |
| `plan` | `php scripts/admin.php plan …` | |
| `check-release` | `php scripts/check-release.php …` | `timeout` 900: it runs the verifies and asks every page (15 min) |
| `mongosh` | `mongosh [--quiet] --eval 'var DRY_RUN = true' scripts/patch/.js` | the database a name (this machine's server: never a host, an uri, an option), the file one of the application's `scripts/patch/`; no other `--eval` |
- **`cd && `**: `mmo`, `phpunit`, `npm` and `node` (their `cd` key) also run in a repository of
the checklist's recipe (`recipe.repositories`: a library of the hub, the hub, the application): `cd
~/dev/src/MMO/master/libs/core && phpunit --filter x`, or one of its `runFolders`: folders a ▶ may `cd` to without
their changes being listed (no diff item) -- `"runFolders": ["~/dev/src/MMO/master", "~/dev/src/MMO/master/libs/core",
{"label": "hub", "path": "~/dev/src/MMO/master"}]`; my.oviepro's front checklist gets the release's repositories as
its `runFolders` (`boards/my.oviepro-deploy.front-recette.py`). A folder that is neither (`/tmp`, `libs/`), or holds
`..`, is refused.
- **`timeout`**: a rule's own limit, in seconds (`check-release`: 900); the others `runTimeout` (600). An item never
sets its own.
- **`readOnly`**: the commands of the application's `admin.php` that only read, which the application declares
(`"readOnly": ["cpam-orphans", "unattached-declarations", "incomplete-declarations", "invalid-nir"]` in my.oviepro's
`configs/dev.json`): they run without `--dry-run`, never with a word of `readOnlyForbids` (`--fix`: `invalid-nir
--fix` is a reprise; `invalid-nir --fix --dry-run` runs as any dry run). A name `adminScript` (`{root}/admin.php`)
does not declare (`#[Command([['const' => 'name']], ...)]`, `#[InteractiveCommand('name', ...)]`) is refused.
- **`mongosh`**: a patch's dry run, as the patch reads it (`DRY_RUN === true`: lists, writes nothing), on a database of
this machine's server named by itself -- `configs/config.json`'s, or a copy of a customer's beside it --; the run
passes `var DRY_RUN = true` as one argument, no shell.
- **Passed**: exit code 0, `match` found (without a `match`, `expect`, as before) and `notMatch` absent
(`ChecklistController::Verdict`, the output's colours left aside): the item is checked. Exit 0 without the `match`:
« read the output against what is expected », unchecked; `notMatch` found (`unwanted`) or another code: failed.
- **The builder derives `match`** for a `run` that has none (`ChecklistBuilder::DeriveMatch`, the `cd folder &&`
left aside):
| command | `match` | `notMatch` |
|---|---|---|
| `php tools/mmo.php verify …`, `… i18n …` | `/^ok\.$/m` (its last line) | `/^failed:/m` |
| `phpunit …` | `/^OK \(/m` | `/FAILURES!\|ERRORS!/` |
| `npm test` | `/\b0 failed\b/` (mmo.client's runner: « 505 passed, 0 failed ») | |
| `node --test …` | `/\bfail 0\b/` | |
| `php admin.php …` (a dry run, a listing that reads) | `/END\(200\)/` (the framework's last line) | `/!! \|Uncaught\|Fatal error/` (`shell\error`, `!! END(500) !!`) |
| `mongosh … DRY_RUN …` | `/dry run/` (« (dry run: nothing written) ») | `/MongoServerError\|MongoNetworkError\|Uncaught/` |
| `php scripts/check-release.php …` | `/point\(s\) prouvé\(s\)/u` (its summary) | as `admin.php` |
| `php scripts/admin.php plan …` | `/versions/` | as `admin.php` |
Another command keeps its `expect` as what is found.
`commands` is a map: a configuration adds a rule by name, or takes one away with `null`. The command runs in its
`cwd` repository, else in the first folder -- the application's, then the recipe's repositories -- holding the
script it names (`php tools/mmo.php`: the hub), as the web server's user, `runTimeout` seconds at most (10 minutes),
its output (`2>&1`, its last 200 kB) answered and shown in the item. What the web server's user may not read stops the
command with its cause: `php scripts/admin.php plan` on a `.mmo/deploy.history.json` kept `600` says `the deployment
history [...] is not readable by [www-data]` and the command its owner gives it the right with (`setfacl -m
u:www-data:r .../.mmo/deploy.history.json`). Exit code 0, and `expect` found: the item is
checked by whoever ran it (their box, on a checklist with members; not at all when they are not one).
### The diffs
`GET api/checklists/{name}/git/{repository}/diff.json` and `…/diff/file.json` (`?from=&to=&path=`) are mmo.git's
DiffController on the checklist's recipe's repositories -- `{repository}` its label made a path segment
(`hub (MMO/master)` is `hub-MMO-master`, the checklist's `repositories` lists them) --, nothing else: a request never
names a folder. Against the working tree, a new file git does not track yet is shown too, all of it added. They hand
out the code: the same rule as ▶ -- this machine only -- with `diffScope` (`dev.checklists`). Developer > Diff
(`developer/diff?checklist=&repository=&path=&from=&to=`) shows them in ``; the checklist page's `diffPage`
names another page.
### The decisions
`POST api/checklists/{name}/items/{item}/decide.json` `{option}` posts `decisionComment` on the item's ticket (`{by}`,
`{date}`, `{option}`, `{question}`), records the option (`decisions`) and checks the item. A decision is taken once:
409 when it was, here or on the ticket -- a comment reading as `decisionComment` up to `{option}` (« Decision of … (…)** »),
posted since the line was asked, or naming the question or an option. The page then shows « Decided on … » without
buttons (`decided`, in the answer).
## The recipe
```jsonc
{
"repositories": [
{"label": "acme", "path": "~/dev/src/acme", "together": ["modules/old/"], "exclude": ["libs/"], "pages": true}
],
"treated": "/\\*\\*Claude — (résultat|result)\\*\\*/u",
"site": "http://localhost/acme/",
"pages": "acme — pages",
"checks": {"12": {"page": "gestion/accidents", "check": "sorted by date, newest first", "url": "gestion/accidents"}},
"items": [{"group": "before committing", "section": "checks", "title": "`php tools/mmo.php verify` passes"}],
"itemsFile": "src/pages/admin/release-checklist.items.json",
"texts": {"diff": "Relire le diff : `git -C {root} diff -- {path}`"},
"screens": {"Profil": "entity?uid={uid}", "declaration/AccidentTravail": "gestion/accidents"},
"delivery": "/^(livraison|delivery)\\b/iu"
}
```
1. **items**, **itemsFile**: written by hand, first. The file holds flat items, or a page by page test:
`{members, types, round, sections: [{label, icon, recheck, pages: [{label, path, note, recheck, checks: [{label, type,
recheck}]}]}]}`, whose `members`, `types`, `round` and sections' icons the checklist takes too (the section is the
group, the page the section, the check's type the kind; `round` and `recheck`: see *Declared checklists*).
2. **repositories**: one `code` item per file of `git status --porcelain -uall`, under the repository's `label`; its
section is its folder (two levels, three under `src/`), a page's folder for a file of `src/pages` unless
`"pages": false`. `together` makes one item of a folder's files, `exclude` leaves paths out. `~/` is the home of
whoever owns the application's folder (not the web server's). The item names the treated tickets whose
discussion cites the file -- by its path, or its last three or two parts, `a/{b,c}.php` written out; a file no
ticket cites says it is a transverse change.
3. **pages**: for each treated ticket -- one whose discussion matches `treated`, or that `checks` names -- its
functional checks: `checks[id]` (one, or a list), else the last ```` ```check ```` block of its discussion, else
of its description, one check per line:
````markdown
```check
gestion/accidents | sorted by date, newest first
home (mobile 390 px) | home | the drawer closes on Escape
```
````
`page | what to check`, or `page | url | what to check`; a relative url is relative to `site`. A treated ticket
with no check gets one item asking to check its result on screen.
4. **texts**: the words of the generated items, in the application's language: `M`, `D`, `??`, `A`, `R`, `T` (the
states), `files`, `diff`, `new`, `deleted`, `orphan`, `pages`, `root`, `page`, `unchecked`, `uncheckedDetail`,
`beforeCommit` (the group of the checks of this machine), `actions`, `diffSection`, `runSection`, `decisionSection`,
`manualSection` (the other lines of the ```` ```check ```` blocks: see *One click per item*), `openRecord`,
`aRecord` (the first step of a check whose address needs a record).
5. **account**: the regular expression finding as whom a page's check is done, in its text (its first group), when
the recipe has no **accounts** (*The test accounts*): the profiles that recognize it, by key.
6. **screens**: a screen a ```` ```check ```` line names without its address -- by its page's name (`Profil`), or by
the path of an address needing a record (`declaration/AccidentTravail`, its sub-pages too) -- and the url opening it
(a list); `{uid}`: the connected user's. **delivery**: the page names of a delivery on a server (`manual`).
`POST api/checklists/{name}/build.json` answers the checklist and `build: {orphans, unmatched, unmatchedAccounts}`: the
changed files no ticket cites, the treated tickets no file names, the checks speaking of an account no profile knows.
## The API
| | |
|---|---|
| `GET api/checklists.json` | `default`, and every checklist: name, title, description, board, version, release, progress, declared |
| `GET api/checklists/{name}.json` | one, with `progress`, `tickets` (each ticket its items name: title, status, total, checked, verified), `decided` (the decision items answered: `{at, option}`), `repositories` (each recipe repository's name in the diffs' url), `accounts` (each profile: username, label, description, self, exists) and `accountsCommand`, `expects` (by item id: `{sees, hides}`, the labels of `testData`'s `expect`) and `testDataCommand` |
| `PUT api/checklists/{name}.json` | `{version, title, description, board, boardUrl, members, recipe, refPrefix, items}`: create (version 0) or change; 409 when saved meanwhile |
| `POST api/checklists/{name}/build.json` | rebuild the items from the recipe |
| `PUT api/checklists/{name}/items/{item}.json` | `{checked, member}`; `{item}` its id or its ref |
| `POST api/checklists/{name}/reset.json` | `{name}`: a new round, every box unchecked, a line of `history` |
| `POST api/checklists/{name}/tickets/{ticket}/done.json` | move a verified ticket to the board's first done status, with a comment |
| `POST api/checklists/{name}/items/{item}/run.json` | run the item's command (this machine, `runScope`, `commands`); `run: {command, cwd, exitCode, passed, output, duration, checked}` |
| `POST api/checklists/{name}/items/{item}/decide.json` | `{option}`: post the decision on the item's ticket, check the item; 409 when decided |
| `GET api/checklists/{name}/git/{repository}/diff.json`, `…/diff/file.json` | the recipe's repositories' diffs, for `` (this machine, `diffScope`) |
| `GET developer/open-as?checklist=&item=` | the item's page in its test account's session (this machine, `dev.checklists` and `sudo.switchAccount`, a `test.` account, a page of the site) |
| Boards | |
|---|---|
| `GET api/boards.json` | every board: name, title, version, updatedAt, tickets |
| `GET api/boards/{board}.json` | `{board, title, version, updatedAt, data}`, each ticket counting its `comments`; an unknown board is empty, version 0 |
| `PUT api/boards/{board}.json` | `{version, data}`: the whole board; 409 with the board as it is when it was saved meanwhile |
| `GET api/boards/{board}/prompts.json` | `?agent=claude&status=todo&done=1`: the tickets' AI prompts, and `markdown`, the brief |
| `GET`, `POST api/boards/{board}/{ticket}/comments.json` | a ticket's discussion; `{body, parent}` comments it |
| `PUT`, `DELETE api/boards/{board}/{ticket}/comments/{comment}.json` | `{body}` changes a comment |
## From PHP
```php
use MMO\admin\helpers\{Checklist, ChecklistBuilder, KanbanBoard};
$board = KanbanBoard::Edit(\MMO\filename('{root}/boards/acme.json'), fn(KanbanBoard $b) => $b);
Checklist::Edit(\MMO\filename('{root}/checklists/acme-2.0.json'), function (Checklist $list) use ($board) {
$builder = new ChecklistBuilder($list->data['recipe'] ?? [], $board);
$list->setItems($builder->build());
print_r($builder->orphans());
}, true);
```