@yemi33/minions 0.1.2087 → 0.1.2088
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/dashboard/js/refresh.js +598 -160
- package/dashboard/js/render-dispatch.js +77 -0
- package/dashboard/js/render-inbox.js +72 -0
- package/dashboard/js/render-meetings.js +55 -0
- package/dashboard/js/render-plans.js +14 -9
- package/dashboard/js/render-prd.js +13 -6
- package/dashboard/js/render-prs.js +55 -0
- package/dashboard/js/render-watches.js +16 -0
- package/dashboard/js/render-work-items.js +70 -0
- package/dashboard/js/settings.js +1 -5
- package/dashboard/js/state.js +9 -3
- package/dashboard.js +400 -358
- package/docs/security.md +23 -0
- package/engine/ado.js +54 -54
- package/engine/cli.js +3 -38
- package/engine/db/index.js +1 -1
- package/engine/db/migrations/002-dispatches.js +1 -1
- package/engine/db/migrations/003-work-items.js +1 -1
- package/engine/db/migrations/004-pull-requests.js +1 -1
- package/engine/dispatch.js +8 -2
- package/engine/github.js +38 -38
- package/engine/lifecycle.js +192 -18
- package/engine/projects.js +92 -0
- package/engine/queries.js +61 -129
- package/engine/shared.js +85 -89
- package/engine/watches.js +5 -5
- package/engine.js +23 -34
- package/package.json +2 -2
package/docs/security.md
CHANGED
|
@@ -71,6 +71,29 @@ system. Its threat model:
|
|
|
71
71
|
- Baseline **security headers** (CSP, `X-Content-Type-Options`,
|
|
72
72
|
`Referrer-Policy`, clickjacking protections) applied to every response
|
|
73
73
|
via `shared.buildSecurityHeaders()`.
|
|
74
|
+
- **Narrowed `Access-Control-Allow-Origin` on GET/HEAD reads**
|
|
75
|
+
(P-bfa2c-cors-wildcard). Before this change the dashboard echoed
|
|
76
|
+
`Access-Control-Allow-Origin: *` on every read response, which let
|
|
77
|
+
any cross-origin browser page (e.g. `https://attacker.com`) issue
|
|
78
|
+
`fetch('http://localhost:7331/api/*')` and **read** the JSON body —
|
|
79
|
+
exposing operator-private state (config, PR data, work items, agent
|
|
80
|
+
transcripts). The GET/HEAD prelude now echoes ACAO **only** when the
|
|
81
|
+
request's `Origin` header matches the dashboard's own served origin
|
|
82
|
+
(`http://localhost:7331`) or an entry in
|
|
83
|
+
`config.engine.allowedDashboardOrigins` (default `[]`). Requests
|
|
84
|
+
without an `Origin` header (curl, uptime monitors, Node
|
|
85
|
+
`http.request`) receive **no** ACAO header — that path was already
|
|
86
|
+
cross-origin-unreadable and is preserved verbatim. To opt a
|
|
87
|
+
reverse-proxy origin in, set in `config.json`:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{ "engine": { "allowedDashboardOrigins": ["https://minions.example.com"] } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Entries are matched verbatim against the request `Origin` header
|
|
94
|
+
(scheme + host + port; no path, no wildcards). See
|
|
95
|
+
`shared.isAllowedDashboardOrigin()` in
|
|
96
|
+
[`engine/shared.js`](../engine/shared.js).
|
|
74
97
|
|
|
75
98
|
### Residual risks tracked elsewhere
|
|
76
99
|
|
package/engine/ado.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const childProcess = require('child_process');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
|
|
9
|
+
const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, BUILD_STATUS, REVIEW_STATUS, FETCH_TIMEOUT_MS, ADO_TOKEN_REFRESH_MAX_RETRIES, createThrottleTracker } = shared;
|
|
10
10
|
const { getPrs } = require('./queries');
|
|
11
11
|
const { mutateJsonFileLocked } = shared;
|
|
12
12
|
const { acquireAdoToken } = require('./ado-token');
|
|
@@ -396,16 +396,16 @@ function applyAdoPrMetadata(pr, prData) {
|
|
|
396
396
|
|
|
397
397
|
/** Classify an array of ADO build records into a single status string. */
|
|
398
398
|
function classifyBuildStatus(prBuilds) {
|
|
399
|
-
if (!prBuilds.length) return
|
|
399
|
+
if (!prBuilds.length) return BUILD_STATUS.NONE;
|
|
400
400
|
// partiallySucceeded = warnings, not failures — counts as passing
|
|
401
401
|
const hasFailed = prBuilds.some(b => b.result === 'failed' || b.result === 'canceled');
|
|
402
402
|
const allDone = prBuilds.every(b => b.status === 'completed');
|
|
403
403
|
const allPassed = prBuilds.every(b => b.result === 'succeeded' || b.result === 'partiallySucceeded');
|
|
404
404
|
const hasRunning = prBuilds.some(b => b.status === 'inProgress' || b.status === 'notStarted');
|
|
405
|
-
if (hasFailed && allDone) return
|
|
406
|
-
if (allDone && allPassed) return
|
|
407
|
-
if (hasRunning) return
|
|
408
|
-
return
|
|
405
|
+
if (hasFailed && allDone) return BUILD_STATUS.FAILING;
|
|
406
|
+
if (allDone && allPassed) return BUILD_STATUS.PASSING;
|
|
407
|
+
if (hasRunning) return BUILD_STATUS.RUNNING;
|
|
408
|
+
return BUILD_STATUS.NONE;
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
/**
|
|
@@ -424,10 +424,10 @@ function classifyBuildStatus(prBuilds) {
|
|
|
424
424
|
*/
|
|
425
425
|
function classifyStaleBuilds(allBuilds, cachedBuildStatus) {
|
|
426
426
|
const staleStatus = classifyBuildStatus(allBuilds);
|
|
427
|
-
if (staleStatus ===
|
|
428
|
-
return { buildStatus:
|
|
427
|
+
if (staleStatus === BUILD_STATUS.FAILING) {
|
|
428
|
+
return { buildStatus: BUILD_STATUS.FAILING, buildStaleMergeCommit: true };
|
|
429
429
|
}
|
|
430
|
-
return { buildStatus: cachedBuildStatus ||
|
|
430
|
+
return { buildStatus: cachedBuildStatus || BUILD_STATUS.NONE, buildStaleMergeCommit: false };
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
/**
|
|
@@ -474,10 +474,10 @@ async function queueFreshAdoBuild({ orgBase, project, prNumber, definitionId, to
|
|
|
474
474
|
|
|
475
475
|
/** Map ADO reviewer vote array to a review status string. */
|
|
476
476
|
function votesToReviewStatus(votes) {
|
|
477
|
-
if (votes.some(v => v === -10)) return
|
|
478
|
-
if (votes.some(v => v >= 5)) return
|
|
479
|
-
if (votes.some(v => v === -5)) return
|
|
480
|
-
return
|
|
477
|
+
if (votes.some(v => v === -10)) return REVIEW_STATUS.CHANGES_REQUESTED;
|
|
478
|
+
if (votes.some(v => v >= 5)) return REVIEW_STATUS.APPROVED;
|
|
479
|
+
if (votes.some(v => v === -5)) return REVIEW_STATUS.WAITING;
|
|
480
|
+
return REVIEW_STATUS.PENDING;
|
|
481
481
|
}
|
|
482
482
|
|
|
483
483
|
// ─── Reviewer Vote Snapshots (W-mpg58wv3) ────────────────────────────────────
|
|
@@ -735,7 +735,7 @@ async function adoFetch(url, token, opts = {}) {
|
|
|
735
735
|
const method = (typeof opts === 'object' && opts.method) || 'GET';
|
|
736
736
|
const body = (typeof opts === 'object' && opts.body) || undefined;
|
|
737
737
|
const timeout = (typeof opts === 'object' && Number.isFinite(opts.timeout)) ? opts.timeout : 30000;
|
|
738
|
-
const MAX_RETRIES =
|
|
738
|
+
const MAX_RETRIES = ADO_TOKEN_REFRESH_MAX_RETRIES;
|
|
739
739
|
const res = await fetch(url, {
|
|
740
740
|
method,
|
|
741
741
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
@@ -936,8 +936,8 @@ async function forEachActivePr(config, token, callback) {
|
|
|
936
936
|
if (idx >= 0) {
|
|
937
937
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
938
938
|
// The disk version may have been set to 'approved' by another writer after we read
|
|
939
|
-
if (currentPrs[idx].reviewStatus ===
|
|
940
|
-
after.reviewStatus =
|
|
939
|
+
if (currentPrs[idx].reviewStatus === REVIEW_STATUS.APPROVED && after.reviewStatus !== REVIEW_STATUS.APPROVED) {
|
|
940
|
+
after.reviewStatus = REVIEW_STATUS.APPROVED;
|
|
941
941
|
}
|
|
942
942
|
shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
943
943
|
}
|
|
@@ -1016,12 +1016,12 @@ async function pollPrStatus(config) {
|
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
1018
|
if (newStatus === PR_STATUS.MERGED || newStatus === PR_STATUS.ABANDONED) {
|
|
1019
|
-
if (pr.reviewStatus ===
|
|
1020
|
-
pr.reviewStatus = newStatus === PR_STATUS.MERGED ?
|
|
1019
|
+
if (pr.reviewStatus === REVIEW_STATUS.WAITING) {
|
|
1020
|
+
pr.reviewStatus = newStatus === PR_STATUS.MERGED ? REVIEW_STATUS.APPROVED : REVIEW_STATUS.PENDING;
|
|
1021
1021
|
log('info', `PR ${pr.id} reviewStatus: waiting → ${pr.reviewStatus} (${newStatus})`);
|
|
1022
1022
|
}
|
|
1023
1023
|
// Clear stale build status — checks won't be polled after close
|
|
1024
|
-
if (pr.buildStatus && pr.buildStatus !==
|
|
1024
|
+
if (pr.buildStatus && pr.buildStatus !== BUILD_STATUS.NONE) {
|
|
1025
1025
|
delete pr.buildStatus;
|
|
1026
1026
|
delete pr.buildFailReason;
|
|
1027
1027
|
delete pr.buildErrorLog;
|
|
@@ -1063,7 +1063,7 @@ async function pollPrStatus(config) {
|
|
|
1063
1063
|
// F6 not yet shipped (field absent).
|
|
1064
1064
|
delete pr.humanFeedback.editsSeen;
|
|
1065
1065
|
pr.fixDispatched = false;
|
|
1066
|
-
if (pr.reviewStatus !==
|
|
1066
|
+
if (pr.reviewStatus !== REVIEW_STATUS.APPROVED) pr.reviewStatus = REVIEW_STATUS.PENDING;
|
|
1067
1067
|
log('info', `PR ${pr.id} reopened — reset transient state (reviewStatus=${pr.reviewStatus})`);
|
|
1068
1068
|
}
|
|
1069
1069
|
}
|
|
@@ -1156,10 +1156,10 @@ async function pollPrStatus(config) {
|
|
|
1156
1156
|
|
|
1157
1157
|
const reviewers = prData.reviewers || [];
|
|
1158
1158
|
const votes = reviewers.map(r => r.vote).filter(v => v !== undefined);
|
|
1159
|
-
let newReviewStatus = pr.reviewStatus ||
|
|
1159
|
+
let newReviewStatus = pr.reviewStatus || REVIEW_STATUS.PENDING;
|
|
1160
1160
|
// Once approved, it stays approved permanently
|
|
1161
|
-
if (pr.reviewStatus ===
|
|
1162
|
-
newReviewStatus =
|
|
1161
|
+
if (pr.reviewStatus === REVIEW_STATUS.APPROVED) {
|
|
1162
|
+
newReviewStatus = REVIEW_STATUS.APPROVED;
|
|
1163
1163
|
// Re-approve: ADO resets votes when target branch (master) advances, even though
|
|
1164
1164
|
// the source branch is unchanged. Re-apply the approval vote via API.
|
|
1165
1165
|
if (!votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
|
|
@@ -1176,15 +1176,15 @@ async function pollPrStatus(config) {
|
|
|
1176
1176
|
}
|
|
1177
1177
|
} else if (votes.length > 0) {
|
|
1178
1178
|
if (votes.some(v => v === -10)) {
|
|
1179
|
-
if (pr.reviewStatus ===
|
|
1180
|
-
newReviewStatus =
|
|
1179
|
+
if (pr.reviewStatus === REVIEW_STATUS.WAITING && pr.minionsReview?.fixedAt && (!pr.lastPushedAt || pr.lastPushedAt <= pr.minionsReview.fixedAt)) {
|
|
1180
|
+
newReviewStatus = REVIEW_STATUS.WAITING;
|
|
1181
1181
|
} else {
|
|
1182
|
-
newReviewStatus =
|
|
1182
|
+
newReviewStatus = REVIEW_STATUS.CHANGES_REQUESTED;
|
|
1183
1183
|
}
|
|
1184
1184
|
}
|
|
1185
|
-
else if (votes.some(v => v >= 5)) newReviewStatus =
|
|
1186
|
-
else if (votes.some(v => v === -5)) newReviewStatus =
|
|
1187
|
-
else newReviewStatus =
|
|
1185
|
+
else if (votes.some(v => v >= 5)) newReviewStatus = REVIEW_STATUS.APPROVED;
|
|
1186
|
+
else if (votes.some(v => v === -5)) newReviewStatus = REVIEW_STATUS.WAITING;
|
|
1187
|
+
else newReviewStatus = REVIEW_STATUS.PENDING;
|
|
1188
1188
|
}
|
|
1189
1189
|
|
|
1190
1190
|
// Store human reviewer names who approved or requested changes
|
|
@@ -1215,7 +1215,7 @@ async function pollPrStatus(config) {
|
|
|
1215
1215
|
// inbox warning only; never touches the vote, never auto-dispatches,
|
|
1216
1216
|
// never posts a comment. See engine/ado-vote-snapshots.json for storage.
|
|
1217
1217
|
try {
|
|
1218
|
-
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout:
|
|
1218
|
+
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: FETCH_TIMEOUT_MS.ADO_API }).catch(() => null);
|
|
1219
1219
|
const myId = identityData?.authenticatedUser?.id;
|
|
1220
1220
|
if (myId) {
|
|
1221
1221
|
const myReviewer = reviewers.find(r => String(r?.id || '').toLowerCase() === String(myId).toLowerCase());
|
|
@@ -1257,7 +1257,7 @@ async function pollPrStatus(config) {
|
|
|
1257
1257
|
// merge commit (same ref accumulates builds across all prior pushes to the PR).
|
|
1258
1258
|
const prNumber = pr.prNumber;
|
|
1259
1259
|
const mergeCommitId = prData.lastMergeCommit?.commitId;
|
|
1260
|
-
let buildStatus = pr.buildStatus ||
|
|
1260
|
+
let buildStatus = pr.buildStatus || BUILD_STATUS.NONE;
|
|
1261
1261
|
let buildFailReason = pr.buildFailReason || '';
|
|
1262
1262
|
let buildFailureSignature = pr.buildFailureSignature || '';
|
|
1263
1263
|
let buildStatusResolved = true;
|
|
@@ -1277,12 +1277,12 @@ async function pollPrStatus(config) {
|
|
|
1277
1277
|
const buildsData = await adoFetch(buildsUrl, token);
|
|
1278
1278
|
const allBuilds = buildsData?.value || [];
|
|
1279
1279
|
const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
|
|
1280
|
-
buildStatus =
|
|
1280
|
+
buildStatus = BUILD_STATUS.NONE;
|
|
1281
1281
|
buildFailReason = '';
|
|
1282
1282
|
|
|
1283
1283
|
if (prBuilds.length > 0) {
|
|
1284
1284
|
buildStatus = classifyBuildStatus(prBuilds);
|
|
1285
|
-
if (buildStatus ===
|
|
1285
|
+
if (buildStatus === BUILD_STATUS.FAILING) {
|
|
1286
1286
|
const failed = prBuilds.find(b => b.result === 'failed');
|
|
1287
1287
|
buildFailReason = failed?.definition?.name || 'Build failed';
|
|
1288
1288
|
buildFailureSignature = shared.safeSlugComponent([
|
|
@@ -1332,11 +1332,11 @@ async function pollPrStatus(config) {
|
|
|
1332
1332
|
} catch (e) {
|
|
1333
1333
|
buildStatusResolved = false;
|
|
1334
1334
|
buildStatusStaleDetail = `ADO build query failed: ${e.message}`;
|
|
1335
|
-
log('warn', `ADO build query for ${pr.id}: ${e.message}; preserving previous buildStatus '${pr.buildStatus ||
|
|
1335
|
+
log('warn', `ADO build query for ${pr.id}: ${e.message}; preserving previous buildStatus '${pr.buildStatus || BUILD_STATUS.NONE}'`);
|
|
1336
1336
|
}
|
|
1337
1337
|
}
|
|
1338
1338
|
} else {
|
|
1339
|
-
buildStatus =
|
|
1339
|
+
buildStatus = BUILD_STATUS.NONE;
|
|
1340
1340
|
buildFailReason = '';
|
|
1341
1341
|
}
|
|
1342
1342
|
|
|
@@ -1357,7 +1357,7 @@ async function pollPrStatus(config) {
|
|
|
1357
1357
|
|
|
1358
1358
|
if (buildStatusResolved) {
|
|
1359
1359
|
if (pr.buildStatus !== buildStatus) {
|
|
1360
|
-
log('info', `PR ${pr.id} build: ${pr.buildStatus ||
|
|
1360
|
+
log('info', `PR ${pr.id} build: ${pr.buildStatus || BUILD_STATUS.NONE} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
|
|
1361
1361
|
pr.buildStatus = buildStatus;
|
|
1362
1362
|
if (buildFailReason) pr.buildFailReason = buildFailReason;
|
|
1363
1363
|
else delete pr.buildFailReason;
|
|
@@ -1365,8 +1365,8 @@ async function pollPrStatus(config) {
|
|
|
1365
1365
|
else delete pr.buildFailureSignature;
|
|
1366
1366
|
// Build transitioned — clear grace period and auto-complete flag
|
|
1367
1367
|
delete pr._buildFixPushedAt;
|
|
1368
|
-
if (buildStatus ===
|
|
1369
|
-
if (buildStatus !==
|
|
1368
|
+
if (buildStatus === BUILD_STATUS.FAILING) delete pr._autoCompleted;
|
|
1369
|
+
if (buildStatus !== BUILD_STATUS.FAILING) {
|
|
1370
1370
|
delete pr._buildFailNotified;
|
|
1371
1371
|
delete pr._buildStatusStale;
|
|
1372
1372
|
delete pr._buildStatusDetail;
|
|
@@ -1376,7 +1376,7 @@ async function pollPrStatus(config) {
|
|
|
1376
1376
|
// update but no new builds have been triggered yet (filter by sourceVersion
|
|
1377
1377
|
// returns []), which previously wiped the last known error log and caused
|
|
1378
1378
|
// fix agents to be dispatched blind.
|
|
1379
|
-
if (buildStatus ===
|
|
1379
|
+
if (buildStatus === BUILD_STATUS.PASSING) {
|
|
1380
1380
|
delete pr.buildErrorLog;
|
|
1381
1381
|
delete pr.buildFailureSignature;
|
|
1382
1382
|
// Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
|
|
@@ -1385,7 +1385,7 @@ async function pollPrStatus(config) {
|
|
|
1385
1385
|
}
|
|
1386
1386
|
updated = true;
|
|
1387
1387
|
}
|
|
1388
|
-
if (buildStatus ===
|
|
1388
|
+
if (buildStatus === BUILD_STATUS.FAILING) {
|
|
1389
1389
|
if (buildFailReason && pr.buildFailReason !== buildFailReason) {
|
|
1390
1390
|
pr.buildFailReason = buildFailReason;
|
|
1391
1391
|
updated = true;
|
|
@@ -1415,7 +1415,7 @@ async function pollPrStatus(config) {
|
|
|
1415
1415
|
// one queue per PR per 30 min via pr._lastBuildRequeueAt. Only PR-
|
|
1416
1416
|
// validation definitions are queued; non-PR builds are filtered out
|
|
1417
1417
|
// upstream. Failures are non-fatal — logged warn and skipped.
|
|
1418
|
-
if (buildStatusResolved && buildStatus ===
|
|
1418
|
+
if (buildStatusResolved && buildStatus === BUILD_STATUS.FAILING && buildStaleMergeCommit && staleFailingDefinitions.length > 0) {
|
|
1419
1419
|
if (shouldRequeueStaleBuild(pr)) {
|
|
1420
1420
|
let queuedCount = 0;
|
|
1421
1421
|
for (const def of staleFailingDefinitions) {
|
|
@@ -1439,7 +1439,7 @@ async function pollPrStatus(config) {
|
|
|
1439
1439
|
}
|
|
1440
1440
|
|
|
1441
1441
|
// Auto-complete: set auto-complete on PR when builds green + review approved
|
|
1442
|
-
if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus ===
|
|
1442
|
+
if (pr.status === PR_STATUS.ACTIVE && pr.reviewStatus === REVIEW_STATUS.APPROVED && pr.buildStatus === BUILD_STATUS.PASSING && !pr._autoCompleted) {
|
|
1443
1443
|
const autoComplete = config.engine?.autoCompletePrs === true; // opt-in
|
|
1444
1444
|
if (autoComplete) {
|
|
1445
1445
|
try {
|
|
@@ -1806,7 +1806,7 @@ async function reconcilePrs(config) {
|
|
|
1806
1806
|
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
1807
1807
|
agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
|
|
1808
1808
|
branch,
|
|
1809
|
-
reviewStatus:
|
|
1809
|
+
reviewStatus: REVIEW_STATUS.PENDING,
|
|
1810
1810
|
status: 'active',
|
|
1811
1811
|
created: adoPr.creationDate || ts(),
|
|
1812
1812
|
url: prUrl,
|
|
@@ -1832,7 +1832,7 @@ async function reconcilePrs(config) {
|
|
|
1832
1832
|
title: (adoPr.title || `PR #${adoPr.pullRequestId}`).slice(0, 120),
|
|
1833
1833
|
agent: (linkedItem?.dispatched_to || adoPr.createdBy?.displayName || 'unknown').toLowerCase(),
|
|
1834
1834
|
branch,
|
|
1835
|
-
reviewStatus:
|
|
1835
|
+
reviewStatus: REVIEW_STATUS.PENDING,
|
|
1836
1836
|
status: 'active',
|
|
1837
1837
|
created: adoPr.creationDate || ts(),
|
|
1838
1838
|
url: prUrl,
|
|
@@ -1900,7 +1900,7 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
1900
1900
|
// SEC-02: use in-process adoFetch rather than a shell-out — keeps the bearer
|
|
1901
1901
|
// token out of the process argv list where any local process could read it.
|
|
1902
1902
|
// 4s timeout preserves the original request-cancellation semantics via AbortSignal.
|
|
1903
|
-
const prData = await adoFetch(url, token, { timeout:
|
|
1903
|
+
const prData = await adoFetch(url, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
|
|
1904
1904
|
if (!prData) return null;
|
|
1905
1905
|
const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
|
|
1906
1906
|
if (votes.length === 0) return 'pending';
|
|
@@ -1965,10 +1965,10 @@ async function resetReviewerNegativeVote(pr, project) {
|
|
|
1965
1965
|
const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
|
|
1966
1966
|
const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
|
|
1967
1967
|
// 4s timeout — same budget as checkLiveReviewStatus.
|
|
1968
|
-
const prData = await adoFetch(prUrl, token, { timeout:
|
|
1968
|
+
const prData = await adoFetch(prUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
|
|
1969
1969
|
if (!prData) return null;
|
|
1970
1970
|
// Identify our authenticated reviewer entry.
|
|
1971
|
-
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout:
|
|
1971
|
+
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: FETCH_TIMEOUT_MS.ADO_API }).catch(() => null);
|
|
1972
1972
|
const myId = identityData?.authenticatedUser?.id;
|
|
1973
1973
|
if (!myId) return null;
|
|
1974
1974
|
const myReviewer = (prData.reviewers || []).find(r => String(r?.id || '').toLowerCase() === String(myId).toLowerCase());
|
|
@@ -1988,7 +1988,7 @@ async function resetReviewerNegativeVote(pr, project) {
|
|
|
1988
1988
|
await adoFetch(`${repoBase}/pullrequests/${prNum}/reviewers/${myId}?api-version=7.1`, token, {
|
|
1989
1989
|
method: 'PUT',
|
|
1990
1990
|
body: JSON.stringify({ vote: 10 }),
|
|
1991
|
-
timeout:
|
|
1991
|
+
timeout: FETCH_TIMEOUT_MS.ADO_API,
|
|
1992
1992
|
});
|
|
1993
1993
|
log('info', `PR ${pr.id}: reset reviewer vote ${myVote} → 10 on verdict flip`);
|
|
1994
1994
|
return { attempted: true, changed: true, fromVote: myVote, toVote: 10 };
|
|
@@ -2041,7 +2041,7 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
2041
2041
|
// 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
|
|
2042
2042
|
// gate; we'd rather miss a freshness signal and fall back to cache than
|
|
2043
2043
|
// block dispatch on a slow ADO call.
|
|
2044
|
-
const prData = await adoFetch(prUrl, token, { timeout:
|
|
2044
|
+
const prData = await adoFetch(prUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
|
|
2045
2045
|
if (!prData) return null;
|
|
2046
2046
|
|
|
2047
2047
|
// Conflict signal — ADO reports `mergeStatus: 'conflicts'` when the merge
|
|
@@ -2058,7 +2058,7 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
2058
2058
|
if (prData.status === 'active') {
|
|
2059
2059
|
const mergeCommitId = prData.lastMergeCommit?.commitId;
|
|
2060
2060
|
if (mergeCommitId) {
|
|
2061
|
-
const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO live build check', { timeout:
|
|
2061
|
+
const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO live build check', { timeout: FETCH_TIMEOUT_MS.ADO_API });
|
|
2062
2062
|
if (!buildRepositoryGuid) {
|
|
2063
2063
|
buildStatusStale = true;
|
|
2064
2064
|
buildStatusDetail = 'ADO Builds API requires a repository GUID; repository GUID could not be resolved from project.repositoryId/project.repoName';
|
|
@@ -2066,13 +2066,13 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
2066
2066
|
try {
|
|
2067
2067
|
const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
|
|
2068
2068
|
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodeURIComponent(buildRepositoryGuid)}&repositoryType=TfsGit&$top=25&api-version=7.1`;
|
|
2069
|
-
const buildsData = await adoFetch(buildsUrl, token, { timeout:
|
|
2069
|
+
const buildsData = await adoFetch(buildsUrl, token, { timeout: FETCH_TIMEOUT_MS.ADO_API });
|
|
2070
2070
|
const allBuilds = buildsData?.value || [];
|
|
2071
2071
|
const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
|
|
2072
2072
|
if (prBuilds.length > 0) {
|
|
2073
2073
|
buildStatus = classifyBuildStatus(prBuilds);
|
|
2074
2074
|
} else if (allBuilds.length === 0) {
|
|
2075
|
-
buildStatus =
|
|
2075
|
+
buildStatus = BUILD_STATUS.NONE;
|
|
2076
2076
|
} else {
|
|
2077
2077
|
// Stale merge-commit (Issue #2747) — mirror pollPrStatus.
|
|
2078
2078
|
// Stale-failing flips to 'failing' + buildStaleMergeCommit so
|
|
@@ -2095,7 +2095,7 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
2095
2095
|
} else {
|
|
2096
2096
|
// No merge commit yet — likely conflict or fresh PR. Treat as 'none'
|
|
2097
2097
|
// so a stale 'failing' cache can be cleared by the caller.
|
|
2098
|
-
buildStatus =
|
|
2098
|
+
buildStatus = BUILD_STATUS.NONE;
|
|
2099
2099
|
}
|
|
2100
2100
|
}
|
|
2101
2101
|
|
|
@@ -2182,7 +2182,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
|
|
|
2182
2182
|
let buildStatus = buildStatusStale ? null : classifyBuildStatus(prBuilds);
|
|
2183
2183
|
let buildErrorLog = null;
|
|
2184
2184
|
|
|
2185
|
-
if (buildStatus ===
|
|
2185
|
+
if (buildStatus === BUILD_STATUS.FAILING) {
|
|
2186
2186
|
try {
|
|
2187
2187
|
const failedBuilds = prBuilds.filter(b => b.result === 'failed').map(b => ({
|
|
2188
2188
|
state: 'failed', _buildId: String(b.id),
|
package/engine/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
9
|
+
const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
12
12
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
|
|
@@ -471,41 +471,6 @@ const commands = {
|
|
|
471
471
|
try { shared.applyLegacyCcModelMigration(config, { logger: e.log }); }
|
|
472
472
|
catch (err) { e.log('warn', `legacy ccModel migration failed: ${err.message}`); }
|
|
473
473
|
|
|
474
|
-
// Drop persisted statusWorkItemsRetentionDays=7 (the prior baked-in default)
|
|
475
|
-
// so the new default of 0 (no trim) reaches installs that opened Settings
|
|
476
|
-
// before the flip. Explicit non-7 values are preserved. We mutate in-memory
|
|
477
|
-
// AND rewrite config.json so the fix is permanent — the shim in shared.js
|
|
478
|
-
// can then retire on schedule without users regressing.
|
|
479
|
-
try {
|
|
480
|
-
const applied = shared.applyStatusWorkItemsRetentionMigration(config, { logger: e.log });
|
|
481
|
-
if (applied) {
|
|
482
|
-
const configPath = path.join(shared.MINIONS_DIR, 'config.json');
|
|
483
|
-
shared.mutateJsonFileLocked(configPath, (onDisk) => {
|
|
484
|
-
if (onDisk && onDisk.engine && onDisk.engine.statusWorkItemsRetentionDays === 7) {
|
|
485
|
-
delete onDisk.engine.statusWorkItemsRetentionDays;
|
|
486
|
-
}
|
|
487
|
-
return onDisk;
|
|
488
|
-
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
catch (err) { e.log('warn', `statusWorkItemsRetentionDays migration failed: ${err.message}`); }
|
|
492
|
-
|
|
493
|
-
// Same treatment for statusMeetingsRetentionDays — the meetings slice had
|
|
494
|
-
// the same 7-day baked-in default and the same data-loss UX.
|
|
495
|
-
try {
|
|
496
|
-
const applied = shared.applyStatusMeetingsRetentionMigration(config, { logger: e.log });
|
|
497
|
-
if (applied) {
|
|
498
|
-
const configPath = path.join(shared.MINIONS_DIR, 'config.json');
|
|
499
|
-
shared.mutateJsonFileLocked(configPath, (onDisk) => {
|
|
500
|
-
if (onDisk && onDisk.engine && onDisk.engine.statusMeetingsRetentionDays === 7) {
|
|
501
|
-
delete onDisk.engine.statusMeetingsRetentionDays;
|
|
502
|
-
}
|
|
503
|
-
return onDisk;
|
|
504
|
-
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
catch (err) { e.log('warn', `statusMeetingsRetentionDays migration failed: ${err.message}`); }
|
|
508
|
-
|
|
509
474
|
// Auto-heal projects missing workSources (cloned-repo / hand-rolled-config
|
|
510
475
|
// footgun): without this block, discoverFromWorkItems / discoverFromPrs
|
|
511
476
|
// bail silently and the engine looks healthy but never dispatches. The
|
|
@@ -1604,8 +1569,8 @@ const commands = {
|
|
|
1604
1569
|
}
|
|
1605
1570
|
if (exists && name === 'pullRequests') {
|
|
1606
1571
|
const prs = safeJson(filePath) || [];
|
|
1607
|
-
const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus ===
|
|
1608
|
-
const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus ===
|
|
1572
|
+
const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
|
|
1573
|
+
const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
|
|
1609
1574
|
console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
|
|
1610
1575
|
}
|
|
1611
1576
|
if (exists && name === 'workItems') {
|
package/engine/db/index.js
CHANGED
|
@@ -25,7 +25,7 @@ function _resolveDbPath() {
|
|
|
25
25
|
// Lazy-require shared/queries so this module can be safely required
|
|
26
26
|
// before MINIONS_DIR is computed (e.g. in tests). Falls back to
|
|
27
27
|
// process.env.MINIONS_HOME when available.
|
|
28
|
-
const envHome = process.env.
|
|
28
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
29
29
|
let minionsDir = envHome;
|
|
30
30
|
if (!minionsDir) {
|
|
31
31
|
try { minionsDir = require('../shared').MINIONS_DIR; } catch { /* shared not loaded */ }
|
|
@@ -24,7 +24,7 @@ const path = require('path');
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
|
|
26
26
|
function _resolveMinionsDir() {
|
|
27
|
-
const envHome = process.env.
|
|
27
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
28
28
|
if (envHome) return envHome;
|
|
29
29
|
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
30
30
|
}
|
|
@@ -20,7 +20,7 @@ const path = require('path');
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
|
|
22
22
|
function _resolveMinionsDir() {
|
|
23
|
-
const envHome = process.env.
|
|
23
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
24
24
|
if (envHome) return envHome;
|
|
25
25
|
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
26
26
|
}
|
|
@@ -21,7 +21,7 @@ const path = require('path');
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
|
|
23
23
|
function _resolveMinionsDir() {
|
|
24
|
-
const envHome = process.env.
|
|
24
|
+
const envHome = process.env.MINIONS_TEST_DIR || process.env.MINIONS_HOME;
|
|
25
25
|
if (envHome) return envHome;
|
|
26
26
|
try { return require('../../shared').MINIONS_DIR; } catch { return null; }
|
|
27
27
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -967,7 +967,13 @@ function cleanDispatchEntries(matchFn) {
|
|
|
967
967
|
const filesToDelete = [];
|
|
968
968
|
const dispatchDirsToRemove = [];
|
|
969
969
|
try {
|
|
970
|
-
|
|
970
|
+
// Route through mutateDispatch so the SQL-backed store (Phase 1) and the
|
|
971
|
+
// dispatch.json mirror stay coherent. Writing dispatch.json directly with
|
|
972
|
+
// mutateJsonFileLocked here would leave stale rows in the `dispatches`
|
|
973
|
+
// table; the very next mutateDispatch call (e.g. removeProject's
|
|
974
|
+
// step 7.5 orphan-tagging pass) would then mirror SQL back to JSON and
|
|
975
|
+
// resurrect the entries we just drained.
|
|
976
|
+
mutateDispatch((dispatch) => {
|
|
971
977
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
972
978
|
dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
|
|
973
979
|
const before = dispatch[queue].length;
|
|
@@ -1000,7 +1006,7 @@ function cleanDispatchEntries(matchFn) {
|
|
|
1000
1006
|
removed += before - dispatch[queue].length;
|
|
1001
1007
|
}
|
|
1002
1008
|
return dispatch;
|
|
1003
|
-
}
|
|
1009
|
+
});
|
|
1004
1010
|
} catch { return 0; }
|
|
1005
1011
|
// Kill processes outside the lock — taskkill on Windows can take hundreds of ms
|
|
1006
1012
|
for (const pid of pidsToKill) {
|