create-agent-rig 0.5.0 → 0.6.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/CHANGELOG.md +170 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Queue adapter: Jira issues, via the REST API.
|
|
2
|
+
// All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
2
3
|
//
|
|
3
4
|
// The second adapter exists to prove the seam holds: everything about *selection*
|
|
4
5
|
// lives in `core.mjs` and is imported, not re-derived. An adapter that answers
|
|
@@ -17,8 +18,14 @@
|
|
|
17
18
|
//
|
|
18
19
|
// { "adapter": "jira", "options": { "project": "ABC" } }
|
|
19
20
|
// { "adapter": "jira", "options": { "jql": "project = ABC AND ..." } }
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// { "adapter": "jira", "options": { "project": "ABC", "owner": "my-repo" } }
|
|
22
|
+
//
|
|
23
|
+
// `owner` names this checkout for the `owner-<name>` label (AR-132): an item
|
|
24
|
+
// marked for another repository is held, and a checkout that declares no
|
|
25
|
+
// owner holds every marked item, since it cannot confirm a match.
|
|
26
|
+
import { duplicateOf, fingerprintOf, validateProposal, ownerOfLabels, lifecycleOf } from './core.mjs';
|
|
27
|
+
import { withAsOf } from './as-of.mjs';
|
|
28
|
+
import { recordEscalation, recordTakeUp } from '../run-state.mjs';
|
|
22
29
|
|
|
23
30
|
export const name = 'jira';
|
|
24
31
|
|
|
@@ -101,8 +108,21 @@ export const toTicket = (issue) => {
|
|
|
101
108
|
tier: labels.includes('elevated') ? 'elevated' : 'normal',
|
|
102
109
|
blockedBy,
|
|
103
110
|
blocks,
|
|
104
|
-
|
|
111
|
+
// English names first; a localised board ("Höchste", "Mittel") falls back
|
|
112
|
+
// to the numeric priority id. Neither → 999.
|
|
113
|
+
priority:
|
|
114
|
+
PRIORITY[String(fields.priority?.name ?? '').toLowerCase()] ??
|
|
115
|
+
(Number.isFinite(Number(fields.priority?.id)) && Number(fields.priority?.id) > 0
|
|
116
|
+
? Number(fields.priority?.id)
|
|
117
|
+
: 999),
|
|
105
118
|
createdAt: toIso(fields.created),
|
|
119
|
+
// The take-up marker for revalidation at SELECT (`core.mjs` › revalidationOf):
|
|
120
|
+
// the tracker's own last-modified field. That it moves on every status
|
|
121
|
+
// change, edit and comment is Jira's contract, assumed and not checked
|
|
122
|
+
// here. `null` when the search did not carry it — never `''`, which would
|
|
123
|
+
// compare equal to itself and read as "unchanged" where the truth is "not
|
|
124
|
+
// looked".
|
|
125
|
+
updatedAt: toIso(fields.updated),
|
|
106
126
|
// Flattened from the document description — the same text this adapter
|
|
107
127
|
// already reads internally, now visible to the shared hygiene checks.
|
|
108
128
|
body: descriptionTextOf(issue) || null,
|
|
@@ -112,6 +132,11 @@ export const toTicket = (issue) => {
|
|
|
112
132
|
: labels.includes('trigger-human')
|
|
113
133
|
? 'human'
|
|
114
134
|
: null,
|
|
135
|
+
// The repository this item belongs to (AR-132): `owner-<name>`, or null.
|
|
136
|
+
owner: ownerOfLabels(labels),
|
|
137
|
+
// The lifecycle and the scheduling flag (AR-144): `lifecycleOf` above the seam
|
|
138
|
+
// owns the semantics; this adapter only hands it the labels.
|
|
139
|
+
...lifecycleOf(labels),
|
|
115
140
|
};
|
|
116
141
|
};
|
|
117
142
|
|
|
@@ -148,8 +173,55 @@ export const EXCLUDED_LABELS = ['triage', 'operator-queue'];
|
|
|
148
173
|
* innermost groups. `AND` binds tighter than `OR` in JQL, so the flat form means
|
|
149
174
|
* `(a AND b) OR empty`, which is the intent.
|
|
150
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* A Jira project key: one uppercase letter, then up to nine of [A-Z0-9_].
|
|
178
|
+
* Exported so the refusal below can name the rule it applied (AR-51).
|
|
179
|
+
*/
|
|
180
|
+
export const PROJECT_KEY = /^[A-Z][A-Z0-9_]{1,9}$/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The one place `options.project` and `options.jql` from `.claude/queue.json`
|
|
184
|
+
* reach the query. Both used to be interpolated raw (AR-51): a committed
|
|
185
|
+
* `queue.json` — a file a pull request can edit — could make this adapter read
|
|
186
|
+
* another board, or anything the JQL grammar allows. A project key is now
|
|
187
|
+
* validated against PROJECT_KEY, and an explicit `jql` must begin with
|
|
188
|
+
* `project = <KEY>` — the same key when `options.project` is also given — so
|
|
189
|
+
* an override has to NAME this board. ⚠ Naming is not confinement: a query
|
|
190
|
+
* that leads with `project = AR` may still say `OR project = X` after it, and
|
|
191
|
+
* the credential's own scope is what bounds that. The residual is accepted
|
|
192
|
+
* because `.claude/queue.json` is part of the rulebook (`guard-rulebook`) and
|
|
193
|
+
* a declared elevated path, so a change widening it reaches the model lane.
|
|
194
|
+
*/
|
|
195
|
+
/** The project key a config names — options.project, or the key options.jql leads with. */
|
|
196
|
+
export const projectKeyOf = ({ project = null, jql = null } = {}) => {
|
|
197
|
+
buildJql({ project, jql }); // the same refusals, once
|
|
198
|
+
if (project) return String(project);
|
|
199
|
+
return /^\s*project\s*=\s*([A-Z][A-Z0-9_]{1,9})\b/.exec(String(jql))[1];
|
|
200
|
+
};
|
|
201
|
+
|
|
151
202
|
export const buildJql = ({ project = null, jql = null } = {}) => {
|
|
152
|
-
if (
|
|
203
|
+
if (project !== null && project !== undefined && !PROJECT_KEY.test(String(project))) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`options.project ${JSON.stringify(project)} is not a Jira project key — it must match ` +
|
|
206
|
+
`${PROJECT_KEY.source}. It is interpolated into JQL, so anything else is refused, not quoted.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (jql) {
|
|
210
|
+
const lead = /^\s*project\s*=\s*([A-Z][A-Z0-9_]{1,9})\b/.exec(String(jql));
|
|
211
|
+
if (!lead) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'options.jql must begin with `project = <KEY>` (a key matching ' +
|
|
214
|
+
`${PROJECT_KEY.source}) — a query that does not name its project can read any board.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (project && lead[1] !== project) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`options.jql names project ${lead[1]} while options.project is ${project} — an override ` +
|
|
220
|
+
'may narrow the query, never point it at another board.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return jql;
|
|
224
|
+
}
|
|
153
225
|
if (!project) {
|
|
154
226
|
throw new Error(
|
|
155
227
|
'the jira adapter needs either options.project or options.jql in ' +
|
|
@@ -186,23 +258,91 @@ export const requireCredentials = (env = process.env) => {
|
|
|
186
258
|
return { baseUrl, email: env.JIRA_EMAIL, token: env.JIRA_API_TOKEN };
|
|
187
259
|
};
|
|
188
260
|
|
|
189
|
-
|
|
261
|
+
/** Statuses worth one more try: rate-limited, or a gateway that will be back. */
|
|
262
|
+
const TRANSIENT = new Set([429, 502, 503, 504]);
|
|
263
|
+
const MAX_ATTEMPTS = 4;
|
|
264
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
265
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The wait before the next attempt: `Retry-After` in seconds when the server
|
|
269
|
+
* names one, otherwise 500 ms doubling per attempt. Bounded by the attempt cap.
|
|
270
|
+
*/
|
|
271
|
+
const MAX_RETRY_AFTER_MS = 60_000;
|
|
272
|
+
const retryDelayMs = (response, attempt) => {
|
|
273
|
+
// Seconds form only; the HTTP-date form is not parsed and falls to backoff
|
|
274
|
+
// (› "falls back to the backoff when Retry-After is an HTTP-date, which it
|
|
275
|
+
// does not parse"). Capped, because a header is input like any other:
|
|
276
|
+
// `Retry-After: 86400` must not sleep the loop for a day (› "caps Retry-After
|
|
277
|
+
// so a hostile header cannot sleep the loop for a day").
|
|
278
|
+
const header = Number(response?.headers?.get?.('Retry-After'));
|
|
279
|
+
if (Number.isFinite(header) && header > 0) return Math.min(header * 1000, MAX_RETRY_AFTER_MS);
|
|
280
|
+
return 500 * 2 ** (attempt - 1);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One HTTP call to Jira, with the three things AR-54 added to it:
|
|
285
|
+
*
|
|
286
|
+
* - a timeout (`timeoutMs`, default 20 s) through an AbortController — a stalled
|
|
287
|
+
* connection used to block selection forever, and a loop that cannot read its
|
|
288
|
+
* queue must stop, not hang;
|
|
289
|
+
* - a retry on 429/502/503/504, at most `MAX_ATTEMPTS`, honouring `Retry-After`;
|
|
290
|
+
* 401/403/404 and every other status fail at once — a bad credential is not
|
|
291
|
+
* transient, and retrying it only delays the diagnosis;
|
|
292
|
+
* - `retry.sleep` injectable, so a test measures the delay it would have waited
|
|
293
|
+
* instead of waiting it.
|
|
294
|
+
*
|
|
295
|
+
* Pinned in the generator's `test/template/queue-jira.test.ts` (absent in a
|
|
296
|
+
* generated rig) › "hands fetch an AbortSignal", › "rejects naming the timeout
|
|
297
|
+
* and the route when fetch never resolves", › "a 429 followed by a 200 yields
|
|
298
|
+
* the 200 body", › "sleeps for the Retry-After the 429 carried, in
|
|
299
|
+
* milliseconds", › "gives up after four consecutive 503s, naming the status and
|
|
300
|
+
* the attempts" and › "does not retry a 401 — a bad credential is not transient".
|
|
301
|
+
*/
|
|
302
|
+
const request = async (
|
|
303
|
+
route,
|
|
304
|
+
{ method = 'GET', body = null, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, retry = {} } = {},
|
|
305
|
+
) => {
|
|
190
306
|
const { baseUrl, email, token } = requireCredentials(env);
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
307
|
+
const sleep = retry.sleep ?? defaultSleep;
|
|
308
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
311
|
+
let response;
|
|
312
|
+
let payload = null;
|
|
313
|
+
try {
|
|
314
|
+
response = await fetch(`${baseUrl}${route}`, {
|
|
315
|
+
method,
|
|
316
|
+
headers: {
|
|
317
|
+
Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
|
|
318
|
+
Accept: 'application/json',
|
|
319
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
320
|
+
},
|
|
321
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
322
|
+
signal: controller.signal,
|
|
323
|
+
});
|
|
324
|
+
// The body is read INSIDE the timed region: headers can arrive and the
|
|
325
|
+
// body then stall, which is the same hung connection with a 200 on it
|
|
326
|
+
// (› "keeps the timeout armed while the body is read").
|
|
327
|
+
if (response.ok && response.status !== 204) payload = await response.json();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error?.name === 'AbortError' || controller.signal.aborted) {
|
|
330
|
+
throw new Error(`jira ${method} ${route} timed out after ${timeoutMs} ms`, { cause: error });
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
}
|
|
336
|
+
if (response.ok) return payload;
|
|
337
|
+
if (TRANSIENT.has(response.status) && attempt < MAX_ATTEMPTS) {
|
|
338
|
+
await sleep(retryDelayMs(response, attempt));
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
201
341
|
// The status alone; never echo the response body, which can carry the token
|
|
202
342
|
// back in an error envelope.
|
|
203
|
-
|
|
343
|
+
const attempts = TRANSIENT.has(response.status) ? ` after ${attempt} attempts` : '';
|
|
344
|
+
throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}${attempts}`);
|
|
204
345
|
}
|
|
205
|
-
return response.status === 204 ? null : response.json();
|
|
206
346
|
};
|
|
207
347
|
|
|
208
348
|
// `description` is requested because the triage dedupe matches the fingerprint
|
|
@@ -214,14 +354,26 @@ const request = async (route, { method = 'GET', body = null, env = process.env }
|
|
|
214
354
|
// decides how hard this is to notice: sending the joined form the retired query
|
|
215
355
|
// parameter wanted answers `400 Invalid request payload`, not a 200 with empty
|
|
216
356
|
// fields. It fails loudly, so a wrong value here cannot sit undetected.
|
|
217
|
-
const FIELDS = [
|
|
357
|
+
const FIELDS = [
|
|
358
|
+
'summary',
|
|
359
|
+
'status',
|
|
360
|
+
'labels',
|
|
361
|
+
'priority',
|
|
362
|
+
'created',
|
|
363
|
+
'updated',
|
|
364
|
+
'issuelinks',
|
|
365
|
+
'description',
|
|
366
|
+
];
|
|
218
367
|
|
|
219
368
|
// --- the adapter contract ------------------------------------------------------
|
|
220
369
|
|
|
221
370
|
/**
|
|
222
371
|
* Query fresh every time — the queue changes as the loop closes items and
|
|
223
372
|
* unblocks their dependents, so a list read at the start of a run is wrong by the
|
|
224
|
-
* second task. `issues` is the offline seam the tests use.
|
|
373
|
+
* second task. `issues` is the offline seam the tests use. Since AR-54 `limit`
|
|
374
|
+
* is the PAGE size, not a result cap: `search` walks every page up to its own
|
|
375
|
+
* `hardCap`, which this function leaves at the default (› "returns both pages
|
|
376
|
+
* as one list").
|
|
225
377
|
*/
|
|
226
378
|
export const listEligible = async ({
|
|
227
379
|
issues = null,
|
|
@@ -254,18 +406,106 @@ export const listEligible = async ({
|
|
|
254
406
|
* path and not the auth. Both searching call sites (`listEligible` and the
|
|
255
407
|
* `proposeTriage` dedupe) come through here, which is why one fix covers both.
|
|
256
408
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
409
|
+
* Pages through `nextPageToken` until the server sends none, or `hardCap`
|
|
410
|
+
* issues (default 1000) are in hand, or `maxPages` requests (default 100) have
|
|
411
|
+
* been made — each cap is announced on stderr, never
|
|
412
|
+
* silent, because a board whose tail is dropped is exactly the board the loop
|
|
413
|
+
* would otherwise believe it had read. `timeoutMs` and `retry` travel down to
|
|
414
|
+
* every page. Pinned in the generator's `test/template/queue-jira.test.ts`
|
|
415
|
+
* (absent in a generated rig) › "returns both pages as one list", › "sends the
|
|
416
|
+
* token from page 1 in the body of the request for page 2" and › "stops at
|
|
417
|
+
* hardCap and says on stderr that the list was capped".
|
|
262
418
|
*/
|
|
263
|
-
export const search = async ({
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
419
|
+
export const search = async ({
|
|
420
|
+
project = null,
|
|
421
|
+
jql = null,
|
|
422
|
+
limit = 100,
|
|
423
|
+
env = process.env,
|
|
424
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
425
|
+
retry = {},
|
|
426
|
+
hardCap = 1000,
|
|
427
|
+
maxPages = 100,
|
|
428
|
+
} = {}) => {
|
|
429
|
+
const query = buildJql({ project, jql });
|
|
430
|
+
const issues = [];
|
|
431
|
+
// Bounds beside hardCap, because a cap on issues alone is no bound at all
|
|
432
|
+
// against a server that repeats a token: a token equal to the one just sent
|
|
433
|
+
// ends the walk, and so does `maxPages` (default 100 requests), each with a
|
|
434
|
+
// stderr line. An EMPTY page with a fresh token is NOT a stop — the enhanced
|
|
435
|
+
// search endpoint may return short or empty pages while later pages exist,
|
|
436
|
+
// and stopping there drops a real tail (› "keeps walking past an empty page
|
|
437
|
+
// that carries a fresh token", › "stops paging when a page brings no issues,
|
|
438
|
+
// even if the token repeats", › "caps the number of requests outright, and
|
|
439
|
+
// says so on stderr").
|
|
440
|
+
let nextPageToken = null;
|
|
441
|
+
let pages = 0;
|
|
442
|
+
do {
|
|
443
|
+
pages += 1;
|
|
444
|
+
const page = await request('/rest/api/3/search/jql', {
|
|
445
|
+
method: 'POST',
|
|
446
|
+
body: {
|
|
447
|
+
jql: query,
|
|
448
|
+
maxResults: limit,
|
|
449
|
+
fields: FIELDS,
|
|
450
|
+
...(nextPageToken ? { nextPageToken } : {}),
|
|
451
|
+
},
|
|
452
|
+
env,
|
|
453
|
+
timeoutMs,
|
|
454
|
+
retry,
|
|
455
|
+
});
|
|
456
|
+
const received = page?.issues ?? [];
|
|
457
|
+
issues.push(...received.slice(0, Math.max(0, hardCap - issues.length)));
|
|
458
|
+
const sent = nextPageToken;
|
|
459
|
+
nextPageToken = page?.isLast === true ? null : (page?.nextPageToken ?? null);
|
|
460
|
+
if (nextPageToken && nextPageToken === sent) {
|
|
461
|
+
process.stderr.write(
|
|
462
|
+
`jira search: the server repeated page token ${JSON.stringify(sent)} — ` +
|
|
463
|
+
'stopping the walk; the tail of this board may not have been read\n',
|
|
464
|
+
);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
if (nextPageToken && pages >= maxPages) {
|
|
468
|
+
process.stderr.write(
|
|
469
|
+
`jira search: capped at ${maxPages} requests with more pages available — ` +
|
|
470
|
+
'the tail of this board was not read; raise maxPages or narrow the JQL\n',
|
|
471
|
+
);
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
if (issues.length >= hardCap && nextPageToken) {
|
|
475
|
+
process.stderr.write(
|
|
476
|
+
`jira search: capped at ${hardCap} issues with more pages available — ` +
|
|
477
|
+
'the tail of this board was not read; raise hardCap or narrow the JQL\n',
|
|
478
|
+
);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
} while (nextPageToken);
|
|
482
|
+
return { issues };
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* One item by key, mapped raw — closed included. `listEligible` drops closed
|
|
487
|
+
* items because selection must never take one; the close point needs to see
|
|
488
|
+
* exactly that one. Honours the same offline `issues` seam.
|
|
489
|
+
*/
|
|
490
|
+
export const find = async (id, { issues = null, env = process.env } = {}) => {
|
|
491
|
+
if (issues) {
|
|
492
|
+
return issues.map(toTicket).find((ticket) => String(ticket.id) === String(id)) ?? null;
|
|
493
|
+
}
|
|
494
|
+
// 🔴 By key, never through `search`: `buildJql` carries `statusCategory != Done`
|
|
495
|
+
// for selection's sake, so a search can never return the closed item this
|
|
496
|
+
// point exists to see. A 404 is "the tracker has no such item" — `null`;
|
|
497
|
+
// every other failure is raised as it is.
|
|
498
|
+
try {
|
|
499
|
+
const issue = await request(
|
|
500
|
+
`/rest/api/3/issue/${encodeURIComponent(String(id))}?fields=${FIELDS.join(',')}`,
|
|
501
|
+
{ env },
|
|
502
|
+
);
|
|
503
|
+
return issue ? toTicket(issue) : null;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (/ 404 /.test(String(error?.message))) return null;
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
269
509
|
|
|
270
510
|
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
271
511
|
|
|
@@ -274,6 +514,52 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
|
|
|
274
514
|
* worked while it still reads as available is invisible to the human and
|
|
275
515
|
* re-selectable by the very next query.
|
|
276
516
|
*/
|
|
517
|
+
/**
|
|
518
|
+
* Re-record the item's marker after a write of this adapter's own (AR-140).
|
|
519
|
+
*
|
|
520
|
+
* Every write here — a claim, a comment, a close, an escalation — moves the
|
|
521
|
+
* tracker's `updated`, and the next revalidation compared against the take-up
|
|
522
|
+
* from before it — the generator's journal records one run whose every
|
|
523
|
+
* BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
|
|
524
|
+
* over that run). So the marker is read back after the write
|
|
525
|
+
* and recorded as the take-up in the declared run; a hold that still fires is
|
|
526
|
+
* a move by something other than this adapter.
|
|
527
|
+
*
|
|
528
|
+
* ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
|
|
529
|
+
* session posts by another route — a REST call by hand, a connector — moves
|
|
530
|
+
* the marker like anyone else's, and the next check holds on it.
|
|
531
|
+
*
|
|
532
|
+
* Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
|
|
533
|
+
* and a read-back the tracker refused or a stale run directory is announced on
|
|
534
|
+
* stderr, never thrown — a thrown write is retried and lands twice.
|
|
535
|
+
*/
|
|
536
|
+
const recordMarker = (ticket, updatedAt, env) => {
|
|
537
|
+
try {
|
|
538
|
+
recordTakeUp(env.RIG_RUN_DIR, { id: ticket.id, updatedAt });
|
|
539
|
+
} catch (error) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
`${ticket.id}: the write landed, but its marker was NOT re-recorded in ` +
|
|
542
|
+
`${env.RIG_RUN_DIR} — ${error.message}\n`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const rebaseline = async (ticket, env) => {
|
|
548
|
+
if (!env?.RIG_RUN_DIR) return;
|
|
549
|
+
let updatedAt;
|
|
550
|
+
try {
|
|
551
|
+
const after = await request(`/rest/api/3/issue/${ticket.id}?fields=updated`, { env });
|
|
552
|
+
updatedAt = toIso(after?.fields?.updated);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
process.stderr.write(
|
|
555
|
+
`${ticket.id}: the write landed, but its marker was NOT re-recorded in ` +
|
|
556
|
+
`${env.RIG_RUN_DIR} — ${error.message}\n`,
|
|
557
|
+
);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
recordMarker(ticket, updatedAt, env);
|
|
561
|
+
};
|
|
562
|
+
|
|
277
563
|
export const claim = async (ticket, { transitionId = null, env = process.env } = {}) => {
|
|
278
564
|
if (!transitionId) {
|
|
279
565
|
const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
|
|
@@ -293,6 +579,7 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
|
|
|
293
579
|
body: { transition: { id: transitionId } },
|
|
294
580
|
env,
|
|
295
581
|
});
|
|
582
|
+
await rebaseline(ticket, env);
|
|
296
583
|
return { ok: true };
|
|
297
584
|
};
|
|
298
585
|
|
|
@@ -304,9 +591,17 @@ export const comment = async (ticket, body, { env = process.env } = {}) => {
|
|
|
304
591
|
},
|
|
305
592
|
env,
|
|
306
593
|
});
|
|
594
|
+
await rebaseline(ticket, env);
|
|
307
595
|
return { ok: true };
|
|
308
596
|
};
|
|
309
597
|
|
|
598
|
+
/**
|
|
599
|
+
* 🔴 `transitioned` is read back from the tracker, never inferred from the
|
|
600
|
+
* argument. The first version returned `Boolean(transitionId)` — a fact about
|
|
601
|
+
* the call, reported as a fact about the issue — so a transition the workflow
|
|
602
|
+
* rejected, or one that landed in a status outside the `done` category, was
|
|
603
|
+
* published as a close (AR-135).
|
|
604
|
+
*/
|
|
310
605
|
export const close = async (ticket, { prUrl = null, transitionId = null, env = process.env } = {}) => {
|
|
311
606
|
await comment(ticket, prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.', { env });
|
|
312
607
|
if (transitionId) {
|
|
@@ -316,7 +611,20 @@ export const close = async (ticket, { prUrl = null, transitionId = null, env = p
|
|
|
316
611
|
env,
|
|
317
612
|
});
|
|
318
613
|
}
|
|
319
|
-
|
|
614
|
+
// One read-back for both facts the close needs: the status that proves the
|
|
615
|
+
// transition, and the marker the write produced (AR-140) — `comment` above
|
|
616
|
+
// already re-baselined once; this is the read after the transition.
|
|
617
|
+
const after = await request(`/rest/api/3/issue/${ticket.id}?fields=status,updated`, { env });
|
|
618
|
+
const fields = after?.fields ?? {};
|
|
619
|
+
// Through the same announce-never-throw path as `rebaseline`: the round
|
|
620
|
+
// that inlined `recordTakeUp` here reintroduced a throw on a stale run
|
|
621
|
+
// directory after the transition had landed.
|
|
622
|
+
if (env?.RIG_RUN_DIR) recordMarker(ticket, toIso(fields.updated), env);
|
|
623
|
+
return {
|
|
624
|
+
ok: true,
|
|
625
|
+
transitioned: statusCategory(fields) === 'done',
|
|
626
|
+
status: fields.status?.name ?? null,
|
|
627
|
+
};
|
|
320
628
|
};
|
|
321
629
|
|
|
322
630
|
/**
|
|
@@ -331,6 +639,7 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
|
|
|
331
639
|
body: { update: { labels: [{ add: 'escalated' }] } },
|
|
332
640
|
env,
|
|
333
641
|
});
|
|
642
|
+
await rebaseline(ticket, env);
|
|
334
643
|
// Counted through the one recorder, never a counter of this adapter's own —
|
|
335
644
|
// "twice in a row" has to mean the same thing on every tracker.
|
|
336
645
|
recordEscalation(env.RIG_RUN_DIR);
|
|
@@ -352,8 +661,10 @@ export const triageItemFor = (proposal) => {
|
|
|
352
661
|
`- part to change — ${proposal.part}`,
|
|
353
662
|
`- proposed change — ${proposal.change}`,
|
|
354
663
|
`- how the next run proves it — ${proposal.proof}`,
|
|
664
|
+
...(proposal.measured ? [`- measured — ${proposal.measured}`, `- inferred — ${proposal.inferred}`] : []),
|
|
355
665
|
'',
|
|
356
666
|
`fingerprint: ${fingerprint}`,
|
|
667
|
+
...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
|
|
357
668
|
'',
|
|
358
669
|
'The loop proposes; the owner patches. Self-applying a change to its own',
|
|
359
670
|
'rulebook is how an unattended run drifts irreversibly.',
|
|
@@ -365,22 +676,36 @@ export const triageItemFor = (proposal) => {
|
|
|
365
676
|
};
|
|
366
677
|
|
|
367
678
|
/** File the proposal, or increment the one already carrying this fingerprint. */
|
|
679
|
+
/**
|
|
680
|
+
* The proposals on file, as `{ id, body }` — every `triage`-labelled issue, the
|
|
681
|
+
* body being its DESCRIPTION, which is where the fingerprint and `asOf` were
|
|
682
|
+
* written. An earlier dedupe mapped candidates through `toTicket` — which emits
|
|
683
|
+
* no body at all — so the predicate was always false and twenty identical stops
|
|
684
|
+
* filed twenty issues against the tracker.
|
|
685
|
+
*/
|
|
686
|
+
export const listProposals = async ({
|
|
687
|
+
existing = null,
|
|
688
|
+
project = null,
|
|
689
|
+
jql = null,
|
|
690
|
+
env = process.env,
|
|
691
|
+
retry = {},
|
|
692
|
+
} = {}) => {
|
|
693
|
+
if (existing) return existing;
|
|
694
|
+
// Project-qualified, like every query this adapter sends (AR-51): the key is
|
|
695
|
+
// options.project, or the one options.jql leads with — `buildJql` refuses
|
|
696
|
+
// both when they disagree, so the triage query can only read this board.
|
|
697
|
+
const key = projectKeyOf({ project, jql });
|
|
698
|
+
const response = await search({ jql: `project = ${key} AND labels = triage ORDER BY created DESC`, env, retry });
|
|
699
|
+
return response.issues.map((issue) => ({ id: issue.key, body: descriptionTextOf(issue) }));
|
|
700
|
+
};
|
|
701
|
+
|
|
368
702
|
export const proposeTriage = async (
|
|
369
|
-
|
|
370
|
-
{ project = null, existing = null, env = process.env } = {},
|
|
703
|
+
rawProposal,
|
|
704
|
+
{ project = null, jql = null, existing = null, env = process.env, retry = {} } = {},
|
|
371
705
|
) => {
|
|
706
|
+
const proposal = withAsOf(rawProposal);
|
|
372
707
|
const item = triageItemFor(proposal);
|
|
373
|
-
|
|
374
|
-
// written. The previous version mapped candidates through `toTicket` — which
|
|
375
|
-
// emits no body at all — so the predicate was always false and twenty identical
|
|
376
|
-
// stops filed twenty issues against the tracker.
|
|
377
|
-
const found =
|
|
378
|
-
existing ??
|
|
379
|
-
(await search({ jql: 'labels = triage ORDER BY created DESC', env })).issues.map((issue) => ({
|
|
380
|
-
id: issue.key,
|
|
381
|
-
body: descriptionTextOf(issue),
|
|
382
|
-
}));
|
|
383
|
-
const duplicate = duplicateOf(item, found);
|
|
708
|
+
const duplicate = duplicateOf(item, await listProposals({ existing, project, jql, env, retry }));
|
|
384
709
|
|
|
385
710
|
if (duplicate) {
|
|
386
711
|
await comment(duplicate, `Seen again (fingerprint ${item.fingerprint}). Incrementing.`, { env });
|
|
@@ -389,7 +714,7 @@ export const proposeTriage = async (
|
|
|
389
714
|
if (!project) {
|
|
390
715
|
throw new Error('filing a triage proposal needs options.project');
|
|
391
716
|
}
|
|
392
|
-
await request('/rest/api/3/issue', {
|
|
717
|
+
const created = await request('/rest/api/3/issue', {
|
|
393
718
|
method: 'POST',
|
|
394
719
|
body: {
|
|
395
720
|
fields: {
|
|
@@ -406,5 +731,29 @@ export const proposeTriage = async (
|
|
|
406
731
|
},
|
|
407
732
|
env,
|
|
408
733
|
});
|
|
409
|
-
|
|
734
|
+
// The proposal's own baseline (AR-138): its marker as filed, recorded as a
|
|
735
|
+
// take-up in the run that filed it, so the next run that is offered it
|
|
736
|
+
// compares against something. Read back rather than assumed — the marker is
|
|
737
|
+
// the tracker's, and `created` carries only the key. No run directory →
|
|
738
|
+
// nothing recorded, and the proposal is still filed.
|
|
739
|
+
//
|
|
740
|
+
// 🔴 Best-effort, and it says so: the proposal is FILED by now, and a throw
|
|
741
|
+
// here — a read-back the tracker refused, a stale or unwritable run
|
|
742
|
+
// directory in `updateState` — would tell the caller the filing failed when
|
|
743
|
+
// it succeeded, and the natural response (file again) double-files. The same
|
|
744
|
+
// defect `recordEscalation` closes for escalations; announced on stderr,
|
|
745
|
+
// because a baseline silently missing reads as a first sight later.
|
|
746
|
+
const id = created?.key ?? null;
|
|
747
|
+
if (id && env.RIG_RUN_DIR) {
|
|
748
|
+
try {
|
|
749
|
+
const after = await request(`/rest/api/3/issue/${id}?fields=updated`, { env });
|
|
750
|
+
recordTakeUp(env.RIG_RUN_DIR, { id, updatedAt: toIso(after?.fields?.updated) });
|
|
751
|
+
} catch (error) {
|
|
752
|
+
process.stderr.write(
|
|
753
|
+
`proposeTriage: ${id} is filed, but its baseline was NOT recorded in ` +
|
|
754
|
+
`${env.RIG_RUN_DIR} — ${error.message}\n`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return { ok: true, filed: item.title, id, item };
|
|
410
759
|
};
|