@yemi33/minions 0.1.2452 → 0.1.2454
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/render-prs.js +43 -24
- package/dashboard/js/render-work-items.js +6 -1
- package/dashboard/js/settings.js +8 -0
- package/dashboard/shared/pr-author.js +75 -0
- package/dashboard/shared/pr-filters.js +28 -5
- package/dashboard/styles.css +12 -0
- package/dashboard-build.js +1 -1
- package/docs/pr-author-identity.md +63 -10
- package/engine/api/settings-validation.js +25 -0
- package/engine/core/operator-identity.js +23 -1
- package/engine/core/queries.js +26 -0
- package/engine/core/shared.js +138 -8
- package/engine/db/migrations/032-review-enrolled-pr-context-only.js +95 -0
- package/package.json +1 -1
|
@@ -150,31 +150,50 @@ function prAutomationToggle(idMatch, automation, label, enabled) {
|
|
|
150
150
|
// renders "Unknown" rather than borrowing the agent name. Links to the provider
|
|
151
151
|
// profile only when a validated http(s) URL is present.
|
|
152
152
|
function _prAuthorCellHtml(pr) {
|
|
153
|
-
var
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
var
|
|
157
|
-
if (a && typeof a === 'object') {
|
|
158
|
-
login = a.login || '';
|
|
159
|
-
displayName = a.displayName || '';
|
|
160
|
-
url = a.url || '';
|
|
161
|
-
} else if (typeof a === 'string') {
|
|
162
|
-
login = a;
|
|
163
|
-
}
|
|
164
|
-
var primary = displayName || login;
|
|
153
|
+
var info = (window.MinionsPrAuthor && window.MinionsPrAuthor.display)
|
|
154
|
+
? window.MinionsPrAuthor.display(pr ? pr.author : null)
|
|
155
|
+
: { name: '', handle: '', url: '' };
|
|
156
|
+
var primary = info.name;
|
|
165
157
|
if (!primary) {
|
|
166
158
|
return '<span style="color:var(--muted);font-size:var(--text-base)" title="Author unavailable">Unknown</span>';
|
|
167
159
|
}
|
|
168
|
-
var secondary =
|
|
160
|
+
var secondary = info.handle;
|
|
169
161
|
var inner = escapeHtml(primary)
|
|
170
162
|
+ (secondary ? ' <span style="color:var(--muted);font-size:var(--text-base)">@' + escapeHtml(secondary) + '</span>' : '');
|
|
171
163
|
var titleText = secondary ? primary + ' (' + secondary + ')' : primary;
|
|
172
|
-
if (
|
|
173
|
-
return '<a class="pr-agent" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + inner + '</a>';
|
|
164
|
+
if (info.url) {
|
|
165
|
+
return '<a class="pr-agent" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(info.url)) + '" target="_blank" rel="noopener">' + inner + '</a>';
|
|
174
166
|
}
|
|
175
167
|
return '<span class="pr-agent" title="' + escapeHtml(titleText) + '">' + inner + '</span>';
|
|
176
168
|
}
|
|
177
169
|
|
|
170
|
+
// W-msd8pr2300ev95e1 — Foreign-author guardrail for the AutoFix column. When
|
|
171
|
+
// auto-fix is ENABLED and the PR was NOT authored by the current authenticated
|
|
172
|
+
// identity for that provider, render a prominent warning so the operator sees
|
|
173
|
+
// that Minions may push commits to someone else's PR. Ownership tri-state is
|
|
174
|
+
// stamped engine-side (pr._authorOwnership) from STABLE provider identities and
|
|
175
|
+
// fails CLOSED: 'unknown' shows an "author ownership unknown" warning rather
|
|
176
|
+
// than silently treating the PR as self-authored. Never warns when auto-fix is
|
|
177
|
+
// off (no automatic pushes happen) or when ownership is 'self'.
|
|
178
|
+
function _prAutoFixForeignAuthorWarning(pr, autoFixEnabled) {
|
|
179
|
+
if (!autoFixEnabled) return '';
|
|
180
|
+
var ownership = pr && pr._authorOwnership;
|
|
181
|
+
if (ownership === 'self') return '';
|
|
182
|
+
var isForeign = ownership === 'foreign';
|
|
183
|
+
// Author name is presentation-only (never an email) via the shared formatter.
|
|
184
|
+
var authorName = (window.MinionsPrAuthor && window.MinionsPrAuthor.name)
|
|
185
|
+
? window.MinionsPrAuthor.name(pr ? pr.author : null) : '';
|
|
186
|
+
var label = isForeign
|
|
187
|
+
? (authorName
|
|
188
|
+
? 'Auto-fix enabled for another author\'s PR (author: ' + authorName + ')'
|
|
189
|
+
: 'Auto-fix enabled for another author\'s PR')
|
|
190
|
+
: 'Auto-fix enabled but author ownership unknown';
|
|
191
|
+
var cls = 'pr-autofix-warning' + (isForeign ? ' pr-autofix-warning--foreign' : ' pr-autofix-warning--unknown');
|
|
192
|
+
return '<span class="' + cls + '" role="img" '
|
|
193
|
+
+ 'title="' + escapeHtml(label) + '" '
|
|
194
|
+
+ 'aria-label="' + escapeHtml(label) + '">\u26A0</span>';
|
|
195
|
+
}
|
|
196
|
+
|
|
178
197
|
function prRow(pr) {
|
|
179
198
|
// Minions review (agent) state — separate from ADO human review
|
|
180
199
|
const sq = pr.minionsReview || {};
|
|
@@ -325,6 +344,7 @@ function prRow(pr) {
|
|
|
325
344
|
: pr.contextOnly !== true;
|
|
326
345
|
var autoFixBtn = prAutomationToggle(idMatch, 'fix', 'Auto Fix', autoFixEnabled);
|
|
327
346
|
var autoReviewBtn = prAutomationToggle(idMatch, 'review', 'Auto Review', autoReviewEnabled);
|
|
347
|
+
var autoFixWarning = _prAutoFixForeignAuthorWarning(pr, autoFixEnabled);
|
|
328
348
|
// Title attrs live on the inner element (link/span/badge) so hovering the
|
|
329
349
|
// ellipsis-truncated content reveals the full text. Cell tags stay bare so
|
|
330
350
|
// the header-to-cell count assertion in test/unit.test.js continues to
|
|
@@ -348,7 +368,7 @@ function prRow(pr) {
|
|
|
348
368
|
'<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
|
|
349
369
|
'<td>' + authorCellHtml + '</td>' +
|
|
350
370
|
'<td>' + reviewerCell + '</td>' +
|
|
351
|
-
'<td>' + (autoFixBtn
|
|
371
|
+
'<td>' + (autoFixBtn ? autoFixBtn + autoFixWarning : '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
|
|
352
372
|
'<td>' + (autoReviewBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
|
|
353
373
|
'<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
|
|
354
374
|
'<td><button class="btn-destructive" style="padding:1px 5px" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
|
|
@@ -622,15 +642,14 @@ function _renderPrDetail(pr) {
|
|
|
622
642
|
// so operators can answer both "who is this for?" and "which agent handled it?".
|
|
623
643
|
let author = '';
|
|
624
644
|
{
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const primary = displayName || login;
|
|
645
|
+
const info = (window.MinionsPrAuthor && window.MinionsPrAuthor.display)
|
|
646
|
+
? window.MinionsPrAuthor.display(pr.author)
|
|
647
|
+
: { name: '', handle: '', url: '' };
|
|
648
|
+
const primary = info.name;
|
|
630
649
|
if (primary) {
|
|
631
|
-
const secondary =
|
|
632
|
-
const valHtml =
|
|
633
|
-
? '<a href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(primary) + '</a>' + secondary
|
|
650
|
+
const secondary = info.handle ? ' <span style="color:var(--muted)">@' + escapeHtml(info.handle) + '</span>' : '';
|
|
651
|
+
const valHtml = info.url
|
|
652
|
+
? '<a href="' + escapeHtml(safeUrl(info.url)) + '" target="_blank" rel="noopener">' + escapeHtml(primary) + '</a>' + secondary
|
|
634
653
|
: escapeHtml(primary) + secondary;
|
|
635
654
|
author = '<div><strong style="color:var(--muted);font-size:var(--text-base)">Author:</strong> ' + valHtml + '</div>';
|
|
636
655
|
} else {
|
|
@@ -210,7 +210,12 @@ function wiRow(item) {
|
|
|
210
210
|
var prRef = prFollowup.parent_pr_id || prFollowup.parent_pr_url;
|
|
211
211
|
var prNumMatch = String(prRef).match(/(\d+)(?!.*\d)/);
|
|
212
212
|
var prLabel = prNumMatch ? ('PR #' + prNumMatch[1]) : 'parent PR';
|
|
213
|
-
|
|
213
|
+
var followupBy = prFollowup.parent_comment_author
|
|
214
|
+
? ((window.MinionsPrAuthor && window.MinionsPrAuthor.name)
|
|
215
|
+
? window.MinionsPrAuthor.name(prFollowup.parent_comment_author)
|
|
216
|
+
: prFollowup.parent_comment_author)
|
|
217
|
+
: '';
|
|
218
|
+
followupChip = ' <a class="pr-badge draft" style="font-size:var(--text-sm);text-decoration:none" target="_blank" rel="noopener" href="' + escapeHtml(prFollowup.parent_pr_url) + '" title="Follow-up dispatched from ' + escapeHtml(prRef) + (followupBy ? ' by ' + escapeHtml(followupBy) : '') + '" onclick="event.stopPropagation()">↩ from ' + escapeHtml(prLabel) + '</a>';
|
|
214
219
|
}
|
|
215
220
|
return '<tr data-wi-id="' + escapeHtml(item.id) + '" style="cursor:pointer" onclick="if(shouldIgnoreSelectionClick(event))return;openWorkItemDetail(\'' + escapeHtml(item.id) + '\',\'' + escapeHtml(item._source || '') + '\')">' +
|
|
216
221
|
'<td style="min-width:280px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escapeHtml((item.title || '').slice(0, 200)) + '">' + escapeHtml(item.title || '') + followupChip + '</td>' +
|
package/dashboard/js/settings.js
CHANGED
|
@@ -780,6 +780,10 @@ async function openSettings() {
|
|
|
780
780
|
settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
|
|
781
781
|
settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
|
|
782
782
|
'</div>' +
|
|
783
|
+
'<div class="settings-grid-2">' +
|
|
784
|
+
settingsField('ADO identity id', 'set-operatorAdoIdentityId', (e.operatorAdoIdentity && e.operatorAdoIdentity.id) || '', '', 'Your Azure DevOps identity GUID. Used ONLY to determine whether an auto-fix-enabled ADO PR is your own vs another author\'s (foreign-author warning). Empty = ADO ownership shows as "unknown". Never an email.') +
|
|
785
|
+
settingsField('ADO identity descriptor', 'set-operatorAdoIdentityDescriptor', (e.operatorAdoIdentity && e.operatorAdoIdentity.descriptor) || '', '', 'Your Azure DevOps identity descriptor (e.g. aad.<base64>). Alternative stable identity for the foreign-author ownership check. Never an email.') +
|
|
786
|
+
'</div>' +
|
|
783
787
|
'<h4>Experimental Behavior</h4>' +
|
|
784
788
|
'<div class="settings-stack">' +
|
|
785
789
|
settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
|
|
@@ -1724,6 +1728,10 @@ async function saveSettings() {
|
|
|
1724
1728
|
spawnPhaseGraceMs: secToMs('set-spawnPhaseGraceMs'),
|
|
1725
1729
|
spawnPhaseMaxCpuSeconds: document.getElementById('set-spawnPhaseMaxCpuSeconds').value,
|
|
1726
1730
|
operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
|
|
1731
|
+
operatorAdoIdentity: {
|
|
1732
|
+
id: (document.getElementById('set-operatorAdoIdentityId')?.value ?? '').trim(),
|
|
1733
|
+
descriptor: (document.getElementById('set-operatorAdoIdentityDescriptor')?.value ?? '').trim(),
|
|
1734
|
+
},
|
|
1727
1735
|
autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
|
|
1728
1736
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
1729
1737
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Shared PR author display formatter (browser). Single source of truth for
|
|
2
|
+
// turning a structured PR author identity into a human-readable NAME that never
|
|
3
|
+
// exposes an email address, reused by every dashboard surface that renders a PR
|
|
4
|
+
// author (the classic Pull Requests table cell + detail panel, and the shared
|
|
5
|
+
// author filter — which slim embeds too). Mirrors engine/core/shared.js
|
|
6
|
+
// prAuthorDisplayName so the label the operator sees matches the projected SQL
|
|
7
|
+
// label; keep the two in sync (pinned by test/unit/pr-author.test.js).
|
|
8
|
+
//
|
|
9
|
+
// The structured identity fields (login/id/descriptor/url) are left untouched:
|
|
10
|
+
// this is a presentation-only transform, not destructive normalization of the
|
|
11
|
+
// machine identity used for filtering, matching, and reconciliation.
|
|
12
|
+
|
|
13
|
+
(function () {
|
|
14
|
+
function _text(value) {
|
|
15
|
+
if (value == null) return '';
|
|
16
|
+
return typeof value === 'string' ? value.trim() : String(value).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A human-readable name from ONE identity fragment that never contains an
|
|
20
|
+
// email address. Any '@' forces local-part extraction ("Name <addr>" keeps the
|
|
21
|
+
// display part; a bare/embedded address drops the domain). '' when unusable.
|
|
22
|
+
function _fragmentName(raw) {
|
|
23
|
+
var s = _text(raw);
|
|
24
|
+
if (!s) return '';
|
|
25
|
+
var m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
|
|
26
|
+
if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
|
|
27
|
+
if (s.indexOf('@') !== -1) {
|
|
28
|
+
s = s.replace(/<([^>]*)>/, '$1');
|
|
29
|
+
return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
|
|
30
|
+
}
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Accept the canonical author shape, a raw provider createdBy object, or a
|
|
35
|
+
// bare string. `login` also reads ADO's `uniqueName`; `displayName` its `name`.
|
|
36
|
+
function _fields(author) {
|
|
37
|
+
if (author && typeof author === 'object') {
|
|
38
|
+
return {
|
|
39
|
+
displayName: _text(author.displayName) || _text(author.name),
|
|
40
|
+
login: _text(author.login) || _text(author.uniqueName),
|
|
41
|
+
id: _text(author.id),
|
|
42
|
+
url: _text(author.url),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (typeof author === 'string') return { displayName: '', login: _text(author), id: '', url: '' };
|
|
46
|
+
return { displayName: '', login: '', id: '', url: '' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Human-readable name that NEVER exposes an email address. '' → render Unknown.
|
|
50
|
+
function name(author) {
|
|
51
|
+
var f = _fields(author);
|
|
52
|
+
return _fragmentName(f.displayName) || _fragmentName(f.login) || f.id || '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Optional secondary handle (rendered as "@handle"). Only a real login with no
|
|
56
|
+
// '@' that adds information beyond the name — never an email address.
|
|
57
|
+
function handle(author) {
|
|
58
|
+
var f = _fields(author);
|
|
59
|
+
if (f.login && f.login.indexOf('@') === -1 && f.login !== name(author)) return f.login;
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A validated http(s) profile URL, or '' — so a caller never links an
|
|
64
|
+
// unvalidated href.
|
|
65
|
+
function url(author) {
|
|
66
|
+
var f = _fields(author);
|
|
67
|
+
return /^https?:\/\//i.test(f.url) ? f.url : '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function display(author) {
|
|
71
|
+
return { name: name(author), handle: handle(author), url: url(author) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
window.MinionsPrAuthor = { name: name, handle: handle, url: url, display: display };
|
|
75
|
+
})();
|
|
@@ -35,14 +35,37 @@ function _prFilterKey(value) {
|
|
|
35
35
|
return window.MinionsRecordFilters.key(value);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function _prAuthorSource(pr) {
|
|
39
|
+
if (!pr || typeof pr !== 'object') return null;
|
|
40
|
+
return pr.author || pr.createdBy || pr.created_by || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Stable machine identity for the author filter VALUE (grouping key). Prefers a
|
|
44
|
+
// non-email provider id/descriptor/login so two people who happen to share a
|
|
45
|
+
// display name do not collapse, and an email is never used as a visible label.
|
|
46
|
+
// The displayed option text stays the email-free name from normalizePrAuthor.
|
|
47
|
+
function _prAuthorMachineKey(source) {
|
|
48
|
+
if (!source) return '';
|
|
49
|
+
if (typeof source === 'string') return source;
|
|
50
|
+
if (typeof source !== 'object') return '';
|
|
51
|
+
return _prFilterText(source.id)
|
|
52
|
+
|| _prFilterText(source.descriptor)
|
|
53
|
+
|| _prFilterText(source.login)
|
|
54
|
+
|| _prFilterText(source.uniqueName)
|
|
55
|
+
|| _prFilterText(source.displayName)
|
|
56
|
+
|| _prFilterText(source.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
function normalizePrAuthor(pr) {
|
|
39
|
-
if (!pr || typeof pr !== 'object') return '';
|
|
40
60
|
// Real author only. Deliberately NOT falling back to pr.agent: the Minions
|
|
41
61
|
// agent is a separate identity (W-mscibegy005e8b74) and a legacy record with
|
|
42
62
|
// no author must group/render as Unknown rather than borrow the agent name.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
// The email-free display name comes from the shared formatter so the filter
|
|
64
|
+
// dropdown matches the PR table's Author column (W-msd8ls85).
|
|
65
|
+
var src = _prAuthorSource(pr);
|
|
66
|
+
if (!src) return '';
|
|
67
|
+
if (window.MinionsPrAuthor && window.MinionsPrAuthor.name) return window.MinionsPrAuthor.name(src);
|
|
68
|
+
return _prFilterName(src);
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
function normalizePrLifecycleStatus(pr) {
|
|
@@ -96,7 +119,7 @@ function _prFilterEntry(pr, dimension) {
|
|
|
96
119
|
var value = '';
|
|
97
120
|
if (dimension === 'author') {
|
|
98
121
|
label = normalizePrAuthor(pr);
|
|
99
|
-
value = _prFilterKey(label);
|
|
122
|
+
value = _prFilterKey(_prAuthorMachineKey(_prAuthorSource(pr)) || label);
|
|
100
123
|
} else if (dimension === 'lifecycle') {
|
|
101
124
|
value = normalizePrLifecycleStatus(pr);
|
|
102
125
|
label = PR_LIFECYCLE_FILTER_LABELS[value] || _formatPrFilterLabel(value);
|
package/dashboard/styles.css
CHANGED
|
@@ -518,6 +518,18 @@
|
|
|
518
518
|
outline: 2px solid var(--blue); outline-offset: 2px;
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/* W-msd8pr2300ev95e1 — AutoFix foreign-author guardrail warning icon. Sits
|
|
522
|
+
beside the Auto Fix toggle when auto-fix is enabled on a PR the current
|
|
523
|
+
identity did not author (foreign), or when ownership can't be established
|
|
524
|
+
(unknown). Cursor:help so the accessible tooltip is discoverable. */
|
|
525
|
+
.pr-autofix-warning {
|
|
526
|
+
display: inline-block; margin-left: var(--space-3);
|
|
527
|
+
vertical-align: middle; cursor: help; font-size: var(--text-base);
|
|
528
|
+
line-height: 1;
|
|
529
|
+
}
|
|
530
|
+
.pr-autofix-warning--foreign { color: var(--red, #f85149); }
|
|
531
|
+
.pr-autofix-warning--unknown { color: var(--yellow, #d29922); }
|
|
532
|
+
|
|
521
533
|
.archive-btn {
|
|
522
534
|
background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
|
|
523
535
|
font-size: var(--text-base); padding: var(--space-2) var(--space-5); border-radius: var(--radius-sm); cursor: pointer; transition: all var(--transition-base); margin-left: auto;
|
package/dashboard-build.js
CHANGED
|
@@ -13,7 +13,7 @@ const MINIONS_DIR = __dirname;
|
|
|
13
13
|
// 'wi-filters' publishes window.MinionsWorkItemFilters (the Work Items table's
|
|
14
14
|
// filter + sort semantics), consumed by render-work-items.js — hence it precedes
|
|
15
15
|
// the renderer bundle (DASHBOARD_JS_FILES) too.
|
|
16
|
-
const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
|
|
16
|
+
const DASHBOARD_SHARED_JS = ['record-filters', 'pr-merge-state', 'pr-author', 'pr-filters', 'wi-filters', 'cc-suggestions', 'cc-limits', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
|
|
17
17
|
|
|
18
18
|
// ── Canonical classic-dashboard assembly manifest ──────────────────────────
|
|
19
19
|
// Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
|
|
@@ -62,8 +62,15 @@ agent id. `pr.agent` is now **never** a human login.
|
|
|
62
62
|
non-empty incoming value wins, otherwise the existing value is preserved, so a
|
|
63
63
|
**partial provider refresh never erases a richer author already stored**. A
|
|
64
64
|
provider change trusts the newer identity wholesale.
|
|
65
|
-
- `
|
|
66
|
-
|
|
65
|
+
- `prAuthorDisplayName(author)` — the **email-free** human-readable name every
|
|
66
|
+
surface should show. Prefers a real `displayName` → `login`, and only when the
|
|
67
|
+
sole usable identity is an email does it fall back to the **local-part** (domain
|
|
68
|
+
dropped). Any `@` anywhere forces local-part extraction, so a malformed value
|
|
69
|
+
(`"Name <a@b>"`, `weird@x@y`, a bare address) can never leak an `@`. Returns
|
|
70
|
+
`''` (→ **Unknown**) when nothing usable remains. Presentation only — the
|
|
71
|
+
structured `login`/`id`/`descriptor` fields are left untouched.
|
|
72
|
+
- `prAuthorLabel(author)` — thin delegate to `prAuthorDisplayName` (kept for
|
|
73
|
+
callers wanting a "label"); therefore also email-free.
|
|
67
74
|
|
|
68
75
|
`upsertPullRequestRecord` normalizes `author` on the create path and merges it
|
|
69
76
|
via `mergePrAuthorIdentity` on the update path (and in `_mergeDuplicatePrInto`),
|
|
@@ -76,7 +83,11 @@ manual link, dashboard enrich, dedup — converge on one merge behavior.
|
|
|
76
83
|
projection column (plus `idx_pr_author`). The canonical structured identity
|
|
77
84
|
always lives in the row's `data` JSON; the column is a label projection for
|
|
78
85
|
filters/sort. `engine/persistence/pull-requests-store.js` writes it via a local
|
|
79
|
-
`_authorLabel(pr.author)` (displayName → login → id).
|
|
86
|
+
`_authorLabel(pr.author)` (displayName → login → id). This column feeds only the
|
|
87
|
+
`idx_pr_author` index for server-side filter/sort — it is **never selected back
|
|
88
|
+
or surfaced to any client** (reads return the `data` JSON), so it may still
|
|
89
|
+
contain an email and is deliberately left as machine-facing projection, not a
|
|
90
|
+
display label.
|
|
80
91
|
|
|
81
92
|
Backfill is **fail-closed**. A legacy row whose `agent` is a human login (i.e.
|
|
82
93
|
not `'human'`, not a configured Minions agent id from `config.json`, not
|
|
@@ -96,14 +107,26 @@ alongside the existing `agent` field.
|
|
|
96
107
|
|
|
97
108
|
## Dashboard
|
|
98
109
|
|
|
110
|
+
Every dashboard surface that shows a PR author routes through **one** shared
|
|
111
|
+
browser formatter, `window.MinionsPrAuthor` (`dashboard/shared/pr-author.js`),
|
|
112
|
+
which mirrors the engine `prAuthorDisplayName` so the operator-visible name is
|
|
113
|
+
always **email-free** — an ADO author stored as `Name <email>` or a bare email
|
|
114
|
+
`uniqueName` renders as the name / email local-part, never the address. It is
|
|
115
|
+
listed in `DASHBOARD_SHARED_JS` before `pr-filters`, so classic and slim share it.
|
|
116
|
+
|
|
99
117
|
`dashboard/js/render-prs.js` replaces the Pull Requests table's **Agent** column
|
|
100
|
-
with **Author** (display name + `@
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
with **Author** (`MinionsPrAuthor.display` → name + optional `@handle` — a handle
|
|
119
|
+
is offered only for a real non-email login, safely escaped, linked to the
|
|
120
|
+
provider profile only when a validated `http(s)` URL is present). The Minions
|
|
121
|
+
agent stays visible in the PR detail panel under **Minions agent**, next to the
|
|
122
|
+
**Author** line, so operators can still answer both "who is this for?" and "which
|
|
123
|
+
agent handled it?". The shared author filter (`dashboard/shared/pr-filters.js`)
|
|
124
|
+
shows the same email-free **label** while grouping on a stable machine **key**
|
|
125
|
+
(`id` → `descriptor` → `login` → … via `_prAuthorMachineKey`), so two people who
|
|
126
|
+
share a display name never collapse and the filter stays aligned with the column
|
|
127
|
+
(legacy rows group as Unknown). The work-item follow-up chip
|
|
128
|
+
(`dashboard/js/render-work-items.js`) strips the email from a
|
|
129
|
+
`pr_followup.parent_comment_author` the same way.
|
|
107
130
|
|
|
108
131
|
## Backward-compatible reads
|
|
109
132
|
|
|
@@ -112,3 +135,33 @@ filter and the displayed column stay aligned (legacy rows group as Unknown).
|
|
|
112
135
|
author string keeps rendering. This is a tolerant read, not a retained
|
|
113
136
|
compatibility shim — the overloaded `agent = login` write behavior was removed
|
|
114
137
|
outright (no `docs/deprecated.json` entry required).
|
|
138
|
+
|
|
139
|
+
## AutoFix foreign-author guardrail (W-msd8pr2300ev95e1)
|
|
140
|
+
|
|
141
|
+
When AutoFix is enabled on a PR whose author is **not** the current authenticated
|
|
142
|
+
identity for that provider/repo scope, the Pull Requests table's **AutoFix** cell
|
|
143
|
+
renders a prominent warning icon beside the toggle: a red `⚠`
|
|
144
|
+
("Auto-fix enabled for another author's PR", naming the author when known) or, if
|
|
145
|
+
ownership cannot be established, a yellow `⚠` ("author ownership unknown"). This
|
|
146
|
+
never changes behavior — enabling auto-fix on another author's PR stays an
|
|
147
|
+
explicit operator action — it only surfaces the risk.
|
|
148
|
+
|
|
149
|
+
Ownership is resolved by `shared.resolvePrAuthorOwnership(pr, selfByProvider)`
|
|
150
|
+
(`self` | `foreign` | `unknown`) using **stable provider identities only** —
|
|
151
|
+
GitHub canonical `login` / numeric `id`; ADO identity `id` / `descriptor`. Display
|
|
152
|
+
names and emails are never compared, and the determination **fails closed** to
|
|
153
|
+
`unknown` (shown as the neutral warning, never silently treated as self) whenever
|
|
154
|
+
either side's stable identity is missing.
|
|
155
|
+
|
|
156
|
+
`engine/core/queries.js#getPullRequests` stamps `pr._authorOwnership` once per
|
|
157
|
+
build, resolving the current identity per provider:
|
|
158
|
+
|
|
159
|
+
- **GitHub** — `operator-identity.resolveGithubViewerLoginStrict(config)`, which
|
|
160
|
+
accepts only `engine.operatorLogin` or `gh api user` (never a git-email / OS
|
|
161
|
+
fallback).
|
|
162
|
+
- **ADO** — the optional operator-configured `engine.operatorAdoIdentity`
|
|
163
|
+
(`{ id?, descriptor? }`). There is no automatic ADO current-identity seam, so
|
|
164
|
+
when it is unset every ADO PR reports `unknown` (fail-closed). Set it under
|
|
165
|
+
**Settings → Operator & Comments → ADO identity id / descriptor**. It is a
|
|
166
|
+
read-only identity value (like `operatorLogin`), never an email — the ADO
|
|
167
|
+
`uniqueName`/login is deliberately excluded from both storage and comparison.
|
|
@@ -108,6 +108,7 @@ const ENGINE_SPECIAL_FIELDS = new Set([
|
|
|
108
108
|
'ccWorkerIdleTimeoutMs',
|
|
109
109
|
'worktreeRoot',
|
|
110
110
|
'operatorLogin',
|
|
111
|
+
'operatorAdoIdentity',
|
|
111
112
|
'defaultCli',
|
|
112
113
|
'defaultModel',
|
|
113
114
|
'ccCli',
|
|
@@ -338,6 +339,30 @@ function applyEngineSettings(candidate, bodyEngine, errors) {
|
|
|
338
339
|
const value = parseClearableString(bodyEngine.operatorLogin, 'engine.operatorLogin', errors);
|
|
339
340
|
setOrDelete(engine, 'operatorLogin', value);
|
|
340
341
|
}
|
|
342
|
+
if (hasOwn(bodyEngine, 'operatorAdoIdentity')) {
|
|
343
|
+
// Structured ADO self-identity used ONLY by the AutoFix foreign-author
|
|
344
|
+
// guardrail (id/descriptor — never email). An empty object clears it.
|
|
345
|
+
const raw = bodyEngine.operatorAdoIdentity;
|
|
346
|
+
if (raw === null || raw === undefined || CLEAR_SENTINELS.has(raw)) {
|
|
347
|
+
delete engine.operatorAdoIdentity;
|
|
348
|
+
} else if (typeof raw === 'object') {
|
|
349
|
+
const next = {};
|
|
350
|
+
if (raw.id !== undefined && raw.id !== null && raw.id !== '') {
|
|
351
|
+
const id = parseString(raw.id, 'engine.operatorAdoIdentity.id', errors);
|
|
352
|
+
if (id) next.id = id;
|
|
353
|
+
}
|
|
354
|
+
if (raw.descriptor !== undefined && raw.descriptor !== null && raw.descriptor !== '') {
|
|
355
|
+
const descriptor = parseString(raw.descriptor, 'engine.operatorAdoIdentity.descriptor', errors);
|
|
356
|
+
if (descriptor) next.descriptor = descriptor;
|
|
357
|
+
}
|
|
358
|
+
if (Object.keys(next).length) engine.operatorAdoIdentity = next;
|
|
359
|
+
else delete engine.operatorAdoIdentity;
|
|
360
|
+
} else {
|
|
361
|
+
errors.add('engine.operatorAdoIdentity', 'invalid-object',
|
|
362
|
+
'engine.operatorAdoIdentity must be an object with id and/or descriptor', raw,
|
|
363
|
+
{ expected: 'object' });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
341
366
|
for (const key of ['defaultCli', 'ccCli']) {
|
|
342
367
|
if (!hasOwn(bodyEngine, key)) continue;
|
|
343
368
|
const value = parseRuntimeName(bodyEngine[key], `engine.${key}`, errors);
|
|
@@ -75,9 +75,31 @@ function resolveOperatorLogin(config, { force = false } = {}) {
|
|
|
75
75
|
|
|
76
76
|
// Clear the process-lifetime cache. Called by the dashboard when the operator
|
|
77
77
|
// login override changes in Settings so the next resolve picks up the new value.
|
|
78
|
-
function _resetOperatorLoginCacheForTest() { _cached = null; }
|
|
78
|
+
function _resetOperatorLoginCacheForTest() { _cached = null; _cachedGithubViewer = null; }
|
|
79
|
+
|
|
80
|
+
// ── Strict GitHub viewer login (W-msd8pr2300ev95e1) ─────────────────────────
|
|
81
|
+
// Unlike resolveOperatorLogin, this NEVER falls back to the git-email localpart
|
|
82
|
+
// or the OS username — those are not GitHub logins and must never be used to
|
|
83
|
+
// decide "did the current GitHub token author this PR?". It accepts ONLY:
|
|
84
|
+
// 1. `config.engine.operatorLogin` — explicit Settings override
|
|
85
|
+
// 2. `gh api user --jq .login` — the token's own canonical login
|
|
86
|
+
// and returns '' otherwise, so the caller fails CLOSED (author ownership
|
|
87
|
+
// "unknown") rather than guessing self-authorship from a weak fallback.
|
|
88
|
+
let _cachedGithubViewer = null;
|
|
89
|
+
function resolveGithubViewerLoginStrict(config, { force = false } = {}) {
|
|
90
|
+
if (!force && _cachedGithubViewer !== null) return _cachedGithubViewer;
|
|
91
|
+
const override = config?.engine?.operatorLogin;
|
|
92
|
+
if (override && typeof override === 'string' && override.trim()) {
|
|
93
|
+
_cachedGithubViewer = override.trim();
|
|
94
|
+
return _cachedGithubViewer;
|
|
95
|
+
}
|
|
96
|
+
const ghLogin = _execImpl('gh api user --jq .login');
|
|
97
|
+
_cachedGithubViewer = ghLogin ? ghLogin : '';
|
|
98
|
+
return _cachedGithubViewer;
|
|
99
|
+
}
|
|
79
100
|
|
|
80
101
|
module.exports = {
|
|
81
102
|
resolveOperatorLogin,
|
|
103
|
+
resolveGithubViewerLoginStrict,
|
|
82
104
|
_resetOperatorLoginCacheForTest,
|
|
83
105
|
};
|
package/engine/core/queries.js
CHANGED
|
@@ -1151,6 +1151,28 @@ function getPullRequests(config) {
|
|
|
1151
1151
|
const projectByName = new Map(projects.map(p => [p.name, p]));
|
|
1152
1152
|
const allPrs = [];
|
|
1153
1153
|
|
|
1154
|
+
// W-msd8pr2300ev95e1 — resolve the CURRENT authenticated identity per provider
|
|
1155
|
+
// ONCE (cached) so we can stamp each PR's author-ownership tri-state for the
|
|
1156
|
+
// AutoFix foreign-author guardrail. STABLE identities only (GitHub canonical
|
|
1157
|
+
// login / numeric id; ADO id / descriptor) — never display names or emails.
|
|
1158
|
+
// Fails CLOSED: a missing/blank provider entry yields 'unknown'.
|
|
1159
|
+
const selfByProvider = {};
|
|
1160
|
+
try {
|
|
1161
|
+
const ghLogin = require('./operator-identity').resolveGithubViewerLoginStrict(config);
|
|
1162
|
+
if (ghLogin) selfByProvider.github = { login: ghLogin };
|
|
1163
|
+
} catch { /* identity resolution is best-effort; absence → unknown */ }
|
|
1164
|
+
try {
|
|
1165
|
+
const adoSelf = config?.engine?.operatorAdoIdentity;
|
|
1166
|
+
if (adoSelf && typeof adoSelf === 'object') {
|
|
1167
|
+
const entry = {};
|
|
1168
|
+
if (adoSelf.id) entry.id = String(adoSelf.id);
|
|
1169
|
+
if (adoSelf.descriptor) entry.descriptor = String(adoSelf.descriptor);
|
|
1170
|
+
// Deliberately NOT copying uniqueName/login: ADO login is an email, and
|
|
1171
|
+
// the guardrail contract forbids comparing emails.
|
|
1172
|
+
if (Object.keys(entry).length) selfByProvider.ado = entry;
|
|
1173
|
+
}
|
|
1174
|
+
} catch { /* best-effort */ }
|
|
1175
|
+
|
|
1154
1176
|
// SQL is the canonical (and only) PR store after Phase 9.
|
|
1155
1177
|
const store = require('../persistence/pull-requests-store');
|
|
1156
1178
|
const sqlPrs = store.readAllPullRequests() || [];
|
|
@@ -1231,6 +1253,10 @@ function getPullRequests(config) {
|
|
|
1231
1253
|
// dashboards / API consumers can render a chip without re-parsing
|
|
1232
1254
|
// _noOpFixes themselves.
|
|
1233
1255
|
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
1256
|
+
// W-msd8pr2300ev95e1 — author-ownership tri-state for the AutoFix
|
|
1257
|
+
// foreign-author warning. 'self' | 'foreign' | 'unknown' (fail closed).
|
|
1258
|
+
try { pr._authorOwnership = shared.resolvePrAuthorOwnership(pr, selfByProvider); }
|
|
1259
|
+
catch { pr._authorOwnership = shared.PR_AUTHOR_OWNERSHIP.UNKNOWN; }
|
|
1234
1260
|
allPrs.push(pr);
|
|
1235
1261
|
}
|
|
1236
1262
|
allPrs.sort((a, b) => {
|
package/engine/core/shared.js
CHANGED
|
@@ -8702,11 +8702,113 @@ function mergePrAuthorIdentity(existing, incoming) {
|
|
|
8702
8702
|
return out;
|
|
8703
8703
|
}
|
|
8704
8704
|
|
|
8705
|
-
//
|
|
8706
|
-
|
|
8705
|
+
// A human-readable name from ONE identity fragment that never contains an email
|
|
8706
|
+
// address:
|
|
8707
|
+
// - "Display Name <user@example.com>" → "Display Name"
|
|
8708
|
+
// - "user@example.com" → "user" (local-part, domain dropped)
|
|
8709
|
+
// - "Display Name" → "Display Name"
|
|
8710
|
+
// Any '@' anywhere forces local-part extraction, so a malformed address can
|
|
8711
|
+
// never leak through. Returns '' when nothing usable remains.
|
|
8712
|
+
function _authorFragmentName(raw) {
|
|
8713
|
+
let s = _authorField(raw, 256);
|
|
8714
|
+
if (!s) return '';
|
|
8715
|
+
// "Name <addr>" — keep the display part when it is a real (non-email) name.
|
|
8716
|
+
const m = s.match(/^\s*"?([^"<]*?)"?\s*<[^>]*>\s*$/);
|
|
8717
|
+
if (m && m[1].trim() && m[1].indexOf('@') === -1) return m[1].trim();
|
|
8718
|
+
if (s.indexOf('@') !== -1) {
|
|
8719
|
+
s = s.replace(/<([^>]*)>/, '$1'); // unwrap a "<addr>"
|
|
8720
|
+
return s.split('@')[0].replace(/^["'<\s]+|["'>\s]+$/g, '').trim();
|
|
8721
|
+
}
|
|
8722
|
+
return s;
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
// Human-readable author name that NEVER exposes an email address. Prefers a real
|
|
8726
|
+
// provider display name / login, and only when the sole usable identity is an
|
|
8727
|
+
// email does it fall back to the local-part (no domain). Returns '' (→ renders
|
|
8728
|
+
// "Unknown") when nothing usable is present. The structured identity fields
|
|
8729
|
+
// (login/id/descriptor) are untouched — this is a presentation label only.
|
|
8730
|
+
function prAuthorDisplayName(author) {
|
|
8707
8731
|
const a = normalizePrAuthorIdentity(author);
|
|
8708
8732
|
if (!a) return '';
|
|
8709
|
-
return a.displayName
|
|
8733
|
+
return _authorFragmentName(a.displayName)
|
|
8734
|
+
|| _authorFragmentName(a.login)
|
|
8735
|
+
|| _authorField(a.id, 64)
|
|
8736
|
+
|| '';
|
|
8737
|
+
}
|
|
8738
|
+
|
|
8739
|
+
// Human-readable label for a PR author (typed SQL column + filter/search text).
|
|
8740
|
+
// Routed through prAuthorDisplayName so the projected label is always email-free.
|
|
8741
|
+
function prAuthorLabel(author) {
|
|
8742
|
+
return prAuthorDisplayName(author);
|
|
8743
|
+
}
|
|
8744
|
+
|
|
8745
|
+
// ── Foreign-author ownership for the AutoFix guardrail (W-msd8pr2300ev95e1) ──
|
|
8746
|
+
//
|
|
8747
|
+
// Decide whether the CURRENT authenticated identity for a PR's provider authored
|
|
8748
|
+
// that PR, so the dashboard can warn when auto-fix is enabled on someone else's
|
|
8749
|
+
// PR (Minions would push commits to a PR the operator only asked to read/review).
|
|
8750
|
+
//
|
|
8751
|
+
// STABLE identities only — never display names or emails:
|
|
8752
|
+
// GitHub: canonical login (case-insensitive) or numeric id.
|
|
8753
|
+
// ADO: identity id or descriptor (exact), or uniqueName login (ci).
|
|
8754
|
+
//
|
|
8755
|
+
// Fail CLOSED: returns 'unknown' whenever EITHER the PR author identity or the
|
|
8756
|
+
// current provider identity cannot be established. The caller must render an
|
|
8757
|
+
// "author ownership unknown" warning rather than treating unknown as self.
|
|
8758
|
+
//
|
|
8759
|
+
// `selfByProvider` is `{ github: { login, id }, ado: { id, descriptor, login } }`
|
|
8760
|
+
// resolved by the caller (queries.js) from the per-provider token identity; a
|
|
8761
|
+
// missing provider entry (or empty fields) yields 'unknown' for that provider.
|
|
8762
|
+
const PR_AUTHOR_OWNERSHIP = { SELF: 'self', FOREIGN: 'foreign', UNKNOWN: 'unknown' };
|
|
8763
|
+
|
|
8764
|
+
function _ciEq(a, b) {
|
|
8765
|
+
const x = _authorField(a, 256).toLowerCase();
|
|
8766
|
+
const y = _authorField(b, 256).toLowerCase();
|
|
8767
|
+
return !!x && x === y;
|
|
8768
|
+
}
|
|
8769
|
+
function _exactEq(a, b) {
|
|
8770
|
+
const x = _authorField(a, 256);
|
|
8771
|
+
const y = _authorField(b, 256);
|
|
8772
|
+
return !!x && x === y;
|
|
8773
|
+
}
|
|
8774
|
+
|
|
8775
|
+
function _prProviderFromRecord(pr, author) {
|
|
8776
|
+
const p = _authorField(author && author.provider, 32).toLowerCase();
|
|
8777
|
+
if (p === 'github' || p === 'ado') return p;
|
|
8778
|
+
const id = _authorField(pr && pr.id, 128).toLowerCase();
|
|
8779
|
+
if (id.startsWith('github:') || id.startsWith('gh:')) return 'github';
|
|
8780
|
+
if (id.startsWith('ado:')) return 'ado';
|
|
8781
|
+
const url = _authorField(pr && pr.url, 400).toLowerCase();
|
|
8782
|
+
if (url.includes('github.com')) return 'github';
|
|
8783
|
+
if (url.includes('dev.azure.com') || url.includes('visualstudio.com') || url.includes('/_git/')) return 'ado';
|
|
8784
|
+
return '';
|
|
8785
|
+
}
|
|
8786
|
+
|
|
8787
|
+
function resolvePrAuthorOwnership(pr, selfByProvider) {
|
|
8788
|
+
const author = normalizePrAuthorIdentity(pr && pr.author);
|
|
8789
|
+
if (!author) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
|
|
8790
|
+
const provider = _prProviderFromRecord(pr, author);
|
|
8791
|
+
if (!provider) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
|
|
8792
|
+
const self = selfByProvider && selfByProvider[provider];
|
|
8793
|
+
if (!self) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
|
|
8794
|
+
|
|
8795
|
+
if (provider === 'github') {
|
|
8796
|
+
const haveSelf = _authorField(self.login, 256) || _authorField(self.id, 64);
|
|
8797
|
+
const haveAuthor = _authorField(author.login, 256) || _authorField(author.id, 64);
|
|
8798
|
+
if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
|
|
8799
|
+
if (_exactEq(self.id, author.id) || _ciEq(self.login, author.login)) return PR_AUTHOR_OWNERSHIP.SELF;
|
|
8800
|
+
return PR_AUTHOR_OWNERSHIP.FOREIGN;
|
|
8801
|
+
}
|
|
8802
|
+
// ADO: compare stable identity id/descriptor ONLY. ADO `login` is the
|
|
8803
|
+
// uniqueName (a sign-in email), and the guardrail contract forbids comparing
|
|
8804
|
+
// emails, so it is deliberately excluded here.
|
|
8805
|
+
const haveSelf = _authorField(self.id, 64) || _authorField(self.descriptor, 256);
|
|
8806
|
+
const haveAuthor = _authorField(author.id, 64) || _authorField(author.descriptor, 256);
|
|
8807
|
+
if (!haveSelf || !haveAuthor) return PR_AUTHOR_OWNERSHIP.UNKNOWN;
|
|
8808
|
+
if (_exactEq(self.id, author.id) || _exactEq(self.descriptor, author.descriptor)) {
|
|
8809
|
+
return PR_AUTHOR_OWNERSHIP.SELF;
|
|
8810
|
+
}
|
|
8811
|
+
return PR_AUTHOR_OWNERSHIP.FOREIGN;
|
|
8710
8812
|
}
|
|
8711
8813
|
|
|
8712
8814
|
function _jsonEqual(a, b) {
|
|
@@ -9396,16 +9498,35 @@ function classifyPrRefForVerification(prRef, project = null) {
|
|
|
9396
9498
|
// .parent_pr_url). Description-only PR mentions are INTENTIONALLY skipped
|
|
9397
9499
|
// per the "structured-vs-loose split" — enrollment must be intentional.
|
|
9398
9500
|
//
|
|
9501
|
+
// Enrollment mode is type-dependent (W-msd8pr2300ev95e1): mutating PR-requiring
|
|
9502
|
+
// types (fix / test / build-fix-complex) enroll AUTO-MANAGED (contextOnly:false)
|
|
9503
|
+
// — the normal coding workflow; a read-only `review` WI enrolls CONTEXT-ONLY so
|
|
9504
|
+
// a "review this PR" request never silently enables auto-fix on the PR.
|
|
9505
|
+
//
|
|
9399
9506
|
// Returns:
|
|
9400
|
-
// { skipped: true, reason }
|
|
9401
|
-
// { alreadyEnrolled: true, id }
|
|
9402
|
-
// { enrolled: true, id, scope }
|
|
9507
|
+
// { skipped: true, reason } — not a pr-requiring type / no ref / no URL / upsert error
|
|
9508
|
+
// { alreadyEnrolled: true, id } — PR already tracked
|
|
9509
|
+
// { enrolled: true, id, scope, contextOnly } — newly enrolled (contextOnly true for review)
|
|
9403
9510
|
const _PR_REQUIRING_TYPES = new Set([
|
|
9404
9511
|
WORK_TYPE.FIX,
|
|
9405
9512
|
WORK_TYPE.BUILD_FIX_COMPLEX,
|
|
9406
9513
|
WORK_TYPE.REVIEW,
|
|
9407
9514
|
WORK_TYPE.TEST,
|
|
9408
9515
|
]);
|
|
9516
|
+
// W-msd8pr2300ev95e1 — READ-ONLY PR-requiring types. A `review` WI enrolls the
|
|
9517
|
+
// PR record so the review dispatch clears the `pr_not_found` gate (the #536
|
|
9518
|
+
// rationale), but a human "review this PR" request is one-shot / read-review
|
|
9519
|
+
// intent by default: it must NOT enable auto-fix, auto-review, auto-merge, or
|
|
9520
|
+
// any future automatic fix / re-review dispatch on someone else's PR. So a
|
|
9521
|
+
// review enrollment is context-only. The explicit review WI still dispatches
|
|
9522
|
+
// (isExplicitlyRequestedPrDispatch keeps work-item-sourced review dispatches
|
|
9523
|
+
// even on a contextOnly PR), and the upsert's non-demote guard means a review
|
|
9524
|
+
// WI that happens to target an ALREADY auto-managed PR (a normal coding
|
|
9525
|
+
// workflow) never demotes it. Mutating PR-requiring types (fix / test /
|
|
9526
|
+
// build-fix-complex) stay auto-managed — that is the normal coding workflow.
|
|
9527
|
+
const _READ_ONLY_PR_ENROLL_TYPES = new Set([
|
|
9528
|
+
WORK_TYPE.REVIEW,
|
|
9529
|
+
]);
|
|
9409
9530
|
function autoEnrollPrFromWorkItem(item, project, minionsDir) {
|
|
9410
9531
|
if (!item || !_PR_REQUIRING_TYPES.has(item.type)) return { skipped: true, reason: 'not-pr-requiring-type' };
|
|
9411
9532
|
const prRef = extractStructuredWorkItemPrRef(item);
|
|
@@ -9420,6 +9541,7 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
|
|
|
9420
9541
|
const parsedUrl = parsePrUrl(url);
|
|
9421
9542
|
const prNum = parsedUrl ? parsedUrl.prNumber : null;
|
|
9422
9543
|
const prId = getCanonicalPrId(project, prRef, url);
|
|
9544
|
+
const readOnlyEnroll = _READ_ONLY_PR_ENROLL_TYPES.has(item.type);
|
|
9423
9545
|
try {
|
|
9424
9546
|
const result = upsertPullRequestRecord(source, {
|
|
9425
9547
|
id: prId,
|
|
@@ -9432,10 +9554,15 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
|
|
|
9432
9554
|
status: 'active',
|
|
9433
9555
|
created: new Date().toISOString(),
|
|
9434
9556
|
url,
|
|
9435
|
-
|
|
9557
|
+
// Read-review intent enrolls context-only with the per-row automation
|
|
9558
|
+
// controls explicitly OFF (belt-and-suspenders over the contextOnly
|
|
9559
|
+
// default derivation), so no later default change can silently re-enable
|
|
9560
|
+
// auto-fix on a review-enrolled PR.
|
|
9561
|
+
contextOnly: readOnlyEnroll,
|
|
9562
|
+
...(readOnlyEnroll ? { autoFixEnabled: false, autoReviewEnabled: false } : {}),
|
|
9436
9563
|
}, { project, itemId: item.id });
|
|
9437
9564
|
return result.created
|
|
9438
|
-
? { enrolled: true, id: result.id, scope: stateScope(source) }
|
|
9565
|
+
? { enrolled: true, id: result.id, scope: stateScope(source), contextOnly: readOnlyEnroll }
|
|
9439
9566
|
: { alreadyEnrolled: true, id: result.id };
|
|
9440
9567
|
} catch (e) {
|
|
9441
9568
|
log('warn', `autoEnrollPrFromWorkItem ${item.id}: ${e.message}`);
|
|
@@ -11087,6 +11214,9 @@ module.exports = {
|
|
|
11087
11214
|
normalizePrAuthorIdentity,
|
|
11088
11215
|
mergePrAuthorIdentity,
|
|
11089
11216
|
prAuthorLabel,
|
|
11217
|
+
prAuthorDisplayName,
|
|
11218
|
+
resolvePrAuthorOwnership,
|
|
11219
|
+
PR_AUTHOR_OWNERSHIP,
|
|
11090
11220
|
applyPrFieldDelta,
|
|
11091
11221
|
normalizePrRecord,
|
|
11092
11222
|
normalizePrRecords,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// engine/db/migrations/032-review-enrolled-pr-context-only.js
|
|
2
|
+
//
|
|
3
|
+
// W-msd8pr2300ev95e1 — repair PR records that were SILENTLY auto-enrolled by a
|
|
4
|
+
// review request.
|
|
5
|
+
//
|
|
6
|
+
// Regression: PR #536 widened `autoEnrollPrFromWorkItem` from FIX-only to
|
|
7
|
+
// {FIX, REVIEW, TEST} and enrolled every one with `contextOnly:false`. Because
|
|
8
|
+
// `isPrAutoFixEnabled` / `isPrAutoReviewEnabled` derive from `!contextOnly`
|
|
9
|
+
// when no explicit boolean is stored, a human "review this PR" request turned
|
|
10
|
+
// another author's PR into an AUTO-MANAGED record — the engine then dispatched
|
|
11
|
+
// fixes/re-reviews and pushed commits to a PR the operator only asked to read.
|
|
12
|
+
// The code fix makes review enrollment context-only going forward; this
|
|
13
|
+
// migration conservatively repairs records already enrolled that way.
|
|
14
|
+
//
|
|
15
|
+
// CONSERVATIVE, STRUCTURED-PROVENANCE ONLY — never title/description text:
|
|
16
|
+
// 1. status === 'active' (don't touch merged/abandoned)
|
|
17
|
+
// 2. contextOnly !== true (currently auto-managed by default)
|
|
18
|
+
// 3. NO explicit autoFixEnabled/autoReviewEnabled boolean stored
|
|
19
|
+
// → an explicitly TRACKED PR (POST /api/pull-requests/link,
|
|
20
|
+
// /api/pr-action/track, or the per-row automation toggle) always
|
|
21
|
+
// persists these booleans, so this gate alone preserves every
|
|
22
|
+
// intentionally-tracked record.
|
|
23
|
+
// 4. agent === 'human' (the sentinel autoEnroll stamps;
|
|
24
|
+
// excludes agent-authored/synced coding PRs, which stay tracked)
|
|
25
|
+
// 5. prdItems links to >= 1 still-existing work item, and EVERY linked,
|
|
26
|
+
// still-existing work item is type `review` (no fix/test/implement/…):
|
|
27
|
+
// → proves the record's only provenance is a read-review request. If a
|
|
28
|
+
// mutating WI is linked, or no linked WI still exists, we cannot prove
|
|
29
|
+
// review-only provenance and DO NOT touch the record.
|
|
30
|
+
//
|
|
31
|
+
// Repair = set contextOnly:true + autoFixEnabled:false + autoReviewEnabled:false
|
|
32
|
+
// (belt-and-suspenders), matching the new read-review enrollment shape.
|
|
33
|
+
|
|
34
|
+
function _hasTable(db, table) {
|
|
35
|
+
return !!db.prepare(`
|
|
36
|
+
SELECT 1 FROM sqlite_master WHERE type='table' AND name=?
|
|
37
|
+
`).get(table);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
version: 32,
|
|
42
|
+
description: 'repair review-enrolled PRs to context-only (W-msd8pr2300ev95e1)',
|
|
43
|
+
up(db) {
|
|
44
|
+
if (!_hasTable(db, 'pull_requests')) return;
|
|
45
|
+
|
|
46
|
+
// Build id -> type map for every work item. Archived items live in the same
|
|
47
|
+
// `work_items` table (migration 021 added an `archived` column, not a
|
|
48
|
+
// separate table), so this single scan covers both live and archived rows.
|
|
49
|
+
const wiTypeById = new Map();
|
|
50
|
+
if (_hasTable(db, 'work_items')) {
|
|
51
|
+
for (const row of db.prepare('SELECT id, type FROM work_items').all()) {
|
|
52
|
+
if (row && row.id) wiTypeById.set(String(row.id), String(row.type || '').trim().toLowerCase());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rows = db.prepare('SELECT scope, id, agent, status, data FROM pull_requests').all();
|
|
57
|
+
const update = db.prepare('UPDATE pull_requests SET data = ? WHERE scope = ? AND id = ?');
|
|
58
|
+
|
|
59
|
+
let repaired = 0;
|
|
60
|
+
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
let record;
|
|
63
|
+
try { record = JSON.parse(row.data); }
|
|
64
|
+
catch { continue; }
|
|
65
|
+
if (!record || typeof record !== 'object') continue;
|
|
66
|
+
|
|
67
|
+
// (1) active only.
|
|
68
|
+
if (String(record.status || row.status || '').toLowerCase() !== 'active') continue;
|
|
69
|
+
// (2) currently auto-managed by default (not already context-only).
|
|
70
|
+
if (record.contextOnly === true) continue;
|
|
71
|
+
// (3) no explicit per-row automation booleans → excludes intentional tracking.
|
|
72
|
+
if (typeof record.autoFixEnabled === 'boolean' || typeof record.autoReviewEnabled === 'boolean') continue;
|
|
73
|
+
// (4) enrolled under the human sentinel (autoEnroll stamp), not an agent author.
|
|
74
|
+
if (String(record.agent || row.agent || '').trim().toLowerCase() !== 'human') continue;
|
|
75
|
+
|
|
76
|
+
// (5) structured provenance: every linked, still-existing WI is a review.
|
|
77
|
+
const links = Array.isArray(record.prdItems) ? record.prdItems : [];
|
|
78
|
+
const knownTypes = [];
|
|
79
|
+
for (const wiId of links) {
|
|
80
|
+
const t = wiTypeById.get(String(wiId));
|
|
81
|
+
if (t) knownTypes.push(t);
|
|
82
|
+
}
|
|
83
|
+
if (knownTypes.length === 0) continue; // cannot prove provenance → leave alone
|
|
84
|
+
if (!knownTypes.every(t => t === 'review')) continue; // a mutating WI is linked → coding workflow
|
|
85
|
+
|
|
86
|
+
record.contextOnly = true;
|
|
87
|
+
record.autoFixEnabled = false;
|
|
88
|
+
record.autoReviewEnabled = false;
|
|
89
|
+
update.run(JSON.stringify(record), row.scope, row.id);
|
|
90
|
+
repaired += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`[db-migrate] v32: repaired ${repaired} review-enrolled PR record(s) to context-only`);
|
|
94
|
+
},
|
|
95
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2454",
|
|
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"
|