@arjunkhera/atlas 0.0.0 → 0.2.1
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/plugin.json +18 -0
- package/.mcp.json +18 -0
- package/README.md +32 -1
- package/agents/artifact-format/example.html +71 -0
- package/agents/artifact-format/system.css +273 -0
- package/agents/artifact-renderer.md +132 -0
- package/agents/capability-reader.md +100 -0
- package/agents/code-explorer.md +108 -0
- package/agents/reviewer-architect.md +78 -0
- package/agents/reviewer-pm.md +81 -0
- package/agents/reviewer-security.md +82 -0
- package/bin/atlas +5 -0
- package/door/cli.mjs +280 -0
- package/door/lib/helpers.mjs +139 -0
- package/door/lib/install.mjs +260 -0
- package/door/lib/kind-drift.mjs +76 -0
- package/door/lib/onboarding.mjs +130 -0
- package/door/lib/pull-request.mjs +119 -0
- package/door/lib/releases.mjs +42 -0
- package/door/lib/status.mjs +47 -0
- package/door/lib/tooling.mjs +119 -0
- package/package.json +24 -3
- package/shape/check.mjs +1797 -0
- package/shape/releases.json +15 -0
- package/skills/sdlc-task/SKILL.md +174 -0
- package/skills/sdlc-task/lifecycle.yaml +27 -0
- package/skills/sdlc-task/templates/design-doc.md +118 -0
- package/skills/sdlc-task/templates/resume-anchor.md +25 -0
- package/work/code-read.mjs +65 -0
- package/work/lib/action-store.mjs +84 -0
- package/work/lib/capability.mjs +487 -0
- package/work/lib/child-env.mjs +11 -0
- package/work/lib/client.mjs +214 -0
- package/work/lib/code-read.mjs +238 -0
- package/work/lib/delivery.mjs +624 -0
- package/work/lib/mcp-stdio.mjs +68 -0
- package/work/lib/questions.mjs +278 -0
- package/work/lib/sweep.mjs +189 -0
- package/work/lib/verb-fields.mjs +53 -0
- package/work/lib/verbs.mjs +511 -0
- package/work/manifest-0.5.0.json +609 -0
- package/work/mcp.mjs +216 -0
- package/work/protected-paths.json +38 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// Atlas M1 slice L3: delivery, reopen and movement.
|
|
2
|
+
// Design: the Atlas M1 study, D1 (the delivery and
|
|
3
|
+
// reopen marks), D2 (the verbs and the door), D3 (the "In flight" view),
|
|
4
|
+
// D6 findings R1, R2, R9, R10, R14, and the frozen L3 paragraph of the lock
|
|
5
|
+
// block. The verbs run behind the same door, the same journal, the same
|
|
6
|
+
// registry checks and the same writer stamp as slices L1 and L2.
|
|
7
|
+
//
|
|
8
|
+
// Three rules hold this slice together:
|
|
9
|
+
// 1. The done verb runs the three live checks itself, with GET only. The
|
|
10
|
+
// caller never supplies a merge state, a gate run or a verdict (R10).
|
|
11
|
+
// 2. A reopen never rewrites a delivery. Each mark is its own record; a
|
|
12
|
+
// reopen points at the delivery it corrects with the corrects edge (R9).
|
|
13
|
+
// 3. Movement is read, never stored. The view computes it at question time
|
|
14
|
+
// from the marks and from the repos' pull requests (D3 rule 5, R1).
|
|
15
|
+
import { refuse, text, isoDate, url, stringList, sha256 } from './verb-fields.mjs';
|
|
16
|
+
import { oneLine, ROW_LINE_MAX } from './questions.mjs';
|
|
17
|
+
|
|
18
|
+
export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
|
|
19
|
+
export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
|
|
20
|
+
|
|
21
|
+
// A check run that concluded one of these did not pass the gate. GitHub's own
|
|
22
|
+
// vocabulary; neutral, skipped and stale are not failures.
|
|
23
|
+
export const FATAL_CONCLUSIONS = Object.freeze(['failure', 'timed_out', 'action_required', 'cancelled', 'startup_failure']);
|
|
24
|
+
const PASSING_CONCLUSION = 'success';
|
|
25
|
+
|
|
26
|
+
const MAX_PRS = 32;
|
|
27
|
+
const MAX_REPOS = 16;
|
|
28
|
+
const MAX_ITEMS = 500;
|
|
29
|
+
const PRS_PER_REPO = 100;
|
|
30
|
+
const CHECK_RUNS = 100;
|
|
31
|
+
const UNREGISTERED_SHOWN = 5;
|
|
32
|
+
const UNREGISTERED_KEPT = 25;
|
|
33
|
+
|
|
34
|
+
// One list line: the tail is what the reader needs, so the title yields to it
|
|
35
|
+
// inside the budget, and the indent goes on afterwards (reviews H10 and H20).
|
|
36
|
+
export function listLine(head, tail, indent = ' ', max = ROW_LINE_MAX) {
|
|
37
|
+
const end = oneLine(tail, max - indent.length);
|
|
38
|
+
const budget = max - end.length - indent.length - 3;
|
|
39
|
+
return budget < 1 ? `${indent}${end}` : `${indent}${oneLine(head, budget)} · ${end}`;
|
|
40
|
+
}
|
|
41
|
+
const DAY = 86_400_000;
|
|
42
|
+
|
|
43
|
+
const PR_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#].*)?$/;
|
|
44
|
+
// A deploy identity as a health endpoint reports it: "sha-3cceb68", "3cceb68",
|
|
45
|
+
// "git-3cceb68". Anything else is not a commit and is refused.
|
|
46
|
+
const COMMIT = /^(?:sha-|commit-|git-|rev-)?([0-9a-f]{7,40})$/;
|
|
47
|
+
const COMMIT_FIELDS = ['commit', 'sha', 'revision', 'version', 'build', 'git_sha'];
|
|
48
|
+
|
|
49
|
+
export const parsePullUrl = (value, name) => {
|
|
50
|
+
const match = PR_URL.exec(text(value, name, { max: 500 }).trim());
|
|
51
|
+
if (!match) refuse(`${name} must be a GitHub pull request URL, for example https://github.com/owner/repo/pull/12`);
|
|
52
|
+
return { repo: match[1], number: Number(match[2]), url: `https://github.com/${match[1]}/pull/${match[2]}` };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// The deployed commit, read from the health endpoint's own answer. The caller
|
|
56
|
+
// never supplies it; a health endpoint that names no commit fails the check.
|
|
57
|
+
export function deployedCommitOf(body) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try { parsed = JSON.parse(body); } catch { return null; }
|
|
60
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
61
|
+
for (const key of COMMIT_FIELDS) {
|
|
62
|
+
const value = parsed[key];
|
|
63
|
+
if (typeof value !== 'string') continue;
|
|
64
|
+
const match = COMMIT.exec(value.trim().toLowerCase());
|
|
65
|
+
if (match) return { commit: match[1], field: key, reported: value };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// GitHub lists every check run a commit ever had, re-runs included. Only the
|
|
71
|
+
// last run of each name says what the gate concluded.
|
|
72
|
+
export function latestCheckRuns(runs) {
|
|
73
|
+
const byName = new Map();
|
|
74
|
+
for (const run of runs ?? []) {
|
|
75
|
+
const name = typeof run?.name === 'string' ? run.name : '';
|
|
76
|
+
if (!name) continue;
|
|
77
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
78
|
+
byName.get(name).push(run);
|
|
79
|
+
}
|
|
80
|
+
const order = (run) => `${String(run.completed_at ?? run.started_at ?? '')}#${String(run.id ?? 0).padStart(24, '0')}`;
|
|
81
|
+
return [...byName.entries()]
|
|
82
|
+
.map(([name, list]) => {
|
|
83
|
+
const sorted = [...list].sort((a, b) => order(a).localeCompare(order(b)));
|
|
84
|
+
const last = sorted.at(-1);
|
|
85
|
+
// Every earlier run of this name that concluded fatally. A red run that a
|
|
86
|
+
// later run superseded is recorded, never dropped: only a later SUCCESS
|
|
87
|
+
// clears it, and any other later conclusion leaves it standing.
|
|
88
|
+
const superseded = sorted.slice(0, -1).filter((run) => FATAL_CONCLUSIONS.includes(run.conclusion ?? ''))
|
|
89
|
+
.map((run) => ({ conclusion: run.conclusion, url: run.html_url ?? '', completed_at: run.completed_at ?? null }));
|
|
90
|
+
return { name, conclusion: last.conclusion ?? null, status: last.status ?? null, url: last.html_url ?? '', completed_at: last.completed_at ?? null, attempts: sorted.length, ...(superseded.length ? { superseded_failures: superseded } : {}) };
|
|
91
|
+
})
|
|
92
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const daysBetween = (from, to) => {
|
|
96
|
+
const start = Date.parse(from ?? ''), end = Date.parse(to ?? '');
|
|
97
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
98
|
+
return Math.max(0, Math.floor((end - start) / DAY));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// The legacy seal of the first slice, read as a delivery mark (R14). The seal
|
|
102
|
+
// is never rewritten; the view reads it where it lies.
|
|
103
|
+
export function legacySealOf(item) {
|
|
104
|
+
const sealed = item.fields?.sealed_at;
|
|
105
|
+
if (typeof sealed !== 'string' || !sealed) return null;
|
|
106
|
+
return {
|
|
107
|
+
mark_id: null, kind: 'delivery', verdict: 'legacy', marked_at: sealed,
|
|
108
|
+
item: item.id, product: item.fields?.product ?? '', prs: [],
|
|
109
|
+
source: `legacy seal, verdict ${item.fields?.seal_verdict ?? 'unknown'}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The marks of each item, oldest first. One read of the mark records serves
|
|
114
|
+
// every item in a view.
|
|
115
|
+
export function marksByItem(rows, itemIds) {
|
|
116
|
+
const byItem = new Map();
|
|
117
|
+
for (const row of rows ?? []) {
|
|
118
|
+
const id = row.fields?.item;
|
|
119
|
+
if (typeof id !== 'string' || (itemIds && !itemIds.has(id))) continue;
|
|
120
|
+
if (!byItem.has(id)) byItem.set(id, []);
|
|
121
|
+
byItem.get(id).push(row);
|
|
122
|
+
}
|
|
123
|
+
for (const list of byItem.values()) list.sort((a, b) => String(a.fields.marked_at ?? '').localeCompare(String(b.fields.marked_at ?? '')));
|
|
124
|
+
return byItem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Movement, read at question time and never stored (D3 rule 5). The three
|
|
128
|
+
// helpers below are what the "In flight" view computes movement with. Slice L4
|
|
129
|
+
// calls the same three for part 3 of the six-part answer, so one rule about
|
|
130
|
+
// what counts as movement serves both views.
|
|
131
|
+
|
|
132
|
+
// Every repo in view, its recent pull requests, read once and shared by every
|
|
133
|
+
// item. A repo GitHub could not read is named; the view still answers from the
|
|
134
|
+
// graph (review H11).
|
|
135
|
+
export async function readPullsByRepo(repoIds, readGitHub, perRepo = PRS_PER_REPO) {
|
|
136
|
+
const pullsByRepo = new Map(), repoErrors = [];
|
|
137
|
+
for (const repo of repoIds) {
|
|
138
|
+
try {
|
|
139
|
+
const rows = await readGitHub(`repos/${repo}/pulls?state=all&sort=updated&direction=desc&per_page=${perRepo}`);
|
|
140
|
+
pullsByRepo.set(repo, (Array.isArray(rows) ? rows : []).map((pr) => ({
|
|
141
|
+
repo, number: pr.number, url: pr.html_url ?? `https://github.com/${repo}/pull/${pr.number}`, title: oneLine(pr.title ?? '', 200),
|
|
142
|
+
branch: pr.head?.ref ?? '', merged_at: pr.merged_at ?? null, updated_at: pr.updated_at ?? pr.created_at ?? '',
|
|
143
|
+
state: pr.merged_at ? 'merged' : (pr.state ?? 'open'), names: `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.head?.ref ?? ''}`,
|
|
144
|
+
})));
|
|
145
|
+
} catch (error) { pullsByRepo.set(repo, []); repoErrors.push({ repo, reason: `GitHub did not answer: ${error?.message ?? String(error)}` }); }
|
|
146
|
+
}
|
|
147
|
+
return { pullsByRepo, repoErrors };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A pull request belongs to an item when the item links it (item_link, kind
|
|
151
|
+
// pr) or when its title, body or branch names the item id (R1).
|
|
152
|
+
export function pullsOfItem(item, pullsByRepo, fallbackRepos = []) {
|
|
153
|
+
const fields = item.fields ?? {};
|
|
154
|
+
const linked = new Set((Array.isArray(fields.links) ? fields.links : []).filter((link) => link?.kind === 'pr').map((link) => link.url));
|
|
155
|
+
const mine = [];
|
|
156
|
+
for (const repo of (Array.isArray(fields.repos) ? fields.repos : fallbackRepos)) {
|
|
157
|
+
for (const pull of pullsByRepo.get(repo) ?? []) {
|
|
158
|
+
const how = linked.has(pull.url) ? 'link' : (pull.names.includes(item.id) ? 'names the item id' : null);
|
|
159
|
+
if (how) mine.push({ ...pull, names: undefined, matched_by: how });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return mine;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The last thing that moved on an item: its own dates, its marks, its legacy
|
|
166
|
+
// seal and its pull requests, newest first.
|
|
167
|
+
export function movementOf({ item, marks = [], pulls = [], legacy = null, readAt }) {
|
|
168
|
+
const fields = item.fields ?? {};
|
|
169
|
+
const moments = [
|
|
170
|
+
{ at: fields.locked_at, source: 'locked' }, { at: fields.stage_changed_at, source: `stage ${fields.stage ?? ''}` },
|
|
171
|
+
{ at: fields.resumed_at, source: 'resumed' }, { at: fields.parked_at, source: 'parked' },
|
|
172
|
+
{ at: fields.delivered_at, source: 'delivered' }, { at: fields.reopened_at, source: 'reopened' },
|
|
173
|
+
...marks.map((mark) => ({ at: mark.fields?.marked_at, source: `${mark.fields?.kind} mark` })),
|
|
174
|
+
...(legacy ? [{ at: legacy.marked_at, source: 'legacy seal' }] : []),
|
|
175
|
+
...pulls.map((pull) => ({ at: pull.merged_at ?? pull.updated_at, source: `${pull.state} #${pull.number}` })),
|
|
176
|
+
{ at: item.updated_at, source: 'record' }, { at: item.created_at, source: 'created' },
|
|
177
|
+
].filter((moment) => typeof moment.at === 'string' && moment.at && !Number.isNaN(Date.parse(moment.at)));
|
|
178
|
+
moments.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
179
|
+
const last = moments[0] ?? null;
|
|
180
|
+
return { last, days: last ? daysBetween(last.at, readAt) : null, moments };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A reopen corrects the delivery it names. The first reopen of a delivery is
|
|
184
|
+
// the correction; a later one is refused by the verb, so an older record with
|
|
185
|
+
// two is read this way too (review H7).
|
|
186
|
+
export function correctionsOf(marks = []) {
|
|
187
|
+
const correctedBy = new Map();
|
|
188
|
+
for (const mark of marks) {
|
|
189
|
+
const corrects = mark.fields?.corrects;
|
|
190
|
+
if (mark.fields?.kind === 'reopen' && typeof corrects === 'string' && !correctedBy.has(corrects)) correctedBy.set(corrects, mark.id);
|
|
191
|
+
}
|
|
192
|
+
return correctedBy;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function createDeliveryVerbs(core) {
|
|
196
|
+
const { client, label, now, typeNamed, registry, requireProduct, requireItem, ownerPresent, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
|
|
197
|
+
|
|
198
|
+
const github = async (path) => {
|
|
199
|
+
if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
|
|
200
|
+
try { return await readGitHub(path); }
|
|
201
|
+
catch (error) { refuse(`GitHub could not read ${path}: ${error?.message ?? 'unknown error'}`); }
|
|
202
|
+
};
|
|
203
|
+
const live = async (target, name) => {
|
|
204
|
+
if (typeof readLive !== 'function') refuse('this verb needs a live reader; the server supplies one');
|
|
205
|
+
try { return await readLive(target); }
|
|
206
|
+
catch (error) { refuse(`${name} did not answer: ${error?.message ?? 'unknown error'}`); }
|
|
207
|
+
};
|
|
208
|
+
// The reopen door (R9). The owner-present flag, or the lead in this session.
|
|
209
|
+
const requireOwnerOrLead = (verb) => { if (!ownerPresent && label.agent !== 'lead') refuse(`${verb} is for the owner or the lead in session; this session is agent "${label.agent}"`); };
|
|
210
|
+
|
|
211
|
+
const markOf = async (id, itemId) => {
|
|
212
|
+
const type = await typeNamed('mark');
|
|
213
|
+
let entity;
|
|
214
|
+
try { entity = await client.getEntity(id); }
|
|
215
|
+
catch (error) { if (error?.status === 404) refuse(`unknown mark id: ${id}`); throw error; }
|
|
216
|
+
if (entity.type_id !== type.id) refuse(`entity ${id} is not a delivery or reopen mark`);
|
|
217
|
+
if (itemId && entity.fields?.item !== itemId) refuse(`mark ${id} belongs to item ${entity.fields?.item}, not ${itemId}`);
|
|
218
|
+
return entity;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
async function marksOf(itemIds) {
|
|
222
|
+
const type = await typeNamed('mark');
|
|
223
|
+
return marksByItem(await client.listAllEntities({ type: type.name, state: 'active' }), itemIds);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------
|
|
227
|
+
// The three live checks. Every one is a GET. They all run before any write,
|
|
228
|
+
// so a failed check leaves nothing behind.
|
|
229
|
+
// ---------------------------------------------------------------------
|
|
230
|
+
function functionalSpec(value) {
|
|
231
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) refuse('functional is required: { url, contains, status? }. The verb GETs that URL and checks the answer.');
|
|
232
|
+
for (const key of ['method', 'body', 'headers', 'data', 'json']) if (key in value) refuse(`functional.${key} is not accepted; the live checks are GET only and send no body`);
|
|
233
|
+
const target = url(value.url, 'functional.url');
|
|
234
|
+
// A check that asserts nothing is not a check. It must say what the answer
|
|
235
|
+
// has to carry, or the verb would pass on any 200.
|
|
236
|
+
if (value.contains === undefined) refuse('functional.contains is required: name at least one string the answer must carry, so the check says something about the change');
|
|
237
|
+
const contains = stringList(value.contains, 'functional.contains', { max: 16 }).map((entry) => text(entry, 'functional.contains', { max: 500 }));
|
|
238
|
+
if (!contains.length) refuse('functional.contains must name at least one string the answer must carry');
|
|
239
|
+
let status = 200;
|
|
240
|
+
if (value.status !== undefined) {
|
|
241
|
+
// A delivery is proven by an answer, not by an error. 404 is not a pass.
|
|
242
|
+
if (!Number.isInteger(value.status) || value.status < 200 || value.status > 299) refuse('functional.status must be a 2xx status; a delivery is proven by an answer, not by an error');
|
|
243
|
+
status = value.status;
|
|
244
|
+
}
|
|
245
|
+
return { url: target, contains, status };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Where the functional check may go. The owner writes check_paths on the
|
|
249
|
+
// product; without it the verb may read only the health endpoint's own path.
|
|
250
|
+
// The check runs with the Engram credential, so an open origin would let any
|
|
251
|
+
// in-session agent read any path and learn from the refusal what it holds.
|
|
252
|
+
function allowedPath(spec, product, healthUrl) {
|
|
253
|
+
const health = new URL(healthUrl);
|
|
254
|
+
const target = new URL(spec.url);
|
|
255
|
+
if (target.origin !== health.origin) refuse(`functional.url must be on the same origin as the product's health endpoint (${health.origin}); the check runs against the deployed app, not another host`);
|
|
256
|
+
const allowed = Array.isArray(product.fields?.check_paths) ? product.fields.check_paths.filter((path) => typeof path === 'string' && path.startsWith('/')) : [];
|
|
257
|
+
const paths = allowed.length ? allowed : [health.pathname];
|
|
258
|
+
const under = (prefix) => target.pathname === prefix || target.pathname.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
|
|
259
|
+
if (!paths.some(under)) refuse(`functional.url path ${target.pathname} is not one the owner opened for checks on product ${product.fields.registry_id}. Allowed: ${paths.join(', ')}. The owner adds a path with product edit, check_paths.`);
|
|
260
|
+
// The path alone decides. An earlier draft compared the query too, so
|
|
261
|
+
// /healthz?probe=1 passed as a functional check (review H15).
|
|
262
|
+
if (target.pathname === health.pathname) refuse('functional.url must not be the health endpoint itself; the functional check is scoped to the change, and the health endpoint is check two');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function checkHealth(healthUrl) {
|
|
266
|
+
const answer = await live(healthUrl, 'the health endpoint');
|
|
267
|
+
const ok = answer.status >= 200 && answer.status < 300;
|
|
268
|
+
const found = ok ? deployedCommitOf(answer.body) : null;
|
|
269
|
+
return {
|
|
270
|
+
name: 'health-endpoint-answers', result: ok && found ? 'pass' : 'fail', url: healthUrl, status: answer.status,
|
|
271
|
+
...(found ? { deployed_commit: found.commit, reported: found.reported, field: found.field } : {}),
|
|
272
|
+
detail: !ok ? `the health endpoint answered ${answer.status}` : (found ? `${healthUrl} answered ${answer.status}; deployed commit ${found.commit}` : 'the health endpoint answered, but named no commit in commit, sha, revision, version, build or git_sha'),
|
|
273
|
+
checked_at: now(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function checkContains(pulls, deployed) {
|
|
278
|
+
const detail = [];
|
|
279
|
+
let result = 'pass';
|
|
280
|
+
for (const pull of pulls) {
|
|
281
|
+
const compare = await github(`repos/${pull.repo}/compare/${pull.merge_commit_sha}...${deployed}`);
|
|
282
|
+
const status = compare?.status ?? 'unknown';
|
|
283
|
+
const contains = status === 'identical' || status === 'ahead';
|
|
284
|
+
if (!contains) result = 'fail';
|
|
285
|
+
detail.push({ pr: pull.url, merge_commit_sha: pull.merge_commit_sha, deployed, compare: status, behind_by: compare?.behind_by ?? null, contains });
|
|
286
|
+
}
|
|
287
|
+
return { name: 'deployed-commit-contains-the-prs', result, deployed_commit: deployed, prs: detail, checked_at: now(),
|
|
288
|
+
detail: result === 'pass' ? `the deployed commit ${deployed} carries all ${pulls.length} merged pull request${pulls.length === 1 ? '' : 's'}` : `the deployed commit ${deployed} does not carry ${detail.filter((row) => !row.contains).map((row) => row.pr).join(', ')}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function checkFunctional(spec, healthUrl) {
|
|
292
|
+
const answer = await live(spec.url, 'the functional check');
|
|
293
|
+
const body = String(answer.body ?? '');
|
|
294
|
+
const missing = spec.contains.filter((needle) => !body.includes(needle));
|
|
295
|
+
const statusOk = answer.status === spec.status;
|
|
296
|
+
const result = statusOk && !missing.length ? 'pass' : 'fail';
|
|
297
|
+
return {
|
|
298
|
+
name: 'read-only-functional-check', result, url: spec.url, method: 'GET', status: answer.status, expected_status: spec.status,
|
|
299
|
+
// The answer itself never enters the graph: a digest, its size, and which
|
|
300
|
+
// strings the caller asked for were found.
|
|
301
|
+
body_sha256: sha256(body), body_bytes: Buffer.byteLength(body), contains: spec.contains, missing,
|
|
302
|
+
detail: result === 'pass' ? `GET ${spec.url} answered ${answer.status}${spec.contains.length ? ` and carried ${spec.contains.length} expected string${spec.contains.length === 1 ? '' : 's'}` : ''}` : (!statusOk ? `GET ${spec.url} answered ${answer.status}, not ${spec.status}` : `GET ${spec.url} did not carry: ${missing.join(', ')}`),
|
|
303
|
+
checked_at: now(),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const verbs = {
|
|
308
|
+
// The delivering agent marks its own change done, after the verb runs the
|
|
309
|
+
// three live checks itself. No owner flag: the agent that shipped it calls
|
|
310
|
+
// this, and the lead catches up a delivery made outside a session (R2).
|
|
311
|
+
async item_done(args, requestId, meta) {
|
|
312
|
+
const item = await requireItem(args.item_id);
|
|
313
|
+
const fields = item.fields ?? {};
|
|
314
|
+
const replay = alreadyPatched(item, requestId, meta);
|
|
315
|
+
if (!replay && fields.lifecycle !== 'active') refuse(`item ${item.id} is ${fields.lifecycle}; only an active item is marked done`);
|
|
316
|
+
const reg = await registry();
|
|
317
|
+
const product = await requireProduct(fields.product, reg);
|
|
318
|
+
// A retry of an interrupted write finishes what landed, and it is read
|
|
319
|
+
// FIRST, before any refusal that depends on registry state. A repo kind
|
|
320
|
+
// edited between two attempts would otherwise send the retry down the
|
|
321
|
+
// strict path, refuse it for a missing health endpoint, and strand the
|
|
322
|
+
// mark this same request already wrote (architect review, finding 8).
|
|
323
|
+
// It runs no check again: the checks proved THIS delivery when the mark
|
|
324
|
+
// was written, and a check that has since gone red would otherwise
|
|
325
|
+
// strand a mark the item never points at (reviews G1 and G2 of Batch 11,
|
|
326
|
+
// finding H5 of Batch 13).
|
|
327
|
+
const type = await typeNamed('mark');
|
|
328
|
+
const standing = await landed(type, requestId, meta);
|
|
329
|
+
if (standing) {
|
|
330
|
+
await edge(standing, item, 'references', requestId);
|
|
331
|
+
const held = Array.isArray(fields.delivery_marks) ? fields.delivery_marks : [];
|
|
332
|
+
const finished = held.includes(standing.id) ? item : await patch(item, 'item_done', requestId, {
|
|
333
|
+
lifecycle: 'done', delivery_state: 'delivered', delivered_at: standing.fields.marked_at, delivery_marks: [...held, standing.id],
|
|
334
|
+
}, { digest: meta?.digest });
|
|
335
|
+
return { verb: 'item_done', item_id: finished.id, version: finished.version, lifecycle: finished.fields.lifecycle, mark_id: standing.id,
|
|
336
|
+
delivered_at: standing.fields.marked_at, prs: (standing.fields.prs ?? []).map((pull) => pull.url), deployed_commit: standing.fields.deployed?.commit ?? null,
|
|
337
|
+
checks: (standing.fields.checks ?? []).map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
|
|
338
|
+
verified_by: (standing.fields.verified_by ?? []).map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: standing.fields.gate_warnings ?? [],
|
|
339
|
+
replayed_from_graph: true };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// F4, ordered by the owner: what proves a delivery follows the repo,
|
|
343
|
+
// not one product's shape. Amendment A1.7.
|
|
344
|
+
//
|
|
345
|
+
// Only a `library` is relaxed. A library's merge IS its delivery: there
|
|
346
|
+
// is nothing after it. A `schema` repo is the opposite: this system's
|
|
347
|
+
// own release procedure applies a migration to the node AFTER the merge,
|
|
348
|
+
// so a merge-proved "delivered" would be false about production. A
|
|
349
|
+
// `schema` repo therefore stays on the strict path with `service`
|
|
350
|
+
// (architect review of this batch, finding 1).
|
|
351
|
+
//
|
|
352
|
+
// The strict path is also the default: an item with no registered repo,
|
|
353
|
+
// or a repo whose kind was never set, keeps the three checks. Nothing
|
|
354
|
+
// weakens by omission, and a mixed-kind item takes the strict path
|
|
355
|
+
// because one of its repos demands it.
|
|
356
|
+
const named = (Array.isArray(fields.repos) ? fields.repos : []).map((id) => reg.repos.get(id)).filter(Boolean);
|
|
357
|
+
const unset = named.filter((repo) => !repo.fields?.kind).map((repo) => repo.fields.registry_id);
|
|
358
|
+
const strictKinds = named.filter((repo) => repo.fields?.kind && repo.fields.kind !== 'library').map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`);
|
|
359
|
+
const live = !named.length || unset.length > 0 || strictKinds.length > 0;
|
|
360
|
+
const healthUrl = text(product.fields?.health_url, `product ${product.fields.registry_id} health_url`, { optional: true, max: 2000 });
|
|
361
|
+
if (live && !healthUrl) {
|
|
362
|
+
const why = !named.length ? 'the item names no registered repo' : (unset.length ? `repo ${unset.join(', ')} carries no kind` : `${strictKinds.join(', ')}`);
|
|
363
|
+
refuse(`product ${product.fields.registry_id} has no health_url, and the live checks apply because ${why}; the owner sets it with product edit, or sets the repo kind to library, which is the one kind whose merge is its delivery`);
|
|
364
|
+
}
|
|
365
|
+
// Which pull requests deliver this item. The caller names them, always.
|
|
366
|
+
// An earlier draft fell back to the item's own pr links, and the first
|
|
367
|
+
// live run marked the wrong item done: that item's one pr link was the
|
|
368
|
+
// pull request that LOCKED its design, with role "lock". A link says a
|
|
369
|
+
// pull request belongs to the item; it does not say the item shipped.
|
|
370
|
+
// The delivering agent knows what it delivered, so it says so.
|
|
371
|
+
if (args.prs === undefined) refuse('prs is required: name the merged pull requests this delivery shipped. The item\'s pr links are not read, because a link can be a design or a lock, not a delivery.');
|
|
372
|
+
const given = stringList(args.prs, 'prs', { max: MAX_PRS });
|
|
373
|
+
if (!given.length) refuse('prs must name at least one merged pull request');
|
|
374
|
+
let spec = null;
|
|
375
|
+
if (live) { spec = functionalSpec(args.functional); allowedPath(spec, product, healthUrl); }
|
|
376
|
+
else if (args.functional !== undefined) refuse(`functional is not accepted for this delivery: ${named.map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`).join(', ')}, so there is no deployed page to read. The merge and the gate prove it.`);
|
|
377
|
+
const note = text(args.note, 'note', { optional: true, max: 2000 }) ?? '';
|
|
378
|
+
|
|
379
|
+
const repos = new Set(Array.isArray(fields.repos) ? fields.repos : []);
|
|
380
|
+
const pulls = [];
|
|
381
|
+
for (const entry of given) {
|
|
382
|
+
const parsed = parsePullUrl(entry, 'prs');
|
|
383
|
+
if (!reg.repos.has(parsed.repo)) refuse(`pull request ${parsed.url} is in ${parsed.repo}, which is not a registered repo`);
|
|
384
|
+
if (repos.size && !repos.has(parsed.repo)) refuse(`pull request ${parsed.url} is in ${parsed.repo}, which is not one of the item's repos (${[...repos].join(', ')})`);
|
|
385
|
+
// The merge state comes from GitHub, never from the caller (R10).
|
|
386
|
+
const pr = await github(`repos/${parsed.repo}/pulls/${parsed.number}`);
|
|
387
|
+
if (pr?.merged !== true) refuse(`pull request ${parsed.url} is not merged on GitHub; a delivery is marked after the merge`);
|
|
388
|
+
const mergeCommit = text(pr.merge_commit_sha, `merge_commit_sha of ${parsed.url}`, { max: 64 });
|
|
389
|
+
pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// The three live checks, for a delivery that reaches a running service.
|
|
393
|
+
let health = null, contains = null, functional = null;
|
|
394
|
+
if (live) {
|
|
395
|
+
// Check one: the health endpoint answers, and says which commit is live.
|
|
396
|
+
health = await checkHealth(healthUrl);
|
|
397
|
+
if (health.result !== 'pass') refuse(`the live check failed: ${health.detail}`);
|
|
398
|
+
// Check two: that commit carries every pull request.
|
|
399
|
+
contains = await checkContains(pulls, health.deployed_commit);
|
|
400
|
+
if (contains.result !== 'pass') refuse(`the live check failed: ${contains.detail}`);
|
|
401
|
+
// Check three: a read-only functional check scoped to the change.
|
|
402
|
+
functional = await checkFunctional(spec, healthUrl);
|
|
403
|
+
if (functional.result !== 'pass') refuse(`the live check failed: ${functional.detail}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The gate run, fetched from GitHub for each merge commit (R10).
|
|
407
|
+
const verified = [], warnings = [];
|
|
408
|
+
for (const pull of pulls) {
|
|
409
|
+
const answer = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}/check-runs?per_page=${CHECK_RUNS}`);
|
|
410
|
+
const runs = latestCheckRuns(answer?.check_runs);
|
|
411
|
+
const failed = runs.filter((run) => FATAL_CONCLUSIONS.includes(run.conclusion ?? ''));
|
|
412
|
+
const passed = runs.filter((run) => run.conclusion === PASSING_CONCLUSION);
|
|
413
|
+
if (failed.length) refuse(`the merge gate is red for ${pull.url}: ${failed.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}`);
|
|
414
|
+
if (!passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
|
|
415
|
+
// A check name whose LATEST run succeeded is green. A name whose earlier
|
|
416
|
+
// run was red and whose later run was skipped or neutral is not red and
|
|
417
|
+
// not green: the verb cannot tell a re-run that fixed it from a gate
|
|
418
|
+
// that was switched off. It does not guess. It marks the delivery and
|
|
419
|
+
// writes the warning on the mark, where the owner reads it and can
|
|
420
|
+
// reopen. The lock text does not set a gate policy; this is the lead's
|
|
421
|
+
// rule for M1, and it is recorded, never silent.
|
|
422
|
+
for (const run of runs) {
|
|
423
|
+
if (!run.superseded_failures || run.conclusion === PASSING_CONCLUSION) continue;
|
|
424
|
+
const last = run.superseded_failures.at(-1);
|
|
425
|
+
warnings.push({ pr: pull.url, check: run.name, earlier: last.conclusion, earlier_url: last.url, latest: run.conclusion, latest_url: run.url,
|
|
426
|
+
detail: `${run.name} concluded ${last.conclusion} for ${pull.url}, and its later run concluded ${run.conclusion}, not success. The verb cannot tell a fixed gate from a gate that was switched off.` });
|
|
427
|
+
}
|
|
428
|
+
verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name) });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// One row per check that ran. For a repo with no deploy target the row
|
|
432
|
+
// says what was proven instead: every named pull request is merged, and
|
|
433
|
+
// the gate below is green. The merge commits are on the mark's prs.
|
|
434
|
+
const checks = live ? [contains, health, functional] : [{
|
|
435
|
+
name: 'every named pull request is merged',
|
|
436
|
+
result: 'pass',
|
|
437
|
+
detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).`,
|
|
438
|
+
checked_at: now(),
|
|
439
|
+
}];
|
|
440
|
+
const mark = await create(type, requestId, 'item_done', {
|
|
441
|
+
digest: meta?.digest,
|
|
442
|
+
title: oneLine(`Delivered · ${item.title} · ${pulls.map((pull) => `#${pull.number}`).join(' ')}`, 200),
|
|
443
|
+
body: checks.map((check) => `${check.name}: ${check.result} — ${check.detail}`).join('\n'),
|
|
444
|
+
fields: {
|
|
445
|
+
kind: 'delivery', verdict: 'delivered', item: item.id, product: product.fields.registry_id, marked_at: now(),
|
|
446
|
+
prs: pulls.map((pull) => ({ url: pull.url, repo: pull.repo, number: pull.number, title: pull.title, branch: pull.branch, merged_at: pull.merged_at, merge_commit_sha: pull.merge_commit_sha })),
|
|
447
|
+
...(live ? { deployed: { health_url: healthUrl, commit: health.deployed_commit, reported: health.reported, status: health.status, checked_at: health.checked_at } } : {}),
|
|
448
|
+
checks, verified_by: verified, ...(warnings.length ? { gate_warnings: warnings } : {}), ...(note ? { note } : {}),
|
|
449
|
+
},
|
|
450
|
+
tags: ['atlas-mark', `product:${product.fields.registry_id}`, ...pulls.map((pull) => `repo:${pull.repo}`)],
|
|
451
|
+
});
|
|
452
|
+
await edge(mark, item, 'references', requestId);
|
|
453
|
+
const marks = Array.isArray(fields.delivery_marks) ? fields.delivery_marks : [];
|
|
454
|
+
const patched = replay ? item : await patch(item, 'item_done', requestId, {
|
|
455
|
+
lifecycle: 'done', delivery_state: 'delivered', delivered_at: mark.fields.marked_at,
|
|
456
|
+
delivery_marks: marks.includes(mark.id) ? marks : [...marks, mark.id],
|
|
457
|
+
}, { digest: meta?.digest });
|
|
458
|
+
return {
|
|
459
|
+
verb: 'item_done', item_id: patched.id, version: patched.version, lifecycle: patched.fields.lifecycle, mark_id: mark.id,
|
|
460
|
+
delivered_at: mark.fields.marked_at, prs: pulls.map((pull) => pull.url), deployed_commit: health?.deployed_commit ?? null,
|
|
461
|
+
proof: live ? 'live-checks' : 'merge-and-gate',
|
|
462
|
+
checks: checks.map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
|
|
463
|
+
verified_by: verified.map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: warnings,
|
|
464
|
+
...(replay ? { replayed_from_graph: true } : {}),
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// Reopen: the owner or the lead in session, with a reason and the failed
|
|
469
|
+
// check as a URL. It appends a mark that corrects the delivery. The
|
|
470
|
+
// delivery mark is never touched (R9).
|
|
471
|
+
async item_reopen(args, requestId, meta) {
|
|
472
|
+
requireOwnerOrLead('item reopen');
|
|
473
|
+
const item = await requireItem(args.item_id);
|
|
474
|
+
const fields = item.fields ?? {};
|
|
475
|
+
const reason = text(args.reason, 'reason', { max: 4000 });
|
|
476
|
+
const failedCheck = url(args.failed_check, 'failed_check');
|
|
477
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_reopen', item_id: item.id, version: item.version, lifecycle: fields.lifecycle, delivery_state: fields.delivery_state ?? null, reason, replayed_from_graph: true };
|
|
478
|
+
const marks = Array.isArray(fields.delivery_marks) ? fields.delivery_marks : [];
|
|
479
|
+
if (!marks.length) refuse(`item ${item.id} carries no delivery mark; there is nothing to reopen`);
|
|
480
|
+
// One reopen per delivery. A delivery that is already corrected cannot be
|
|
481
|
+
// corrected again: the second live run of Batch 12 stacked two reopens on
|
|
482
|
+
// one delivery because the default kept choosing the same mark.
|
|
483
|
+
const held = [];
|
|
484
|
+
for (const id of marks) held.push(await markOf(id, item.id));
|
|
485
|
+
const alreadyCorrected = new Map();
|
|
486
|
+
for (const mark of held) if (mark.fields?.kind === 'reopen' && typeof mark.fields.corrects === 'string' && !alreadyCorrected.has(mark.fields.corrects)) alreadyCorrected.set(mark.fields.corrects, mark.id);
|
|
487
|
+
let corrected;
|
|
488
|
+
if (args.corrects !== undefined) corrected = await markOf(text(args.corrects, 'corrects', { max: 200 }), item.id);
|
|
489
|
+
else corrected = [...held].reverse().find((mark) => mark.fields?.kind === 'delivery' && !alreadyCorrected.has(mark.id));
|
|
490
|
+
if (!corrected) refuse(`item ${item.id} has no standing delivery mark to correct; every delivery on it is already corrected`);
|
|
491
|
+
if (corrected.fields?.kind !== 'delivery') refuse(`mark ${corrected.id} is a ${corrected.fields?.kind} mark; a reopen corrects a delivery mark`);
|
|
492
|
+
if (alreadyCorrected.has(corrected.id)) refuse(`delivery mark ${corrected.id} is already corrected by ${alreadyCorrected.get(corrected.id)}; mark the item done again before reopening it again`);
|
|
493
|
+
const type = await typeNamed('mark');
|
|
494
|
+
const prior = await landed(type, requestId, meta);
|
|
495
|
+
const mark = prior ?? await create(type, requestId, 'item_reopen', {
|
|
496
|
+
digest: meta?.digest, title: oneLine(`Reopened · ${item.title}`, 200), body: `${reason}\n\nFailed check: ${failedCheck}\nCorrects delivery mark ${corrected.id}.`,
|
|
497
|
+
fields: {
|
|
498
|
+
kind: 'reopen', verdict: 'reopened', item: item.id, product: fields.product ?? '', marked_at: now(),
|
|
499
|
+
reason, failed_check: failedCheck, corrects: corrected.id,
|
|
500
|
+
},
|
|
501
|
+
tags: ['atlas-mark', ...(fields.product ? [`product:${fields.product}`] : [])],
|
|
502
|
+
});
|
|
503
|
+
// The corrects edge of 0.2.0, from the reopen to the delivery it corrects.
|
|
504
|
+
await edge(mark, corrected, 'corrects', requestId);
|
|
505
|
+
await edge(mark, item, 'references', requestId);
|
|
506
|
+
const patched = await patch(item, 'item_reopen', requestId, {
|
|
507
|
+
lifecycle: 'active', delivery_state: 'reopened', reopened_at: mark.fields.marked_at,
|
|
508
|
+
delivery_marks: marks.includes(mark.id) ? marks : [...marks, mark.id],
|
|
509
|
+
}, { digest: meta?.digest });
|
|
510
|
+
return { verb: 'item_reopen', item_id: patched.id, version: patched.version, lifecycle: 'active', delivery_state: 'reopened', mark_id: mark.id, corrects: corrected.id, failed_check: failedCheck, reason, ...(prior ? { replayed_from_graph: true } : {}) };
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
// D3: the "In flight" view. Computed at question time, per product, never
|
|
514
|
+
// stored. Movement comes from the marks and from the repos' pull requests.
|
|
515
|
+
async in_flight(args = {}) {
|
|
516
|
+
const reg = await registry();
|
|
517
|
+
let only = null;
|
|
518
|
+
if (args.product !== undefined) only = (await requireProduct(args.product, reg)).fields.registry_id;
|
|
519
|
+
const since = args.since === undefined ? null : isoDate(args.since, 'since');
|
|
520
|
+
const container = await typeNamed('container');
|
|
521
|
+
const all = await client.listAllEntities({ type: container.name, state: 'active' });
|
|
522
|
+
const registered = (row) => typeof row.fields?.product === 'string' && reg.products.has(row.fields.product);
|
|
523
|
+
const items = all.filter((row) => registered(row) && (!only || row.fields.product === only));
|
|
524
|
+
// D3 rule 2: a row names its product. An item with no registered product
|
|
525
|
+
// cannot be a row, so it is named here instead of dropped in silence.
|
|
526
|
+
// Slice L5's sweep is what gives these items a product.
|
|
527
|
+
const unnamed = all.filter((row) => !registered(row)).map((row) => ({
|
|
528
|
+
item: row.id, title: oneLine(row.title, 120), lifecycle: row.fields?.lifecycle ?? 'unknown',
|
|
529
|
+
...(row.fields?.sealed_at ? { legacy_seal: { sealed_at: row.fields.sealed_at, verdict: row.fields.seal_verdict ?? 'unknown' } } : {}),
|
|
530
|
+
reason: typeof row.fields?.product === 'string' && row.fields.product ? `product "${row.fields.product}" is not in the registry` : 'the item names no product; the slice L5 sweep gives it one',
|
|
531
|
+
}));
|
|
532
|
+
// The tenant holds dozens of these. The view keeps a bounded sample, the
|
|
533
|
+
// sealed ones first, and reports the true count (review H16).
|
|
534
|
+
// A product-scoped read gets the count and no rows: an item with no
|
|
535
|
+
// product cannot belong to the product the reader asked about.
|
|
536
|
+
const unregistered = only ? [] : [...unnamed.filter((row) => row.legacy_seal), ...unnamed.filter((row) => !row.legacy_seal)].slice(0, UNREGISTERED_KEPT);
|
|
537
|
+
const unregisteredTotal = unnamed.length;
|
|
538
|
+
if (items.length > MAX_ITEMS) refuse(`${items.length} items are registered; the view reads at most ${MAX_ITEMS}. Narrow it with a product id.`);
|
|
539
|
+
const byId = new Map(items.map((row) => [row.id, row]));
|
|
540
|
+
const marks = await marksOf(new Set(byId.keys()));
|
|
541
|
+
|
|
542
|
+
// Movement from git: every registered repo of the products in view, its
|
|
543
|
+
// recent pull requests, once per repo and shared by every item.
|
|
544
|
+
const repoIds = [...reg.repos.values()].filter((repo) => !only || repo.fields.product === only).map((repo) => repo.fields.registry_id).sort();
|
|
545
|
+
if (repoIds.length > MAX_REPOS) refuse(`${repoIds.length} repos are registered for this view; it reads at most ${MAX_REPOS}. Narrow it with a product id.`);
|
|
546
|
+
const { pullsByRepo, repoErrors } = await readPullsByRepo(repoIds, readGitHub);
|
|
547
|
+
|
|
548
|
+
const readAt = now();
|
|
549
|
+
const rows = [], delivered = [], notMarked = [];
|
|
550
|
+
for (const item of items) {
|
|
551
|
+
const fields = item.fields ?? {};
|
|
552
|
+
const own = marks.get(item.id) ?? [];
|
|
553
|
+
// R14: only a delivery mark replaces a legacy seal. A reopen mark, or
|
|
554
|
+
// any later mark kind, must not erase it (review H9, batch 13).
|
|
555
|
+
const legacy = own.some((mark) => mark.fields?.kind === 'delivery') ? null : legacySealOf(item);
|
|
556
|
+
// A reopen corrects the delivery it names. The corrected delivery stays
|
|
557
|
+
// in the graph and stays visible here, but it no longer stands: its
|
|
558
|
+
// pull requests go back to "merged, not marked" (design finding R9).
|
|
559
|
+
const correctedBy = correctionsOf(own);
|
|
560
|
+
const deliveries = own.filter((mark) => mark.fields.kind === 'delivery');
|
|
561
|
+
const standing = deliveries.filter((mark) => !correctedBy.has(mark.id));
|
|
562
|
+
const mine = pullsOfItem(item, pullsByRepo, repoIds);
|
|
563
|
+
const markedUrls = new Set(standing.flatMap((mark) => (mark.fields.prs ?? []).map((pull) => pull.url)));
|
|
564
|
+
const merged = mine.filter((pull) => pull.state === 'merged');
|
|
565
|
+
// R2: a delivery made outside a session shows as "merged, not marked"
|
|
566
|
+
// until the lead catches it up with the same done verb.
|
|
567
|
+
const open = merged.filter((pull) => !markedUrls.has(pull.url));
|
|
568
|
+
const { last, days } = movementOf({ item, marks: own, pulls: mine, legacy, readAt });
|
|
569
|
+
const state = fields.lifecycle === 'done' ? 'delivered' : (fields.delivery_state === 'reopened' ? 'reopened' : fields.lifecycle);
|
|
570
|
+
|
|
571
|
+
const inScope = fields.lifecycle === 'active' || fields.lifecycle === 'done';
|
|
572
|
+
for (const pull of (inScope ? open : [])) notMarked.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, pr: pull.url, title: pull.title, merged_at: pull.merged_at, days: daysBetween(pull.merged_at, readAt), matched_by: pull.matched_by, note: 'merged, not marked' });
|
|
573
|
+
for (const mark of deliveries) if (!since || String(mark.fields.marked_at ?? '') >= since) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: mark.id, verdict: correctedBy.has(mark.id) ? 'corrected' : (mark.fields.verdict ?? 'delivered'), ...(correctedBy.has(mark.id) ? { corrected_by: correctedBy.get(mark.id) } : {}), delivered_at: mark.fields.marked_at, prs: (mark.fields.prs ?? []).map((pull) => pull.url), deployed_commit: mark.fields.deployed?.commit ?? null, gate_warnings: mark.fields.gate_warnings ?? [] });
|
|
574
|
+
if (legacy && (!since || String(legacy.marked_at) >= since)) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: null, verdict: 'legacy', delivered_at: legacy.marked_at, prs: [], deployed_commit: null, source: legacy.source });
|
|
575
|
+
|
|
576
|
+
// D3 rule 1: in flight is active work. Delivered and paused items leave
|
|
577
|
+
// the rows; what was delivered since a date has its own list.
|
|
578
|
+
if (fields.lifecycle !== 'active') continue;
|
|
579
|
+
rows.push({
|
|
580
|
+
item: item.id, product: fields.product, stage: fields.stage ?? '', driver: fields.driver ?? '', days_since_last_change: days,
|
|
581
|
+
last_change_at: last?.at ?? null, last_change_source: last?.source ?? 'nothing recorded', delivery_state: state,
|
|
582
|
+
lines: [
|
|
583
|
+
// The id is what a reader types back, so the title gives way to it
|
|
584
|
+
// inside the line budget, as the "Needs me" row does (review G6).
|
|
585
|
+
oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
|
|
586
|
+
oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'} · next: ${fields.next_step || 'not set'}`),
|
|
587
|
+
oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}`),
|
|
588
|
+
],
|
|
589
|
+
folded: { title: item.title, next_step: fields.next_step ?? '', scope: fields.scope?.text ?? '', pieces: fields.split_children ?? [], prs: mine.map((pull) => ({ url: pull.url, state: pull.state, matched_by: pull.matched_by, merged_at: pull.merged_at, updated_at: pull.updated_at })), marks: own.map((mark) => ({ id: mark.id, kind: mark.fields.kind, verdict: mark.fields.verdict, at: mark.fields.marked_at })) },
|
|
590
|
+
citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
// The owner's rule: order by days since last change, latest on top, and
|
|
594
|
+
// show the days on the row.
|
|
595
|
+
rows.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
|
|
596
|
+
delivered.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
|
|
597
|
+
notMarked.sort((a, b) => (a.days ?? 0) - (b.days ?? 0));
|
|
598
|
+
const products = [...new Set(rows.map((row) => row.product))].sort();
|
|
599
|
+
const byProduct = products.map((product) => ({ product, rows: rows.filter((row) => row.product === product) }));
|
|
600
|
+
const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
|
|
601
|
+
if (notMarked.length) block.push([`merged, not marked (${notMarked.length})`, ...notMarked.map((row) => listLine(row.title, `${row.pr} · ${row.item} · merged ${row.days} day${row.days === 1 ? '' : 's'} ago`))].join('\n'));
|
|
602
|
+
if (repoErrors.length) block.push([`GitHub did not answer for ${repoErrors.length} repo${repoErrors.length === 1 ? '' : 's'}; movement below is from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
|
|
603
|
+
if (unregisteredTotal && !only) {
|
|
604
|
+
// The list can be long, and a long list is not a view. The sealed ones
|
|
605
|
+
// come first, because a legacy seal is a delivery the owner may want to
|
|
606
|
+
// see; the rest are a count and a pointer to the slice L5 sweep.
|
|
607
|
+
const sealed = unnamed.filter((row) => row.legacy_seal);
|
|
608
|
+
const shown = unregistered.slice(0, UNREGISTERED_SHOWN);
|
|
609
|
+
block.push([
|
|
610
|
+
`not in the registry (${unregisteredTotal}${sealed.length ? `, ${sealed.length} with a legacy seal` : ''}); the slice L5 sweep gives them a product`,
|
|
611
|
+
...shown.map((row) => listLine(row.title, `${row.item} · ${row.legacy_seal ? `legacy seal ${String(row.legacy_seal.sealed_at).slice(0, 10)}, verdict ${row.legacy_seal.verdict}` : row.lifecycle}`)),
|
|
612
|
+
...(unregisteredTotal > shown.length ? [` and ${unregisteredTotal - shown.length} more, ${unregistered.length - shown.length} of them in this answer`] : []),
|
|
613
|
+
].join('\n'));
|
|
614
|
+
}
|
|
615
|
+
if (since && delivered.length) block.push([`delivered since ${since} (${delivered.length})`, ...delivered.map((row) => listLine(row.item_title, `${row.verdict}${(row.gate_warnings ?? []).length ? `, gate warning on ${row.gate_warnings.map((warning) => warning.check).join(' ')}` : ''} · ${String(row.delivered_at ?? '').slice(0, 10)} · ${row.prs.join(' ') || 'no pull request on the mark'}`))].join('\n'));
|
|
616
|
+
return {
|
|
617
|
+
verb: 'in_flight', read_at: readAt, product: only, since, rows: rows.length, products: byProduct,
|
|
618
|
+
merged_not_marked: notMarked, delivered: since ? delivered : delivered.slice(0, 20), unregistered, unregistered_total: unregisteredTotal, repos_read: repoIds, repo_errors: repoErrors,
|
|
619
|
+
text: block.join('\n\n') || 'Nothing is in flight: no active item on a registered product.',
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
return verbs;
|
|
624
|
+
}
|