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.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. 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}...HEAD`
11
- * - `detectConflicts` → `git merge-tree --write-tree {baseRef} HEAD` (predict-only)
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', ['pr', 'view', String(pr), '--json', PR_VIEW_FIELDS]);
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 rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
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: String(data.state || 'OPEN').toUpperCase(),
169
+ headSha: typeof data.headRefOid === 'string' ? data.headRefOid : '',
170
+ state: lifecycleReadable ? data.state.toUpperCase() : 'UNKNOWN',
106
171
  mergeable: data.mergeable || 'UNKNOWN',
107
- mergeStateStatus: data.mergeStateStatus || 'UNKNOWN',
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: Boolean(data.isDraft),
113
- // The rollup mixes two GraphQL types: a CheckRun (name/status/conclusion/
114
- // detailsUrl) and a legacy commit StatusContext (context/state/targetUrl —
115
- // Vercel/Netlify/other deploy+quality bots). Both are normalized into ONE
116
- // shape here so the failing/pending classification treats them identically;
117
- // `state` fills the conclusion slot for a StatusContext (SUCCESS/FAILURE/
118
- // ERROR/PENDING) and `targetUrl` fills the details link.
119
- checks: rollup.map((check) => ({
120
- name: check.name || check.context || '',
121
- status: check.status || check.state || '',
122
- conclusion: check.conclusion || check.state || '',
123
- databaseId: check.databaseId,
124
- detailsUrl: check.detailsUrl || check.targetUrl,
125
- })),
126
- // gh pr view cannot return review threads; the shepherd reads them via
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
- * Known limitation of the fallback: the rollup only lists contexts that
147
- * PRODUCED a run, so a required context that never ran at all is invisible on
148
- * this path missing-required detection is best-effort when the source is the
149
- * rollup. That is strictly better than a permanent UNKNOWN.
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 readRequiredChecks({ owner, repo, base, pr }) {
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
- // Protection unreadable (auth/scope/not-protected/unexpected shape) — fall back
167
- // to the rollup `isRequired` set the Actions token CAN read.
168
- const fromRollup = this._readRollupRequired({ owner, repo, pr });
169
- if (Array.isArray(fromRollup)) {
170
- this.lastRequiredSource = 'rollup';
171
- return fromRollup;
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
- if (Array.isArray(data.contexts)) return data.contexts;
191
- if (Array.isArray(data.checks)) return data.checks.map((c) => c.context).filter(Boolean);
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
- // Unreadable protection (auth/scope/not-protected) — fall back to rollup.
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 = Number.parseInt(String(pr), 10);
217
- if (!Number.isInteger(prNum) || prNum <= 0) return null;
218
- // pr is inlined as a validated integer (no injection); owner/repo are GitHub
219
- // name-charset identifiers. Shape verified against the live GraphQL API.
220
- const query = `query { repository(owner: "${owner}", name: "${repo}") { `
221
- + `pullRequest(number: ${prNum}) { headRef { target { ... on Commit { `
222
- + `statusCheckRollup { contexts(first: 100) { nodes { __typename `
223
- + `... on CheckRun { name isRequired(pullRequestNumber: ${prNum}) } `
224
- + `... on StatusContext { context isRequired(pullRequestNumber: ${prNum}) } `
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 raw = this._gh('gh', ['api', 'graphql', '-f', `query=${query}`]);
228
- const data = JSON.parse(raw || '{}');
229
- const nodes = data
230
- && data.data
231
- && data.data.repository
232
- && data.data.repository.pullRequest
233
- && data.data.repository.pullRequest.headRef
234
- && data.data.repository.pullRequest.headRef.target
235
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup
236
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup.contexts
237
- && data.data.repository.pullRequest.headRef.target.statusCheckRollup.contexts.nodes;
238
- // No rollup at all (e.g. statusCheckRollup null) → cannot determine the set.
239
- if (!Array.isArray(nodes)) return null;
240
- const required = [];
241
- for (const node of nodes) {
242
- if (node && node.isRequired === true) {
243
- const name = node.name || node.context;
244
- if (name) required.push(name);
245
- }
246
- }
247
- // Dedupe matrix duplicates (same context reported by multiple jobs).
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}...HEAD`],
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 HEAD against the CURRENT `origin/<base>`, not a
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 = (t.comments && t.comments.nodes) || [];
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: !!t.isResolved,
345
- isOutdated: !!t.isOutdated,
346
- comments: allComments.map(c => ({
347
- author: (c.author && c.author.login) || '',
348
- body: c.body || '',
349
- // REST comment id (needed to REPLY to the thread). `fullDatabaseId` is
350
- // the string-encoded integer id; null when GitHub omits it.
351
- commentId: c.fullDatabaseId ? String(c.fullDatabaseId) : null,
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
- // A thread whose first comment page is truncated → fetch the rest.
373
- if (t.comments?.pageInfo?.hasNextPage) {
374
- t.comments = { nodes: this._fetchAllThreadComments(t.id, t.comments) };
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
- return t;
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 JSON.parse(this._gh('gh', args) || '{}');
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 conn = runPage(after);
420
- for (const node of (conn?.nodes || [])) all.push(mapNode(node));
421
- after = conn?.pageInfo?.hasNextPage ? conn.pageInfo.endCursor : null;
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
- } while (after && guard < MAX_PAGES);
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
- const acc = [...((initial && initial.nodes) || [])];
438
- let after = initial && initial.pageInfo && initial.pageInfo.hasNextPage ? initial.pageInfo.endCursor : null;
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 && guard < MAX_PAGES) {
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 = JSON.parse(raw || '{}');
447
- const conn = data.data && data.data.node && data.data.node.comments;
448
- acc.push(...((conn && conn.nodes) || []));
449
- after = conn && conn.pageInfo && conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
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
- // `id` is the stable REST comment id — the PR monitor keys `comment.posted`
476
- // on it so a re-read never re-emits an already-seen comment. Falls back to
477
- // author+createdAt when GitHub omits it.
478
- id: c.fullDatabaseId ? String(c.fullDatabaseId) : `${c.author?.login || ''}:${c.createdAt || ''}`,
479
- author: c.author?.login || '',
480
- authorTypename: c.author?.__typename || '',
481
- body: c.body || '',
482
- createdAt: c.createdAt || '',
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: a CodeRabbit review from
491
- * an earlier commit whose `commit.oid` no longer matches HEAD is STALE, so the
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|null, commitOid: string|null, body: 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
- author: String(r.author?.login || '').toLowerCase(),
505
- authorTypename: r.author?.__typename || '',
506
- state: String(r.state || '').toUpperCase(),
507
- submittedAt: r.submittedAt || null,
508
- commitOid: r.commit?.oid || null,
509
- body: r.body || '',
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
- if (!r.author) continue;
517
- latest.set(r.author, r);
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', ['pr', 'view', String(pr), '--json', 'commits', '-q', '.commits[-1].committedDate']);
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 HEAD WITHOUT
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, 'HEAD'],
770
+ ['merge-tree', '--write-tree', '--name-only', '--no-messages', baseRef, headRef],
556
771
  opts,
557
772
  );
558
773
  return { supported: true, conflicted: false, files: [] };