forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- 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/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- 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/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -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/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- 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/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -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 +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- 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().
|
|
@@ -59,6 +102,9 @@ function classifyAuthError(error) {
|
|
|
59
102
|
if (status === 401 || /HTTP 401|bad credentials|token expired/i.test(text)) {
|
|
60
103
|
return { class: 'expired' };
|
|
61
104
|
}
|
|
105
|
+
if (status === 429 || /HTTP 429/i.test(text)) {
|
|
106
|
+
return { class: 'rate-limit', retryAfter };
|
|
107
|
+
}
|
|
62
108
|
if (status === 403 || /HTTP 403/i.test(text)) {
|
|
63
109
|
if (retryAfter || /rate limit|secondary rate/i.test(text)) {
|
|
64
110
|
return { class: 'rate-limit', retryAfter };
|
|
@@ -73,6 +119,7 @@ class PrStateAdapter {
|
|
|
73
119
|
* @param {object} [options]
|
|
74
120
|
* @param {Function} [options.gh] - Runner for `gh` (cmd, args[]) → string.
|
|
75
121
|
* @param {Function} [options.git] - Runner for `git` (cmd, args[]) → string.
|
|
122
|
+
* @param {string} [options.repository] - Exact owner/repo for numeric PR reads.
|
|
76
123
|
*/
|
|
77
124
|
constructor(options = {}) {
|
|
78
125
|
this.id = options.id || 'pr-state-adapter';
|
|
@@ -88,6 +135,8 @@ class PrStateAdapter {
|
|
|
88
135
|
});
|
|
89
136
|
this._gh = options.gh || defaultRunner;
|
|
90
137
|
this._git = options.git || defaultRunner;
|
|
138
|
+
this._repository = options.repository || null;
|
|
139
|
+
this.lastProtectionStatus = null;
|
|
91
140
|
}
|
|
92
141
|
|
|
93
142
|
/**
|
|
@@ -97,34 +146,46 @@ class PrStateAdapter {
|
|
|
97
146
|
* @returns {Promise<{ headSha: string, mergeable: string, mergeStateStatus: string, checks: object[], threads: object[] }>}
|
|
98
147
|
*/
|
|
99
148
|
async readState(pr) {
|
|
100
|
-
const raw = this._gh('gh', [
|
|
149
|
+
const raw = this._gh('gh', [
|
|
150
|
+
'pr', 'view', String(pr),
|
|
151
|
+
...(this._repository ? ['--repo', this._repository] : []),
|
|
152
|
+
'--json', PR_VIEW_FIELDS,
|
|
153
|
+
]);
|
|
101
154
|
const data = JSON.parse(raw || '{}');
|
|
102
|
-
const
|
|
155
|
+
const lifecycleReadable = typeof data.state === 'string'
|
|
156
|
+
&& ['OPEN', 'MERGED', 'CLOSED'].includes(data.state.toUpperCase())
|
|
157
|
+
&& typeof data.isDraft === 'boolean';
|
|
158
|
+
const mergeStateStatus = typeof data.mergeStateStatus === 'string'
|
|
159
|
+
? data.mergeStateStatus.toUpperCase()
|
|
160
|
+
: '';
|
|
161
|
+
const mergeStateReadable = MERGE_STATE_STATUSES.has(mergeStateStatus)
|
|
162
|
+
&& mergeStateStatus !== 'UNKNOWN';
|
|
163
|
+
const rollupReadable = Array.isArray(data.statusCheckRollup)
|
|
164
|
+
&& data.statusCheckRollup.every(validRollupNode);
|
|
165
|
+
const providerEvidenceReadable = lifecycleReadable && mergeStateReadable && rollupReadable
|
|
166
|
+
&& typeof data.headRefOid === 'string' && FULL_HEAD_SHA.test(data.headRefOid);
|
|
167
|
+
const rollup = rollupReadable ? data.statusCheckRollup : [];
|
|
103
168
|
return {
|
|
104
|
-
headSha: data.headRefOid
|
|
105
|
-
state:
|
|
169
|
+
headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
|
|
170
|
+
state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
|
|
106
171
|
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.
|
|
172
|
+
mergeStateStatus: mergeStateReadable ? mergeStateStatus : 'UNKNOWN',
|
|
111
173
|
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.
|
|
174
|
+
isDraft: typeof data.isDraft === 'boolean' ? data.isDraft : null,
|
|
175
|
+
providerEvidenceReadable,
|
|
176
|
+
checks: rollup.map((check) => {
|
|
177
|
+
const statusContext = check.__typename === 'StatusContext';
|
|
178
|
+
const state = String(check.state || '').toUpperCase();
|
|
179
|
+
return {
|
|
180
|
+
name: check.name || check.context || '',
|
|
181
|
+
status: statusContext
|
|
182
|
+
? (['SUCCESS', 'FAILURE', 'ERROR'].includes(state) ? 'COMPLETED' : 'IN_PROGRESS')
|
|
183
|
+
: String(check.status).toUpperCase(),
|
|
184
|
+
conclusion: statusContext ? state : String(check.conclusion || '').toUpperCase(),
|
|
185
|
+
databaseId: check.databaseId,
|
|
186
|
+
detailsUrl: check.detailsUrl || check.targetUrl,
|
|
187
|
+
};
|
|
188
|
+
}),
|
|
128
189
|
threads: [],
|
|
129
190
|
};
|
|
130
191
|
}
|
|
@@ -143,33 +204,32 @@ class PrStateAdapter {
|
|
|
143
204
|
* hold (it is what `gh pr checks --required` uses) and it covers BOTH classic
|
|
144
205
|
* branch protection AND repository rulesets.
|
|
145
206
|
*
|
|
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.
|
|
207
|
+
* Rollup `isRequired` data cannot prove the complete protected set because a
|
|
208
|
+
* required context that never ran is absent. `lastRequiredSource` is therefore
|
|
209
|
+
* `'protection'` only when an authoritative set is returned, and `null` when
|
|
210
|
+
* protection is unreadable.
|
|
154
211
|
* Re-throws non-auth protection errors (unchanged).
|
|
155
212
|
*
|
|
156
213
|
* @param {{ owner: string, repo: string, base: string, pr?: string|number }} ctx
|
|
157
214
|
* @returns {Promise<string[] | null>}
|
|
158
215
|
*/
|
|
159
|
-
async
|
|
216
|
+
async readRequiredCheckPolicy({ owner, repo, base }) {
|
|
160
217
|
this.lastRequiredSource = null;
|
|
161
218
|
const fromProtection = this._readProtectionRequired({ owner, repo, base });
|
|
162
219
|
if (Array.isArray(fromProtection)) {
|
|
163
220
|
this.lastRequiredSource = 'protection';
|
|
164
221
|
return fromProtection;
|
|
165
222
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async readRequiredChecks({ owner, repo, base, pr }) {
|
|
227
|
+
const fromProtection = await this.readRequiredCheckPolicy({ owner, repo, base });
|
|
228
|
+
if (Array.isArray(fromProtection)) return [...new Set(fromProtection.map((entry) => entry.context))];
|
|
229
|
+
if (this.lastProtectionStatus !== 'unavailable') return null;
|
|
230
|
+
// Rollup `isRequired` is retained only as diagnostic evidence. It is not the
|
|
231
|
+
// authoritative policy and therefore cannot authorize merge readiness.
|
|
232
|
+
this._readRollupRequired({ owner, repo, pr });
|
|
173
233
|
return null;
|
|
174
234
|
}
|
|
175
235
|
|
|
@@ -183,19 +243,63 @@ class PrStateAdapter {
|
|
|
183
243
|
* @returns {string[] | null}
|
|
184
244
|
*/
|
|
185
245
|
_readProtectionRequired({ owner, repo, base }) {
|
|
246
|
+
this.lastProtectionStatus = 'malformed';
|
|
186
247
|
const apiPath = `repos/${owner}/${repo}/branches/${encodeURIComponent(base)}/protection/required_status_checks`;
|
|
187
248
|
try {
|
|
188
249
|
const raw = this._gh('gh', ['api', apiPath]);
|
|
189
250
|
const data = JSON.parse(raw || '{}');
|
|
190
|
-
|
|
191
|
-
|
|
251
|
+
const hasContexts = Object.prototype.hasOwnProperty.call(data, 'contexts');
|
|
252
|
+
const hasChecks = Object.prototype.hasOwnProperty.call(data, 'checks');
|
|
253
|
+
if ((hasContexts && !Array.isArray(data.contexts))
|
|
254
|
+
|| (hasChecks && !Array.isArray(data.checks))) return null;
|
|
255
|
+
const normalizeContexts = (items) => {
|
|
256
|
+
if (!Array.isArray(items)) return null;
|
|
257
|
+
if (items.some((item) => typeof item !== 'string' || item.trim().length === 0)) return null;
|
|
258
|
+
return [...new Set(items)];
|
|
259
|
+
};
|
|
260
|
+
const contexts = normalizeContexts(data.contexts);
|
|
261
|
+
let checks = null;
|
|
262
|
+
if (Array.isArray(data.checks)) {
|
|
263
|
+
if (data.checks.some((item) => !item || typeof item.context !== 'string'
|
|
264
|
+
|| item.context.length === 0
|
|
265
|
+
|| !Object.prototype.hasOwnProperty.call(item, 'app_id')
|
|
266
|
+
|| (item.app_id !== null
|
|
267
|
+
&& (!Number.isInteger(item.app_id) || item.app_id <= 0)))) return null;
|
|
268
|
+
const deduped = new Map();
|
|
269
|
+
const identityByContext = new Map();
|
|
270
|
+
for (const item of data.checks) {
|
|
271
|
+
const entry = { context: item.context, appId: item.app_id };
|
|
272
|
+
const identity = entry.appId === null ? '*' : String(entry.appId);
|
|
273
|
+
if (identityByContext.has(entry.context)
|
|
274
|
+
&& identityByContext.get(entry.context) !== identity) return null;
|
|
275
|
+
identityByContext.set(entry.context, identity);
|
|
276
|
+
deduped.set(`${entry.context}\u0000${identity}`, entry);
|
|
277
|
+
}
|
|
278
|
+
checks = [...deduped.values()];
|
|
279
|
+
}
|
|
280
|
+
if (contexts && checks) {
|
|
281
|
+
const contextNames = [...contexts].sort((a, b) => a.localeCompare(b));
|
|
282
|
+
const checkNames = [...new Set(checks.map((entry) => entry.context))].sort((a, b) => a.localeCompare(b));
|
|
283
|
+
if (contextNames.length !== checkNames.length
|
|
284
|
+
|| contextNames.some((value, index) => value !== checkNames[index])) return null;
|
|
285
|
+
this.lastProtectionStatus = 'authoritative';
|
|
286
|
+
return checks;
|
|
287
|
+
}
|
|
288
|
+
if (contexts) {
|
|
289
|
+
this.lastProtectionStatus = 'authoritative';
|
|
290
|
+
return contexts.map((context) => ({ context, appId: null }));
|
|
291
|
+
}
|
|
292
|
+
if (checks) {
|
|
293
|
+
this.lastProtectionStatus = 'authoritative';
|
|
294
|
+
return checks;
|
|
295
|
+
}
|
|
192
296
|
// Unexpected/changed payload shape — treat as unreadable, not "no required
|
|
193
297
|
// checks", so merge readiness is never computed from bad data.
|
|
194
298
|
return null;
|
|
195
299
|
} catch (error) {
|
|
196
300
|
const auth = classifyAuthError(error);
|
|
197
301
|
if (auth) {
|
|
198
|
-
|
|
302
|
+
this.lastProtectionStatus = 'unavailable';
|
|
199
303
|
return null;
|
|
200
304
|
}
|
|
201
305
|
throw error;
|
|
@@ -213,41 +317,38 @@ class PrStateAdapter {
|
|
|
213
317
|
* @returns {string[] | null}
|
|
214
318
|
*/
|
|
215
319
|
_readRollupRequired({ owner, repo, pr }) {
|
|
216
|
-
const prNum =
|
|
217
|
-
if (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
+ `} } } } } } } } }`;
|
|
320
|
+
const prNum = canonicalPrNumber(pr);
|
|
321
|
+
if (prNum === null) return null;
|
|
322
|
+
const query = [
|
|
323
|
+
'query($after:String){repository(owner:"', owner, '",name:"', repo, '"){',
|
|
324
|
+
'pullRequest(number:', prNum, '){headRef{target{... on Commit{statusCheckRollup{',
|
|
325
|
+
'contexts(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{__typename ',
|
|
326
|
+
'... on CheckRun{name isRequired(pullRequestNumber:', prNum, ')} ',
|
|
327
|
+
'... on StatusContext{context isRequired(pullRequestNumber:', prNum, ')}}}}}}}}}}',
|
|
328
|
+
].join('');
|
|
226
329
|
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)];
|
|
330
|
+
const projected = this._paginateConnection(
|
|
331
|
+
(after) => {
|
|
332
|
+
const args = ['api', 'graphql', '-f', `query=${query}`];
|
|
333
|
+
if (after) args.push('-f', `after=${after}`);
|
|
334
|
+
const data = this._parseGraphqlResponse(this._gh('gh', args));
|
|
335
|
+
return data?.data?.repository?.pullRequest?.headRef?.target
|
|
336
|
+
?.statusCheckRollup?.contexts;
|
|
337
|
+
},
|
|
338
|
+
(node) => {
|
|
339
|
+
if (!['CheckRun', 'StatusContext'].includes(node.__typename)
|
|
340
|
+
|| typeof node.isRequired !== 'boolean') {
|
|
341
|
+
throw new Error('Rollup required-check node is malformed');
|
|
342
|
+
}
|
|
343
|
+
const name = node.__typename === 'CheckRun' ? node.name : node.context;
|
|
344
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
345
|
+
throw new Error('Rollup required-check node has no context identity');
|
|
346
|
+
}
|
|
347
|
+
return node.isRequired ? name : null;
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
return [...new Set(projected.filter(Boolean))];
|
|
249
351
|
} catch {
|
|
250
|
-
// GraphQL unreadable (auth/network/etc.) — fail closed to null.
|
|
251
352
|
return null;
|
|
252
353
|
}
|
|
253
354
|
}
|
|
@@ -258,13 +359,13 @@ class PrStateAdapter {
|
|
|
258
359
|
* `cwd` is threaded through to the git runner so divergence is computed
|
|
259
360
|
* against the target worktree/checkout, not the process directory.
|
|
260
361
|
*
|
|
261
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
362
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
262
363
|
* @returns {Promise<{ behind: number, ahead: number }>}
|
|
263
364
|
*/
|
|
264
|
-
async readDivergence({ baseRef, cwd }) {
|
|
365
|
+
async readDivergence({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
265
366
|
const out = this._git(
|
|
266
367
|
'git',
|
|
267
|
-
['rev-list', '--left-right', '--count', `${baseRef}
|
|
368
|
+
['rev-list', '--left-right', '--count', `${baseRef}...${headRef}`],
|
|
268
369
|
cwd ? { cwd } : undefined,
|
|
269
370
|
);
|
|
270
371
|
const [behindRaw = '0', aheadRaw = '0'] = String(out).trim().split(/\s+/);
|
|
@@ -286,7 +387,8 @@ class PrStateAdapter {
|
|
|
286
387
|
|
|
287
388
|
/**
|
|
288
389
|
* Fetch the base ref from its remote so a subsequent `readDivergence`/
|
|
289
|
-
* `detectConflicts` compares
|
|
390
|
+
* `detectConflicts` compares the selected PR head (or legacy `HEAD` default)
|
|
391
|
+
* against the CURRENT `origin/<base>`, not a
|
|
290
392
|
* stale local remote-tracking ref (audit A6: a stale ref reports a false
|
|
291
393
|
* `behind=0` / false "no conflict"). `baseRef` is `<remote>/<branch>` (e.g.
|
|
292
394
|
* `origin/master`); the remote and branch are split back out for `git fetch`.
|
|
@@ -326,6 +428,8 @@ class PrStateAdapter {
|
|
|
326
428
|
* @returns {Promise<object[]>}
|
|
327
429
|
*/
|
|
328
430
|
async readComments({ owner, repo, pr }) {
|
|
431
|
+
const number = canonicalPrNumber(pr);
|
|
432
|
+
if (!number) throw new Error('PR selector must be one canonical positive decimal number');
|
|
329
433
|
// `id`/`path`/`line` are surfaced so a consumer (e.g. the monitor bundle in
|
|
330
434
|
// lib/pr-bundle.js) can hand an agent the thread id to resolve and the
|
|
331
435
|
// file/line to act on — not just the body. Added fields are backward
|
|
@@ -334,22 +438,26 @@ class PrStateAdapter {
|
|
|
334
438
|
// Both connections are FULLY paginated (cursors, not a first:100 cap): a
|
|
335
439
|
// large PR must never silently drop a thread or a later reply, or the bundle
|
|
336
440
|
// would declare "complete" on partial data and the monitor would skip work.
|
|
337
|
-
const threads = this._fetchAllReviewThreads({ owner, repo, pr });
|
|
441
|
+
const threads = this._fetchAllReviewThreads({ owner, repo, pr: number });
|
|
338
442
|
return threads.map((t) => {
|
|
339
|
-
const allComments =
|
|
443
|
+
const allComments = t.comments.nodes;
|
|
340
444
|
return {
|
|
341
|
-
threadId: t.id
|
|
445
|
+
threadId: t.id,
|
|
342
446
|
path: t.path || null,
|
|
343
447
|
line: typeof t.line === 'number' ? t.line : null,
|
|
344
|
-
isResolved:
|
|
345
|
-
isOutdated:
|
|
346
|
-
comments: allComments.map(c =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
448
|
+
isResolved: t.isResolved,
|
|
449
|
+
isOutdated: t.isOutdated,
|
|
450
|
+
comments: allComments.map((c) => {
|
|
451
|
+
const commentId = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
|
|
452
|
+
const author = String(c.author?.login || '');
|
|
453
|
+
const authorType = String(c.author?.__typename || '');
|
|
454
|
+
if (!commentId) throw new Error(`Review thread ${t.id} comment is missing stable database id`);
|
|
455
|
+
if (!author || !GITHUB_ACTOR_TYPENAMES.has(authorType)) {
|
|
456
|
+
throw new Error(`Review thread ${t.id} comment ${commentId} is missing valid author identity`);
|
|
457
|
+
}
|
|
458
|
+
if (typeof c.body !== 'string') throw new Error(`Review thread ${t.id} comment ${commentId} has malformed body`);
|
|
459
|
+
return { author, authorType, body: c.body, commentId };
|
|
460
|
+
}),
|
|
353
461
|
};
|
|
354
462
|
});
|
|
355
463
|
}
|
|
@@ -364,16 +472,20 @@ class PrStateAdapter {
|
|
|
364
472
|
* @returns {object[]} raw thread nodes (comments.nodes carries the full chain)
|
|
365
473
|
*/
|
|
366
474
|
_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}}}}}}}';
|
|
475
|
+
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
476
|
return this._paginateConnection(
|
|
369
477
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
370
478
|
?.data?.repository?.pullRequest?.reviewThreads,
|
|
371
479
|
(t) => {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
480
|
+
if (typeof t.id !== 'string' || !t.id
|
|
481
|
+
|| typeof t.isResolved !== 'boolean' || typeof t.isOutdated !== 'boolean') {
|
|
482
|
+
throw new Error('GraphQL review thread node is malformed');
|
|
375
483
|
}
|
|
376
|
-
|
|
484
|
+
const comments = this._validateGraphqlConnection(t.comments, `Review thread ${t.id} comments`);
|
|
485
|
+
const nodes = comments.pageInfo.hasNextPage
|
|
486
|
+
? this._fetchAllThreadComments(t.id, comments)
|
|
487
|
+
: comments.nodes;
|
|
488
|
+
return { ...t, comments: { nodes } };
|
|
377
489
|
},
|
|
378
490
|
);
|
|
379
491
|
}
|
|
@@ -395,7 +507,31 @@ class PrStateAdapter {
|
|
|
395
507
|
'-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${pr}`,
|
|
396
508
|
];
|
|
397
509
|
if (after) args.push('-f', `after=${after}`);
|
|
398
|
-
return
|
|
510
|
+
return this._parseGraphqlResponse(this._gh('gh', args));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
_parseGraphqlResponse(raw) {
|
|
514
|
+
const parsed = JSON.parse(raw || '{}');
|
|
515
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
516
|
+
throw new Error('GraphQL response envelope is malformed');
|
|
517
|
+
}
|
|
518
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'errors')
|
|
519
|
+
&& (!Array.isArray(parsed.errors) || parsed.errors.length > 0)) {
|
|
520
|
+
throw new Error('GraphQL response contains errors');
|
|
521
|
+
}
|
|
522
|
+
return parsed;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
_validateGraphqlConnection(conn, label = 'GraphQL connection') {
|
|
526
|
+
if (!conn || typeof conn !== 'object' || Array.isArray(conn)
|
|
527
|
+
|| !Array.isArray(conn.nodes) || conn.nodes.some((node) => !node || typeof node !== 'object' || Array.isArray(node))
|
|
528
|
+
|| !conn.pageInfo || typeof conn.pageInfo !== 'object' || Array.isArray(conn.pageInfo)
|
|
529
|
+
|| typeof conn.pageInfo.hasNextPage !== 'boolean'
|
|
530
|
+
|| (conn.pageInfo.endCursor !== null && typeof conn.pageInfo.endCursor !== 'string')
|
|
531
|
+
|| (conn.pageInfo.hasNextPage && !conn.pageInfo.endCursor)) {
|
|
532
|
+
throw new Error(`${label} is missing valid nodes/pageInfo`);
|
|
533
|
+
}
|
|
534
|
+
return conn;
|
|
399
535
|
}
|
|
400
536
|
|
|
401
537
|
/**
|
|
@@ -416,11 +552,18 @@ class PrStateAdapter {
|
|
|
416
552
|
let guard = 0;
|
|
417
553
|
const MAX_PAGES = 1000;
|
|
418
554
|
do {
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
555
|
+
const previous = after;
|
|
556
|
+
const conn = this._validateGraphqlConnection(runPage(after));
|
|
557
|
+
for (const node of conn.nodes) all.push(mapNode(node));
|
|
558
|
+
after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
559
|
+
if (after !== null && after === previous) {
|
|
560
|
+
throw new Error('GraphQL pagination cursor did not advance');
|
|
561
|
+
}
|
|
422
562
|
guard += 1;
|
|
423
|
-
|
|
563
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
564
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
565
|
+
}
|
|
566
|
+
} while (after !== null);
|
|
424
567
|
return all;
|
|
425
568
|
}
|
|
426
569
|
|
|
@@ -433,21 +576,33 @@ class PrStateAdapter {
|
|
|
433
576
|
* @returns {object[]} the full comment-node list (first page + all later pages)
|
|
434
577
|
*/
|
|
435
578
|
_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
|
|
579
|
+
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}}}}}';
|
|
580
|
+
if (typeof threadId !== 'string' || !threadId) throw new Error('Review thread id is malformed');
|
|
581
|
+
let conn = this._validateGraphqlConnection(initial, `Review thread ${threadId} comments`);
|
|
582
|
+
const acc = [...conn.nodes];
|
|
583
|
+
let after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
439
584
|
let guard = 0;
|
|
440
585
|
const MAX_PAGES = 1000;
|
|
441
|
-
while (after
|
|
586
|
+
while (after !== null) {
|
|
587
|
+
const previous = after;
|
|
442
588
|
const raw = this._gh('gh', [
|
|
443
589
|
'api', 'graphql', '-f', `query=${query}`,
|
|
444
590
|
'-f', `id=${threadId}`, '-f', `after=${after}`,
|
|
445
591
|
]);
|
|
446
|
-
const data =
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
592
|
+
const data = this._parseGraphqlResponse(raw);
|
|
593
|
+
conn = this._validateGraphqlConnection(
|
|
594
|
+
data?.data?.node?.comments,
|
|
595
|
+
`Review thread ${threadId} comments`,
|
|
596
|
+
);
|
|
597
|
+
acc.push(...conn.nodes);
|
|
598
|
+
after = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
599
|
+
if (after !== null && after === previous) {
|
|
600
|
+
throw new Error('GraphQL pagination cursor did not advance');
|
|
601
|
+
}
|
|
450
602
|
guard += 1;
|
|
603
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
604
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
605
|
+
}
|
|
451
606
|
}
|
|
452
607
|
return acc;
|
|
453
608
|
}
|
|
@@ -467,56 +622,112 @@ class PrStateAdapter {
|
|
|
467
622
|
// `author{__typename login}` surfaces the GraphQL actor TYPE ('Bot' vs 'User')
|
|
468
623
|
// so a non-human direct comment can be detected GENERICALLY — by mechanism,
|
|
469
624
|
// 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}}}}}';
|
|
625
|
+
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
626
|
return this._paginateConnection(
|
|
472
627
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
473
628
|
?.data?.repository?.pullRequest?.comments,
|
|
474
|
-
(c) =>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
629
|
+
(c) => {
|
|
630
|
+
const id = c.fullDatabaseId ? String(c.fullDatabaseId) : '';
|
|
631
|
+
if (!id) throw new Error('Issue comment is missing stable database id');
|
|
632
|
+
if (!c.author?.login) throw new Error(`Issue comment ${id} is missing author identity`);
|
|
633
|
+
if (!GITHUB_ACTOR_TYPENAMES.has(String(c.author?.__typename || ''))) {
|
|
634
|
+
throw new Error(`Issue comment ${id} has missing or malformed actor type`);
|
|
635
|
+
}
|
|
636
|
+
if (typeof c.body !== 'string' || typeof c.createdAt !== 'string' || !c.createdAt) {
|
|
637
|
+
throw new Error(`Issue comment ${id} has malformed body or timestamp`);
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
id,
|
|
641
|
+
author: c.author.login,
|
|
642
|
+
authorTypename: c.author.__typename,
|
|
643
|
+
body: c.body,
|
|
644
|
+
createdAt: c.createdAt,
|
|
645
|
+
updatedAt: c.updatedAt || c.createdAt,
|
|
646
|
+
};
|
|
647
|
+
},
|
|
484
648
|
);
|
|
485
649
|
}
|
|
486
650
|
|
|
487
651
|
/**
|
|
488
652
|
* Read submitted PR REVIEWS (not inline threads) — the latest review per
|
|
489
653
|
* 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`
|
|
654
|
+
* review-at-head signal that catches the #365 race: any reviewer submission
|
|
655
|
+
* from an earlier commit whose `commit.oid` no longer matches HEAD is STALE,
|
|
656
|
+
* so the post-push re-review is still pending. Uses GraphQL because `gh pr view`
|
|
493
657
|
* cannot return a review's target commit oid. Fully paginated by cursor.
|
|
494
658
|
*
|
|
495
659
|
* @param {{ owner: string, repo: string, pr: string }} ctx
|
|
496
|
-
* @returns {Promise<Array<{ author: string, state: string, submittedAt: string
|
|
660
|
+
* @returns {Promise<Array<{ author: string, state: string, createdAt: string, updatedAt: string, submittedAt: string, activityAt: string, commitOid: string, body: string }>>}
|
|
497
661
|
*/
|
|
498
662
|
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}}}}}';
|
|
663
|
+
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
664
|
const all = this._paginateConnection(
|
|
501
665
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
502
666
|
?.data?.repository?.pullRequest?.reviews,
|
|
503
|
-
(r) =>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
667
|
+
(r) => {
|
|
668
|
+
if (typeof r.id !== 'string' || !r.id) {
|
|
669
|
+
throw new Error('Review is missing stable GraphQL id');
|
|
670
|
+
}
|
|
671
|
+
const id = r.id;
|
|
672
|
+
if (typeof r.author?.login !== 'string' || !r.author.login) {
|
|
673
|
+
throw new Error(`Review ${id} is missing author identity`);
|
|
674
|
+
}
|
|
675
|
+
const author = r.author.login.toLowerCase().replace(/\[bot\]$/, '');
|
|
676
|
+
if (!author) throw new Error(`Review ${id} has empty normalized author identity`);
|
|
677
|
+
if (typeof r.author?.__typename !== 'string'
|
|
678
|
+
|| !GITHUB_ACTOR_TYPENAMES.has(r.author.__typename)) {
|
|
679
|
+
throw new Error(`Review ${id} has missing or malformed actor type`);
|
|
680
|
+
}
|
|
681
|
+
const state = typeof r.state === 'string' ? r.state.toUpperCase() : '';
|
|
682
|
+
if (!PULL_REQUEST_REVIEW_STATES.has(state)
|
|
683
|
+
|| typeof r.createdAt !== 'string' || !r.createdAt
|
|
684
|
+
|| typeof r.updatedAt !== 'string' || !r.updatedAt
|
|
685
|
+
|| typeof r.submittedAt !== 'string' || !r.submittedAt
|
|
686
|
+
|| typeof r.commit?.oid !== 'string' || !FULL_HEAD_SHA.test(r.commit.oid)
|
|
687
|
+
|| typeof r.body !== 'string') {
|
|
688
|
+
throw new Error(`Review ${id} has malformed state, timestamp, commit, or body`);
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
id,
|
|
692
|
+
author,
|
|
693
|
+
authorTypename: r.author.__typename,
|
|
694
|
+
state,
|
|
695
|
+
createdAt: r.createdAt,
|
|
696
|
+
updatedAt: r.updatedAt,
|
|
697
|
+
submittedAt: r.submittedAt,
|
|
698
|
+
commitOid: r.commit.oid,
|
|
699
|
+
body: r.body,
|
|
700
|
+
};
|
|
701
|
+
},
|
|
511
702
|
);
|
|
512
703
|
// Keep only the LATEST review per author (first:100 yields oldest→newest, so
|
|
513
704
|
// a later entry supersedes an earlier one from the same login).
|
|
514
705
|
const latest = new Map();
|
|
706
|
+
const latestSubmission = new Map();
|
|
707
|
+
const latestActivity = new Map();
|
|
515
708
|
for (const r of all) {
|
|
516
|
-
|
|
517
|
-
|
|
709
|
+
const submitted = Date.parse(r.submittedAt);
|
|
710
|
+
if (!Number.isFinite(submitted)) {
|
|
711
|
+
throw new Error(`Review ${r.id} has malformed submission timestamp`);
|
|
712
|
+
}
|
|
713
|
+
if (!latestSubmission.has(r.author) || submitted >= latestSubmission.get(r.author)) {
|
|
714
|
+
latest.set(r.author, r);
|
|
715
|
+
latestSubmission.set(r.author, submitted);
|
|
716
|
+
}
|
|
717
|
+
const activity = Math.max(
|
|
718
|
+
Date.parse(r.createdAt),
|
|
719
|
+
Date.parse(r.updatedAt),
|
|
720
|
+
Date.parse(r.submittedAt),
|
|
721
|
+
);
|
|
722
|
+
if (!Number.isFinite(activity)) {
|
|
723
|
+
throw new Error(`Review ${r.id} has malformed activity timestamps`);
|
|
724
|
+
}
|
|
725
|
+
latestActivity.set(r.author, Math.max(latestActivity.get(r.author) || 0, activity));
|
|
518
726
|
}
|
|
519
|
-
return Array.from(latest.values())
|
|
727
|
+
return Array.from(latest.values()).map((review) => ({
|
|
728
|
+
...review,
|
|
729
|
+
activityAt: new Date(latestActivity.get(review.author)).toISOString(),
|
|
730
|
+
}));
|
|
520
731
|
}
|
|
521
732
|
|
|
522
733
|
/**
|
|
@@ -530,29 +741,33 @@ class PrStateAdapter {
|
|
|
530
741
|
* @returns {Promise<number|null>} epoch ms of the head commit, or null.
|
|
531
742
|
*/
|
|
532
743
|
async readHeadCommitTime({ pr }) {
|
|
533
|
-
const raw = this._gh('gh', [
|
|
744
|
+
const raw = this._gh('gh', [
|
|
745
|
+
'pr', 'view', String(pr),
|
|
746
|
+
...(this._repository ? ['--repo', this._repository] : []),
|
|
747
|
+
'--json', 'commits', '-q', '.commits[-1].committedDate',
|
|
748
|
+
]);
|
|
534
749
|
const t = Date.parse(String(raw || '').trim());
|
|
535
750
|
return Number.isFinite(t) ? t : null;
|
|
536
751
|
}
|
|
537
752
|
|
|
538
753
|
/**
|
|
539
|
-
* Predict files that would conflict when merging `baseRef` into
|
|
754
|
+
* Predict files that would conflict when merging `baseRef` into `headRef` WITHOUT
|
|
540
755
|
* touching the working tree, via `git merge-tree --write-tree`. Returns
|
|
541
756
|
* `{ supported: false, reason }` when conflict prediction is unavailable (git
|
|
542
757
|
* < 2.38, an unreadable ref, or a non-conflict error) so the bundle degrades
|
|
543
758
|
* gracefully rather than failing the whole gather.
|
|
544
759
|
*
|
|
545
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
760
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
546
761
|
* @returns {Promise<{ supported: boolean, conflicted?: boolean, files?: string[], reason?: string }>}
|
|
547
762
|
*/
|
|
548
|
-
async detectConflicts({ baseRef, cwd }) {
|
|
763
|
+
async detectConflicts({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
549
764
|
const opts = cwd ? { cwd } : undefined;
|
|
550
765
|
try {
|
|
551
766
|
// Exit 0 = clean merge. `--name-only` reduces the conflict report to bare
|
|
552
767
|
// paths; `--no-messages` suppresses the human-readable conflict prose.
|
|
553
768
|
this._git(
|
|
554
769
|
'git',
|
|
555
|
-
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef,
|
|
770
|
+
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
|
|
556
771
|
opts,
|
|
557
772
|
);
|
|
558
773
|
return { supported: true, conflicted: false, files: [] };
|