forge-workflow 0.1.0-beta.3 → 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 +21 -1
- package/bin/forge.js +16 -369
- 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 +9 -4
- 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 +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- 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 +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- 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 +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -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/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- 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 +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- 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 +40 -0
- 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/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- 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 +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- 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/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- 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().
|
|
@@ -78,13 +121,17 @@ class PrStateAdapter {
|
|
|
78
121
|
this.id = options.id || 'pr-state-adapter';
|
|
79
122
|
this.kind = 'pr-state';
|
|
80
123
|
this.name = options.name || this.id;
|
|
124
|
+
// windowsHide keeps the shepherd's per-poll gh/git calls from flashing a
|
|
125
|
+
// console window on Windows when the watcher runs detached (issue 931e7924).
|
|
81
126
|
const defaultRunner = (cmd, args, opts = {}) => execFileSync(cmd, args, {
|
|
82
127
|
encoding: 'utf8',
|
|
83
128
|
timeout: options.timeout || 30000,
|
|
129
|
+
windowsHide: true,
|
|
84
130
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
85
131
|
});
|
|
86
132
|
this._gh = options.gh || defaultRunner;
|
|
87
133
|
this._git = options.git || defaultRunner;
|
|
134
|
+
this.lastProtectionStatus = null;
|
|
88
135
|
}
|
|
89
136
|
|
|
90
137
|
/**
|
|
@@ -96,80 +143,220 @@ class PrStateAdapter {
|
|
|
96
143
|
async readState(pr) {
|
|
97
144
|
const raw = this._gh('gh', ['pr', 'view', String(pr), '--json', PR_VIEW_FIELDS]);
|
|
98
145
|
const data = JSON.parse(raw || '{}');
|
|
99
|
-
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 : [];
|
|
100
159
|
return {
|
|
101
|
-
headSha: data.headRefOid
|
|
102
|
-
state:
|
|
160
|
+
headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
|
|
161
|
+
state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
|
|
103
162
|
mergeable: data.mergeable || 'UNKNOWN',
|
|
104
|
-
mergeStateStatus:
|
|
105
|
-
// PR-level merge blockers (surfaced by the pull signal). `reviewDecision` is
|
|
106
|
-
// '' when no review is required; normalized to null so consumers can treat
|
|
107
|
-
// "not required" and "unknown" uniformly. `isDraft` blocks merge outright.
|
|
163
|
+
mergeStateStatus: mergeStateReadable ? mergeStateStatus : 'UNKNOWN',
|
|
108
164
|
reviewDecision: data.reviewDecision || null,
|
|
109
|
-
isDraft:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// 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
|
+
}),
|
|
125
180
|
threads: [],
|
|
126
181
|
};
|
|
127
182
|
}
|
|
128
183
|
|
|
129
184
|
/**
|
|
130
|
-
* Read the
|
|
185
|
+
* Read the required-checks set for a PR, with a two-source strategy so the
|
|
186
|
+
* verdict is not permanently UNKNOWN in CI.
|
|
131
187
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
188
|
+
* 1. **Branch protection** (`.../protection/required_status_checks`) — the
|
|
189
|
+
* authoritative set, but this REST endpoint needs repo `Administration:read`,
|
|
190
|
+
* which GitHub Actions' `GITHUB_TOKEN` can NEVER hold (administration is not a
|
|
191
|
+
* grantable `permissions:` scope). So in CI this ALWAYS 403/404s and the set
|
|
192
|
+
* was permanently null → verdict UNKNOWN on every PR.
|
|
193
|
+
* 2. **statusCheckRollup `isRequired`** (GraphQL, on the PR head commit) — the
|
|
194
|
+
* fallback. It is readable with the plain PR-read scope the Actions token DOES
|
|
195
|
+
* hold (it is what `gh pr checks --required` uses) and it covers BOTH classic
|
|
196
|
+
* branch protection AND repository rulesets.
|
|
136
197
|
*
|
|
137
|
-
*
|
|
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.
|
|
202
|
+
* Re-throws non-auth protection errors (unchanged).
|
|
203
|
+
*
|
|
204
|
+
* @param {{ owner: string, repo: string, base: string, pr?: string|number }} ctx
|
|
138
205
|
* @returns {Promise<string[] | null>}
|
|
139
206
|
*/
|
|
140
|
-
async
|
|
207
|
+
async readRequiredCheckPolicy({ owner, repo, base }) {
|
|
208
|
+
this.lastRequiredSource = null;
|
|
209
|
+
const fromProtection = this._readProtectionRequired({ owner, repo, base });
|
|
210
|
+
if (Array.isArray(fromProtection)) {
|
|
211
|
+
this.lastRequiredSource = 'protection';
|
|
212
|
+
return fromProtection;
|
|
213
|
+
}
|
|
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 });
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Branch-protection required set, or `null` when unreadable (auth/scope/
|
|
229
|
+
* not-protected/unexpected shape). Re-throws non-auth errors so a genuine
|
|
230
|
+
* outage is not silently masked. Split out so `readRequiredChecks` can fall
|
|
231
|
+
* back cleanly.
|
|
232
|
+
*
|
|
233
|
+
* @param {{ owner: string, repo: string, base: string }} ctx
|
|
234
|
+
* @returns {string[] | null}
|
|
235
|
+
*/
|
|
236
|
+
_readProtectionRequired({ owner, repo, base }) {
|
|
237
|
+
this.lastProtectionStatus = 'malformed';
|
|
141
238
|
const apiPath = `repos/${owner}/${repo}/branches/${encodeURIComponent(base)}/protection/required_status_checks`;
|
|
142
239
|
try {
|
|
143
240
|
const raw = this._gh('gh', ['api', apiPath]);
|
|
144
241
|
const data = JSON.parse(raw || '{}');
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|
|
147
287
|
// Unexpected/changed payload shape — treat as unreadable, not "no required
|
|
148
288
|
// checks", so merge readiness is never computed from bad data.
|
|
149
289
|
return null;
|
|
150
290
|
} catch (error) {
|
|
151
291
|
const auth = classifyAuthError(error);
|
|
152
292
|
if (auth) {
|
|
153
|
-
|
|
293
|
+
this.lastProtectionStatus = 'unavailable';
|
|
154
294
|
return null;
|
|
155
295
|
}
|
|
156
296
|
throw error;
|
|
157
297
|
}
|
|
158
298
|
}
|
|
159
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Fallback required set from the PR head commit's `statusCheckRollup`, reading
|
|
302
|
+
* per-context `isRequired(pullRequestNumber:)` via GraphQL. Readable with plain
|
|
303
|
+
* PR-read scope (unlike branch protection). Returns the deduped names of every
|
|
304
|
+
* required CheckRun/StatusContext, `[]` when the rollup is readable but nothing
|
|
305
|
+
* is required, or `null` when the rollup itself is unreadable (fail-closed).
|
|
306
|
+
*
|
|
307
|
+
* @param {{ owner: string, repo: string, pr?: string|number }} ctx
|
|
308
|
+
* @returns {string[] | null}
|
|
309
|
+
*/
|
|
310
|
+
_readRollupRequired({ owner, repo, pr }) {
|
|
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('');
|
|
320
|
+
try {
|
|
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))];
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
160
347
|
/**
|
|
161
348
|
* Read ahead/behind divergence against the base ref.
|
|
162
349
|
*
|
|
163
350
|
* `cwd` is threaded through to the git runner so divergence is computed
|
|
164
351
|
* against the target worktree/checkout, not the process directory.
|
|
165
352
|
*
|
|
166
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
353
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
167
354
|
* @returns {Promise<{ behind: number, ahead: number }>}
|
|
168
355
|
*/
|
|
169
|
-
async readDivergence({ baseRef, cwd }) {
|
|
356
|
+
async readDivergence({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
170
357
|
const out = this._git(
|
|
171
358
|
'git',
|
|
172
|
-
['rev-list', '--left-right', '--count', `${baseRef}
|
|
359
|
+
['rev-list', '--left-right', '--count', `${baseRef}...${headRef}`],
|
|
173
360
|
cwd ? { cwd } : undefined,
|
|
174
361
|
);
|
|
175
362
|
const [behindRaw = '0', aheadRaw = '0'] = String(out).trim().split(/\s+/);
|
|
@@ -191,7 +378,8 @@ class PrStateAdapter {
|
|
|
191
378
|
|
|
192
379
|
/**
|
|
193
380
|
* Fetch the base ref from its remote so a subsequent `readDivergence`/
|
|
194
|
-
* `detectConflicts` compares
|
|
381
|
+
* `detectConflicts` compares the selected PR head (or legacy `HEAD` default)
|
|
382
|
+
* against the CURRENT `origin/<base>`, not a
|
|
195
383
|
* stale local remote-tracking ref (audit A6: a stale ref reports a false
|
|
196
384
|
* `behind=0` / false "no conflict"). `baseRef` is `<remote>/<branch>` (e.g.
|
|
197
385
|
* `origin/master`); the remote and branch are split back out for `git fetch`.
|
|
@@ -231,6 +419,8 @@ class PrStateAdapter {
|
|
|
231
419
|
* @returns {Promise<object[]>}
|
|
232
420
|
*/
|
|
233
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');
|
|
234
424
|
// `id`/`path`/`line` are surfaced so a consumer (e.g. the monitor bundle in
|
|
235
425
|
// lib/pr-bundle.js) can hand an agent the thread id to resolve and the
|
|
236
426
|
// file/line to act on — not just the body. Added fields are backward
|
|
@@ -239,22 +429,26 @@ class PrStateAdapter {
|
|
|
239
429
|
// Both connections are FULLY paginated (cursors, not a first:100 cap): a
|
|
240
430
|
// large PR must never silently drop a thread or a later reply, or the bundle
|
|
241
431
|
// would declare "complete" on partial data and the monitor would skip work.
|
|
242
|
-
const threads = this._fetchAllReviewThreads({ owner, repo, pr });
|
|
432
|
+
const threads = this._fetchAllReviewThreads({ owner, repo, pr: number });
|
|
243
433
|
return threads.map((t) => {
|
|
244
|
-
const allComments =
|
|
434
|
+
const allComments = t.comments.nodes;
|
|
245
435
|
return {
|
|
246
|
-
threadId: t.id
|
|
436
|
+
threadId: t.id,
|
|
247
437
|
path: t.path || null,
|
|
248
438
|
line: typeof t.line === 'number' ? t.line : null,
|
|
249
|
-
isResolved:
|
|
250
|
-
isOutdated:
|
|
251
|
-
comments: allComments.map(c =>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}),
|
|
258
452
|
};
|
|
259
453
|
});
|
|
260
454
|
}
|
|
@@ -269,16 +463,20 @@ class PrStateAdapter {
|
|
|
269
463
|
* @returns {object[]} raw thread nodes (comments.nodes carries the full chain)
|
|
270
464
|
*/
|
|
271
465
|
_fetchAllReviewThreads({ owner, repo, pr }) {
|
|
272
|
-
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}}}}}}}';
|
|
273
467
|
return this._paginateConnection(
|
|
274
468
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
275
469
|
?.data?.repository?.pullRequest?.reviewThreads,
|
|
276
470
|
(t) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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');
|
|
280
474
|
}
|
|
281
|
-
|
|
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 } };
|
|
282
480
|
},
|
|
283
481
|
);
|
|
284
482
|
}
|
|
@@ -300,7 +498,31 @@ class PrStateAdapter {
|
|
|
300
498
|
'-F', `o=${owner}`, '-F', `n=${repo}`, '-F', `pr=${pr}`,
|
|
301
499
|
];
|
|
302
500
|
if (after) args.push('-f', `after=${after}`);
|
|
303
|
-
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;
|
|
304
526
|
}
|
|
305
527
|
|
|
306
528
|
/**
|
|
@@ -321,11 +543,18 @@ class PrStateAdapter {
|
|
|
321
543
|
let guard = 0;
|
|
322
544
|
const MAX_PAGES = 1000;
|
|
323
545
|
do {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
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
|
+
}
|
|
327
553
|
guard += 1;
|
|
328
|
-
|
|
554
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
555
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
556
|
+
}
|
|
557
|
+
} while (after !== null);
|
|
329
558
|
return all;
|
|
330
559
|
}
|
|
331
560
|
|
|
@@ -338,21 +567,33 @@ class PrStateAdapter {
|
|
|
338
567
|
* @returns {object[]} the full comment-node list (first page + all later pages)
|
|
339
568
|
*/
|
|
340
569
|
_fetchAllThreadComments(threadId, initial) {
|
|
341
|
-
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}}}}}';
|
|
342
|
-
|
|
343
|
-
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;
|
|
344
575
|
let guard = 0;
|
|
345
576
|
const MAX_PAGES = 1000;
|
|
346
|
-
while (after
|
|
577
|
+
while (after !== null) {
|
|
578
|
+
const previous = after;
|
|
347
579
|
const raw = this._gh('gh', [
|
|
348
580
|
'api', 'graphql', '-f', `query=${query}`,
|
|
349
581
|
'-f', `id=${threadId}`, '-f', `after=${after}`,
|
|
350
582
|
]);
|
|
351
|
-
const data =
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
593
|
guard += 1;
|
|
594
|
+
if (guard >= MAX_PAGES && after !== null) {
|
|
595
|
+
throw new Error('GraphQL pagination exceeded the page limit');
|
|
596
|
+
}
|
|
356
597
|
}
|
|
357
598
|
return acc;
|
|
358
599
|
}
|
|
@@ -372,56 +613,112 @@ class PrStateAdapter {
|
|
|
372
613
|
// `author{__typename login}` surfaces the GraphQL actor TYPE ('Bot' vs 'User')
|
|
373
614
|
// so a non-human direct comment can be detected GENERICALLY — by mechanism,
|
|
374
615
|
// not by a hardcoded bot-name list — which fails closed for unknown bots.
|
|
375
|
-
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}}}}}';
|
|
376
617
|
return this._paginateConnection(
|
|
377
618
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
378
619
|
?.data?.repository?.pullRequest?.comments,
|
|
379
|
-
(c) =>
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
+
},
|
|
389
639
|
);
|
|
390
640
|
}
|
|
391
641
|
|
|
392
642
|
/**
|
|
393
643
|
* Read submitted PR REVIEWS (not inline threads) — the latest review per
|
|
394
644
|
* author, each with the commit it was submitted against. This is the
|
|
395
|
-
* review-at-head signal that catches the #365 race:
|
|
396
|
-
* an earlier commit whose `commit.oid` no longer matches HEAD is STALE,
|
|
397
|
-
* 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`
|
|
398
648
|
* cannot return a review's target commit oid. Fully paginated by cursor.
|
|
399
649
|
*
|
|
400
650
|
* @param {{ owner: string, repo: string, pr: string }} ctx
|
|
401
|
-
* @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 }>>}
|
|
402
652
|
*/
|
|
403
653
|
async readReviews({ owner, repo, pr }) {
|
|
404
|
-
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}}}}}';
|
|
405
655
|
const all = this._paginateConnection(
|
|
406
656
|
(after) => this._ghGraphqlPage(query, { owner, repo, pr }, after)
|
|
407
657
|
?.data?.repository?.pullRequest?.reviews,
|
|
408
|
-
(r) =>
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
},
|
|
416
693
|
);
|
|
417
694
|
// Keep only the LATEST review per author (first:100 yields oldest→newest, so
|
|
418
695
|
// a later entry supersedes an earlier one from the same login).
|
|
419
696
|
const latest = new Map();
|
|
697
|
+
const latestSubmission = new Map();
|
|
698
|
+
const latestActivity = new Map();
|
|
420
699
|
for (const r of all) {
|
|
421
|
-
|
|
422
|
-
|
|
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));
|
|
423
717
|
}
|
|
424
|
-
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
|
+
}));
|
|
425
722
|
}
|
|
426
723
|
|
|
427
724
|
/**
|
|
@@ -441,23 +738,23 @@ class PrStateAdapter {
|
|
|
441
738
|
}
|
|
442
739
|
|
|
443
740
|
/**
|
|
444
|
-
* Predict files that would conflict when merging `baseRef` into
|
|
741
|
+
* Predict files that would conflict when merging `baseRef` into `headRef` WITHOUT
|
|
445
742
|
* touching the working tree, via `git merge-tree --write-tree`. Returns
|
|
446
743
|
* `{ supported: false, reason }` when conflict prediction is unavailable (git
|
|
447
744
|
* < 2.38, an unreadable ref, or a non-conflict error) so the bundle degrades
|
|
448
745
|
* gracefully rather than failing the whole gather.
|
|
449
746
|
*
|
|
450
|
-
* @param {{ baseRef: string, cwd?: string }} ctx
|
|
747
|
+
* @param {{ baseRef: string, cwd?: string, headRef?: string }} ctx
|
|
451
748
|
* @returns {Promise<{ supported: boolean, conflicted?: boolean, files?: string[], reason?: string }>}
|
|
452
749
|
*/
|
|
453
|
-
async detectConflicts({ baseRef, cwd }) {
|
|
750
|
+
async detectConflicts({ baseRef, cwd, headRef = 'HEAD' }) {
|
|
454
751
|
const opts = cwd ? { cwd } : undefined;
|
|
455
752
|
try {
|
|
456
753
|
// Exit 0 = clean merge. `--name-only` reduces the conflict report to bare
|
|
457
754
|
// paths; `--no-messages` suppresses the human-readable conflict prose.
|
|
458
755
|
this._git(
|
|
459
756
|
'git',
|
|
460
|
-
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef,
|
|
757
|
+
['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
|
|
461
758
|
opts,
|
|
462
759
|
);
|
|
463
760
|
return { supported: true, conflicted: false, files: [] };
|