forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5
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/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +16 -374
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/sqlite-driver.js +153 -29
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +33 -1
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* reversible side-effects used by the PR shepherd:
|
|
8
8
|
* - `readState` → `gh pr view --json ...`
|
|
9
9
|
* - `readRequiredChecks` → `gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`
|
|
10
|
-
* - `readDivergence` → `git rev-list --left-right --count {baseRef}...
|
|
11
|
-
* - `detectConflicts` → `git merge-tree --write-tree {baseRef}
|
|
10
|
+
* - `readDivergence` → `git rev-list --left-right --count {baseRef}...{headRef}`
|
|
11
|
+
* - `detectConflicts` → `git merge-tree --write-tree {baseRef} {headRef}` (predict-only)
|
|
12
12
|
* - `rerunFailedChecks` → `gh run rerun <id> --failed`
|
|
13
13
|
* - `replyToThread` → shell-out to `.claude/scripts/review-resolve.sh reply` (reply ONLY,
|
|
14
14
|
* never resolve — resolution stays with the semantic `/review` agent)
|
|
@@ -25,6 +25,49 @@
|
|
|
25
25
|
|
|
26
26
|
const { execFileSync } = require('node:child_process');
|
|
27
27
|
|
|
28
|
+
const GITHUB_ACTOR_TYPENAMES = new Set([
|
|
29
|
+
'Bot', 'EnterpriseUserAccount', 'Mannequin', 'Organization', 'User',
|
|
30
|
+
]);
|
|
31
|
+
const FULL_HEAD_SHA = /^[0-9a-f]{40}$/i;
|
|
32
|
+
const POSITIVE_PR_NUMBER = /^[1-9][0-9]*$/;
|
|
33
|
+
const CHECK_RUN_STATUSES = new Set(['QUEUED', 'IN_PROGRESS', 'COMPLETED', 'WAITING', 'PENDING', 'REQUESTED']);
|
|
34
|
+
const CHECK_RUN_CONCLUSIONS = new Set([
|
|
35
|
+
'', 'SUCCESS', 'FAILURE', 'NEUTRAL', 'CANCELLED', 'SKIPPED', 'TIMED_OUT',
|
|
36
|
+
'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE',
|
|
37
|
+
]);
|
|
38
|
+
const STATUS_CONTEXT_STATES = new Set(['ERROR', 'EXPECTED', 'FAILURE', 'PENDING', 'SUCCESS']);
|
|
39
|
+
const MERGE_STATE_STATUSES = new Set([
|
|
40
|
+
'BEHIND', 'BLOCKED', 'CLEAN', 'DIRTY', 'DRAFT', 'HAS_HOOKS', 'UNKNOWN', 'UNSTABLE',
|
|
41
|
+
]);
|
|
42
|
+
const PULL_REQUEST_REVIEW_STATES = new Set([
|
|
43
|
+
'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function canonicalPrNumber(value) {
|
|
47
|
+
const raw = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value;
|
|
48
|
+
return typeof raw === 'string' && POSITIVE_PR_NUMBER.test(raw) ? Number(raw) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validRollupNode(check) {
|
|
52
|
+
if (!check || typeof check !== 'object' || Array.isArray(check)) return false;
|
|
53
|
+
if (check.__typename === 'CheckRun') {
|
|
54
|
+
const status = String(check.status || '').toUpperCase();
|
|
55
|
+
const conclusion = String(check.conclusion || '').toUpperCase();
|
|
56
|
+
return typeof check.name === 'string' && Boolean(check.name.trim())
|
|
57
|
+
&& CHECK_RUN_STATUSES.has(status)
|
|
58
|
+
&& Object.prototype.hasOwnProperty.call(check, 'conclusion')
|
|
59
|
+
&& (check.conclusion === null || typeof check.conclusion === 'string')
|
|
60
|
+
&& CHECK_RUN_CONCLUSIONS.has(conclusion)
|
|
61
|
+
&& (status === 'COMPLETED' ? Boolean(conclusion) : !conclusion);
|
|
62
|
+
}
|
|
63
|
+
if (check.__typename === 'StatusContext') {
|
|
64
|
+
return typeof check.context === 'string' && Boolean(check.context.trim())
|
|
65
|
+
&& typeof check.state === 'string'
|
|
66
|
+
&& STATUS_CONTEXT_STATES.has(check.state.toUpperCase());
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
28
71
|
// NOTE: `reviewThreads` is NOT a valid `gh pr view --json` field — requesting it
|
|
29
72
|
// makes `gh` exit non-zero ("Unknown JSON field"), which crashed readState on every
|
|
30
73
|
// real PR. Review threads are read separately via GraphQL in readComments().
|
|
@@ -88,6 +131,7 @@ class PrStateAdapter {
|
|
|
88
131
|
});
|
|
89
132
|
this._gh = options.gh || defaultRunner;
|
|
90
133
|
this._git = options.git || defaultRunner;
|
|
134
|
+
this.lastProtectionStatus = null;
|
|
91
135
|
}
|
|
92
136
|
|
|
93
137
|
/**
|
|
@@ -99,32 +143,40 @@ class PrStateAdapter {
|
|
|
99
143
|
async readState(pr) {
|
|
100
144
|
const raw = this._gh('gh', ['pr', 'view', String(pr), '--json', PR_VIEW_FIELDS]);
|
|
101
145
|
const data = JSON.parse(raw || '{}');
|
|
102
|
-
const
|
|
146
|
+
const lifecycleReadable = typeof data.state === 'string'
|
|
147
|
+
&& ['OPEN', 'MERGED', 'CLOSED'].includes(data.state.toUpperCase())
|
|
148
|
+
&& typeof data.isDraft === 'boolean';
|
|
149
|
+
const mergeStateStatus = typeof data.mergeStateStatus === 'string'
|
|
150
|
+
? data.mergeStateStatus.toUpperCase()
|
|
151
|
+
: '';
|
|
152
|
+
const mergeStateReadable = MERGE_STATE_STATUSES.has(mergeStateStatus)
|
|
153
|
+
&& mergeStateStatus !== 'UNKNOWN';
|
|
154
|
+
const rollupReadable = Array.isArray(data.statusCheckRollup)
|
|
155
|
+
&& data.statusCheckRollup.every(validRollupNode);
|
|
156
|
+
const providerEvidenceReadable = lifecycleReadable && mergeStateReadable && rollupReadable
|
|
157
|
+
&& typeof data.headRefOid === 'string' && FULL_HEAD_SHA.test(data.headRefOid);
|
|
158
|
+
const rollup = rollupReadable ? data.statusCheckRollup : [];
|
|
103
159
|
return {
|
|
104
|
-
headSha: data.headRefOid
|
|
105
|
-
state:
|
|
160
|
+
headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
|
|
161
|
+
state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
|
|
106
162
|
mergeable: data.mergeable || 'UNKNOWN',
|
|
107
|
-
mergeStateStatus:
|
|
108
|
-
// PR-level merge blockers (surfaced by the pull signal). `reviewDecision` is
|
|
109
|
-
// '' when no review is required; normalized to null so consumers can treat
|
|
110
|
-
// "not required" and "unknown" uniformly. `isDraft` blocks merge outright.
|
|
163
|
+
mergeStateStatus: mergeStateReadable ? mergeStateStatus : 'UNKNOWN',
|
|
111
164
|
reviewDecision: data.reviewDecision || null,
|
|
112
|
-
isDraft:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// readComments() (GraphQL). Kept for return-shape stability, always empty here.
|
|
165
|
+
isDraft: typeof data.isDraft === 'boolean' ? data.isDraft : null,
|
|
166
|
+
providerEvidenceReadable,
|
|
167
|
+
checks: rollup.map((check) => {
|
|
168
|
+
const statusContext = check.__typename === 'StatusContext';
|
|
169
|
+
const state = String(check.state || '').toUpperCase();
|
|
170
|
+
return {
|
|
171
|
+
name: check.name || check.context || '',
|
|
172
|
+
status: statusContext
|
|
173
|
+
? (['SUCCESS', 'FAILURE', 'ERROR'].includes(state) ? 'COMPLETED' : 'IN_PROGRESS')
|
|
174
|
+
: String(check.status).toUpperCase(),
|
|
175
|
+
conclusion: statusContext ? state : String(check.conclusion || '').toUpperCase(),
|
|
176
|
+
databaseId: check.databaseId,
|
|
177
|
+
detailsUrl: check.detailsUrl || check.targetUrl,
|
|
178
|
+
};
|
|
179
|
+
}),
|
|
128
180
|
threads: [],
|
|
129
181
|
};
|
|
130
182
|
}
|
|
@@ -143,33 +195,32 @@ class PrStateAdapter {
|
|
|
143
195
|
* hold (it is what `gh pr checks --required` uses) and it covers BOTH classic
|
|
144
196
|
* branch protection AND repository rulesets.
|
|
145
197
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* Returns `null` only when BOTH sources are unreadable (existing fail-closed
|
|
152
|
-
* behaviour). `lastRequiredSource` records which source answered
|
|
153
|
-
* (`'protection'` | `'rollup'` | `null`) so callers can surface it as evidence.
|
|
198
|
+
* Rollup `isRequired` data cannot prove the complete protected set because a
|
|
199
|
+
* required context that never ran is absent. `lastRequiredSource` is therefore
|
|
200
|
+
* `'protection'` only when an authoritative set is returned, and `null` when
|
|
201
|
+
* protection is unreadable.
|
|
154
202
|
* Re-throws non-auth protection errors (unchanged).
|
|
155
203
|
*
|
|
156
204
|
* @param {{ owner: string, repo: string, base: string, pr?: string|number }} ctx
|
|
157
205
|
* @returns {Promise<string[] | null>}
|
|
158
206
|
*/
|
|
159
|
-
async
|
|
207
|
+
async readRequiredCheckPolicy({ owner, repo, base }) {
|
|
160
208
|
this.lastRequiredSource = null;
|
|
161
209
|
const fromProtection = this._readProtectionRequired({ owner, repo, base });
|
|
162
210
|
if (Array.isArray(fromProtection)) {
|
|
163
211
|
this.lastRequiredSource = 'protection';
|
|
164
212
|
return fromProtection;
|
|
165
213
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async readRequiredChecks({ owner, repo, base, pr }) {
|
|
218
|
+
const fromProtection = await this.readRequiredCheckPolicy({ owner, repo, base });
|
|
219
|
+
if (Array.isArray(fromProtection)) return [...new Set(fromProtection.map((entry) => entry.context))];
|
|
220
|
+
if (this.lastProtectionStatus !== 'unavailable') return null;
|
|
221
|
+
// Rollup `isRequired` is retained only as diagnostic evidence. It is not the
|
|
222
|
+
// authoritative policy and therefore cannot authorize merge readiness.
|
|
223
|
+
this._readRollupRequired({ owner, repo, pr });
|
|
173
224
|
return null;
|
|
174
225
|
}
|
|
175
226
|
|
|
@@ -183,19 +234,63 @@ class PrStateAdapter {
|
|
|
183
234
|
* @returns {string[] | null}
|
|
184
235
|
*/
|
|
185
236
|
_readProtectionRequired({ owner, repo, base }) {
|
|
237
|
+
this.lastProtectionStatus = 'malformed';
|
|
186
238
|
const apiPath = `repos/${owner}/${repo}/branches/${encodeURIComponent(base)}/protection/required_status_checks`;
|
|
187
239
|
try {
|
|
188
240
|
const raw = this._gh('gh', ['api', apiPath]);
|
|
189
241
|
const data = JSON.parse(raw || '{}');
|
|
190
|
-
|
|
191
|
-
|
|
242
|
+
const hasContexts = Object.prototype.hasOwnProperty.call(data, 'contexts');
|
|
243
|
+
const hasChecks = Object.prototype.hasOwnProperty.call(data, 'checks');
|
|
244
|
+
if ((hasContexts && !Array.isArray(data.contexts))
|
|
245
|
+
|| (hasChecks && !Array.isArray(data.checks))) return null;
|
|
246
|
+
const normalizeContexts = (items) => {
|
|
247
|
+
if (!Array.isArray(items)) return null;
|
|
248
|
+
if (items.some((item) => typeof item !== 'string' || item.trim().length === 0)) return null;
|
|
249
|
+
return [...new Set(items)];
|
|
250
|
+
};
|
|
251
|
+
const contexts = normalizeContexts(data.contexts);
|
|
252
|
+
let checks = null;
|
|
253
|
+
if (Array.isArray(data.checks)) {
|
|
254
|
+
if (data.checks.some((item) => !item || typeof item.context !== 'string'
|
|
255
|
+
|| item.context.length === 0
|
|
256
|
+
|| !Object.prototype.hasOwnProperty.call(item, 'app_id')
|
|
257
|
+
|| (item.app_id !== null
|
|
258
|
+
&& (!Number.isInteger(item.app_id) || item.app_id <= 0)))) return null;
|
|
259
|
+
const deduped = new Map();
|
|
260
|
+
const identityByContext = new Map();
|
|
261
|
+
for (const item of data.checks) {
|
|
262
|
+
const entry = { context: item.context, appId: item.app_id };
|
|
263
|
+
const identity = entry.appId === null ? '*' : String(entry.appId);
|
|
264
|
+
if (identityByContext.has(entry.context)
|
|
265
|
+
&& identityByContext.get(entry.context) !== identity) return null;
|
|
266
|
+
identityByContext.set(entry.context, identity);
|
|
267
|
+
deduped.set(`${entry.context}\u0000${identity}`, entry);
|
|
268
|
+
}
|
|
269
|
+
checks = [...deduped.values()];
|
|
270
|
+
}
|
|
271
|
+
if (contexts && checks) {
|
|
272
|
+
const contextNames = [...contexts].sort((a, b) => a.localeCompare(b));
|
|
273
|
+
const checkNames = [...new Set(checks.map((entry) => entry.context))].sort((a, b) => a.localeCompare(b));
|
|
274
|
+
if (contextNames.length !== checkNames.length
|
|
275
|
+
|| contextNames.some((value, index) => value !== checkNames[index])) return null;
|
|
276
|
+
this.lastProtectionStatus = 'authoritative';
|
|
277
|
+
return checks;
|
|
278
|
+
}
|
|
279
|
+
if (contexts) {
|
|
280
|
+
this.lastProtectionStatus = 'authoritative';
|
|
281
|
+
return contexts.map((context) => ({ context, appId: null }));
|
|
282
|
+
}
|
|
283
|
+
if (checks) {
|
|
284
|
+
this.lastProtectionStatus = 'authoritative';
|
|
285
|
+
return checks;
|
|
286
|
+
}
|
|
192
287
|
// Unexpected/changed payload shape — treat as unreadable, not "no required
|
|
193
288
|
// checks", so merge readiness is never computed from bad data.
|
|
194
289
|
return null;
|
|
195
290
|
} catch (error) {
|
|
196
291
|
const auth = classifyAuthError(error);
|
|
197
292
|
if (auth) {
|
|
198
|
-
|
|
293
|
+
this.lastProtectionStatus = 'unavailable';
|
|
199
294
|
return null;
|
|
200
295
|
}
|
|
201
296
|
throw error;
|
|
@@ -213,41 +308,38 @@ class PrStateAdapter {
|
|
|
213
308
|
* @returns {string[] | null}
|
|
214
309
|
*/
|
|
215
310
|
_readRollupRequired({ owner, repo, pr }) {
|
|
216
|
-
const prNum =
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
+ `} } } } } } } } }`;
|
|
311
|
+
const prNum = canonicalPrNumber(pr);
|
|
312
|
+
if (prNum === null) return null;
|
|
313
|
+
const query = [
|
|
314
|
+
'query($after:String){repository(owner:"', owner, '",name:"', repo, '"){',
|
|
315
|
+
'pullRequest(number:', prNum, '){headRef{target{... on Commit{statusCheckRollup{',
|
|
316
|
+
'contexts(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{__typename ',
|
|
317
|
+
'... on CheckRun{name isRequired(pullRequestNumber:', prNum, ')} ',
|
|
318
|
+
'... on StatusContext{context isRequired(pullRequestNumber:', prNum, ')}}}}}}}}}}',
|
|
319
|
+
].join('');
|
|
226
320
|
try {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return [...new Set(required)];
|
|
321
|
+
const projected = this._paginateConnection(
|
|
322
|
+
(after) => {
|
|
323
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
324
|
+
if (after) args.push('-f', `after=${after}`);
|
|
325
|
+
const data = this._parseGraphqlResponse(this._gh('gh', args));
|
|
326
|
+
return data?.data?.repository?.pullRequest?.headRef?.target
|
|
327
|
+
?.statusCheckRollup?.contexts;
|
|
328
|
+
},
|
|
329
|
+
(node) => {
|
|
330
|
+
if (!['CheckRun', 'StatusContext'].includes(node.__typename)
|
|
331
|
+
|| typeof node.isRequired !== 'boolean') {
|
|
332
|
+
throw new Error('Rollup required-check node is malformed');
|
|
333
|
+
}
|
|
334
|
+
const name = node.__typename === 'CheckRun' ? node.name : node.context;
|
|
335
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
336
|
+
throw new Error('Rollup required-check node has no context identity');
|
|
337
|
+
}
|
|
338
|
+
return node.isRequired ? name : null;
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
return [...new Set(projected.filter(Boolean))];
|
|
249
342
|
} catch {
|
|
250
|
-
// GraphQL unreadable (auth/network/etc.) — fail closed to null.
|
|
251
343
|
return null;
|
|
252
344
|
}
|
|
253
345
|
}
|
|
@@ -258,13 +350,13 @@ class PrStateAdapter {
|
|
|
258
350
|
* `cwd` is threaded through to the git runner so divergence is computed
|
|
259
351
|
* against the target worktree/checkout, not the process directory.
|
|
260
352
|
*
|
|
261
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
353
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
262
354
|
* @returns {Promise<{ behind: number, ahead: number }>}
|
|
263
355
|
*/
|
|
264
|
-
async readDivergence({ baseRef, cwd }) {
|
|
356
|
+
async readDivergence({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
265
357
|
const out = this._git(
|
|
266
358
|
'git',
|
|
267
|
-
['rev-list', '--left-right', '--count', `${baseRef}
|
|
359
|
+
['rev-list', '--left-right', '--count', `${baseRef}...${headRef}`],
|
|
268
360
|
cwd ? { cwd } : undefined,
|
|
269
361
|
);
|
|
270
362
|
const [behindRaw = '0', aheadRaw = '0'] = String(out).trim().split(/\s+/);
|
|
@@ -286,7 +378,8 @@ class PrStateAdapter {
|
|
|
286
378
|
|
|
287
379
|
/**
|
|
288
380
|
* Fetch the base ref from its remote so a subsequent `readDivergence`/
|
|
289
|
-
* `detectConflicts` compares
|
|
381
|
+
* `detectConflicts` compares the selected PR head (or legacy `HEAD` default)
|
|
382
|
+
* against the CURRENT `origin/<base>`, not a
|
|
290
383
|
* stale local remote-tracking ref (audit A6: a stale ref reports a false
|
|
291
384
|
* `behind=0` / false "no conflict"). `baseRef` is `<remote>/<branch>` (e.g.
|
|
292
385
|
* `origin/master`); the remote and branch are split back out for `git fetch`.
|
|
@@ -326,6 +419,8 @@ class PrStateAdapter {
|
|
|
326
419
|
* @returns {Promise<object[]>}
|
|
327
420
|
*/
|
|
328
421
|
async readComments({ owner, repo, pr }) {
|
|
422
|
+
const number = canonicalPrNumber(pr);
|
|
423
|
+
if (!number) throw new Error('PR selector must be one canonical positive decimal number');
|
|
329
424
|
// `id`/`path`/`line` are surfaced so a consumer (e.g. the monitor bundle in
|
|
330
425
|
// lib/pr-bundle.js) can hand an agent the thread id to resolve and the
|
|
331
426
|
// file/line to act on — not just the body. Added fields are backward
|
|
@@ -334,22 +429,26 @@ class PrStateAdapter {
|
|
|
334
429
|
// Both connections are FULLY paginated (cursors, not a first:100 cap): a
|
|
335
430
|
// large PR must never silently drop a thread or a later reply, or the bundle
|
|
336
431
|
// would declare "complete" on partial data and the monitor would skip work.
|
|
337
|
-
const threads = this._fetchAllReviewThreads({ owner, repo, pr });
|
|
432
|
+
const threads = this._fetchAllReviewThreads({ owner, repo, pr: number });
|
|
338
433
|
return threads.map((t) => {
|
|
339
|
-
const allComments =
|
|
434
|
+
const allComments = t.comments.nodes;
|
|
340
435
|
return {
|
|
341
|
-
threadId: t.id
|
|
436
|
+
threadId: t.id,
|
|
342
437
|
path: t.path || null,
|
|
343
438
|
line: typeof t.line === 'number' ? t.line : null,
|
|
344
|
-
isResolved:
|
|
345
|
-
isOutdated:
|
|
346
|
-
comments: allComments.map(c =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
439
|
+
isResolved: t.isResolved,
|
|
440
|
+
isOutdated: t.isOutdated,
|
|
441
|
+
comments: allComments.map((c) => {
|
|
442
|
+
const commentId = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
|
|
443
|
+
const author = String(c.author?.login || '');
|
|
444
|
+
const authorType = String(c.author?.__typename || '');
|
|
445
|
+
if (!commentId) throw new Error(`Review thread ${t.id} comment is missing stable database id`);
|
|
446
|
+
if (!author || !GITHUB_ACTOR_TYPENAMES.has(authorType)) {
|
|
447
|
+
throw new Error(`Review thread ${t.id} comment ${commentId} is missing valid author identity`);
|
|
448
|
+
}
|
|
449
|
+
if (typeof c.body !== 'string') throw new Error(`Review thread ${t.id} comment ${commentId} has malformed body`);
|
|
450
|
+
return { author, authorType, body: c.body, commentId };
|
|
451
|
+
}),
|
|
353
452
|
};
|
|
354
453
|
});
|
|
355
454
|
}
|
|
@@ -364,16 +463,20 @@ class PrStateAdapter {
|
|
|
364
463
|
* @returns {object[]} raw thread nodes (comments.nodes carries the full chain)
|
|
365
464
|
*/
|
|
366
465
|
_fetchAllReviewThreads({ owner, repo, pr }) {
|
|
367
|
-
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:100){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{login} body}}}}}}}';
|
|
466
|
+
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id isResolved isOutdated path line comments(first:100){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body}}}}}}}';
|
|
368
467
|
return this._paginateConnection(
|
|
369
468
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
370
469
|
?.data?.repository?.pullRequest?.reviewThreads,
|
|
371
470
|
(t) => {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
471
|
+
if (typeof t.id !== 'string' || !t.id
|
|
472
|
+
|| typeof t.isResolved !== 'boolean' || typeof t.isOutdated !== 'boolean') {
|
|
473
|
+
throw new Error('GraphQL review thread node is malformed');
|
|
375
474
|
}
|
|
376
|
-
|
|
475
|
+
const comments = this._validateGraphqlConnection(t.comments, `Review thread ${t.id} comments`);
|
|
476
|
+
const nodes = comments.pageInfo.hasNextPage
|
|
477
|
+
? this._fetchAllThreadComments(t.id, comments)
|
|
478
|
+
: comments.nodes;
|
|
479
|
+
return { ...t, comments: { nodes } };
|
|
377
480
|
},
|
|
378
481
|
);
|
|
379
482
|
}
|
|
@@ -395,7 +498,31 @@ class PrStateAdapter {
|
|
|
395
498
|
'-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${pr}`,
|
|
396
499
|
];
|
|
397
500
|
if (after) args.push('-f', `after=${after}`);
|
|
398
|
-
return
|
|
501
|
+
return this._parseGraphqlResponse(this._gh('gh', args));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
_parseGraphqlResponse(raw) {
|
|
505
|
+
const parsed = JSON.parse(raw || '{}');
|
|
506
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
507
|
+
throw new Error('GraphQL response envelope is malformed');
|
|
508
|
+
}
|
|
509
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'errors')
|
|
510
|
+
&& (!Array.isArray(parsed.errors) || parsed.errors.length > 0)) {
|
|
511
|
+
throw new Error('GraphQL response contains errors');
|
|
512
|
+
}
|
|
513
|
+
return parsed;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_validateGraphqlConnection(conn, label = 'GraphQL connection') {
|
|
517
|
+
if (!conn || typeof conn !== 'object' || Array.isArray(conn)
|
|
518
|
+
|| !Array.isArray(conn.nodes) || conn.nodes.some((node) => !node || typeof node !== 'object' || Array.isArray(node))
|
|
519
|
+
|| !conn.pageInfo || typeof conn.pageInfo !== 'object' || Array.isArray(conn.pageInfo)
|
|
520
|
+
|| typeof conn.pageInfo.hasNextPage !== 'boolean'
|
|
521
|
+
|| (conn.pageInfo.endCursor !== null && typeof conn.pageInfo.endCursor !== 'string')
|
|
522
|
+
|| (conn.pageInfo.hasNextPage && !conn.pageInfo.endCursor)) {
|
|
523
|
+
throw new Error(`${label} is missing valid nodes/pageInfo`);
|
|
524
|
+
}
|
|
525
|
+
return conn;
|
|
399
526
|
}
|
|
400
527
|
|
|
401
528
|
/**
|
|
@@ -416,11 +543,18 @@ class PrStateAdapter {
|
|
|
416
543
|
let guard = 0;
|
|
417
544
|
const MAX_PAGES = 1000;
|
|
418
545
|
do {
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
546
|
+
const previous = after;
|
|
547
|
+
const conn = this._validateGraphqlConnection(runPage(after));
|
|
548
|
+
for (const node of conn.nodes) all.push(mapNode(node));
|
|
549
|
+
after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
550
|
+
if (after !== null && after === previous) {
|
|
551
|
+
throw new Error('GraphQL pagination cursor did not advance');
|
|
552
|
+
}
|
|
422
553
|
guard += 1;
|
|
423
|
-
|
|
554
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
555
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
556
|
+
}
|
|
557
|
+
} while (after !== null);
|
|
424
558
|
return all;
|
|
425
559
|
}
|
|
426
560
|
|
|
@@ -433,21 +567,33 @@ class PrStateAdapter {
|
|
|
433
567
|
* @returns {object[]} the full comment-node list (first page + all later pages)
|
|
434
568
|
*/
|
|
435
569
|
_fetchAllThreadComments(threadId, initial) {
|
|
436
|
-
const query = 'query($id:ID!,$after:String){node(id:$id){... on PullRequestReviewThread{comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{login} body}}}}}';
|
|
437
|
-
|
|
438
|
-
let
|
|
570
|
+
const query = 'query($id:ID!,$after:String){node(id:$id){... on PullRequestReviewThread{comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body}}}}}';
|
|
571
|
+
if (typeof threadId !== 'string' || !threadId) throw new Error('Review thread id is malformed');
|
|
572
|
+
let conn = this._validateGraphqlConnection(initial, `Review thread ${threadId} comments`);
|
|
573
|
+
const acc = [...conn.nodes];
|
|
574
|
+
let after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
439
575
|
let guard = 0;
|
|
440
576
|
const MAX_PAGES = 1000;
|
|
441
|
-
while (after
|
|
577
|
+
while (after !== null) {
|
|
578
|
+
const previous = after;
|
|
442
579
|
const raw = this._gh('gh', [
|
|
443
580
|
'api', 'graphql', '-f', `query=${query}`,
|
|
444
581
|
'-f', `id=${threadId}`, '-f', `after=${after}`,
|
|
445
582
|
]);
|
|
446
|
-
const data =
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
583
|
+
const data = this._parseGraphqlResponse(raw);
|
|
584
|
+
conn = this._validateGraphqlConnection(
|
|
585
|
+
data?.data?.node?.comments,
|
|
586
|
+
`Review thread ${threadId} comments`,
|
|
587
|
+
);
|
|
588
|
+
acc.push(...conn.nodes);
|
|
589
|
+
after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
590
|
+
if (after !== null && after === previous) {
|
|
591
|
+
throw new Error('GraphQL pagination cursor did not advance');
|
|
592
|
+
}
|
|
450
593
|
guard += 1;
|
|
594
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
595
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
596
|
+
}
|
|
451
597
|
}
|
|
452
598
|
return acc;
|
|
453
599
|
}
|
|
@@ -467,56 +613,112 @@ class PrStateAdapter {
|
|
|
467
613
|
// `author{__typename login}` surfaces the GraphQL actor TYPE ('Bot' vs 'User')
|
|
468
614
|
// so a non-human direct comment can be detected GENERICALLY — by mechanism,
|
|
469
615
|
// not by a hardcoded bot-name list — which fails closed for unknown bots.
|
|
470
|
-
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body createdAt}}}}}';
|
|
616
|
+
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){comments(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{fullDatabaseId author{__typename login} body createdAt updatedAt}}}}}';
|
|
471
617
|
return this._paginateConnection(
|
|
472
618
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
473
619
|
?.data?.repository?.pullRequest?.comments,
|
|
474
|
-
(c) =>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
620
|
+
(c) => {
|
|
621
|
+
const id = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
|
|
622
|
+
if (!id) throw new Error('Issue comment is missing stable database id');
|
|
623
|
+
if (!c.author?.login) throw new Error(`Issue comment ${id} is missing author identity`);
|
|
624
|
+
if (!GITHUB_ACTOR_TYPENAMES.has(String(c.author?.__typename || ''))) {
|
|
625
|
+
throw new Error(`Issue comment ${id} has missing or malformed actor type`);
|
|
626
|
+
}
|
|
627
|
+
if (typeof c.body !== 'string' || typeof c.createdAt !== 'string' || !c.createdAt) {
|
|
628
|
+
throw new Error(`Issue comment ${id} has malformed body or timestamp`);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
id,
|
|
632
|
+
author: c.author.login,
|
|
633
|
+
authorTypename: c.author.__typename,
|
|
634
|
+
body: c.body,
|
|
635
|
+
createdAt: c.createdAt,
|
|
636
|
+
updatedAt: c.updatedAt || c.createdAt,
|
|
637
|
+
};
|
|
638
|
+
},
|
|
484
639
|
);
|
|
485
640
|
}
|
|
486
641
|
|
|
487
642
|
/**
|
|
488
643
|
* Read submitted PR REVIEWS (not inline threads) — the latest review per
|
|
489
644
|
* author, each with the commit it was submitted against. This is the
|
|
490
|
-
* review-at-head signal that catches the #365 race:
|
|
491
|
-
* an earlier commit whose `commit.oid` no longer matches HEAD is STALE,
|
|
492
|
-
* post-push re-review is still pending. Uses GraphQL because `gh pr view`
|
|
645
|
+
* review-at-head signal that catches the #365 race: any reviewer submission
|
|
646
|
+
* from an earlier commit whose `commit.oid` no longer matches HEAD is STALE,
|
|
647
|
+
* so the post-push re-review is still pending. Uses GraphQL because `gh pr view`
|
|
493
648
|
* cannot return a review's target commit oid. Fully paginated by cursor.
|
|
494
649
|
*
|
|
495
650
|
* @param {{ owner: string, repo: string, pr: string }} ctx
|
|
496
|
-
* @returns {Promise<Array<{ author: string, state: string, submittedAt: string
|
|
651
|
+
* @returns {Promise<Array<{ author: string, state: string, createdAt: string, updatedAt: string, submittedAt: string, activityAt: string, commitOid: string, body: string }>>}
|
|
497
652
|
*/
|
|
498
653
|
async readReviews({ owner, repo, pr }) {
|
|
499
|
-
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviews(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{author{__typename login} state submittedAt commit{oid} body}}}}}';
|
|
654
|
+
const query = 'query($o:String!,$n:String!,$pr:Int!,$after:String){repository(owner:$o,name:$n){pullRequest(number:$pr){reviews(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{id author{__typename login} state createdAt updatedAt submittedAt commit{oid} body}}}}}';
|
|
500
655
|
const all = this._paginateConnection(
|
|
501
656
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
502
657
|
?.data?.repository?.pullRequest?.reviews,
|
|
503
|
-
(r) =>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
658
|
+
(r) => {
|
|
659
|
+
if (typeof r.id !== 'string' || !r.id) {
|
|
660
|
+
throw new Error('Review is missing stable GraphQL id');
|
|
661
|
+
}
|
|
662
|
+
const id = r.id;
|
|
663
|
+
if (typeof r.author?.login !== 'string' || !r.author.login) {
|
|
664
|
+
throw new Error(`Review ${id} is missing author identity`);
|
|
665
|
+
}
|
|
666
|
+
const author = r.author.login.toLowerCase().replace(/\[bot\]$/, '');
|
|
667
|
+
if (!author) throw new Error(`Review ${id} has empty normalized author identity`);
|
|
668
|
+
if (typeof r.author?.__typename !== 'string'
|
|
669
|
+
|| !GITHUB_ACTOR_TYPENAMES.has(r.author.__typename)) {
|
|
670
|
+
throw new Error(`Review ${id} has missing or malformed actor type`);
|
|
671
|
+
}
|
|
672
|
+
const state = typeof r.state === 'string' ? r.state.toUpperCase() : '';
|
|
673
|
+
if (!PULL_REQUEST_REVIEW_STATES.has(state)
|
|
674
|
+
|| typeof r.createdAt !== 'string' || !r.createdAt
|
|
675
|
+
|| typeof r.updatedAt !== 'string' || !r.updatedAt
|
|
676
|
+
|| typeof r.submittedAt !== 'string' || !r.submittedAt
|
|
677
|
+
|| typeof r.commit?.oid !== 'string' || !FULL_HEAD_SHA.test(r.commit.oid)
|
|
678
|
+
|| typeof r.body !== 'string') {
|
|
679
|
+
throw new Error(`Review ${id} has malformed state, timestamp, commit, or body`);
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
id,
|
|
683
|
+
author,
|
|
684
|
+
authorTypename: r.author.__typename,
|
|
685
|
+
state,
|
|
686
|
+
createdAt: r.createdAt,
|
|
687
|
+
updatedAt: r.updatedAt,
|
|
688
|
+
submittedAt: r.submittedAt,
|
|
689
|
+
commitOid: r.commit.oid,
|
|
690
|
+
body: r.body,
|
|
691
|
+
};
|
|
692
|
+
},
|
|
511
693
|
);
|
|
512
694
|
// Keep only the LATEST review per author (first:100 yields oldest→newest, so
|
|
513
695
|
// a later entry supersedes an earlier one from the same login).
|
|
514
696
|
const latest = new Map();
|
|
697
|
+
const latestSubmission = new Map();
|
|
698
|
+
const latestActivity = new Map();
|
|
515
699
|
for (const r of all) {
|
|
516
|
-
|
|
517
|
-
|
|
700
|
+
const submitted = Date.parse(r.submittedAt);
|
|
701
|
+
if (!Number.isFinite(submitted)) {
|
|
702
|
+
throw new Error(`Review ${r.id} has malformed submission timestamp`);
|
|
703
|
+
}
|
|
704
|
+
if (!latestSubmission.has(r.author) || submitted >= latestSubmission.get(r.author)) {
|
|
705
|
+
latest.set(r.author, r);
|
|
706
|
+
latestSubmission.set(r.author, submitted);
|
|
707
|
+
}
|
|
708
|
+
const activity = Math.max(
|
|
709
|
+
Date.parse(r.createdAt),
|
|
710
|
+
Date.parse(r.updatedAt),
|
|
711
|
+
Date.parse(r.submittedAt),
|
|
712
|
+
);
|
|
713
|
+
if (!Number.isFinite(activity)) {
|
|
714
|
+
throw new Error(`Review ${r.id} has malformed activity timestamps`);
|
|
715
|
+
}
|
|
716
|
+
latestActivity.set(r.author, Math.max(latestActivity.get(r.author) || 0, activity));
|
|
518
717
|
}
|
|
519
|
-
return Array.from(latest.values())
|
|
718
|
+
return Array.from(latest.values()).map((review) => ({
|
|
719
|
+
...review,
|
|
720
|
+
activityAt: new Date(latestActivity.get(review.author)).toISOString(),
|
|
721
|
+
}));
|
|
520
722
|
}
|
|
521
723
|
|
|
522
724
|
/**
|
|
@@ -536,23 +738,23 @@ class PrStateAdapter {
|
|
|
536
738
|
}
|
|
537
739
|
|
|
538
740
|
/**
|
|
539
|
-
* Predict files that would conflict when merging `baseRef` into
|
|
741
|
+
* Predict files that would conflict when merging `baseRef` into `headRef` WITHOUT
|
|
540
742
|
* touching the working tree, via `git merge-tree --write-tree`. Returns
|
|
541
743
|
* `{ supported: false, reason }` when conflict prediction is unavailable (git
|
|
542
744
|
* < 2.38, an unreadable ref, or a non-conflict error) so the bundle degrades
|
|
543
745
|
* gracefully rather than failing the whole gather.
|
|
544
746
|
*
|
|
545
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
747
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
546
748
|
* @returns {Promise<{ supported: boolean, conflicted?: boolean, files?: string[], reason?: string }>}
|
|
547
749
|
*/
|
|
548
|
-
async detectConflicts({ baseRef, cwd }) {
|
|
750
|
+
async detectConflicts({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
549
751
|
const opts = cwd ? { cwd } : undefined;
|
|
550
752
|
try {
|
|
551
753
|
// Exit 0 = clean merge. `--name-only` reduces the conflict report to bare
|
|
552
754
|
// paths; `--no-messages` suppresses the human-readable conflict prose.
|
|
553
755
|
this._git(
|
|
554
756
|
'git',
|
|
555
|
-
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef,
|
|
757
|
+
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
|
|
556
758
|
opts,
|
|
557
759
|
);
|
|
558
760
|
return { supported: true, conflicted: false, files: [] };
|