@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.
Files changed (40) hide show
  1. package/dashboard/js/command-center.js +13 -2
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +446 -14
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. 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` once per process. The shared
153
- // PAT identity does not change during a process lifetime, so a simple
154
- // in-memory cache is sufficient.
155
- let _cachedViewerLogin = null;
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
- if (_cachedViewerLogin) return _cachedViewerLogin;
168
+ async function _resolveViewerLogin(slug) {
169
+ const account = _accountKeyForSlug(slug);
170
+ if (_viewerLoginByAccount.has(account)) return _viewerLoginByAccount.get(account);
159
171
  try {
160
- const result = await _runExec('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
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) _cachedViewerLogin = 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 the cached viewer login without
172
- // shelling out. Pass `null` to force a re-resolve on the next call.
173
- function _setCachedViewerLogin(login) {
174
- _cachedViewerLogin = login ? String(login).toLowerCase() : null;
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 result = await _runExec(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
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 result = await _runExec(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
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
- pr.branch = prData.head.ref;
494
- if (pr._branchResolutionError) delete pr._branchResolutionError;
495
- if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
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
- pr.branch = headBranch;
576
- if (pr._branchResolutionError) delete pr._branchResolutionError;
577
- if (pr._pendingReason === shared.PR_PENDING_REASON.MISSING_BRANCH) delete pr._pendingReason;
578
- updated = true;
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
- await _runExec(`gh pr merge ${prNum} --${mergeMethod} --repo ${slug} --delete-branch`, { timeout: 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
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
- // Resolve viewer login once per poll cycle so we can backfill
865
- // `viewerDidAuthor` on REST comments the field GraphQL would have
866
- // populated. `_resolveViewerLogin` caches process-wide, so repeated calls
867
- // across PRs are free.
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
- const branch = ghPr.head?.ref || '';
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 {