@yemi33/minions 0.1.2164 → 0.1.2166
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 +17 -67
- package/docs/deprecated-process.md +69 -0
- package/docs/deprecated.json +8 -1
- package/engine/project-discovery.js +105 -1
- package/engine/projects.js +139 -1
- package/minions.js +27 -0
- package/package.json +1 -1
package/dashboard.js
CHANGED
|
@@ -7861,84 +7861,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7861
7861
|
const body = await readBody(req);
|
|
7862
7862
|
if (!body.path) return jsonReply(res, 400, { error: 'path required' });
|
|
7863
7863
|
|
|
7864
|
-
//
|
|
7865
|
-
//
|
|
7866
|
-
//
|
|
7867
|
-
|
|
7864
|
+
// W-mq8li79a000889fa: route through projects.addProject so dashboard
|
|
7865
|
+
// and CLI share the same SEC-validate + auto-discover + REST-fallback
|
|
7866
|
+
// + fail-closed gate (rejects ADO links missing repositoryId).
|
|
7867
|
+
const { addProject } = require('./engine/projects');
|
|
7868
|
+
let result;
|
|
7868
7869
|
try {
|
|
7869
|
-
|
|
7870
|
+
result = await addProject(body.path, {
|
|
7870
7871
|
allowNonRepo: body.allowNonRepo === true,
|
|
7871
7872
|
confirmToken: body.confirmToken,
|
|
7872
7873
|
isValidToken: _consumeProjectConfirmToken,
|
|
7874
|
+
name: body.name,
|
|
7875
|
+
worktreeMode: body.worktreeMode,
|
|
7876
|
+
observeAuthors: Array.isArray(body.observeAuthors) ? body.observeAuthors : undefined,
|
|
7873
7877
|
});
|
|
7874
7878
|
} catch (e) {
|
|
7875
7879
|
return jsonReply(res, e.statusCode || 400, {
|
|
7876
7880
|
error: e.message,
|
|
7877
7881
|
...(e.needsConfirmation ? { needsConfirmation: true } : {}),
|
|
7882
|
+
...(e.missing ? { missing: e.missing } : {}),
|
|
7883
|
+
...(e.code ? { code: e.code } : {}),
|
|
7878
7884
|
});
|
|
7879
7885
|
}
|
|
7880
7886
|
|
|
7881
|
-
// Check if already linked under the config lock so concurrent dashboard
|
|
7882
|
-
// adds cannot both pass the preflight check and then clobber config.json.
|
|
7883
|
-
let alreadyLinked = false;
|
|
7884
|
-
mutateDashboardConfig(config => {
|
|
7885
|
-
if (!Array.isArray(config.projects)) config.projects = [];
|
|
7886
|
-
alreadyLinked = config.projects.some(p => shared.sameResolvedPath(p.localPath, target));
|
|
7887
|
-
return config;
|
|
7888
|
-
});
|
|
7889
|
-
if (alreadyLinked) {
|
|
7890
|
-
return jsonReply(res, 400, { error: 'Project already linked at ' + target });
|
|
7891
|
-
}
|
|
7892
|
-
|
|
7893
|
-
// Auto-discover from git repo. Shared with minions.js so CLI and dashboard
|
|
7894
|
-
// handle ADO URL variants and repository GUID enrichment consistently.
|
|
7895
|
-
const detected = projectDiscovery.discoverProjectMetadata(target);
|
|
7896
|
-
if (!detected.name) detected.name = path.basename(target);
|
|
7897
|
-
const description = detected.description || '';
|
|
7898
|
-
|
|
7899
|
-
const rawName = body.name || detected.name;
|
|
7900
|
-
|
|
7901
|
-
// SEC-04: validate project name — rejects path traversal, shell
|
|
7902
|
-
// metacharacters, whitespace, overly long names. Runs BEFORE any
|
|
7903
|
-
// mutation of config.json. Auto-detected names (from package.json /
|
|
7904
|
-
// directory basename) also go through this check so a maliciously
|
|
7905
|
-
// named repo on disk can't inject metacharacters either.
|
|
7906
|
-
let name;
|
|
7907
|
-
try {
|
|
7908
|
-
name = shared.validateProjectName(rawName);
|
|
7909
|
-
} catch (e) {
|
|
7910
|
-
return jsonReply(res, e.statusCode || 400, { error: e.message });
|
|
7911
|
-
}
|
|
7912
|
-
|
|
7913
|
-
const project = projectDiscovery.buildProjectEntry({
|
|
7914
|
-
name, description, localPath: target,
|
|
7915
|
-
repoHost: detected.repoHost || 'github',
|
|
7916
|
-
repositoryId: detected.repositoryId || '',
|
|
7917
|
-
org: detected.org || '',
|
|
7918
|
-
project: detected.project || '',
|
|
7919
|
-
repoName: detected.repoName || name,
|
|
7920
|
-
mainBranch: detected.mainBranch || 'main',
|
|
7921
|
-
prUrlBase: detected.prUrlBase,
|
|
7922
|
-
// P-a3f9b201: thread worktreeMode from POST body. buildProjectEntry
|
|
7923
|
-
// validates via shared.validateWorktreeMode — unknown values bubble
|
|
7924
|
-
// up as HTTP 400 below.
|
|
7925
|
-
worktreeMode: body.worktreeMode,
|
|
7926
|
-
});
|
|
7927
|
-
|
|
7928
|
-
// Create centralized project state files.
|
|
7929
|
-
shared.ensureProjectStateFiles(project);
|
|
7930
|
-
|
|
7931
|
-
let duplicate = false;
|
|
7932
|
-
mutateDashboardConfig(config => {
|
|
7933
|
-
if (!Array.isArray(config.projects)) config.projects = [];
|
|
7934
|
-
if (config.projects.some(p => shared.sameResolvedPath(p.localPath, target))) {
|
|
7935
|
-
duplicate = true;
|
|
7936
|
-
return config;
|
|
7937
|
-
}
|
|
7938
|
-
config.projects.push(project);
|
|
7939
|
-
return config;
|
|
7940
|
-
});
|
|
7941
|
-
if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
|
|
7942
7887
|
reloadConfig(); // Update in-memory project list immediately
|
|
7943
7888
|
warmProjectGitStatusCache(); // Probe the new project's git status in the background
|
|
7944
7889
|
// includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
|
|
@@ -7946,7 +7891,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7946
7891
|
// up to a minute after the add. Matches handleProjectsRemove's behavior.
|
|
7947
7892
|
invalidateStatusCache({ includeSlow: true });
|
|
7948
7893
|
|
|
7949
|
-
return jsonReply(res, 200, {
|
|
7894
|
+
return jsonReply(res, 200, {
|
|
7895
|
+
ok: true,
|
|
7896
|
+
name: result.project.name,
|
|
7897
|
+
path: result.path,
|
|
7898
|
+
detected: result.detected,
|
|
7899
|
+
});
|
|
7950
7900
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
7951
7901
|
}
|
|
7952
7902
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Deprecation tracker — schema and audit process
|
|
2
|
+
|
|
3
|
+
Source of truth: [`docs/deprecated.json`](./deprecated.json) — array of deprecation entries the `cleanup-deprecated` skill walks weekly.
|
|
4
|
+
|
|
5
|
+
Each entry documents one deprecated symbol / config field / endpoint, why it
|
|
6
|
+
exists, when (and under what signal) it is safe to remove, and which tests
|
|
7
|
+
pin its contract until the removal lands.
|
|
8
|
+
|
|
9
|
+
## Common fields
|
|
10
|
+
|
|
11
|
+
The schema is loose — entries grow fields as their gating story matures. The
|
|
12
|
+
following are all optional unless called out as required.
|
|
13
|
+
|
|
14
|
+
- `id` — **required**, kebab-case unique identifier (e.g. `completion-fallback-parsers`).
|
|
15
|
+
- `description` / `reason` — free-form prose explaining what is deprecated and why.
|
|
16
|
+
- `file` / `location` / `code` — pointer(s) to the deprecated symbol(s). `code` is
|
|
17
|
+
the structured form: an array of `{ file, lines, note }` objects.
|
|
18
|
+
- `lines` — comma-separated string of line numbers (drift-prone; cross-check before
|
|
19
|
+
acting).
|
|
20
|
+
- `deprecated` / `targetRemovalDate` — calendar dates (YYYY-MM-DD) marking when
|
|
21
|
+
the entry was registered and when it may be removed. `targetRemovalDate: null`
|
|
22
|
+
signals a non-calendar gate (telemetry / signal-based).
|
|
23
|
+
- `removalGate` — prose describing the signal that must clear before removal.
|
|
24
|
+
- `notes` — free-form post-removal scope or rollout caveats.
|
|
25
|
+
|
|
26
|
+
## Counter-gated entries (telemetry sentinels)
|
|
27
|
+
|
|
28
|
+
When the removal gate is "this counter must read 0 across a sweep window," the
|
|
29
|
+
entry uses these fields:
|
|
30
|
+
|
|
31
|
+
- `telemetryGate` — string referencing the metrics counter that must read zero.
|
|
32
|
+
- `sweepWindowDays` — positive integer (window length in days).
|
|
33
|
+
- `sweepStartDate` — `YYYY-MM-DD` window start.
|
|
34
|
+
- `enforcingTest` / `enforcingSweepWindowTest` — paths to the unit tests that
|
|
35
|
+
pin the gate contract.
|
|
36
|
+
|
|
37
|
+
## Static call-site audit fields (P-c5d9e7b4)
|
|
38
|
+
|
|
39
|
+
Counter-gated entries also opt in to the static call-site audit
|
|
40
|
+
(`test/unit/deprecated-call-site-audit.test.js`). The audit enumerates live
|
|
41
|
+
call sites of the entry's symbols across `engine/`, `dashboard/`, and the
|
|
42
|
+
root-level `.js` files, then asserts every discovered call site is declared
|
|
43
|
+
in either `removalSites` or `allowedCallers`.
|
|
44
|
+
|
|
45
|
+
- `symbols` — **required for the audit to run**. Array of bare identifier
|
|
46
|
+
strings (e.g. `["parseStructuredCompletion", "parseCompletionFieldSummary"]`).
|
|
47
|
+
The audit builds a `\b<symbol>\s*\(` regex per name and skips matches that
|
|
48
|
+
sit immediately after the `function` keyword (i.e. it does not count the
|
|
49
|
+
definition itself as a caller). If `symbols` is missing or empty, the audit
|
|
50
|
+
fails the entry — there is no fallback parse of `description`.
|
|
51
|
+
- `removalSites` — array of `{ file, line, symbol?, reason? }` objects listing
|
|
52
|
+
the call sites that WILL be removed when the deprecation gate clears (the
|
|
53
|
+
"expected, planned" callers). Paths are forward-slash relative to the repo
|
|
54
|
+
root.
|
|
55
|
+
- `allowedCallers` — array of `{ site, reason }` objects (or
|
|
56
|
+
`{ file, line, reason }` — both shapes are accepted) listing call sites that
|
|
57
|
+
are intentionally preserved past the gate (e.g. a sibling deprecation that
|
|
58
|
+
will retire separately). Defaults to `[]` when absent.
|
|
59
|
+
|
|
60
|
+
The audit assertion is `discoveredCallers ⊆ (removalSites ∪ allowedCallers)`.
|
|
61
|
+
On failure, the assertion message names every unexpected `file:line` so the
|
|
62
|
+
audit author can immediately classify each one as a new `removalSites` entry
|
|
63
|
+
or a new `allowedCallers` entry.
|
|
64
|
+
|
|
65
|
+
Entries WITHOUT a `telemetryGate` field are skipped by the audit — calendar-
|
|
66
|
+
gated entries (e.g. `qa-json-sidecars`) and signal-gated entries that don't
|
|
67
|
+
hang on a counter (e.g. `config-claude-binary-override`) don't carry the
|
|
68
|
+
`symbols` / `removalSites` / `allowedCallers` triple and are not enforced by
|
|
69
|
+
the static audit. Add them later if the gate evolves into a counter sentinel.
|
package/docs/deprecated.json
CHANGED
|
@@ -34,7 +34,14 @@
|
|
|
34
34
|
"sweepRationale": "14 days matches the dead-code audit cadence (one full weekly audit cycle plus a buffer week). Window restarted on 2026-06-11 (= merge_date(P-a7b2c1d9) + 1 day, anchor commit eb544aa80dbd64c87b78ad08a17ca3560e627094 merged 2026-06-10T18:04:12Z) because the original 2026-05-27 window was invalidated: the engine/timeout.js caller of parseStructuredCompletion (commit 0f4b9787, 2026-04-30) predated the telemetry counter (commit cea61119, 2026-05-13), so the hidden caller was never instrumented and a zero reading proved nothing. P-a7b2c1d9 removed that hidden caller; the new window measures only callers that the counter actually instruments. Policy decision pending confirmation in open-question #1 of the 2026-05-27 Bug Audit Review meeting conclusion — if the human teammate prefers a different cadence, repin this field and the matching enforcingSweepWindowTest fixture.",
|
|
35
35
|
"enforcingTest": "test/unit/completion-fallback-telemetry.test.js:217-234",
|
|
36
36
|
"enforcingSweepWindowTest": "test/unit/completion-fallback-sweep-window.test.js",
|
|
37
|
-
"
|
|
37
|
+
"symbols": ["parseStructuredCompletion", "parseCompletionFieldSummary"],
|
|
38
|
+
"removalSites": [
|
|
39
|
+
{ "file": "engine/lifecycle.js", "line": 4675, "symbol": "parseStructuredCompletion", "reason": "Gated fallback at engine/lifecycle.js:4675-4700 — removed in the same follow-up PR that drops the parser definitions." },
|
|
40
|
+
{ "file": "engine/lifecycle.js", "line": 4676, "symbol": "parseCompletionFieldSummary", "reason": "Gated fallback at engine/lifecycle.js:4675-4700 — removed in the same follow-up PR that drops the parser definitions." }
|
|
41
|
+
],
|
|
42
|
+
"allowedCallers": [],
|
|
43
|
+
"schemaRef": "docs/deprecated-process.md (see 'Static call-site audit fields')",
|
|
44
|
+
"notes": "Do NOT set removedAt until telemetry confirms zero usage across the sweepWindowDays from sweepStartDate. The follow-up code-removal PR (dropping parseStructuredCompletion at engine/lifecycle.js:3410, parseCompletionFieldSummary at :3608, and the gated fallback at :4675-4700) is dispatched separately once the window is observed clean. The symbols/removalSites/allowedCallers triple is enforced by test/unit/deprecated-call-site-audit.test.js — see docs/deprecated-process.md for the schema."
|
|
38
45
|
},
|
|
39
46
|
{
|
|
40
47
|
"id": "config-claude-binary-override",
|
|
@@ -195,6 +195,61 @@ function runAzJson(execFileSync, args, timeoutMs) {
|
|
|
195
195
|
}));
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// W-mq8li79a000889fa — official ADO REST endpoint for repo lookup.
|
|
199
|
+
// Matches engine/ado.js#resolveAdoBuildRepositoryGuid (the canonical
|
|
200
|
+
// PR-poll path). Path-style encoding mirrors that helper so a project
|
|
201
|
+
// named with reserved chars (e.g. "Project With Spaces") routes the
|
|
202
|
+
// same way at link time as at poll time. The repo segment is URL-
|
|
203
|
+
// encoded — ADO accepts either the repo GUID or repo name here.
|
|
204
|
+
function _buildAdoRepoLookupUrl({ orgUrl, project, repoName }) {
|
|
205
|
+
return `${orgUrl}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.1`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// W-mq8li79a000889fa — REST-based GUID resolver. Returns the canonical
|
|
209
|
+
// repo metadata when ADO responds 200 + JSON; returns null on any
|
|
210
|
+
// failure (auth, network, 404, HTML redirect). Fail-closed semantics
|
|
211
|
+
// are the caller's responsibility — discoverProjectMetadataAsync calls
|
|
212
|
+
// this AFTER `az repos show/list` to fill in the GUID when the CLI is
|
|
213
|
+
// unavailable (typical agent box without Azure CLI installed).
|
|
214
|
+
//
|
|
215
|
+
// Token acquisition uses engine/ado-token.js#acquireAdoToken (az CLI
|
|
216
|
+
// then azureauth fallback). Tests inject `options.fetch` and
|
|
217
|
+
// `options.acquireAdoToken` directly.
|
|
218
|
+
async function resolveAdoRemoteMetadataViaRest(remote, options = {}) {
|
|
219
|
+
if (!remote || !remote.orgUrl || !remote.project || !remote.repoName) return null;
|
|
220
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
221
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
222
|
+
let acquireToken = options.acquireAdoToken;
|
|
223
|
+
if (typeof acquireToken !== 'function') {
|
|
224
|
+
try { acquireToken = require('./ado-token').acquireAdoToken; }
|
|
225
|
+
catch { return null; }
|
|
226
|
+
}
|
|
227
|
+
let token;
|
|
228
|
+
try {
|
|
229
|
+
const result = await acquireToken({});
|
|
230
|
+
token = result && result.token;
|
|
231
|
+
} catch { return null; }
|
|
232
|
+
if (!token) return null;
|
|
233
|
+
|
|
234
|
+
const url = _buildAdoRepoLookupUrl(remote);
|
|
235
|
+
let res;
|
|
236
|
+
try {
|
|
237
|
+
res = await fetchImpl(url, {
|
|
238
|
+
method: 'GET',
|
|
239
|
+
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
240
|
+
});
|
|
241
|
+
} catch { return null; }
|
|
242
|
+
if (!res || !res.ok) return null;
|
|
243
|
+
let text;
|
|
244
|
+
try { text = await res.text(); } catch { return null; }
|
|
245
|
+
if (!text || text.trimStart().startsWith('<')) return null;
|
|
246
|
+
let data;
|
|
247
|
+
try { data = JSON.parse(text); } catch { return null; }
|
|
248
|
+
const normalized = normalizeAzRepoResult(data, remote);
|
|
249
|
+
if (!normalized || !String(normalized.repositoryId || '').trim()) return null;
|
|
250
|
+
return normalized;
|
|
251
|
+
}
|
|
252
|
+
|
|
198
253
|
function resolveAdoRemoteMetadata(remote, options = {}) {
|
|
199
254
|
if (!remote) return null;
|
|
200
255
|
const execFileSync = options.execFileSync || defaultExecFileSync;
|
|
@@ -330,6 +385,42 @@ function discoverProjectMetadata(targetDir, options = {}) {
|
|
|
330
385
|
return result;
|
|
331
386
|
}
|
|
332
387
|
|
|
388
|
+
// W-mq8li79a000889fa — async wrapper that runs the sync `az`-based
|
|
389
|
+
// discovery first, then falls back to the official ADO REST endpoint
|
|
390
|
+
// (via engine/ado-token.js + globalThis.fetch) when the remote is ADO
|
|
391
|
+
// but no repositoryId was resolved. Non-ADO remotes are returned
|
|
392
|
+
// unchanged. Failure to resolve the GUID via REST leaves
|
|
393
|
+
// `repositoryId` empty — callers (engine/projects.js#addProject) MUST
|
|
394
|
+
// fail closed if the linked project ends up without a GUID.
|
|
395
|
+
async function discoverProjectMetadataAsync(targetDir, options = {}) {
|
|
396
|
+
const detected = discoverProjectMetadata(targetDir, options);
|
|
397
|
+
if (!detected || detected.repoHost !== 'ado') return detected;
|
|
398
|
+
if (String(detected.repositoryId || '').trim()) return detected;
|
|
399
|
+
// ADO remote without GUID — try official REST endpoint as a fallback.
|
|
400
|
+
// discoverProjectMetadata always populates org/orgUrl/project/repoName
|
|
401
|
+
// for ADO remotes (via parseAdoRemoteUrl), so this is safe to call.
|
|
402
|
+
const parsedRemote = {
|
|
403
|
+
org: detected.org,
|
|
404
|
+
orgUrl: detected.orgUrl,
|
|
405
|
+
project: detected.project,
|
|
406
|
+
repoName: detected.repoName,
|
|
407
|
+
repoHost: 'ado',
|
|
408
|
+
remoteUrl: detected.remoteUrl,
|
|
409
|
+
prUrlBase: detected.prUrlBase,
|
|
410
|
+
collection: detected.collection || '',
|
|
411
|
+
repositoryId: '',
|
|
412
|
+
};
|
|
413
|
+
const restMeta = await resolveAdoRemoteMetadataViaRest(parsedRemote, options);
|
|
414
|
+
if (restMeta && String(restMeta.repositoryId || '').trim()) {
|
|
415
|
+
Object.assign(detected, restMeta);
|
|
416
|
+
if (!Array.isArray(detected._found)) detected._found = [];
|
|
417
|
+
if (!detected._found.includes('Azure DevOps repository metadata (REST)')) {
|
|
418
|
+
detected._found.push('Azure DevOps repository metadata (REST)');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return detected;
|
|
422
|
+
}
|
|
423
|
+
|
|
333
424
|
function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
|
|
334
425
|
if (prUrlBase) return prUrlBase;
|
|
335
426
|
if (repoHost === 'github') {
|
|
@@ -341,7 +432,7 @@ function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
|
|
|
341
432
|
return '';
|
|
342
433
|
}
|
|
343
434
|
|
|
344
|
-
function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode }) {
|
|
435
|
+
function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode, observeAuthors }) {
|
|
345
436
|
const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
|
|
346
437
|
const host = repoHost || 'github';
|
|
347
438
|
const isAdo = host === 'ado';
|
|
@@ -368,6 +459,17 @@ function buildProjectEntry({ name, description, localPath, repoHost, repositoryI
|
|
|
368
459
|
},
|
|
369
460
|
};
|
|
370
461
|
if (resolvedWorktreeMode !== undefined) entry.worktreeMode = resolvedWorktreeMode;
|
|
462
|
+
// W-mq8li79a000889fa — observeAuthors is the canonical list of author
|
|
463
|
+
// identifiers consumed by the `ado-author-prs` watch (and any future
|
|
464
|
+
// author-scoped watch). Currently only meaningful for ADO projects —
|
|
465
|
+
// the GitHub flow uses watch targets directly. Stored as an array of
|
|
466
|
+
// strings; only persisted when explicitly supplied so existing
|
|
467
|
+
// projects don't gain an empty field on rewrites.
|
|
468
|
+
if (Array.isArray(observeAuthors) && observeAuthors.length > 0) {
|
|
469
|
+
entry.observeAuthors = observeAuthors
|
|
470
|
+
.map(v => String(v || '').trim())
|
|
471
|
+
.filter(Boolean);
|
|
472
|
+
}
|
|
371
473
|
return entry;
|
|
372
474
|
}
|
|
373
475
|
|
|
@@ -391,7 +493,9 @@ module.exports = {
|
|
|
391
493
|
parseAdoRemoteUrl,
|
|
392
494
|
parseGitHubRemoteUrl,
|
|
393
495
|
resolveAdoRemoteMetadata,
|
|
496
|
+
resolveAdoRemoteMetadataViaRest,
|
|
394
497
|
discoverProjectMetadata,
|
|
498
|
+
discoverProjectMetadataAsync,
|
|
395
499
|
buildPrUrlBase,
|
|
396
500
|
buildProjectEntry,
|
|
397
501
|
buildScanResult,
|
package/engine/projects.js
CHANGED
|
@@ -9,8 +9,16 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const shared = require('./shared');
|
|
11
11
|
const dispatch = require('./dispatch');
|
|
12
|
+
const projectDiscovery = require('./project-discovery');
|
|
12
13
|
const { MINIONS_DIR } = shared;
|
|
13
14
|
|
|
15
|
+
function _httpError(status, message, extra) {
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.statusCode = status;
|
|
18
|
+
if (extra) Object.assign(err, extra);
|
|
19
|
+
return err;
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
function _sameProjectName(a, b) {
|
|
15
23
|
return String(a || '').toLowerCase() === String(b || '').toLowerCase();
|
|
16
24
|
}
|
|
@@ -429,4 +437,134 @@ function removeProject(target, options = {}) {
|
|
|
429
437
|
return summary;
|
|
430
438
|
}
|
|
431
439
|
|
|
432
|
-
|
|
440
|
+
// ─── Project Add (W-mq8li79a000889fa) ────────────────────────────────────────
|
|
441
|
+
// Centralized project link helper used by both /api/projects/add (dashboard)
|
|
442
|
+
// and `minions add <path>` (CLI). Mirrors removeProject's "single source of
|
|
443
|
+
// truth" role so add semantics are identical regardless of entry point.
|
|
444
|
+
//
|
|
445
|
+
// Fail-closed contract for Azure DevOps projects:
|
|
446
|
+
// - The linked entry MUST end up with a real repositoryId (GUID). The
|
|
447
|
+
// poller, watches, and conflict-resolution surfaces all key on it; an
|
|
448
|
+
// empty value silently breaks downstream automation.
|
|
449
|
+
// - Resolution path is `az repos show` → `az repos list` → official ADO
|
|
450
|
+
// REST endpoint (`/_apis/git/repositories/{repo}?api-version=7.1`).
|
|
451
|
+
// - When ALL three paths fail to surface a GUID, throw a 4xx-shaped Error
|
|
452
|
+
// and DO NOT persist a partial project entry to config.json. Callers
|
|
453
|
+
// translate `err.statusCode` (default 400) + `err.message` to their
|
|
454
|
+
// transport (HTTP body / CLI stderr).
|
|
455
|
+
//
|
|
456
|
+
// observeAuthors:
|
|
457
|
+
// - Optional array of identifiers (UPNs, display names) the `ado-author-prs`
|
|
458
|
+
// watch will surface PRs from. Stored on the project entry so the watch
|
|
459
|
+
// can validate the project shape up-front instead of failing at evaluate
|
|
460
|
+
// time. Non-ADO projects ignore this field (watch is ADO-only today).
|
|
461
|
+
//
|
|
462
|
+
// Concurrency:
|
|
463
|
+
// - Config is mutated under mutateJsonFileLocked so a concurrent CLI add
|
|
464
|
+
// and dashboard add cannot both pass the pre-check and double-insert.
|
|
465
|
+
function assertProjectLinkOk(project) {
|
|
466
|
+
if (!project || typeof project !== 'object') {
|
|
467
|
+
throw _httpError(400, 'Invalid project entry: must be an object');
|
|
468
|
+
}
|
|
469
|
+
const host = String(project.repoHost || 'github').toLowerCase();
|
|
470
|
+
if (host !== 'ado') return; // GitHub / generic projects have no required GUID
|
|
471
|
+
|
|
472
|
+
const missing = [];
|
|
473
|
+
if (!String(project.repositoryId || '').trim()) missing.push('repositoryId');
|
|
474
|
+
if (!String(project.adoOrg || '').trim()) missing.push('adoOrg');
|
|
475
|
+
if (!String(project.adoProject || '').trim()) missing.push('adoProject');
|
|
476
|
+
if (!String(project.repoName || '').trim()) missing.push('repoName');
|
|
477
|
+
if (missing.length === 0) return;
|
|
478
|
+
|
|
479
|
+
const label = project.name || project.localPath || '<unknown>';
|
|
480
|
+
const hint = missing.includes('repositoryId')
|
|
481
|
+
? ' Resolve via Azure CLI (`az login` + `az repos show`) or by linking from a host where the ADO REST API is reachable.'
|
|
482
|
+
: '';
|
|
483
|
+
throw _httpError(400,
|
|
484
|
+
`ADO project "${label}" missing required field(s): ${missing.join(', ')}.${hint}`,
|
|
485
|
+
{ missing, code: 'PROJECT_LINK_INVALID_ADO' });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function addProject(target, options = {}) {
|
|
489
|
+
const inputPath = typeof target === 'string' ? target : (target && target.path);
|
|
490
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
491
|
+
throw _httpError(400, 'addProject: target path required');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 1. Validate path (must exist + be a git repo OR caller opts in via
|
|
495
|
+
// allowNonRepo + confirmToken — same contract as the dashboard handler).
|
|
496
|
+
const resolved = shared.validateProjectPath(inputPath, {
|
|
497
|
+
allowNonRepo: options.allowNonRepo === true,
|
|
498
|
+
confirmToken: options.confirmToken,
|
|
499
|
+
isValidToken: options.isValidToken,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// 2. Pre-flight duplicate check OUTSIDE the lock so the common case
|
|
503
|
+
// (already-linked) returns a clean 400 without churning the config file.
|
|
504
|
+
// Resolve config path through `shared.MINIONS_DIR` (fresh on each call)
|
|
505
|
+
// so tests that swap MINIONS_DIR via createTestMinionsDir() see the
|
|
506
|
+
// right path even when projects.js itself isn't isolated.
|
|
507
|
+
const configPath = path.join(shared.MINIONS_DIR, 'config.json');
|
|
508
|
+
const existingConfig = shared.safeJson(configPath) || { projects: [] };
|
|
509
|
+
const existingProjects = Array.isArray(existingConfig.projects) ? existingConfig.projects : [];
|
|
510
|
+
if (existingProjects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
|
|
511
|
+
throw _httpError(400, `Project already linked at ${resolved}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 3. Discover metadata. Async path includes the official ADO REST fallback
|
|
515
|
+
// for repositoryId when `az` CLI isn't available — see
|
|
516
|
+
// project-discovery.js#discoverProjectMetadataAsync.
|
|
517
|
+
const detected = await projectDiscovery.discoverProjectMetadataAsync(resolved, options);
|
|
518
|
+
if (!detected.name) detected.name = path.basename(resolved);
|
|
519
|
+
|
|
520
|
+
// 4. Validate name (rejects shell metacharacters, path separators, …).
|
|
521
|
+
const rawName = options.name || detected.name;
|
|
522
|
+
const name = shared.validateProjectName(rawName);
|
|
523
|
+
|
|
524
|
+
// 5. Build entry.
|
|
525
|
+
const project = projectDiscovery.buildProjectEntry({
|
|
526
|
+
name,
|
|
527
|
+
description: options.description !== undefined ? options.description : (detected.description || ''),
|
|
528
|
+
localPath: resolved,
|
|
529
|
+
repoHost: detected.repoHost || 'github',
|
|
530
|
+
repositoryId: detected.repositoryId || '',
|
|
531
|
+
org: detected.org || '',
|
|
532
|
+
project: detected.project || '',
|
|
533
|
+
repoName: detected.repoName || name,
|
|
534
|
+
mainBranch: detected.mainBranch || 'main',
|
|
535
|
+
prUrlBase: detected.prUrlBase,
|
|
536
|
+
worktreeMode: options.worktreeMode,
|
|
537
|
+
observeAuthors: Array.isArray(options.observeAuthors) ? options.observeAuthors : undefined,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// 6. Fail-closed gate. AFTER buildProjectEntry — buildProjectEntry never
|
|
541
|
+
// re-resolves metadata, so this check sees the same shape the engine
|
|
542
|
+
// will. Throws statusCode=400 with `missing[]` populated.
|
|
543
|
+
assertProjectLinkOk(project);
|
|
544
|
+
|
|
545
|
+
// 7. Ensure project state files (projects/<name>/...).
|
|
546
|
+
shared.ensureProjectStateFiles(project);
|
|
547
|
+
|
|
548
|
+
// 8. Persist under config lock. Re-check inside the lock so a concurrent
|
|
549
|
+
// add can't insert a duplicate between our pre-check and the write.
|
|
550
|
+
let duplicate = false;
|
|
551
|
+
shared.mutateJsonFileLocked(configPath, (config) => {
|
|
552
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) config = { projects: [], agents: {}, engine: {} };
|
|
553
|
+
if (!Array.isArray(config.projects)) config.projects = [];
|
|
554
|
+
if (config.projects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
|
|
555
|
+
duplicate = true;
|
|
556
|
+
return config;
|
|
557
|
+
}
|
|
558
|
+
config.projects.push(project);
|
|
559
|
+
return config;
|
|
560
|
+
}, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });
|
|
561
|
+
|
|
562
|
+
if (duplicate) throw _httpError(400, `Project already linked at ${resolved}`);
|
|
563
|
+
|
|
564
|
+
return { ok: true, project, detected, path: resolved };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
module.exports.assertProjectLinkOk = assertProjectLinkOk;
|
|
568
|
+
module.exports.addProject = addProject;
|
|
569
|
+
|
|
570
|
+
module.exports.removeProject = removeProject;
|
package/minions.js
CHANGED
|
@@ -129,12 +129,39 @@ async function addProject(targetDir) {
|
|
|
129
129
|
const repositoryId = await ask('Repository ID (GUID, optional)', detected.repositoryId || '');
|
|
130
130
|
const mainBranch = await ask('Main branch', detected.mainBranch || 'main');
|
|
131
131
|
|
|
132
|
+
// W-mq8li79a000889fa: prompt for observed authors on ADO projects so the
|
|
133
|
+
// ado-author-prs watch plugin has a configured author list to track.
|
|
134
|
+
// Empty string -> no observeAuthors persisted (back-compat).
|
|
135
|
+
let observeAuthors;
|
|
136
|
+
if (String(repoHost || '').toLowerCase() === 'ado') {
|
|
137
|
+
const raw = await ask('Observe authors (comma-separated emails, optional)', '');
|
|
138
|
+
const list = String(raw || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
139
|
+
if (list.length > 0) observeAuthors = list;
|
|
140
|
+
}
|
|
141
|
+
|
|
132
142
|
rl.close();
|
|
133
143
|
|
|
134
144
|
const projectEntry = buildProjectEntry({
|
|
135
145
|
name, description, localPath: target, repoHost, repositoryId, org, project, repoName, mainBranch,
|
|
136
146
|
prUrlBase: detected.prUrlBase,
|
|
147
|
+
observeAuthors,
|
|
137
148
|
});
|
|
149
|
+
|
|
150
|
+
// W-mq8li79a000889fa: fail-closed gate. ADO projects must have repositoryId
|
|
151
|
+
// (the engine's ADO REST endpoints take a repo GUID, not a name) plus org +
|
|
152
|
+
// project + repoName for the dashboard's PR routing to work. Surface the
|
|
153
|
+
// missing fields up front rather than letting the CLI silently persist a
|
|
154
|
+
// broken project.
|
|
155
|
+
try {
|
|
156
|
+
require('./engine/projects').assertProjectLinkOk(projectEntry);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.log(`\n Error: ${e.message}`);
|
|
159
|
+
if (Array.isArray(e.missing) && e.missing.length > 0) {
|
|
160
|
+
console.log(` Missing required field(s): ${e.missing.join(', ')}`);
|
|
161
|
+
}
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
138
165
|
shared.ensureProjectStateFiles(projectEntry);
|
|
139
166
|
config.projects.push(projectEntry);
|
|
140
167
|
saveConfig(config);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2166",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|