@yemi33/minions 0.1.1950 → 0.1.1952
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/command-center.js +13 -2
- package/dashboard/js/modal-qa.js +10 -0
- package/dashboard/js/refresh.js +4 -0
- package/dashboard/js/render-dispatch.js +25 -0
- package/dashboard/js/render-other.js +109 -2
- package/dashboard/js/settings.js +1 -1
- package/dashboard/layout.html +2 -2
- package/dashboard/pages/engine.html +6 -0
- package/dashboard/slim.html +1987 -0
- package/dashboard/styles.css +8 -0
- package/dashboard.js +450 -40
- package/docs/completion-reports.md +25 -0
- package/docs/design-state-storage.md +1 -1
- package/docs/slim-ux/architecture-suggestions.md +467 -0
- package/docs/slim-ux/concepts.md +824 -0
- package/engine/ado-mcp-wrapper.js +33 -7
- package/engine/ado.js +123 -15
- package/engine/cc-worker-pool.js +41 -0
- package/engine/cleanup.js +71 -34
- package/engine/cli.js +37 -0
- package/engine/dispatch.js +32 -9
- package/engine/features.js +6 -0
- package/engine/gh-token.js +137 -0
- package/engine/github.js +166 -29
- package/engine/issues.js +29 -0
- package/engine/keep-process-sweep.js +397 -0
- package/engine/lifecycle.js +150 -33
- package/engine/playbook.js +17 -0
- package/engine/queries.js +71 -0
- package/engine/recovery.js +6 -0
- package/engine/shared.js +446 -14
- package/engine/spawn-agent.js +44 -2
- package/engine/timeout.js +34 -11
- package/engine/worktree-pool.js +410 -0
- package/engine.js +643 -119
- package/package.json +6 -3
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +3 -1
- package/prompts/cc-system.md +24 -0
- package/engine/copilot-models.json +0 -5
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/gh-token.js — Per-slug GitHub PAT resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the PAT for a given owner/repo slug by consulting
|
|
5
|
+
* `config.engine.ghAccounts` and shelling out to `gh auth token --user <account>
|
|
6
|
+
* --hostname github.com`. Threading the resolved token via `GH_TOKEN` lets
|
|
7
|
+
* `gh` calls hit the right account WITHOUT touching the global active account.
|
|
8
|
+
*
|
|
9
|
+
* Lookup chain for a slug `<owner>/<repo>`:
|
|
10
|
+
* 1. `engine.ghAccounts[<owner>]` — exact owner match.
|
|
11
|
+
* 2. `engine.ghAccounts[<owner>/* ]` — owner glob (optional).
|
|
12
|
+
* 3. `engine.ghAccounts['*']` — fleet default.
|
|
13
|
+
* 4. null → caller falls back to the ambient `gh` identity, preserving
|
|
14
|
+
* legacy behavior for unmapped slugs.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the shape of `engine/ado-token.js` (separate sync token acquisition
|
|
17
|
+
* helper, in-memory cache, narrow exec timeout). Tests can prime the cache
|
|
18
|
+
* via `_setTokenForTest(slug, token)` and clear it via `_clearTokenCache()`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { execSync } = require('child_process');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const shared = require('./shared');
|
|
24
|
+
const { safeJson, MINIONS_DIR, log } = shared;
|
|
25
|
+
|
|
26
|
+
const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
27
|
+
const FETCH_TIMEOUT_MS = 10000; // 10s — same ceiling as `gh api user`
|
|
28
|
+
const MAX_BUFFER = 1024 * 1024; // 1 MB — token output is ~80 chars
|
|
29
|
+
const CONFIG_CACHE_MS = 30 * 1000; // 30s — re-read config periodically without hammering fs
|
|
30
|
+
|
|
31
|
+
const _accountTokens = new Map(); // accountName → { token, expiresAt }
|
|
32
|
+
const _slugTokenOverrides = new Map(); // slug → token (test seam)
|
|
33
|
+
|
|
34
|
+
let _cachedConfig = null;
|
|
35
|
+
let _cachedConfigAt = 0;
|
|
36
|
+
|
|
37
|
+
function _readConfig(opts = {}) {
|
|
38
|
+
if (opts.config) return opts.config;
|
|
39
|
+
if (_cachedConfig && (Date.now() - _cachedConfigAt) < CONFIG_CACHE_MS) {
|
|
40
|
+
return _cachedConfig;
|
|
41
|
+
}
|
|
42
|
+
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
43
|
+
_cachedConfig = safeJson(configPath) || {};
|
|
44
|
+
_cachedConfigAt = Date.now();
|
|
45
|
+
return _cachedConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the configured gh account name for a slug, or null when no mapping
|
|
50
|
+
* applies. Mapping precedence: exact owner > owner-glob > fleet default ('*').
|
|
51
|
+
*/
|
|
52
|
+
function resolveAccountForSlug(slug, opts = {}) {
|
|
53
|
+
if (!slug || typeof slug !== 'string') return null;
|
|
54
|
+
const owner = slug.split('/')[0];
|
|
55
|
+
if (!owner) return null;
|
|
56
|
+
|
|
57
|
+
const config = _readConfig(opts);
|
|
58
|
+
const accounts = (config && config.engine && config.engine.ghAccounts) || {};
|
|
59
|
+
if (!accounts || typeof accounts !== 'object') return null;
|
|
60
|
+
|
|
61
|
+
if (accounts[owner]) return String(accounts[owner]);
|
|
62
|
+
const glob = `${owner}/*`;
|
|
63
|
+
if (accounts[glob]) return String(accounts[glob]);
|
|
64
|
+
if (accounts['*']) return String(accounts['*']);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _fetchTokenForAccount(account, opts = {}) {
|
|
69
|
+
if (!account) return null;
|
|
70
|
+
const cached = _accountTokens.get(account);
|
|
71
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
72
|
+
|
|
73
|
+
const run = opts.execSync || execSync;
|
|
74
|
+
try {
|
|
75
|
+
// Argv form via `gh` is safer than constructing a shell string when account
|
|
76
|
+
// names ever include odd chars; using execSync's command form here for
|
|
77
|
+
// consistency with ado-token.js, but the account name flows from a config
|
|
78
|
+
// map under our control (validated at write time).
|
|
79
|
+
const cmd = `gh auth token --user ${account} --hostname github.com`;
|
|
80
|
+
const out = run(cmd, {
|
|
81
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
maxBuffer: MAX_BUFFER,
|
|
85
|
+
});
|
|
86
|
+
const token = String(out || '').trim();
|
|
87
|
+
if (!token) {
|
|
88
|
+
log('warn', `gh-token: empty token returned for account "${account}"`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
_accountTokens.set(account, { token, expiresAt: Date.now() + TOKEN_TTL_MS });
|
|
92
|
+
return token;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
log('warn', `gh-token: failed to fetch token for "${account}": ${e?.message || e}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a GitHub PAT for `slug` (e.g. `opg-microsoft/minions`).
|
|
101
|
+
* Returns the token string on success, or null when no mapping applies and the
|
|
102
|
+
* caller should fall back to the ambient `gh` identity.
|
|
103
|
+
*
|
|
104
|
+
* Test seam: `_setTokenForTest(slug, token)` short-circuits the entire chain
|
|
105
|
+
* so unit tests do not have to mock execSync nor stand up a config file.
|
|
106
|
+
*/
|
|
107
|
+
function resolveTokenForSlug(slug, opts = {}) {
|
|
108
|
+
if (slug && _slugTokenOverrides.has(slug)) return _slugTokenOverrides.get(slug);
|
|
109
|
+
const account = resolveAccountForSlug(slug, opts);
|
|
110
|
+
if (!account) return null;
|
|
111
|
+
return _fetchTokenForAccount(account, opts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Test-only: prime a slug→token override that bypasses config + shell-out. */
|
|
115
|
+
function _setTokenForTest(slug, token) {
|
|
116
|
+
if (!slug) return;
|
|
117
|
+
if (token == null) _slugTokenOverrides.delete(slug);
|
|
118
|
+
else _slugTokenOverrides.set(slug, String(token));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test-only: drop every cache so the next resolve goes through the real path. */
|
|
122
|
+
function _clearTokenCache() {
|
|
123
|
+
_accountTokens.clear();
|
|
124
|
+
_slugTokenOverrides.clear();
|
|
125
|
+
_cachedConfig = null;
|
|
126
|
+
_cachedConfigAt = 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
resolveTokenForSlug,
|
|
131
|
+
resolveAccountForSlug,
|
|
132
|
+
_setTokenForTest,
|
|
133
|
+
_clearTokenCache,
|
|
134
|
+
// Constants exposed for tests + diagnostics.
|
|
135
|
+
TOKEN_TTL_MS,
|
|
136
|
+
FETCH_TIMEOUT_MS,
|
|
137
|
+
};
|
package/engine/github.js
CHANGED
|
@@ -8,6 +8,7 @@ const shared = require('./shared');
|
|
|
8
8
|
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, mutatePullRequests, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const { MINIONS_COMMENT_MARKER_RE } = require('./gh-comment');
|
|
11
|
+
const ghToken = require('./gh-token');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
|
|
13
14
|
// Lazy require to avoid circular dependency — only needed for engine().handlePostMerge
|
|
@@ -149,29 +150,46 @@ function _isMinionsAuthoredComment(c) {
|
|
|
149
150
|
// `pollPrHumanComments` (REST returns `viewerDidAuthor: null`). To make the
|
|
150
151
|
// classifier alive in production without changing every fetch site to
|
|
151
152
|
// GraphQL, we backfill the field by comparing the comment author's login to
|
|
152
|
-
// the viewer login resolved via `gh api user
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
153
|
+
// the viewer login resolved via `gh api user`.
|
|
154
|
+
//
|
|
155
|
+
// W-mp76pw7a001da7c1 — The viewer login is no longer process-wide because
|
|
156
|
+
// `ghApi` now resolves a per-slug token via `engine/gh-token.js`. Different
|
|
157
|
+
// accounts return different logins from `gh api user`, so the cache is keyed
|
|
158
|
+
// by the resolved account name (or the sentinel `__ambient__` when no
|
|
159
|
+
// account mapping applies and the call falls back to the ambient gh
|
|
160
|
+
// identity). One shell-out per distinct account per process is still cheap.
|
|
161
|
+
const _viewerLoginByAccount = new Map();
|
|
162
|
+
const _AMBIENT_ACCOUNT = '__ambient__';
|
|
163
|
+
|
|
164
|
+
function _accountKeyForSlug(slug) {
|
|
165
|
+
return ghToken.resolveAccountForSlug(slug) || _AMBIENT_ACCOUNT;
|
|
166
|
+
}
|
|
156
167
|
|
|
157
|
-
async function _resolveViewerLogin() {
|
|
158
|
-
|
|
168
|
+
async function _resolveViewerLogin(slug) {
|
|
169
|
+
const account = _accountKeyForSlug(slug);
|
|
170
|
+
if (_viewerLoginByAccount.has(account)) return _viewerLoginByAccount.get(account);
|
|
159
171
|
try {
|
|
160
|
-
const
|
|
172
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
173
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : process.env;
|
|
174
|
+
const result = await _runExec('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
|
|
161
175
|
const parsed = JSON.parse(String(result || ''));
|
|
162
176
|
const login = parsed?.login ? String(parsed.login).toLowerCase() : null;
|
|
163
|
-
if (login)
|
|
177
|
+
if (login) _viewerLoginByAccount.set(account, login);
|
|
164
178
|
return login;
|
|
165
179
|
} catch (e) {
|
|
166
|
-
log('warn', `GitHub viewer-login resolution failed: ${e?.message || e}`);
|
|
180
|
+
log('warn', `GitHub viewer-login resolution failed for ${slug || '<no-slug>'}: ${e?.message || e}`);
|
|
167
181
|
return null;
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
// Test hook: lets tests prime or clear
|
|
172
|
-
// shelling out. Pass `null` to force a re-resolve
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
// Test hook: lets tests prime or clear a cached viewer login per account
|
|
186
|
+
// without shelling out. Pass `(account, null)` to force a re-resolve.
|
|
187
|
+
// W-mp76pw7a001da7c1: signature changed from `(login)` → `(account, login)`
|
|
188
|
+
// because the cache is now keyed by the resolved gh account name.
|
|
189
|
+
function _setCachedViewerLogin(account, login) {
|
|
190
|
+
const key = account ? String(account) : _AMBIENT_ACCOUNT;
|
|
191
|
+
if (login == null) _viewerLoginByAccount.delete(key);
|
|
192
|
+
else _viewerLoginByAccount.set(key, String(login).toLowerCase());
|
|
175
193
|
}
|
|
176
194
|
|
|
177
195
|
// Backfill `c.viewerDidAuthor` for an array of REST-fetched comments using
|
|
@@ -254,7 +272,9 @@ async function ghApi(endpoint, slug, opts = {}) {
|
|
|
254
272
|
try {
|
|
255
273
|
const paginateFlag = opts.paginate ? ' --paginate' : '';
|
|
256
274
|
const cmd = `gh api${paginateFlag} "repos/${slug}${endpoint}"`;
|
|
257
|
-
const
|
|
275
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
276
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
277
|
+
const result = await _runExec(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
|
|
258
278
|
const parsed = JSON.parse(result);
|
|
259
279
|
_ghThrottle.recordSuccess();
|
|
260
280
|
return parsed;
|
|
@@ -323,7 +343,9 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
|
|
|
323
343
|
// Always fetch job log — annotations alone often lack test failure details
|
|
324
344
|
try {
|
|
325
345
|
const cmd = `gh api "repos/${slug}/actions/jobs/${run.id}/logs" 2>&1`;
|
|
326
|
-
const
|
|
346
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
347
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
348
|
+
const result = await _runExec(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
|
|
327
349
|
if (result && !result.includes('Not Found')) {
|
|
328
350
|
logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
|
|
329
351
|
}
|
|
@@ -353,6 +375,15 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
353
375
|
let totalUpdated = 0;
|
|
354
376
|
|
|
355
377
|
for (const project of projects) {
|
|
378
|
+
// W-mp625n27000m6e78 — Honor per-project workSources.pullRequests.enabled.
|
|
379
|
+
// Default-ON: only skip when explicitly disabled (=== false). Missing/undefined
|
|
380
|
+
// falls through to global workSources, then defaults to polling. Without this
|
|
381
|
+
// gate, disabling PR polling via the dashboard Settings UI was cosmetic — the
|
|
382
|
+
// engine still polled and could flip PRs to abandoned via the GH_NOT_FOUND
|
|
383
|
+
// trapdoor in pollPrStatus.
|
|
384
|
+
const src = project?.workSources?.pullRequests || config?.workSources?.pullRequests;
|
|
385
|
+
if (src && src.enabled === false) continue;
|
|
386
|
+
|
|
356
387
|
const slug = getRepoSlug(project);
|
|
357
388
|
if (!slug) continue;
|
|
358
389
|
|
|
@@ -490,9 +521,16 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
490
521
|
if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
|
|
491
522
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
492
523
|
if (!pr.branch && prData.head?.ref) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
524
|
+
// P-a7c4d2e8 (F3): validate API-derived branch ref before
|
|
525
|
+
// persistence. Defensive degrade — log and skip on invalid ref
|
|
526
|
+
// rather than crash the poller.
|
|
527
|
+
try {
|
|
528
|
+
pr.branch = shared.validateGitRef(prData.head.ref);
|
|
529
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
530
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
531
|
+
} catch (refErr) {
|
|
532
|
+
log('warn', `GitHub: invalid head.ref for PR ${pr.id}, skipping branch persistence: ${refErr.message}`);
|
|
533
|
+
}
|
|
496
534
|
}
|
|
497
535
|
}
|
|
498
536
|
}
|
|
@@ -572,10 +610,16 @@ async function pollPrStatus(config) {
|
|
|
572
610
|
|
|
573
611
|
const headBranch = prData.head?.ref ? String(prData.head.ref).trim() : '';
|
|
574
612
|
if (headBranch && pr.branch !== headBranch) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
613
|
+
// P-a7c4d2e8 (F3): validate API-derived branch ref before persistence.
|
|
614
|
+
try {
|
|
615
|
+
const validated = shared.validateGitRef(headBranch);
|
|
616
|
+
pr.branch = validated;
|
|
617
|
+
if (pr._branchResolutionError) delete pr._branchResolutionError;
|
|
618
|
+
if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
|
|
619
|
+
updated = true;
|
|
620
|
+
} catch (refErr) {
|
|
621
|
+
log('warn', `GitHub: invalid head.ref "${headBranch.slice(0, 64)}" for PR ${pr.id}, skipping branch persistence: ${refErr.message}`);
|
|
622
|
+
}
|
|
579
623
|
}
|
|
580
624
|
|
|
581
625
|
// Map GitHub PR state to minions status
|
|
@@ -832,7 +876,8 @@ async function pollPrStatus(config) {
|
|
|
832
876
|
if (autoComplete) {
|
|
833
877
|
try {
|
|
834
878
|
const mergeMethod = ['squash', 'merge', 'rebase'].includes(config.engine?.prMergeMethod) ? config.engine.prMergeMethod : 'squash';
|
|
835
|
-
|
|
879
|
+
// P-a7c4d2e8 (F1): argv-form merge call — eliminates shell interpolation of slug/prNum/mergeMethod.
|
|
880
|
+
await shared.shellSafeGh(['pr', 'merge', String(prNum), `--${mergeMethod}`, '--repo', shared.validateGhSlug(slug), '--delete-branch'], { timeout: 30000, maxBuffer: GH_MAX_BUFFER });
|
|
836
881
|
pr._autoCompleted = true;
|
|
837
882
|
log('info', `Auto-completed PR ${pr.id}: builds green + review approved → merged (${mergeMethod})`);
|
|
838
883
|
updated = true;
|
|
@@ -861,12 +906,12 @@ async function pollPrHumanComments(config) {
|
|
|
861
906
|
try { return queries.getDispatch(); }
|
|
862
907
|
catch { return { pending: [], active: [], completed: [] }; }
|
|
863
908
|
})();
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
const viewerLogin = await _resolveViewerLogin();
|
|
909
|
+
// Viewer login is resolved per-PR (cheap on cache hit) because each slug
|
|
910
|
+
// can route to a different gh account via `engine/gh-token.js`. The cache
|
|
911
|
+
// is keyed by the resolved account name, so multiple PRs sharing one
|
|
912
|
+
// account pay only the first shell-out.
|
|
869
913
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
914
|
+
const viewerLogin = await _resolveViewerLogin(slug);
|
|
870
915
|
// Get issue comments (general PR comments)
|
|
871
916
|
const comments = await ghApi(`/issues/${prNum}/comments`, slug);
|
|
872
917
|
if (!comments || !Array.isArray(comments)) return false;
|
|
@@ -1012,7 +1057,17 @@ async function reconcilePrs(config) {
|
|
|
1012
1057
|
for (const ghPr of ghPrs) {
|
|
1013
1058
|
const prUrl = project.prUrlBase ? project.prUrlBase + ghPr.number : ghPr.html_url || '';
|
|
1014
1059
|
const prId = shared.getCanonicalPrId(project, ghPr.number, prUrl);
|
|
1015
|
-
|
|
1060
|
+
// P-a7c4d2e8 (F3): validate API-derived branch ref before persistence
|
|
1061
|
+
// / regex matching. Defensive degrade — log and treat as missing on
|
|
1062
|
+
// invalid ref rather than letting it flow into stored records.
|
|
1063
|
+
const rawBranch = ghPr.head?.ref || '';
|
|
1064
|
+
let branch = '';
|
|
1065
|
+
if (rawBranch) {
|
|
1066
|
+
try { branch = shared.validateGitRef(rawBranch); }
|
|
1067
|
+
catch (refErr) {
|
|
1068
|
+
log('warn', `GitHub: invalid head.ref "${rawBranch.slice(0, 64)}" for PR ${prId}: ${refErr.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1016
1071
|
const wiMatch = branch.match(/(P-[a-z0-9]{6,})/i) || branch.match(/(W-[a-z0-9]{6,})/i) || branch.match(/(PL-[a-z0-9]{6,})/i);
|
|
1017
1072
|
const linkedItemId = wiMatch ? wiMatch[1] : null;
|
|
1018
1073
|
const linkedItem = linkedItemId ? allItems.find(i => i.id === linkedItemId) : null;
|
|
@@ -1134,6 +1189,85 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
1134
1189
|
}
|
|
1135
1190
|
}
|
|
1136
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* W-mp7b1g8q000fea45 — Dismiss prior CHANGES_REQUESTED reviews by the
|
|
1194
|
+
* authenticated viewer when an agent flips their verdict to approved.
|
|
1195
|
+
*
|
|
1196
|
+
* GitHub doesn't auto-supersede prior reviews: a new COMMENTED review (which is
|
|
1197
|
+
* what minions agents post on GH per CLAUDE.md "GitHub review self-approval
|
|
1198
|
+
* constraint") leaves any earlier `CHANGES_REQUESTED` review by the same login
|
|
1199
|
+
* intact, so `checkLiveReviewStatus` still returns 'changes-requested' and the
|
|
1200
|
+
* red ❌ badge stays on the PR. Active dismissal via PUT
|
|
1201
|
+
* /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals
|
|
1202
|
+
* clears the stale red badge.
|
|
1203
|
+
*
|
|
1204
|
+
* Only dismisses reviews whose state is CHANGES_REQUESTED AND whose author
|
|
1205
|
+
* matches the resolved viewer login (i.e., the engine's own gh PAT). Human
|
|
1206
|
+
* CHANGES_REQUESTED reviews under a different login are never touched.
|
|
1207
|
+
*
|
|
1208
|
+
* Returns null on transport failure. Otherwise returns
|
|
1209
|
+
* { attempted: boolean, dismissed: number, errors: number }
|
|
1210
|
+
*/
|
|
1211
|
+
async function dismissPriorViewerChangesRequestedReviews(pr, project) {
|
|
1212
|
+
let tmpFile = null;
|
|
1213
|
+
try {
|
|
1214
|
+
const slug = getRepoSlug(project);
|
|
1215
|
+
if (!slug) return null;
|
|
1216
|
+
const prNum = shared.getPrNumber(pr);
|
|
1217
|
+
if (!prNum) return null;
|
|
1218
|
+
const viewerLogin = await _resolveViewerLogin(slug);
|
|
1219
|
+
if (!viewerLogin) return null;
|
|
1220
|
+
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
1221
|
+
if (!reviews || !Array.isArray(reviews)) return null;
|
|
1222
|
+
// Latest CHANGES_REQUESTED review IDs by the viewer login. The reviews API
|
|
1223
|
+
// returns them in chronological order; we keep all that are still active
|
|
1224
|
+
// (not already dismissed) since GitHub's per-user "latest" semantics are
|
|
1225
|
+
// computed from non-dismissed reviews and we want the badge fully cleared.
|
|
1226
|
+
const viewerLower = String(viewerLogin).toLowerCase();
|
|
1227
|
+
const targets = reviews
|
|
1228
|
+
.filter(r => r && r.state === 'CHANGES_REQUESTED' && String(r.user?.login || '').toLowerCase() === viewerLower)
|
|
1229
|
+
.map(r => r.id)
|
|
1230
|
+
.filter(Number.isFinite);
|
|
1231
|
+
if (targets.length === 0) {
|
|
1232
|
+
return { attempted: false, dismissed: 0, errors: 0 };
|
|
1233
|
+
}
|
|
1234
|
+
const fs = require('fs');
|
|
1235
|
+
const os = require('os');
|
|
1236
|
+
let dismissed = 0;
|
|
1237
|
+
let errors = 0;
|
|
1238
|
+
for (const reviewId of targets) {
|
|
1239
|
+
tmpFile = path.join(os.tmpdir(), `gh-review-dismiss-${process.pid}-${Date.now()}-${reviewId}.json`);
|
|
1240
|
+
const body = JSON.stringify({
|
|
1241
|
+
message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
|
|
1242
|
+
event: 'DISMISS',
|
|
1243
|
+
});
|
|
1244
|
+
try {
|
|
1245
|
+
fs.writeFileSync(tmpFile, body);
|
|
1246
|
+
const cmd = `gh api -X PUT --input "${tmpFile}" "repos/${slug}/pulls/${prNum}/reviews/${reviewId}/dismissals"`;
|
|
1247
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
1248
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
1249
|
+
await _runExec(cmd, { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER, env });
|
|
1250
|
+
dismissed += 1;
|
|
1251
|
+
log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
errors += 1;
|
|
1254
|
+
log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
|
|
1255
|
+
} finally {
|
|
1256
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
1257
|
+
tmpFile = null;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return { attempted: true, dismissed, errors };
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
log('warn', `dismissPriorViewerChangesRequestedReviews for ${pr?.id || 'unknown PR'}: ${e?.message || e}`);
|
|
1263
|
+
return null;
|
|
1264
|
+
} finally {
|
|
1265
|
+
if (tmpFile) {
|
|
1266
|
+
try { require('fs').unlinkSync(tmpFile); } catch {}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1137
1271
|
/**
|
|
1138
1272
|
* Cheap pre-dispatch freshness check for build status and merge-conflict state.
|
|
1139
1273
|
* Mirrors checkLiveReviewStatus — fetches PR data once, classifies check-runs
|
|
@@ -1356,6 +1490,7 @@ module.exports = {
|
|
|
1356
1490
|
reconcileAbandonedPrs, // W-mp60tw0u000j3931 — one-shot startup re-probe of abandoned PRs
|
|
1357
1491
|
checkLiveReviewStatus,
|
|
1358
1492
|
checkLiveBuildAndConflict,
|
|
1493
|
+
dismissPriorViewerChangesRequestedReviews, // W-mp7b1g8q000fea45 — clear stale CHANGES_REQUESTED on verdict flip
|
|
1359
1494
|
isGhThrottled,
|
|
1360
1495
|
getGhThrottleState,
|
|
1361
1496
|
// Exported for testing
|
|
@@ -1369,6 +1504,8 @@ module.exports = {
|
|
|
1369
1504
|
GH_MAX_BUFFER, // exported for testing
|
|
1370
1505
|
GH_POLL_BACKOFF_BASE_MS, // exported for testing
|
|
1371
1506
|
GH_POLL_BACKOFF_MAX_MS, // exported for testing
|
|
1507
|
+
GH_NOT_FOUND, // W-mp625n27000m6e78 — exported for forEachActiveGhPr probe-predicate tests
|
|
1508
|
+
forEachActiveGhPr, // W-mp625n27000m6e78 — exported for workSources-gate + probe tests
|
|
1372
1509
|
_hasMinionsReviewVerdict, // exported for testing
|
|
1373
1510
|
_isAgentComment, // exported for testing
|
|
1374
1511
|
_isNonActionableComment, // exported for testing
|
package/engine/issues.js
CHANGED
|
@@ -6,6 +6,7 @@ const fs = require('fs');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { execFileSync: _execFileSync } = require('child_process');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
+
const ghToken = require('./gh-token');
|
|
9
10
|
|
|
10
11
|
const DEFAULT_REPO = 'yemi33/minions';
|
|
11
12
|
const DEFAULT_LABELS = ['bug'];
|
|
@@ -113,6 +114,34 @@ function ghTokenForUser({ user, execFileSync }) {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
function resolveWritableGitHubEnv({ repo, execFileSync }) {
|
|
117
|
+
// W-mp76pw7a001da7c1 — Try the per-slug resolver FIRST. If `engine.ghAccounts`
|
|
118
|
+
// maps this owner to a specific gh account, use that account's token instead
|
|
119
|
+
// of probing whatever account is globally active. Falls through to the
|
|
120
|
+
// legacy active-account / enumerate-all-accounts path when the resolver
|
|
121
|
+
// returns null (preserves backward compat for unmapped slugs).
|
|
122
|
+
try {
|
|
123
|
+
const mappedToken = ghToken.resolveTokenForSlug(repo);
|
|
124
|
+
if (mappedToken) {
|
|
125
|
+
const ghEnv = { GH_TOKEN: mappedToken };
|
|
126
|
+
try {
|
|
127
|
+
const permission = repoViewerPermission({ repo, execFileSync, ghEnv });
|
|
128
|
+
if (isWritableRepoPermission(permission)) return ghEnv;
|
|
129
|
+
} catch { /* fall through to legacy enumeration */ }
|
|
130
|
+
}
|
|
131
|
+
} catch { /* fall through to legacy enumeration */ }
|
|
132
|
+
|
|
133
|
+
// Final fallback: explicit GH_TOKEN env var. Only consulted when no mapping
|
|
134
|
+
// produced a usable token. Honors the contract spelled out in the task —
|
|
135
|
+
// resolver is primary, GH_TOKEN env is the env-side fallback.
|
|
136
|
+
const envToken = String(process.env.GH_TOKEN || '').trim();
|
|
137
|
+
if (envToken) {
|
|
138
|
+
const ghEnv = { GH_TOKEN: envToken };
|
|
139
|
+
try {
|
|
140
|
+
const permission = repoViewerPermission({ repo, execFileSync, ghEnv });
|
|
141
|
+
if (isWritableRepoPermission(permission)) return ghEnv;
|
|
142
|
+
} catch { /* fall through */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
let activePermission = '';
|
|
117
146
|
let activeError = null;
|
|
118
147
|
try {
|