hivecode-mcp 0.14.1 → 0.14.2
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2384 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +1 -1
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +169 -11
- package/token.js +201 -201
package/hive-room-mode.js
CHANGED
|
@@ -1,1668 +1,1668 @@
|
|
|
1
|
-
// hive-room-mode.js -- room-mode startup validation, coordination, and issue posting (B1, B2 & B2.1)
|
|
2
|
-
//
|
|
3
|
-
// In room mode:
|
|
4
|
-
// npx -y hivecode-mcp@latest github start --room "<room link>" --test "npm test"
|
|
5
|
-
//
|
|
6
|
-
// The watcher runs on the repo machine and joins the room as a passive member "GitHub".
|
|
7
|
-
// Before any issue is picked up or posted, all safety preconditions must be verified.
|
|
8
|
-
//
|
|
9
|
-
// Error codes Q650-Q659 are this module's:
|
|
10
|
-
// Q650 invalid or missing room link
|
|
11
|
-
// Q651 not a git repository (or is home directory)
|
|
12
|
-
// Q652 room is not bound to a git branch in this repository
|
|
13
|
-
// Q653 GitHub login expired or missing on this machine
|
|
14
|
-
// Q654 member runs a version too old for room mode (< 0.13.0)
|
|
15
|
-
// Q655 another room-mode watcher is already running for this room
|
|
16
|
-
// Q656 could not reach relay / connection failed
|
|
17
|
-
// Q657 delivery verification failed (task or chat not received by room)
|
|
18
|
-
|
|
19
|
-
import fs from 'fs'
|
|
20
|
-
import os from 'os'
|
|
21
|
-
import path from 'path'
|
|
22
|
-
import crypto from 'crypto'
|
|
23
|
-
import { spawnSync } from 'child_process'
|
|
24
|
-
import { parseLink, startSync } from './sync.js'
|
|
25
|
-
import { landAndOpen, testCommandFor } from './hive-land.js'
|
|
26
|
-
|
|
27
|
-
export const MIN_ROOM_MODE_VERSION = '0.13.0'
|
|
28
|
-
|
|
29
|
-
function sameDir(a, b) {
|
|
30
|
-
if (!a || !b) return false
|
|
31
|
-
const na = path.resolve(a).toLowerCase().replace(/\\/g, '/')
|
|
32
|
-
const nb = path.resolve(b).toLowerCase().replace(/\\/g, '/')
|
|
33
|
-
return na === nb
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Compare semver versions: returns <0 if a < b, 0 if a == b, >0 if a > b */
|
|
37
|
-
export function compareSemver(a, b) {
|
|
38
|
-
const pa = String(a || '0.0.0').split('.').map((x) => parseInt(x, 10) || 0)
|
|
39
|
-
const pb = String(b || '0.0.0').split('.').map((x) => parseInt(x, 10) || 0)
|
|
40
|
-
for (let i = 0; i < 3; i++) {
|
|
41
|
-
const diff = (pa[i] || 0) - (pb[i] || 0)
|
|
42
|
-
if (diff !== 0) return diff
|
|
43
|
-
}
|
|
44
|
-
return 0
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Check process liveness by PID */
|
|
48
|
-
export function isProcessAlive(pid) {
|
|
49
|
-
if (!pid || typeof pid !== 'number') return false
|
|
50
|
-
try {
|
|
51
|
-
process.kill(pid, 0)
|
|
52
|
-
return true
|
|
53
|
-
} catch (e) {
|
|
54
|
-
return e.code === 'EPERM'
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 1. Validate room link (Q650) */
|
|
59
|
-
export function validateRoomLink(link) {
|
|
60
|
-
const raw = String(link || '').trim()
|
|
61
|
-
if (!raw) {
|
|
62
|
-
return { ok: false, code: 'Q650', why: 'Q650 room link is required (e.g. wss://relay|room-...)' }
|
|
63
|
-
}
|
|
64
|
-
const parsed = parseLink(raw)
|
|
65
|
-
if (!parsed || !parsed.room || parsed.room.trim() === '' || !parsed.relay) {
|
|
66
|
-
return { ok: false, code: 'Q650', why: `Q650 "${raw}" is not a valid room link (expected wss://relay|room-...)` }
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, relay: parsed.relay, room: parsed.room }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 2. Check git repository using git rev-parse --show-toplevel (Q651) */
|
|
72
|
-
export function checkGitRepo(repoPath) {
|
|
73
|
-
if (!repoPath || !fs.existsSync(repoPath)) {
|
|
74
|
-
return { ok: false, code: 'Q651', why: `Q651 "${repoPath}" does not exist.` }
|
|
75
|
-
}
|
|
76
|
-
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.resolve(repoPath), encoding: 'utf8' })
|
|
77
|
-
if (r.status !== 0 || !r.stdout || !r.stdout.trim()) {
|
|
78
|
-
return { ok: false, code: 'Q651', why: `Q651 "${repoPath}" is not inside a git repository. Run this in the repo whose branch the room is for.` }
|
|
79
|
-
}
|
|
80
|
-
const root = path.resolve(r.stdout.trim())
|
|
81
|
-
if (sameDir(root, os.homedir())) {
|
|
82
|
-
return {
|
|
83
|
-
ok: false,
|
|
84
|
-
code: 'Q651',
|
|
85
|
-
why: `Q651 the git repository around ${repoPath} is your home directory (${root}), which is not a project: ${repoPath} is not a repository of its own. Run this in the project's own repository.`,
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return { ok: true, root, repo: root }
|
|
89
|
-
}
|
|
90
|
-
export const getRepoRoot = checkGitRepo
|
|
91
|
-
|
|
92
|
-
/** 3. Check branch binding (Q652) */
|
|
93
|
-
export function checkBranchBinding(repoPath, target) {
|
|
94
|
-
const repoRes = checkGitRepo(repoPath)
|
|
95
|
-
const root = repoRes.ok ? repoRes.root : path.resolve(String(repoPath || '.'))
|
|
96
|
-
|
|
97
|
-
let targetRoom = target
|
|
98
|
-
try {
|
|
99
|
-
const p = parseLink(target)
|
|
100
|
-
if (p && p.room) targetRoom = p.room
|
|
101
|
-
} catch {}
|
|
102
|
-
|
|
103
|
-
const r = spawnSync('git', ['config', '--local', '--get-regexp', '^hivecode\\..*\\.room$'], { cwd: root, encoding: 'utf8' })
|
|
104
|
-
const matchedBranches = []
|
|
105
|
-
if (r.status === 0 && r.stdout) {
|
|
106
|
-
const lines = r.stdout.split(/\r?\n/)
|
|
107
|
-
for (const line of lines) {
|
|
108
|
-
const match = line.match(/^hivecode\.(.*)\.room\s+(.*)$/)
|
|
109
|
-
if (match) {
|
|
110
|
-
const branch = match[1]
|
|
111
|
-
const val = match[2].trim()
|
|
112
|
-
let valRoom = val
|
|
113
|
-
try {
|
|
114
|
-
const vp = parseLink(val)
|
|
115
|
-
if (vp && vp.room) valRoom = vp.room
|
|
116
|
-
} catch {}
|
|
117
|
-
|
|
118
|
-
if (val === target || valRoom === targetRoom || val === targetRoom) {
|
|
119
|
-
matchedBranches.push(branch)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (matchedBranches.length > 1) {
|
|
126
|
-
return {
|
|
127
|
-
ok: false,
|
|
128
|
-
code: 'Q652',
|
|
129
|
-
why: `Q652 multiple branches (${matchedBranches.join(', ')}) are bound to room "${target}". Specify which branch to run or re-bind.`,
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (matchedBranches.length === 1) {
|
|
133
|
-
return { ok: true, branch: matchedBranches[0], room: targetRoom }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
ok: false,
|
|
138
|
-
code: 'Q652',
|
|
139
|
-
why: `Q652 room "${target}" is not bound to a git branch in this repository. Bind it first with: npx -y hivecode-mcp@latest branch <branch> --join "<link>"`,
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** 4. Check GitHub authentication (Q653) */
|
|
144
|
-
export function checkGitHubAuth(opts = {}) {
|
|
145
|
-
const runner = opts.ghRunner || (() => {
|
|
146
|
-
const bin = opts.gh || process.env.HIVE_GH || 'gh'
|
|
147
|
-
try {
|
|
148
|
-
const r = spawnSync(bin, ['auth', 'status'], { encoding: 'utf8', windowsHide: true })
|
|
149
|
-
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error }
|
|
150
|
-
} catch (e) {
|
|
151
|
-
return { status: null, error: e }
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
const res = runner()
|
|
156
|
-
if (res.error || res.status === null) {
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
code: 'Q653',
|
|
160
|
-
why: 'Q653 GitHub CLI (gh) is not installed or not in PATH. Install it from https://cli.github.com, then run: gh auth login',
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (res.status !== 0) {
|
|
164
|
-
return {
|
|
165
|
-
ok: false,
|
|
166
|
-
code: 'Q653',
|
|
167
|
-
why: 'Q653 GitHub login expired on this machine: run gh auth login.',
|
|
168
|
-
detail: (res.stderr || res.stdout || '').trim(),
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return { ok: true }
|
|
172
|
-
}
|
|
173
|
-
export const checkGhAuth = checkGitHubAuth
|
|
174
|
-
|
|
175
|
-
/** 5. Check member versions (Q654) */
|
|
176
|
-
export function checkMemberVersions(members = [], minVersion = MIN_ROOM_MODE_VERSION) {
|
|
177
|
-
let agentCount = 0
|
|
178
|
-
for (const m of members) {
|
|
179
|
-
if (!m) continue
|
|
180
|
-
// Skip humans, passive observers, control room / recorder, and probe connections
|
|
181
|
-
if (m.kind === 'human' || m.passive || /recorder|control[-_ ]?room|conn-check|github/i.test(m.name || '') || /control[-_ ]?room/i.test(m.app || '')) continue
|
|
182
|
-
|
|
183
|
-
if (m.kind === 'ai' || m.kind === undefined) {
|
|
184
|
-
agentCount++
|
|
185
|
-
const ver = m.version || m.hv
|
|
186
|
-
if (!ver) {
|
|
187
|
-
return {
|
|
188
|
-
ok: false,
|
|
189
|
-
code: 'Q654',
|
|
190
|
-
member: m.name,
|
|
191
|
-
why: `Q654 ${m.name} runs an unversioned build (< 0.13.0), too old for room mode. Everyone in the room must run at least ${minVersion}. Update with: npx -y hivecode-mcp@latest setup`,
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (compareSemver(ver, minVersion) < 0) {
|
|
195
|
-
return {
|
|
196
|
-
ok: false,
|
|
197
|
-
code: 'Q654',
|
|
198
|
-
member: m.name,
|
|
199
|
-
version: ver,
|
|
200
|
-
why: `Q654 ${m.name} runs ${ver}, too old for room mode. Everyone in the room must run at least ${minVersion}. Update with: npx -y hivecode-mcp@latest setup`,
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (agentCount === 0) {
|
|
207
|
-
return { ok: true, warn: 'Waiting: no agent is in room' }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return { ok: true }
|
|
211
|
-
}
|
|
212
|
-
export const checkRoomMembers = checkMemberVersions
|
|
213
|
-
|
|
214
|
-
/** 6. Check watcher lock (Q655) */
|
|
215
|
-
export function checkWatcherLock(repoPath, room, opts = {}) {
|
|
216
|
-
const root = path.resolve(String(repoPath || '.'))
|
|
217
|
-
|
|
218
|
-
let gitDir = opts.gitDir
|
|
219
|
-
if (!gitDir) {
|
|
220
|
-
const r = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: root, encoding: 'utf8' })
|
|
221
|
-
if (r.status === 0 && r.stdout && r.stdout.trim()) {
|
|
222
|
-
gitDir = path.resolve(root, r.stdout.trim())
|
|
223
|
-
} else {
|
|
224
|
-
gitDir = path.join(root, '.git')
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const lockDir = opts.lockDir || path.join(gitDir, 'hivecode')
|
|
229
|
-
fs.mkdirSync(lockDir, { recursive: true })
|
|
230
|
-
|
|
231
|
-
const lockFile = path.join(lockDir, `watcher-${room}.lock`)
|
|
232
|
-
const checkAlive = opts.isAlive || isProcessAlive
|
|
233
|
-
const myPid = opts.pid || process.pid
|
|
234
|
-
|
|
235
|
-
const payload = JSON.stringify({
|
|
236
|
-
pid: myPid,
|
|
237
|
-
room,
|
|
238
|
-
repo: root,
|
|
239
|
-
startedAt: new Date().toISOString(),
|
|
240
|
-
}, null, 2)
|
|
241
|
-
|
|
242
|
-
let acquired = false
|
|
243
|
-
try {
|
|
244
|
-
fs.writeFileSync(lockFile, payload, { flag: 'wx', encoding: 'utf8' })
|
|
245
|
-
acquired = true
|
|
246
|
-
} catch (err) {
|
|
247
|
-
if (err.code !== 'EEXIST') throw err
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (!acquired) {
|
|
251
|
-
// Lock file already exists: check if holder is alive
|
|
252
|
-
try {
|
|
253
|
-
const data = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
|
|
254
|
-
if (data && data.pid && checkAlive(data.pid)) {
|
|
255
|
-
return {
|
|
256
|
-
ok: false,
|
|
257
|
-
code: 'Q655',
|
|
258
|
-
pid: data.pid,
|
|
259
|
-
lockFile,
|
|
260
|
-
why: `Q655 Another room-mode watcher is already running for room "${room}" (process ${data.pid}, since ${data.startedAt}).`,
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
} catch {
|
|
264
|
-
// corrupt lock file
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Holder is dead or corrupt: overwrite
|
|
268
|
-
const tmpLock = `${lockFile}.${myPid}.tmp`
|
|
269
|
-
fs.writeFileSync(tmpLock, payload, 'utf8')
|
|
270
|
-
fs.renameSync(tmpLock, lockFile)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
ok: true,
|
|
275
|
-
lockFile,
|
|
276
|
-
pid: myPid,
|
|
277
|
-
release: () => {
|
|
278
|
-
try {
|
|
279
|
-
if (fs.existsSync(lockFile)) {
|
|
280
|
-
const cur = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
|
|
281
|
-
if (cur && cur.pid === myPid) {
|
|
282
|
-
fs.unlinkSync(lockFile)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
} catch {}
|
|
286
|
-
},
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
export const takeRoomWatcherLock = checkWatcherLock
|
|
290
|
-
|
|
291
|
-
/** Connect lightweight peek to verify room connectivity and fetch members if needed */
|
|
292
|
-
export async function checkRoomConnection({ relay, room, token, timeoutMs = 60000 } = {}) {
|
|
293
|
-
const vdir = path.join(os.tmpdir(), `hive-conn-${crypto.randomBytes(6).toString('hex')}`)
|
|
294
|
-
fs.mkdirSync(vdir, { recursive: true })
|
|
295
|
-
let peek = null
|
|
296
|
-
try {
|
|
297
|
-
peek = startSync({
|
|
298
|
-
relay,
|
|
299
|
-
room,
|
|
300
|
-
dir: vdir,
|
|
301
|
-
name: 'conn-check',
|
|
302
|
-
kind: 'ai',
|
|
303
|
-
passive: true,
|
|
304
|
-
syncFiles: false,
|
|
305
|
-
token,
|
|
306
|
-
log: () => {},
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
let synced = false
|
|
310
|
-
await new Promise((res) => {
|
|
311
|
-
const timer = setTimeout(res, timeoutMs)
|
|
312
|
-
if (peek.provider) {
|
|
313
|
-
peek.provider.on('sync', (s) => {
|
|
314
|
-
if (s) {
|
|
315
|
-
synced = true
|
|
316
|
-
clearTimeout(timer)
|
|
317
|
-
res()
|
|
318
|
-
}
|
|
319
|
-
})
|
|
320
|
-
}
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
if (!synced) {
|
|
324
|
-
return {
|
|
325
|
-
ok: false,
|
|
326
|
-
code: 'Q656',
|
|
327
|
-
why: `Q656 could not connect to relay at ${relay} for room ${room}.`,
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const members = peek.members ? peek.members() : []
|
|
332
|
-
return { ok: true, members }
|
|
333
|
-
} catch (e) {
|
|
334
|
-
return {
|
|
335
|
-
ok: false,
|
|
336
|
-
code: 'Q656',
|
|
337
|
-
why: `Q656 connection error to ${relay}: ${e.message}`,
|
|
338
|
-
}
|
|
339
|
-
} finally {
|
|
340
|
-
if (peek && typeof peek.stop === 'function') {
|
|
341
|
-
try { peek.stop() } catch {}
|
|
342
|
-
}
|
|
343
|
-
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch {}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** 7. Unified room-mode start check */
|
|
348
|
-
export async function checkRoomModeStart(opts = {}) {
|
|
349
|
-
// 1. Room link check (Q650)
|
|
350
|
-
const linkRes = validateRoomLink(opts.link || opts.room)
|
|
351
|
-
if (!linkRes.ok) return linkRes
|
|
352
|
-
|
|
353
|
-
// 2. Git repo check (Q651)
|
|
354
|
-
const repoRes = checkGitRepo(opts.repo || process.cwd())
|
|
355
|
-
if (!repoRes.ok) return repoRes
|
|
356
|
-
|
|
357
|
-
// 3. Branch binding check (Q652)
|
|
358
|
-
const bindRes = checkBranchBinding(repoRes.repo, linkRes.room)
|
|
359
|
-
if (!bindRes.ok) return bindRes
|
|
360
|
-
|
|
361
|
-
// 4. GitHub auth check (Q653)
|
|
362
|
-
const authRes = checkGitHubAuth(opts)
|
|
363
|
-
if (!authRes.ok) return authRes
|
|
364
|
-
|
|
365
|
-
// 5. Member versions check (Q654)
|
|
366
|
-
let members = opts.members
|
|
367
|
-
if (members === undefined) {
|
|
368
|
-
const connRes = await checkRoomConnection({
|
|
369
|
-
relay: linkRes.relay,
|
|
370
|
-
room: linkRes.room,
|
|
371
|
-
token: opts.token,
|
|
372
|
-
timeoutMs: opts.timeoutMs || 60000,
|
|
373
|
-
})
|
|
374
|
-
if (!connRes.ok) return connRes
|
|
375
|
-
members = connRes.members
|
|
376
|
-
}
|
|
377
|
-
const verRes = checkMemberVersions(members || [], opts.minVersion || MIN_ROOM_MODE_VERSION)
|
|
378
|
-
if (!verRes.ok) return verRes
|
|
379
|
-
|
|
380
|
-
// 6. Watcher lock check (Q655)
|
|
381
|
-
const lockRes = checkWatcherLock(repoRes.repo, linkRes.room, opts)
|
|
382
|
-
if (!lockRes.ok) return lockRes
|
|
383
|
-
|
|
384
|
-
return {
|
|
385
|
-
ok: true,
|
|
386
|
-
repo: repoRes.repo,
|
|
387
|
-
root: repoRes.root,
|
|
388
|
-
branch: bindRes.branch,
|
|
389
|
-
room: linkRes.room,
|
|
390
|
-
relay: linkRes.relay,
|
|
391
|
-
lockFile: lockRes.lockFile,
|
|
392
|
-
release: lockRes.release,
|
|
393
|
-
warn: verRes.warn,
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// ── B2 & B2.1: Issue Posting & Queue Processing ───────────────────────────────────
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Pure queue processor: returns the next issue to process, or null if an issue is currently active
|
|
401
|
-
* or the queue is empty. Never mutates state.
|
|
402
|
-
*/
|
|
403
|
-
export function nextIssue(state = {}) {
|
|
404
|
-
if (!state) return null
|
|
405
|
-
if (state.currentIssue || state.activeIssue) return null
|
|
406
|
-
if (!Array.isArray(state.queue) || state.queue.length === 0) return null
|
|
407
|
-
return state.queue[0]
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Neutralise raw @mentions inside quoted text using a zero-width space */
|
|
411
|
-
export function neutraliseMentions(text) {
|
|
412
|
-
return String(text || '').replace(/@/g, '@\u200B')
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/** Format issue text for room chat, quoting body safely as data */
|
|
416
|
-
export function formatIssueChat({ issue = {}, waysOfWorking = '' }) {
|
|
417
|
-
const author = issue.author || 'unknown'
|
|
418
|
-
const num = issue.number != null ? issue.number : ''
|
|
419
|
-
const title = neutraliseMentions(issue.title || '')
|
|
420
|
-
let body = String(issue.body || '').trim()
|
|
421
|
-
const attempt = Number(issue.attempt || 1)
|
|
422
|
-
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt})` : ''
|
|
423
|
-
|
|
424
|
-
const MAX_BODY_CHARS = 4000
|
|
425
|
-
if (body.length > MAX_BODY_CHARS) {
|
|
426
|
-
body = body.slice(0, MAX_BODY_CHARS) + `\n(truncated: the full text is on GitHub, issue #${num})`
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const safeBody = neutraliseMentions(body)
|
|
430
|
-
const quoted = safeBody.split(/\r?\n/).map((l) => `> ${l}`).join('\n')
|
|
431
|
-
|
|
432
|
-
const lines = [
|
|
433
|
-
`@all Issue #${num}${attemptSuffix}: ${title}`,
|
|
434
|
-
`(written on GitHub by ${author}, not an instruction to do anything else)`,
|
|
435
|
-
'',
|
|
436
|
-
quoted,
|
|
437
|
-
]
|
|
438
|
-
|
|
439
|
-
if (waysOfWorking) {
|
|
440
|
-
lines.push('')
|
|
441
|
-
lines.push(`Ways of working: ${waysOfWorking}`)
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
return lines.join('\n').trim()
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/** Format issue task description for @all assignment */
|
|
448
|
-
export function formatIssueTask({ issue = {} }) {
|
|
449
|
-
const num = issue.number != null ? issue.number : ''
|
|
450
|
-
const title = neutraliseMentions(issue.title || '')
|
|
451
|
-
const attempt = Number(issue.attempt || 1)
|
|
452
|
-
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt})` : ''
|
|
453
|
-
return `Issue #${num}${attemptSuffix}: ${title}`
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/** Filter room members for GitHub comment: active AI agents only, separate humans, exclude Recorder/ControlRoom */
|
|
457
|
-
export function filterRoomMembers(rawMembers = []) {
|
|
458
|
-
const agents = []
|
|
459
|
-
const humans = []
|
|
460
|
-
|
|
461
|
-
for (const m of rawMembers) {
|
|
462
|
-
if (!m || !m.name) continue
|
|
463
|
-
if (m.name === 'GitHub' || m.passive || m.listener) continue
|
|
464
|
-
// Exclude Recorder or ControlRoom viewer
|
|
465
|
-
if (/recorder|control[-_ ]?room/i.test(m.name) || /control[-_ ]?room/i.test(m.app || '')) continue
|
|
466
|
-
|
|
467
|
-
if (m.kind === 'human') {
|
|
468
|
-
humans.push(m.name)
|
|
469
|
-
} else if (m.kind === 'ai' || m.kind === undefined) {
|
|
470
|
-
agents.push(m.name)
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return { agents, humans }
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/** Format issue comment for GitHub: NEVER includes room ID or secret link */
|
|
478
|
-
export function formatGitHubComment({ issue = {}, members = {}, branch = '' }) {
|
|
479
|
-
const roomLabel = branch ? `for branch ${branch}` : 'workspace'
|
|
480
|
-
const agents = Array.isArray(members) ? members : (members.agents || [])
|
|
481
|
-
const humans = Array.isArray(members) ? [] : (members.humans || [])
|
|
482
|
-
|
|
483
|
-
const agentPart = agents.length ? agents.join(', ') : 'none yet'
|
|
484
|
-
const humanPart = humans.length ? `; humans: ${humans.join(', ')}` : ''
|
|
485
|
-
|
|
486
|
-
return `Sent to room ${roomLabel}. In the room now: ${agentPart}${humanPart}`
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** Verify delivery via a fresh passive peek */
|
|
490
|
-
async function verifyDelivery({ relay, room, token, taskId, chatMarker, timeoutMs = 30000, verifierRunner }) {
|
|
491
|
-
if (verifierRunner) {
|
|
492
|
-
return verifierRunner({ relay, room, taskId, chatMarker })
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const vdir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-verify-${crypto.randomBytes(6).toString('hex')}`))
|
|
496
|
-
let peek = null
|
|
497
|
-
try {
|
|
498
|
-
peek = startSync({
|
|
499
|
-
relay,
|
|
500
|
-
room,
|
|
501
|
-
dir: vdir,
|
|
502
|
-
name: 'GitHub-verify',
|
|
503
|
-
kind: 'ai',
|
|
504
|
-
passive: true,
|
|
505
|
-
syncFiles: false,
|
|
506
|
-
token,
|
|
507
|
-
log: () => {},
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
if (peek && peek.provider) {
|
|
511
|
-
await new Promise((res) => {
|
|
512
|
-
const timer = setTimeout(res, 20000)
|
|
513
|
-
if (peek.provider.synced) {
|
|
514
|
-
clearTimeout(timer)
|
|
515
|
-
return res()
|
|
516
|
-
}
|
|
517
|
-
peek.provider.on('sync', (isSynced) => {
|
|
518
|
-
if (isSynced) {
|
|
519
|
-
clearTimeout(timer)
|
|
520
|
-
res()
|
|
521
|
-
}
|
|
522
|
-
})
|
|
523
|
-
})
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const t0 = Date.now()
|
|
527
|
-
while (Date.now() - t0 < timeoutMs) {
|
|
528
|
-
if (peek.doc) {
|
|
529
|
-
const tasks = peek.doc.getMap('tasks')
|
|
530
|
-
const chat = peek.doc.getArray('chat')
|
|
531
|
-
const hasTask = taskId ? tasks.has(taskId) : true
|
|
532
|
-
const hasChat = chatMarker
|
|
533
|
-
? chat.toArray().some((c) => c && c.text && c.text.includes(chatMarker))
|
|
534
|
-
: true
|
|
535
|
-
if (hasTask && hasChat) return true
|
|
536
|
-
}
|
|
537
|
-
await new Promise((r) => setTimeout(r, 200))
|
|
538
|
-
}
|
|
539
|
-
return false
|
|
540
|
-
} catch {
|
|
541
|
-
return false
|
|
542
|
-
} finally {
|
|
543
|
-
if (peek && typeof peek.stop === 'function') {
|
|
544
|
-
try { peek.stop() } catch {}
|
|
545
|
-
}
|
|
546
|
-
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch {}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Post an issue into the room:
|
|
552
|
-
* - Joins as passive participant "GitHub"
|
|
553
|
-
* - Idempotency: checks for existing task by "GitHub" with marker `issue #<n>`
|
|
554
|
-
* - Dispatches exactly ONE @all task
|
|
555
|
-
* - Dispatches exactly ONE chat message with quoted text as data (neutralising @mentions)
|
|
556
|
-
* - Verifies delivery via fresh passive peek (Q657 on failure)
|
|
557
|
-
* - Returns structured error (Q656) if relay is unreachable
|
|
558
|
-
* - Reads members and returns comment for GitHub
|
|
559
|
-
* - Stops and cleans up passive sync session
|
|
560
|
-
*/
|
|
561
|
-
export async function postIssue(opts = {}) {
|
|
562
|
-
const {
|
|
563
|
-
link,
|
|
564
|
-
issue = {},
|
|
565
|
-
waysOfWorking = '',
|
|
566
|
-
branch = '',
|
|
567
|
-
syncRunner,
|
|
568
|
-
verifierRunner,
|
|
569
|
-
token,
|
|
570
|
-
} = opts
|
|
571
|
-
|
|
572
|
-
const parsed = parseLink(link)
|
|
573
|
-
if (!parsed || !parsed.room) {
|
|
574
|
-
return { ok: false, code: 'Q650', why: 'Q650 invalid room link' }
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
let tempDir = null
|
|
578
|
-
let hive = null
|
|
579
|
-
|
|
580
|
-
try {
|
|
581
|
-
if (syncRunner) {
|
|
582
|
-
hive = await syncRunner({
|
|
583
|
-
relay: parsed.relay,
|
|
584
|
-
room: parsed.room,
|
|
585
|
-
name: 'GitHub',
|
|
586
|
-
kind: 'ai',
|
|
587
|
-
passive: true,
|
|
588
|
-
syncFiles: false,
|
|
589
|
-
token: parsed.token || token,
|
|
590
|
-
})
|
|
591
|
-
} else {
|
|
592
|
-
tempDir = path.join(os.tmpdir(), `hive-gh-${crypto.randomBytes(6).toString('hex')}`)
|
|
593
|
-
fs.mkdirSync(tempDir, { recursive: true })
|
|
594
|
-
hive = startSync({
|
|
595
|
-
relay: parsed.relay,
|
|
596
|
-
room: parsed.room,
|
|
597
|
-
dir: tempDir,
|
|
598
|
-
name: 'GitHub',
|
|
599
|
-
kind: 'ai',
|
|
600
|
-
passive: true,
|
|
601
|
-
syncFiles: false,
|
|
602
|
-
token: parsed.token || token,
|
|
603
|
-
log: () => {},
|
|
604
|
-
})
|
|
605
|
-
|
|
606
|
-
// Wait for sync to establish (up to 60s)
|
|
607
|
-
let isSynced = false
|
|
608
|
-
await new Promise((res) => {
|
|
609
|
-
const timer = setTimeout(res, 60000)
|
|
610
|
-
if (hive.provider) {
|
|
611
|
-
if (hive.provider.synced) {
|
|
612
|
-
isSynced = true
|
|
613
|
-
clearTimeout(timer)
|
|
614
|
-
return res()
|
|
615
|
-
}
|
|
616
|
-
hive.provider.on('sync', (synced) => {
|
|
617
|
-
if (synced) {
|
|
618
|
-
isSynced = true
|
|
619
|
-
clearTimeout(timer)
|
|
620
|
-
res()
|
|
621
|
-
}
|
|
622
|
-
})
|
|
623
|
-
} else {
|
|
624
|
-
isSynced = true
|
|
625
|
-
clearTimeout(timer)
|
|
626
|
-
res()
|
|
627
|
-
}
|
|
628
|
-
})
|
|
629
|
-
if (!isSynced) {
|
|
630
|
-
if (hive && typeof hive.stop === 'function') {
|
|
631
|
-
try { hive.stop() } catch {}
|
|
632
|
-
}
|
|
633
|
-
if (tempDir) {
|
|
634
|
-
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
635
|
-
}
|
|
636
|
-
return {
|
|
637
|
-
ok: false,
|
|
638
|
-
code: 'Q656',
|
|
639
|
-
why: 'Q656 could not sync with room within 60s',
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
} catch (err) {
|
|
644
|
-
return {
|
|
645
|
-
ok: false,
|
|
646
|
-
code: 'Q656',
|
|
647
|
-
why: `Q656 could not reach relay behind ${link}: ${err.message}`,
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
// 1. Idempotency: look for existing task by "GitHub" with marker `issue #<n>` and matching attempt
|
|
653
|
-
const issueNum = String(issue.number != null ? issue.number : '')
|
|
654
|
-
const attempt = Number(issue.attempt || 1)
|
|
655
|
-
const exactRegex = new RegExp(`(?:^|[\\s\\[(:])issue\\s*#${issueNum}(?:\\b|[:\\s\\])])`, 'i')
|
|
656
|
-
const attemptRegex = attempt > 1
|
|
657
|
-
? new RegExp(`\\(attempt\\s*${attempt}\\)`, 'i')
|
|
658
|
-
: null
|
|
659
|
-
const isMatch = (text) => {
|
|
660
|
-
const s = String(text || '')
|
|
661
|
-
if (!exactRegex.test(s)) return false
|
|
662
|
-
if (attempt > 1) {
|
|
663
|
-
return attemptRegex.test(s)
|
|
664
|
-
} else {
|
|
665
|
-
return !/\(attempt\s*\d+\)/i.test(s) || /\(attempt\s*1\)/i.test(s)
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
let existingTask = null
|
|
669
|
-
|
|
670
|
-
if (hive.tasks) {
|
|
671
|
-
const entries = hive.tasks instanceof Map ? hive.tasks.values() : Object.values(hive.tasks)
|
|
672
|
-
for (const t of entries) {
|
|
673
|
-
if (t && t.by === 'GitHub' && isMatch(t.text)) {
|
|
674
|
-
existingTask = t
|
|
675
|
-
break
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
} else if (hive.doc) {
|
|
679
|
-
const tasksMap = hive.doc.getMap('tasks')
|
|
680
|
-
for (const t of tasksMap.values()) {
|
|
681
|
-
if (t && t.by === 'GitHub' && isMatch(t.text)) {
|
|
682
|
-
existingTask = t
|
|
683
|
-
break
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
if (existingTask) {
|
|
689
|
-
const rawMembers = hive.members ? hive.members() : []
|
|
690
|
-
const members = filterRoomMembers(rawMembers)
|
|
691
|
-
const comment = formatGitHubComment({ issue, members, branch })
|
|
692
|
-
return {
|
|
693
|
-
ok: true,
|
|
694
|
-
taskId: existingTask.id,
|
|
695
|
-
already: true,
|
|
696
|
-
members: members.agents,
|
|
697
|
-
comment,
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// 2. Assign @all task
|
|
702
|
-
const taskText = formatIssueTask({ issue })
|
|
703
|
-
const taskId = hive.assign ? hive.assign('all', taskText) : null
|
|
704
|
-
|
|
705
|
-
// 3. Post chat message
|
|
706
|
-
const chatText = formatIssueChat({ issue, waysOfWorking })
|
|
707
|
-
if (hive.say) hive.say(chatText)
|
|
708
|
-
|
|
709
|
-
// 4. Verify delivery via fresh passive peek
|
|
710
|
-
const chatMarker = attempt > 1
|
|
711
|
-
? `Issue #${issue.number} (attempt ${attempt})`
|
|
712
|
-
: `Issue #${issue.number}`
|
|
713
|
-
const verified = verifierRunner
|
|
714
|
-
? await verifierRunner({ relay: parsed.relay, room: parsed.room, taskId, chatMarker })
|
|
715
|
-
: await verifyDelivery({ relay: parsed.relay, room: parsed.room, taskId, chatMarker, token: parsed.token || token })
|
|
716
|
-
|
|
717
|
-
if (!verified) {
|
|
718
|
-
return {
|
|
719
|
-
ok: false,
|
|
720
|
-
code: 'Q657',
|
|
721
|
-
why: 'Q657 delivery verification failed: task or chat was not confirmed by the room.',
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// 5. Read members
|
|
726
|
-
const rawMembers = hive.members ? hive.members() : []
|
|
727
|
-
const members = filterRoomMembers(rawMembers)
|
|
728
|
-
|
|
729
|
-
// 6. Generate GitHub comment
|
|
730
|
-
const comment = formatGitHubComment({ issue, members, branch })
|
|
731
|
-
|
|
732
|
-
return {
|
|
733
|
-
ok: true,
|
|
734
|
-
taskId,
|
|
735
|
-
verified: true,
|
|
736
|
-
members: members.agents,
|
|
737
|
-
comment,
|
|
738
|
-
}
|
|
739
|
-
} finally {
|
|
740
|
-
if (hive && typeof hive.stop === 'function') {
|
|
741
|
-
try { hive.stop() } catch {}
|
|
742
|
-
}
|
|
743
|
-
if (tempDir) {
|
|
744
|
-
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// ── B3: Done Detection & Landing ──────────────────────────────────────────────────
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Compute deterministic fingerprint of a plan: file + content hash for each planned file.
|
|
753
|
-
* Prevents retry loop when landing fails on an unchanged room.
|
|
754
|
-
*/
|
|
755
|
-
export function computePlanFingerprint(plan = [], files = [], hive = null, hashes = null) {
|
|
756
|
-
const allFiles = Array.isArray(files) && files.length
|
|
757
|
-
? files
|
|
758
|
-
: (Array.isArray(plan) ? plan.flatMap((g) => g.files || []) : [])
|
|
759
|
-
const sorted = [...new Set(allFiles)].sort()
|
|
760
|
-
const pieces = sorted.map((f) => {
|
|
761
|
-
let h = ''
|
|
762
|
-
if (hashes && typeof hashes === 'object' && hashes[f]) {
|
|
763
|
-
h = hashes[f]
|
|
764
|
-
} else if (hive && hive.headOf) {
|
|
765
|
-
const head = hive.headOf(f)
|
|
766
|
-
if (head && head.hash) h = head.hash
|
|
767
|
-
} else if (hive && hive.reviewStatus) {
|
|
768
|
-
const rs = hive.reviewStatus(f)
|
|
769
|
-
if (rs && rs.hash) h = rs.hash
|
|
770
|
-
}
|
|
771
|
-
return `${f}:${h}`
|
|
772
|
-
})
|
|
773
|
-
return crypto.createHash('sha256').update(pieces.join(';')).digest('hex')
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function readReviewsForFile(hive, relPath) {
|
|
777
|
-
if (hive && typeof hive.reviews === 'function') {
|
|
778
|
-
return hive.reviews(relPath) || []
|
|
779
|
-
}
|
|
780
|
-
if (hive && hive.doc) {
|
|
781
|
-
const reviewsMap = hive.doc.getMap('reviews')
|
|
782
|
-
const entries = []
|
|
783
|
-
for (const [k, r] of reviewsMap.entries()) {
|
|
784
|
-
if (r && !Array.isArray(r) && (r.file === relPath || k.startsWith(relPath + '::'))) {
|
|
785
|
-
entries.push(r)
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
if (entries.length) return entries.sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
789
|
-
const raw = reviewsMap.get(relPath)
|
|
790
|
-
return Array.isArray(raw) ? raw : []
|
|
791
|
-
}
|
|
792
|
-
return []
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function evaluateFileReview(hive, rel, plan = [], hashes = null) {
|
|
796
|
-
// When dry landAndOpen returned authoritative room content hashes (res.hashes), compare reviews
|
|
797
|
-
// directly against hashes[rel] on the main doc so a fresh passive client with syncFiles:false
|
|
798
|
-
// never reads empty per-file subdocs as contentHash('').
|
|
799
|
-
if (hashes && typeof hashes === 'object' && hashes[rel]) {
|
|
800
|
-
const wantHash = hashes[rel]
|
|
801
|
-
const contributors = new Set(
|
|
802
|
-
(plan || [])
|
|
803
|
-
.filter((g) => g && Array.isArray(g.files) && g.files.includes(rel))
|
|
804
|
-
.map((g) => g.agent)
|
|
805
|
-
.filter(Boolean)
|
|
806
|
-
)
|
|
807
|
-
const allReviews = readReviewsForFile(hive, rel).filter((r) => r && !contributors.has(r.by))
|
|
808
|
-
const matching = allReviews.filter((r) => r.hash === wantHash)
|
|
809
|
-
if (!matching.length) {
|
|
810
|
-
const lastPrior = allReviews.length ? allReviews[allReviews.length - 1] : null
|
|
811
|
-
return {
|
|
812
|
-
verdict: 'unreviewed',
|
|
813
|
-
hash: wantHash,
|
|
814
|
-
stale: allReviews.length > 0,
|
|
815
|
-
priorFail: Boolean(lastPrior && lastPrior.verdict === 'fail'),
|
|
816
|
-
reviews: allReviews,
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
let lastFail = -1
|
|
820
|
-
let lastPass = -1
|
|
821
|
-
for (let i = 0; i < matching.length; i++) {
|
|
822
|
-
if (matching[i].verdict === 'fail') lastFail = i
|
|
823
|
-
if (matching[i].verdict === 'pass') lastPass = i
|
|
824
|
-
}
|
|
825
|
-
return {
|
|
826
|
-
verdict: lastPass > lastFail ? 'pass' : 'fail',
|
|
827
|
-
hash: wantHash,
|
|
828
|
-
stale: false,
|
|
829
|
-
priorFail: false,
|
|
830
|
-
reviews: matching,
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const revStatus = hive && hive.reviewStatus
|
|
835
|
-
? hive.reviewStatus(rel)
|
|
836
|
-
: { verdict: 'unreviewed' }
|
|
837
|
-
const priorList = Array.isArray(revStatus.reviews) ? revStatus.reviews : []
|
|
838
|
-
const lastPrior = priorList.length ? priorList[priorList.length - 1] : null
|
|
839
|
-
return {
|
|
840
|
-
...revStatus,
|
|
841
|
-
priorFail: Boolean(revStatus.verdict === 'unreviewed' && revStatus.stale && lastPrior && lastPrior.verdict === 'fail'),
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
/**
|
|
846
|
-
* B3: Done Detection
|
|
847
|
-
* Evaluates whether an issue task in the room is ready for landing.
|
|
848
|
-
* Ready only when ALL of the following hold:
|
|
849
|
-
* 1. The issue task is done (status === 'done').
|
|
850
|
-
* 2. No live claims are currently held.
|
|
851
|
-
* 3. Every file that landing would change has an explicit PASS review at its current hash
|
|
852
|
-
* (unless allowUnreviewed is true and no file has a fail or stale-after-fail review).
|
|
853
|
-
* 4. No unresolved FAIL review exists on any planned file.
|
|
854
|
-
*/
|
|
855
|
-
export async function whenDone(opts = {}) {
|
|
856
|
-
const {
|
|
857
|
-
link,
|
|
858
|
-
taskId,
|
|
859
|
-
root,
|
|
860
|
-
branch,
|
|
861
|
-
syncRunner,
|
|
862
|
-
landRunner,
|
|
863
|
-
allowUnreviewed = false,
|
|
864
|
-
token,
|
|
865
|
-
} = opts
|
|
866
|
-
|
|
867
|
-
let hive = null
|
|
868
|
-
let tempDir = null
|
|
869
|
-
let ownedHive = false
|
|
870
|
-
|
|
871
|
-
try {
|
|
872
|
-
if (syncRunner) {
|
|
873
|
-
hive = await syncRunner()
|
|
874
|
-
} else {
|
|
875
|
-
const parsed = parseLink(link)
|
|
876
|
-
if (!parsed || !parsed.room) {
|
|
877
|
-
return { ready: false, why: 'invalid room link', reasons: ['invalid room link'] }
|
|
878
|
-
}
|
|
879
|
-
tempDir = path.join(os.tmpdir(), `hive-done-${crypto.randomBytes(6).toString('hex')}`)
|
|
880
|
-
fs.mkdirSync(tempDir, { recursive: true })
|
|
881
|
-
hive = startSync({
|
|
882
|
-
relay: parsed.relay,
|
|
883
|
-
room: parsed.room,
|
|
884
|
-
dir: tempDir,
|
|
885
|
-
name: 'GitHub-done',
|
|
886
|
-
kind: 'ai',
|
|
887
|
-
passive: true,
|
|
888
|
-
syncFiles: false,
|
|
889
|
-
token: parsed.token || token,
|
|
890
|
-
log: () => {},
|
|
891
|
-
})
|
|
892
|
-
ownedHive = true
|
|
893
|
-
// Wait for sync to establish
|
|
894
|
-
await new Promise((res) => {
|
|
895
|
-
const timer = setTimeout(res, 15000)
|
|
896
|
-
if (hive.provider) {
|
|
897
|
-
if (hive.provider.synced) {
|
|
898
|
-
clearTimeout(timer)
|
|
899
|
-
return res()
|
|
900
|
-
}
|
|
901
|
-
hive.provider.on('sync', (synced) => {
|
|
902
|
-
if (synced) {
|
|
903
|
-
clearTimeout(timer)
|
|
904
|
-
res()
|
|
905
|
-
}
|
|
906
|
-
})
|
|
907
|
-
} else {
|
|
908
|
-
res()
|
|
909
|
-
}
|
|
910
|
-
})
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// 1. Task check
|
|
914
|
-
let task = null
|
|
915
|
-
if (hive.tasks) {
|
|
916
|
-
task = hive.tasks instanceof Map ? hive.tasks.get(taskId) : hive.tasks[taskId]
|
|
917
|
-
} else if (hive.doc) {
|
|
918
|
-
const tasksMap = hive.doc.getMap('tasks')
|
|
919
|
-
task = tasksMap.get(taskId)
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
if (!task) {
|
|
923
|
-
return { ready: false, why: `task ${taskId} not found in room`, reasons: [`task ${taskId} not found`] }
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
if (task.status !== 'done') {
|
|
927
|
-
return {
|
|
928
|
-
ready: false,
|
|
929
|
-
why: `task ${taskId} is not done (status: ${task.status})`,
|
|
930
|
-
reasons: [`task is ${task.status}`],
|
|
931
|
-
task,
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// 2. Claims check
|
|
936
|
-
let activeClaims = []
|
|
937
|
-
if (hive.claimsBoard) {
|
|
938
|
-
activeClaims = hive.claimsBoard()
|
|
939
|
-
} else if (hive.doc) {
|
|
940
|
-
const claimsMap = hive.doc.getMap('claims')
|
|
941
|
-
const now = Date.now()
|
|
942
|
-
for (const [reg, c] of claimsMap.entries()) {
|
|
943
|
-
if (c && (!c.at || (now - c.at) < (c.ttl || 300000))) {
|
|
944
|
-
activeClaims.push({ region: reg, ...c })
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
if (activeClaims.length > 0) {
|
|
950
|
-
const claimDesc = activeClaims.map((c) => `@${c.by} holds ${c.region || c.file}`).join(', ')
|
|
951
|
-
return {
|
|
952
|
-
ready: false,
|
|
953
|
-
why: `active claim(s) still held: ${claimDesc}`,
|
|
954
|
-
reasons: activeClaims.map((c) => `claim held on ${c.region || c.file} by @${c.by}`),
|
|
955
|
-
task,
|
|
956
|
-
claims: activeClaims,
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// 3. Plan acquisition via dry landAndOpen (Claude addition b)
|
|
961
|
-
const landFn = landRunner || landAndOpen
|
|
962
|
-
let dryRes = null
|
|
963
|
-
try {
|
|
964
|
-
dryRes = await landFn({ cwd: root, branch, dry: true })
|
|
965
|
-
} catch (err) {
|
|
966
|
-
return {
|
|
967
|
-
ready: false,
|
|
968
|
-
why: `dry landing failed to compute plan: ${err.message}`,
|
|
969
|
-
reasons: [err.message],
|
|
970
|
-
task,
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (!dryRes || dryRes.ok === false) {
|
|
975
|
-
const why = dryRes ? dryRes.why : 'dry landing returned false'
|
|
976
|
-
return {
|
|
977
|
-
ready: false,
|
|
978
|
-
why: `dry landing check refused: ${why}`,
|
|
979
|
-
reasons: [why],
|
|
980
|
-
task,
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
const plannedFiles = (dryRes.plan || []).flatMap((g) => g.files || [])
|
|
985
|
-
const deletions = dryRes.deletions || []
|
|
986
|
-
const hashes = dryRes.hashes || null
|
|
987
|
-
|
|
988
|
-
// 4. Review status check for every planned file
|
|
989
|
-
const reasons = []
|
|
990
|
-
const unreviewedFiles = []
|
|
991
|
-
let hasHardBlock = false
|
|
992
|
-
|
|
993
|
-
for (const rel of plannedFiles) {
|
|
994
|
-
const revStatus = evaluateFileReview(hive, rel, dryRes.plan, hashes)
|
|
995
|
-
|
|
996
|
-
if (revStatus.verdict === 'unreviewed') {
|
|
997
|
-
if (revStatus.priorFail) {
|
|
998
|
-
hasHardBlock = true
|
|
999
|
-
reasons.push(`${rel} was modified after a fail review and must be re-reviewed`)
|
|
1000
|
-
} else if (revStatus.stale) {
|
|
1001
|
-
unreviewedFiles.push(rel)
|
|
1002
|
-
if (!allowUnreviewed) reasons.push(`${rel} was modified after review (stale review)`)
|
|
1003
|
-
} else {
|
|
1004
|
-
unreviewedFiles.push(rel)
|
|
1005
|
-
if (!allowUnreviewed) reasons.push(`${rel} has not been reviewed`)
|
|
1006
|
-
}
|
|
1007
|
-
} else if (revStatus.verdict === 'fail') {
|
|
1008
|
-
hasHardBlock = true
|
|
1009
|
-
reasons.push(`${rel} has an unresolved fail review`)
|
|
1010
|
-
} else if (revStatus.verdict !== 'pass') {
|
|
1011
|
-
hasHardBlock = true
|
|
1012
|
-
reasons.push(`${rel} review verdict is not pass (${revStatus.verdict})`)
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
if (hasHardBlock && allowUnreviewed && unreviewedFiles.length > 0) {
|
|
1017
|
-
for (const rel of unreviewedFiles) {
|
|
1018
|
-
reasons.push(`${rel} has not been reviewed`)
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
if (reasons.length > 0) {
|
|
1023
|
-
const onlyUnreviewed = !hasHardBlock && unreviewedFiles.length > 0
|
|
1024
|
-
return {
|
|
1025
|
-
ready: false,
|
|
1026
|
-
...(onlyUnreviewed ? { blocked: 'unreviewed', unreviewedFiles } : {}),
|
|
1027
|
-
why: reasons.join('; '),
|
|
1028
|
-
reasons,
|
|
1029
|
-
files: plannedFiles,
|
|
1030
|
-
deletions,
|
|
1031
|
-
task,
|
|
1032
|
-
plan: dryRes.plan,
|
|
1033
|
-
hashes,
|
|
1034
|
-
hive,
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
return {
|
|
1039
|
-
ready: true,
|
|
1040
|
-
unreviewedFiles,
|
|
1041
|
-
files: plannedFiles,
|
|
1042
|
-
deletions,
|
|
1043
|
-
task,
|
|
1044
|
-
plan: dryRes.plan,
|
|
1045
|
-
hashes,
|
|
1046
|
-
hive,
|
|
1047
|
-
}
|
|
1048
|
-
} finally {
|
|
1049
|
-
if (ownedHive && hive && typeof hive.stop === 'function') {
|
|
1050
|
-
try { hive.stop() } catch {}
|
|
1051
|
-
}
|
|
1052
|
-
if (tempDir) {
|
|
1053
|
-
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* B3: Landing execution
|
|
1060
|
-
* When whenDone is ready, invokes landAndOpen from hive-land.js.
|
|
1061
|
-
* Handles test failure (Q576) by assigning a fix task back to the room.
|
|
1062
|
-
* Implements plan fingerprint caching to avoid retry loops on failing tests.
|
|
1063
|
-
*/
|
|
1064
|
-
export async function landIssue(opts = {}) {
|
|
1065
|
-
const {
|
|
1066
|
-
link,
|
|
1067
|
-
taskId,
|
|
1068
|
-
issue = {},
|
|
1069
|
-
root,
|
|
1070
|
-
branch,
|
|
1071
|
-
syncRunner,
|
|
1072
|
-
landRunner,
|
|
1073
|
-
allowUnreviewed = false,
|
|
1074
|
-
state = {},
|
|
1075
|
-
token,
|
|
1076
|
-
} = opts
|
|
1077
|
-
|
|
1078
|
-
// 1. Check readiness
|
|
1079
|
-
const wd = await whenDone({
|
|
1080
|
-
link,
|
|
1081
|
-
taskId,
|
|
1082
|
-
root,
|
|
1083
|
-
branch,
|
|
1084
|
-
syncRunner,
|
|
1085
|
-
landRunner,
|
|
1086
|
-
allowUnreviewed,
|
|
1087
|
-
token,
|
|
1088
|
-
})
|
|
1089
|
-
|
|
1090
|
-
if (!wd.ready) {
|
|
1091
|
-
return {
|
|
1092
|
-
ok: false,
|
|
1093
|
-
...(wd.blocked === 'unreviewed' ? { blocked: 'unreviewed', files: wd.unreviewedFiles || wd.files || [] } : {}),
|
|
1094
|
-
why: `Not ready to land: ${wd.why}`,
|
|
1095
|
-
reasons: wd.reasons,
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// 2. Plan fingerprint (Claude addition a: NO RETRY LOOP)
|
|
1100
|
-
const hive = wd.hive
|
|
1101
|
-
const fingerprint = computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)
|
|
1102
|
-
|
|
1103
|
-
if (state.lastFailedFingerprint === fingerprint) {
|
|
1104
|
-
return {
|
|
1105
|
-
ok: false,
|
|
1106
|
-
skipped: true,
|
|
1107
|
-
why: 'No changes since last failed landing; skipping test re-run until room changes',
|
|
1108
|
-
fingerprint,
|
|
1109
|
-
state,
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// 3. Prepare parameters
|
|
1114
|
-
const testCmd = (typeof testCommandFor === 'function')
|
|
1115
|
-
? testCommandFor(root, branch).command
|
|
1116
|
-
: ''
|
|
1117
|
-
const issueNum = issue.number != null ? issue.number : ''
|
|
1118
|
-
const issueTitle = issue.title || (wd.task ? wd.task.text : '') || 'Issue'
|
|
1119
|
-
const title = issueNum ? `${issueTitle} (#${issueNum})` : issueTitle
|
|
1120
|
-
|
|
1121
|
-
let summary = issueTitle
|
|
1122
|
-
if (wd.task && wd.task.note) {
|
|
1123
|
-
summary += '\n\n' + wd.task.note
|
|
1124
|
-
}
|
|
1125
|
-
if (wd.deletions && wd.deletions.length > 0) {
|
|
1126
|
-
summary += '\n\nDeletions:\n' + wd.deletions.map((d) => `- ${d}`).join('\n')
|
|
1127
|
-
}
|
|
1128
|
-
if (wd.unreviewedFiles && wd.unreviewedFiles.length > 0) {
|
|
1129
|
-
summary += `\n\nNot reviewed by a second agent: ${wd.unreviewedFiles.join(', ')}`
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const landFn = landRunner || landAndOpen
|
|
1133
|
-
|
|
1134
|
-
const landRes = await landFn({
|
|
1135
|
-
cwd: root,
|
|
1136
|
-
branch,
|
|
1137
|
-
dry: false,
|
|
1138
|
-
pr: true,
|
|
1139
|
-
test: testCmd,
|
|
1140
|
-
by: 'Hivecode room mode',
|
|
1141
|
-
title,
|
|
1142
|
-
summary,
|
|
1143
|
-
})
|
|
1144
|
-
|
|
1145
|
-
if (!landRes.ok) {
|
|
1146
|
-
const nextState = { ...state, lastFailedFingerprint: fingerprint }
|
|
1147
|
-
|
|
1148
|
-
if (landRes.code === 'Q576') {
|
|
1149
|
-
// Tests failed: post fix task back to the room (never a GitHub comment)
|
|
1150
|
-
if (hive && hive.assign) {
|
|
1151
|
-
hive.assign('all', `Fix failing tests on issue #${issueNum}: ${landRes.why}`)
|
|
1152
|
-
}
|
|
1153
|
-
if (hive && hive.say) {
|
|
1154
|
-
hive.say(`⚠ Tests failed on landing issue #${issueNum} (Q576): ${landRes.why}. Assigned fix task to room.`)
|
|
1155
|
-
}
|
|
1156
|
-
return {
|
|
1157
|
-
ok: false,
|
|
1158
|
-
code: 'Q576',
|
|
1159
|
-
why: landRes.why,
|
|
1160
|
-
fingerprint,
|
|
1161
|
-
state: nextState,
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
return {
|
|
1166
|
-
ok: false,
|
|
1167
|
-
code: landRes.code,
|
|
1168
|
-
why: landRes.why,
|
|
1169
|
-
fingerprint,
|
|
1170
|
-
state: nextState,
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
const nextState = { ...state }
|
|
1175
|
-
delete nextState.lastFailedFingerprint
|
|
1176
|
-
|
|
1177
|
-
return {
|
|
1178
|
-
ok: true,
|
|
1179
|
-
pr: landRes.pr,
|
|
1180
|
-
tip: landRes.tip,
|
|
1181
|
-
plan: landRes.plan,
|
|
1182
|
-
opts: { title, summary, test: testCmd },
|
|
1183
|
-
fingerprint,
|
|
1184
|
-
state: nextState,
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// ── B4: PR Review Comments & /hivecode <text> Comments in Room Mode ───────────────
|
|
1189
|
-
|
|
1190
|
-
const PR_PUSHERS = new Set(['admin', 'maintain', 'write'])
|
|
1191
|
-
const PR_ASSOC = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
|
|
1192
|
-
const PR_COMMENT_MAX = 4000
|
|
1193
|
-
const isBotUser = (u) => !u || u.type === 'Bot' || /\[bot\]$/i.test(String(u.login || ''))
|
|
1194
|
-
const isOursComment = (body) => /<!-- hivecode:[a-z-]+/.test(String(body || ''))
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* Resolve the git commit author of a commented line on a PR.
|
|
1198
|
-
* Blames against the review comment's commit_id / original_commit_id (or PR head sha) so line numbers
|
|
1199
|
-
* match the exact commit the reviewer looked at even if the local branch has moved ahead.
|
|
1200
|
-
*/
|
|
1201
|
-
export function blamePrComment(root, ref, filePath, line, { branch = '' } = {}) {
|
|
1202
|
-
const ln = Number(line)
|
|
1203
|
-
const rel = String(filePath || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
1204
|
-
if (!root || !rel || !Number.isInteger(ln) || ln <= 0) return null
|
|
1205
|
-
|
|
1206
|
-
const candidates = [ref, branch].map((x) => String(x || '').trim()).filter(Boolean)
|
|
1207
|
-
if (candidates.length === 0) return null
|
|
1208
|
-
|
|
1209
|
-
for (const targetRef of candidates) {
|
|
1210
|
-
const r = spawnSync('git', ['blame', '--porcelain', '-L', `${ln},${ln}`, targetRef, '--', rel], {
|
|
1211
|
-
cwd: path.resolve(root),
|
|
1212
|
-
encoding: 'utf8',
|
|
1213
|
-
windowsHide: true,
|
|
1214
|
-
})
|
|
1215
|
-
if (r.status !== 0 || !r.stdout) continue
|
|
1216
|
-
const firstLine = r.stdout.split(/\r?\n/)[0] || ''
|
|
1217
|
-
if (/^0{40}\b/.test(firstLine)) return null
|
|
1218
|
-
const m = r.stdout.match(/^author (.+)$/m)
|
|
1219
|
-
if (!m) return null
|
|
1220
|
-
const author = m[1].trim()
|
|
1221
|
-
if (!author || author === 'Not Committed Yet') return null
|
|
1222
|
-
return author
|
|
1223
|
-
}
|
|
1224
|
-
return null
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
function askOfPrComment(key, c, state = {}) {
|
|
1228
|
-
const body = String((c && c.body) || '').trim()
|
|
1229
|
-
if (key === 'rc') return body
|
|
1230
|
-
if (key === 'rv') {
|
|
1231
|
-
return (c.state === 'CHANGES_REQUESTED' || c.state === 'COMMENTED') ? body : ''
|
|
1232
|
-
}
|
|
1233
|
-
const m = body.match(/^\/hivecode\b\s*([\s\S]*)$/i)
|
|
1234
|
-
if (m) return /^(stop|retry)\b/i.test(m[1].trim()) ? '' : m[1].trim()
|
|
1235
|
-
return key === 'ic' && state.question ? body : ''
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
/**
|
|
1239
|
-
* Poll and batch PR review comments, reviews, and /hivecode <text> comments on the PR/issue.
|
|
1240
|
-
* INTERNAL HELPER: do not persist its returned `state` directly before room delivery succeeds;
|
|
1241
|
-
* external callers (like the watcher) should call `postPrFeedback`, which only advances `seen`
|
|
1242
|
-
* after `verifyDelivery` confirms the task and chat reached the room.
|
|
1243
|
-
* - Ignores untrusted users (non-pushers, bots) and watcher's own <!-- hivecode:... --> comments.
|
|
1244
|
-
* - Ignores ambient conversation without `/hivecode <text>` and APPROVED / PENDING reviews.
|
|
1245
|
-
* - Waits `quietMs` so a multi-comment review settles into ONE batch.
|
|
1246
|
-
* - Includes comment IDs (`rc:<id>`, `rv:<id>`, `pc:<id>`, `ic:<id>`) in the batch signature.
|
|
1247
|
-
*/
|
|
1248
|
-
export function collectPrFeedback(opts = {}) {
|
|
1249
|
-
const {
|
|
1250
|
-
gh,
|
|
1251
|
-
issueNumber,
|
|
1252
|
-
prNumber,
|
|
1253
|
-
state = {},
|
|
1254
|
-
root = '',
|
|
1255
|
-
branch = '',
|
|
1256
|
-
headSha = '',
|
|
1257
|
-
quietMs = 90000,
|
|
1258
|
-
now = () => Date.now(),
|
|
1259
|
-
} = opts
|
|
1260
|
-
|
|
1261
|
-
const seen = { ic: 0, pc: 0, rc: 0, rv: 0, ...(state.seen || {}) }
|
|
1262
|
-
let cmdSeen = Array.isArray(state.cmdSeen) ? [...state.cmdSeen] : []
|
|
1263
|
-
const perms = new Map(Object.entries(state.perms || {}))
|
|
1264
|
-
|
|
1265
|
-
const canPush = (login) => {
|
|
1266
|
-
if (!login || !gh || typeof gh.permission !== 'function') return false
|
|
1267
|
-
const cached = perms.get(login)
|
|
1268
|
-
if (cached && now() - cached.at < 600000) return PR_PUSHERS.has(cached.perm)
|
|
1269
|
-
const perm = gh.permission(login)
|
|
1270
|
-
perms.set(login, { perm, at: now() })
|
|
1271
|
-
return PR_PUSHERS.has(perm)
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const trusted = (item) =>
|
|
1275
|
-
Boolean(
|
|
1276
|
-
item &&
|
|
1277
|
-
!isBotUser(item.user) &&
|
|
1278
|
-
PR_ASSOC.has(String(item.author_association || '').toUpperCase()) &&
|
|
1279
|
-
canPush(item.user.login)
|
|
1280
|
-
)
|
|
1281
|
-
|
|
1282
|
-
let resolvedHeadSha = headSha
|
|
1283
|
-
if (!resolvedHeadSha && prNumber && gh && typeof gh.pull === 'function') {
|
|
1284
|
-
try {
|
|
1285
|
-
const prObj = gh.pull(prNumber)
|
|
1286
|
-
if (prObj && prObj.head && prObj.head.sha) resolvedHeadSha = prObj.head.sha
|
|
1287
|
-
} catch {}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
const streams = []
|
|
1291
|
-
if (prNumber) {
|
|
1292
|
-
streams.push(
|
|
1293
|
-
['rc', () => (gh && gh.reviewComments ? gh.reviewComments(prNumber) : []), 'review comment'],
|
|
1294
|
-
['rv', () => (gh && gh.reviews ? gh.reviews(prNumber) : []), 'review'],
|
|
1295
|
-
['pc', () => (gh && gh.comments ? gh.comments(prNumber) : []), 'comment on the pull request']
|
|
1296
|
-
)
|
|
1297
|
-
}
|
|
1298
|
-
if (issueNumber) {
|
|
1299
|
-
streams.push(['ic', () => (gh && gh.comments ? gh.comments(issueNumber) : []), 'comment on the issue'])
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
const fresh = []
|
|
1303
|
-
for (const [key, read, where] of streams) {
|
|
1304
|
-
const list = read() || []
|
|
1305
|
-
for (const c of list) {
|
|
1306
|
-
if (!c || !(c.id > (seen[key] || 0)) || isOursComment(c.body)) continue
|
|
1307
|
-
if (key === 'rv' && c.state === 'PENDING') continue
|
|
1308
|
-
fresh.push({
|
|
1309
|
-
key,
|
|
1310
|
-
id: c.id,
|
|
1311
|
-
c,
|
|
1312
|
-
where: key === 'rv' ? `${where}, ${String(c.state || '').toLowerCase().replace(/_/g, ' ')}` : where,
|
|
1313
|
-
})
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
const nextSeen = { ...seen }
|
|
1318
|
-
for (const f of fresh) {
|
|
1319
|
-
nextSeen[f.key] = Math.max(nextSeen[f.key] || 0, f.id)
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
const result = { stop: false, retry: false, items: [] }
|
|
1323
|
-
const isCommand = (f) =>
|
|
1324
|
-
(f.key === 'ic' || f.key === 'pc') &&
|
|
1325
|
-
String((f.c && f.c.body) || '').trim().match(/^\/hivecode\s+(stop|retry)\b/i)
|
|
1326
|
-
|
|
1327
|
-
for (const f of fresh) {
|
|
1328
|
-
const cmd = isCommand(f)
|
|
1329
|
-
if (!cmd || cmdSeen.includes(`${f.key}:${f.id}`) || !trusted(f.c)) continue
|
|
1330
|
-
cmdSeen = [...cmdSeen.slice(-40), `${f.key}:${f.id}`]
|
|
1331
|
-
result[cmd[1].toLowerCase()] = true
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
const actionable = fresh.filter((f) => !isCommand(f) && trusted(f.c) && askOfPrComment(f.key, f.c, state))
|
|
1335
|
-
const serializedPerms = Object.fromEntries(perms.entries())
|
|
1336
|
-
|
|
1337
|
-
if (actionable.length === 0) {
|
|
1338
|
-
return {
|
|
1339
|
-
settled: true,
|
|
1340
|
-
stop: result.stop,
|
|
1341
|
-
retry: result.retry,
|
|
1342
|
-
items: [],
|
|
1343
|
-
sig: '',
|
|
1344
|
-
nextSeen,
|
|
1345
|
-
state: { ...state, seen: nextSeen, cmdSeen, settle: null, perms: serializedPerms },
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
const sig = actionable.map((f) => `${f.key}:${f.id}`).join(',')
|
|
1350
|
-
|
|
1351
|
-
if (quietMs > 0) {
|
|
1352
|
-
if (!state.settle || state.settle.sig !== sig) {
|
|
1353
|
-
return {
|
|
1354
|
-
settled: false,
|
|
1355
|
-
stop: result.stop,
|
|
1356
|
-
retry: result.retry,
|
|
1357
|
-
items: [],
|
|
1358
|
-
sig,
|
|
1359
|
-
nextSeen,
|
|
1360
|
-
state: { ...state, seen, cmdSeen, settle: { sig, at: now() }, perms: serializedPerms },
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
if (now() - state.settle.at < quietMs) {
|
|
1364
|
-
return {
|
|
1365
|
-
settled: false,
|
|
1366
|
-
stop: result.stop,
|
|
1367
|
-
retry: result.retry,
|
|
1368
|
-
items: [],
|
|
1369
|
-
sig,
|
|
1370
|
-
nextSeen,
|
|
1371
|
-
state: { ...state, seen, cmdSeen, settle: state.settle, perms: serializedPerms },
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
const items = actionable.map((f) => {
|
|
1377
|
-
const rawBody = askOfPrComment(f.key, f.c, state)
|
|
1378
|
-
const body = rawBody.length > PR_COMMENT_MAX ? `${rawBody.slice(0, PR_COMMENT_MAX)}\n(cut)` : rawBody
|
|
1379
|
-
const filePath = f.c.path || ''
|
|
1380
|
-
// Pair (line, commit_id) on current comments vs (original_line, original_commit_id) on outdated comments
|
|
1381
|
-
// where GitHub sets line=null and commit_id to the new PR head sha.
|
|
1382
|
-
const [line, commitId] = f.c.line
|
|
1383
|
-
? [f.c.line, f.c.commit_id || f.c.original_commit_id || resolvedHeadSha || '']
|
|
1384
|
-
: [f.c.original_line || 0, f.c.original_commit_id || f.c.commit_id || resolvedHeadSha || '']
|
|
1385
|
-
const lineAuthor = (f.key === 'rc' && root && filePath && line > 0)
|
|
1386
|
-
? blamePrComment(root, commitId || branch, filePath, line, { branch })
|
|
1387
|
-
: null
|
|
1388
|
-
|
|
1389
|
-
return {
|
|
1390
|
-
id: f.id,
|
|
1391
|
-
key: f.key,
|
|
1392
|
-
commentKey: `${f.key}:${f.id}`,
|
|
1393
|
-
author: (f.c.user && f.c.user.login) || 'unknown',
|
|
1394
|
-
where: f.where,
|
|
1395
|
-
path: filePath,
|
|
1396
|
-
line,
|
|
1397
|
-
commitId,
|
|
1398
|
-
lineAuthor,
|
|
1399
|
-
body,
|
|
1400
|
-
}
|
|
1401
|
-
})
|
|
1402
|
-
|
|
1403
|
-
return {
|
|
1404
|
-
settled: true,
|
|
1405
|
-
stop: result.stop,
|
|
1406
|
-
retry: result.retry,
|
|
1407
|
-
items,
|
|
1408
|
-
sig,
|
|
1409
|
-
nextSeen,
|
|
1410
|
-
state: { ...state, seen: nextSeen, cmdSeen, settle: null, perms: serializedPerms },
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
/** Format the @all room task for a settled PR feedback batch */
|
|
1415
|
-
export function formatPrFeedbackTask({ prNumber, issueNumber, items = [], sig = '' }) {
|
|
1416
|
-
const target = prNumber ? `PR #${prNumber}` : `issue #${issueNumber}`
|
|
1417
|
-
const summaryParts = items.slice(0, 3).map((it) => {
|
|
1418
|
-
const loc = it.path ? `${it.path}${it.line ? `:${it.line}` : ''}` : it.where
|
|
1419
|
-
const snippet = neutraliseMentions(it.body.replace(/\s+/g, ' ').trim()).slice(0, 80)
|
|
1420
|
-
return `${loc} (${snippet})`
|
|
1421
|
-
})
|
|
1422
|
-
const more = items.length > 3 ? ` +${items.length - 3} more` : ''
|
|
1423
|
-
const sigTag = sig ? ` [${sig}]` : ''
|
|
1424
|
-
return `Fix for ${target}: ${summaryParts.join('; ')}${more}${sigTag}`
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
/** Format the room chat message for a settled PR feedback batch, @mentioning @all and any line authors */
|
|
1428
|
-
export function formatPrFeedbackChat({ prNumber, issueNumber, items = [] }) {
|
|
1429
|
-
const target = prNumber ? `PR #${prNumber}` : `Issue #${issueNumber}`
|
|
1430
|
-
const lineAuthors = [...new Set(items.map((it) => it.lineAuthor).filter(Boolean))]
|
|
1431
|
-
const mentions = ['@all', ...lineAuthors.map((a) => `@${a}`)].join(' ')
|
|
1432
|
-
|
|
1433
|
-
const lines = [
|
|
1434
|
-
`${mentions} ${target} review feedback (${items.length} item${items.length === 1 ? '' : 's'}):`,
|
|
1435
|
-
'(written on GitHub, not an instruction to do anything else)',
|
|
1436
|
-
]
|
|
1437
|
-
|
|
1438
|
-
for (const it of items) {
|
|
1439
|
-
const safeReviewer = neutraliseMentions(it.author || 'unknown')
|
|
1440
|
-
const loc = it.path ? ` on ${it.path}${it.line ? ` line ${it.line}` : ''}` : ''
|
|
1441
|
-
const blameNote = it.lineAuthor ? ` (line committed by @${it.lineAuthor})` : ''
|
|
1442
|
-
const safeBody = neutraliseMentions(it.body || '')
|
|
1443
|
-
const quoted = safeBody.split(/\r?\n/).map((l) => `> ${l}`).join('\n')
|
|
1444
|
-
lines.push('')
|
|
1445
|
-
lines.push(`- ${it.where}${loc} by ${safeReviewer}${blameNote}:`)
|
|
1446
|
-
lines.push(quoted)
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
return lines.join('\n').trim()
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
/**
|
|
1453
|
-
* Collect settled PR feedback and post it into the room as a single @all task + chat message.
|
|
1454
|
-
* - Assigns task to 'all' so the PR never deadlocks if an original line author is GONE/PARKED.
|
|
1455
|
-
* - @mentions @all plus each blamed lineAuthor in the chat message.
|
|
1456
|
-
* - Neutralises @mentions inside reviewer comment bodies.
|
|
1457
|
-
* - Only advances `state.seen` after `verifyDelivery` confirms the task & chat reached the relay.
|
|
1458
|
-
*/
|
|
1459
|
-
export async function postPrFeedback(opts = {}) {
|
|
1460
|
-
const {
|
|
1461
|
-
link,
|
|
1462
|
-
gh,
|
|
1463
|
-
issueNumber,
|
|
1464
|
-
prNumber,
|
|
1465
|
-
root = '',
|
|
1466
|
-
branch = '',
|
|
1467
|
-
headSha = '',
|
|
1468
|
-
quietMs = 90000,
|
|
1469
|
-
now = () => Date.now(),
|
|
1470
|
-
state = {},
|
|
1471
|
-
syncRunner,
|
|
1472
|
-
verifierRunner,
|
|
1473
|
-
token,
|
|
1474
|
-
} = opts
|
|
1475
|
-
|
|
1476
|
-
const parsed = parseLink(link)
|
|
1477
|
-
if (!parsed || !parsed.room) {
|
|
1478
|
-
return { ok: false, code: 'Q650', why: 'Q650 invalid room link', state }
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
const preSeen = { ic: 0, pc: 0, rc: 0, rv: 0, ...(state.seen || {}) }
|
|
1482
|
-
const collected = opts.feedback || collectPrFeedback({
|
|
1483
|
-
gh,
|
|
1484
|
-
issueNumber,
|
|
1485
|
-
prNumber,
|
|
1486
|
-
state,
|
|
1487
|
-
root,
|
|
1488
|
-
branch,
|
|
1489
|
-
headSha,
|
|
1490
|
-
quietMs,
|
|
1491
|
-
now,
|
|
1492
|
-
})
|
|
1493
|
-
|
|
1494
|
-
if (!collected.settled || !collected.items || collected.items.length === 0) {
|
|
1495
|
-
return {
|
|
1496
|
-
ok: true,
|
|
1497
|
-
noop: true,
|
|
1498
|
-
settled: collected.settled,
|
|
1499
|
-
stop: collected.stop,
|
|
1500
|
-
retry: collected.retry,
|
|
1501
|
-
items: [],
|
|
1502
|
-
state: collected.state,
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
const { items, sig, nextSeen } = collected
|
|
1507
|
-
const postedBatches = Array.isArray(state.postedBatches) ? [...state.postedBatches] : []
|
|
1508
|
-
if (sig && postedBatches.includes(sig)) {
|
|
1509
|
-
return {
|
|
1510
|
-
ok: true,
|
|
1511
|
-
already: true,
|
|
1512
|
-
sig,
|
|
1513
|
-
items,
|
|
1514
|
-
state: { ...collected.state, seen: nextSeen, postedBatches },
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
let tempDir = null
|
|
1519
|
-
let hive = null
|
|
1520
|
-
|
|
1521
|
-
try {
|
|
1522
|
-
if (syncRunner) {
|
|
1523
|
-
hive = await syncRunner({
|
|
1524
|
-
relay: parsed.relay,
|
|
1525
|
-
room: parsed.room,
|
|
1526
|
-
name: 'GitHub',
|
|
1527
|
-
kind: 'ai',
|
|
1528
|
-
passive: true,
|
|
1529
|
-
syncFiles: false,
|
|
1530
|
-
token: parsed.token || token,
|
|
1531
|
-
})
|
|
1532
|
-
} else {
|
|
1533
|
-
tempDir = path.join(os.tmpdir(), `hive-prfb-${crypto.randomBytes(6).toString('hex')}`)
|
|
1534
|
-
fs.mkdirSync(tempDir, { recursive: true })
|
|
1535
|
-
hive = startSync({
|
|
1536
|
-
relay: parsed.relay,
|
|
1537
|
-
room: parsed.room,
|
|
1538
|
-
dir: tempDir,
|
|
1539
|
-
name: 'GitHub',
|
|
1540
|
-
kind: 'ai',
|
|
1541
|
-
passive: true,
|
|
1542
|
-
syncFiles: false,
|
|
1543
|
-
token: parsed.token || token,
|
|
1544
|
-
log: () => {},
|
|
1545
|
-
})
|
|
1546
|
-
|
|
1547
|
-
let isSynced = false
|
|
1548
|
-
await new Promise((res) => {
|
|
1549
|
-
const timer = setTimeout(res, 60000)
|
|
1550
|
-
if (hive.provider) {
|
|
1551
|
-
if (hive.provider.synced) {
|
|
1552
|
-
isSynced = true
|
|
1553
|
-
clearTimeout(timer)
|
|
1554
|
-
return res()
|
|
1555
|
-
}
|
|
1556
|
-
hive.provider.on('sync', (synced) => {
|
|
1557
|
-
if (synced) {
|
|
1558
|
-
isSynced = true
|
|
1559
|
-
clearTimeout(timer)
|
|
1560
|
-
res()
|
|
1561
|
-
}
|
|
1562
|
-
})
|
|
1563
|
-
} else {
|
|
1564
|
-
isSynced = true
|
|
1565
|
-
clearTimeout(timer)
|
|
1566
|
-
res()
|
|
1567
|
-
}
|
|
1568
|
-
})
|
|
1569
|
-
|
|
1570
|
-
if (!isSynced) {
|
|
1571
|
-
return {
|
|
1572
|
-
ok: false,
|
|
1573
|
-
code: 'Q656',
|
|
1574
|
-
why: 'Q656 could not sync with room within 60s',
|
|
1575
|
-
state: { ...state, seen: preSeen, settle: state.settle || { sig, at: now() - quietMs } },
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// Check room tasks for existing batch with same sig (crash/restart idempotency)
|
|
1581
|
-
let existingTask = null
|
|
1582
|
-
const matchBatch = (text) => sig && String(text || '').includes(`[${sig}]`)
|
|
1583
|
-
if (hive.tasks) {
|
|
1584
|
-
const entries = hive.tasks instanceof Map ? hive.tasks.values() : Object.values(hive.tasks)
|
|
1585
|
-
for (const t of entries) {
|
|
1586
|
-
if (t && t.by === 'GitHub' && matchBatch(t.text)) {
|
|
1587
|
-
existingTask = t
|
|
1588
|
-
break
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
} else if (hive.doc) {
|
|
1592
|
-
const tasksMap = hive.doc.getMap('tasks')
|
|
1593
|
-
for (const t of tasksMap.values()) {
|
|
1594
|
-
if (t && t.by === 'GitHub' && matchBatch(t.text)) {
|
|
1595
|
-
existingTask = t
|
|
1596
|
-
break
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
if (existingTask) {
|
|
1602
|
-
const updatedBatches = sig && !postedBatches.includes(sig) ? [...postedBatches, sig] : postedBatches
|
|
1603
|
-
return {
|
|
1604
|
-
ok: true,
|
|
1605
|
-
already: true,
|
|
1606
|
-
taskId: existingTask.id,
|
|
1607
|
-
sig,
|
|
1608
|
-
items,
|
|
1609
|
-
state: { ...collected.state, seen: nextSeen, postedBatches: updatedBatches },
|
|
1610
|
-
}
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
const taskText = formatPrFeedbackTask({ prNumber, issueNumber, items, sig })
|
|
1614
|
-
const chatText = formatPrFeedbackChat({ prNumber, issueNumber, items })
|
|
1615
|
-
const chatMarker = prNumber ? `PR #${prNumber} review feedback` : `Issue #${issueNumber} review feedback`
|
|
1616
|
-
|
|
1617
|
-
const taskId = hive.assign ? hive.assign('all', taskText) : null
|
|
1618
|
-
if (hive.say) hive.say(chatText)
|
|
1619
|
-
|
|
1620
|
-
const verified = verifierRunner
|
|
1621
|
-
? await verifierRunner({ relay: parsed.relay, room: parsed.room, taskId, chatMarker })
|
|
1622
|
-
: await verifyDelivery({
|
|
1623
|
-
relay: parsed.relay,
|
|
1624
|
-
room: parsed.room,
|
|
1625
|
-
taskId,
|
|
1626
|
-
chatMarker,
|
|
1627
|
-
token: parsed.token || token,
|
|
1628
|
-
})
|
|
1629
|
-
|
|
1630
|
-
if (!verified) {
|
|
1631
|
-
return {
|
|
1632
|
-
ok: false,
|
|
1633
|
-
code: 'Q657',
|
|
1634
|
-
why: 'Q657 delivery verification failed: PR feedback task or chat was not confirmed by the room.',
|
|
1635
|
-
state: {
|
|
1636
|
-
...collected.state,
|
|
1637
|
-
seen: preSeen,
|
|
1638
|
-
settle: state.settle || { sig, at: now() - quietMs },
|
|
1639
|
-
},
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
const updatedBatches = sig && !postedBatches.includes(sig) ? [...postedBatches, sig] : postedBatches
|
|
1644
|
-
const lineAuthors = [...new Set(items.map((it) => it.lineAuthor).filter(Boolean))]
|
|
1645
|
-
|
|
1646
|
-
return {
|
|
1647
|
-
ok: true,
|
|
1648
|
-
verified: true,
|
|
1649
|
-
taskId,
|
|
1650
|
-
sig,
|
|
1651
|
-
items,
|
|
1652
|
-
lineAuthors,
|
|
1653
|
-
state: {
|
|
1654
|
-
...collected.state,
|
|
1655
|
-
seen: nextSeen,
|
|
1656
|
-
settle: null,
|
|
1657
|
-
postedBatches: updatedBatches,
|
|
1658
|
-
},
|
|
1659
|
-
}
|
|
1660
|
-
} finally {
|
|
1661
|
-
if (hive && typeof hive.stop === 'function') {
|
|
1662
|
-
try { hive.stop() } catch {}
|
|
1663
|
-
}
|
|
1664
|
-
if (tempDir) {
|
|
1665
|
-
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1
|
+
// hive-room-mode.js -- room-mode startup validation, coordination, and issue posting (B1, B2 & B2.1)
|
|
2
|
+
//
|
|
3
|
+
// In room mode:
|
|
4
|
+
// npx -y hivecode-mcp@latest github start --room "<room link>" --test "npm test"
|
|
5
|
+
//
|
|
6
|
+
// The watcher runs on the repo machine and joins the room as a passive member "GitHub".
|
|
7
|
+
// Before any issue is picked up or posted, all safety preconditions must be verified.
|
|
8
|
+
//
|
|
9
|
+
// Error codes Q650-Q659 are this module's:
|
|
10
|
+
// Q650 invalid or missing room link
|
|
11
|
+
// Q651 not a git repository (or is home directory)
|
|
12
|
+
// Q652 room is not bound to a git branch in this repository
|
|
13
|
+
// Q653 GitHub login expired or missing on this machine
|
|
14
|
+
// Q654 member runs a version too old for room mode (< 0.13.0)
|
|
15
|
+
// Q655 another room-mode watcher is already running for this room
|
|
16
|
+
// Q656 could not reach relay / connection failed
|
|
17
|
+
// Q657 delivery verification failed (task or chat not received by room)
|
|
18
|
+
|
|
19
|
+
import fs from 'fs'
|
|
20
|
+
import os from 'os'
|
|
21
|
+
import path from 'path'
|
|
22
|
+
import crypto from 'crypto'
|
|
23
|
+
import { spawnSync } from 'child_process'
|
|
24
|
+
import { parseLink, startSync } from './sync.js'
|
|
25
|
+
import { landAndOpen, testCommandFor } from './hive-land.js'
|
|
26
|
+
|
|
27
|
+
export const MIN_ROOM_MODE_VERSION = '0.13.0'
|
|
28
|
+
|
|
29
|
+
function sameDir(a, b) {
|
|
30
|
+
if (!a || !b) return false
|
|
31
|
+
const na = path.resolve(a).toLowerCase().replace(/\\/g, '/')
|
|
32
|
+
const nb = path.resolve(b).toLowerCase().replace(/\\/g, '/')
|
|
33
|
+
return na === nb
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Compare semver versions: returns <0 if a < b, 0 if a == b, >0 if a > b */
|
|
37
|
+
export function compareSemver(a, b) {
|
|
38
|
+
const pa = String(a || '0.0.0').split('.').map((x) => parseInt(x, 10) || 0)
|
|
39
|
+
const pb = String(b || '0.0.0').split('.').map((x) => parseInt(x, 10) || 0)
|
|
40
|
+
for (let i = 0; i < 3; i++) {
|
|
41
|
+
const diff = (pa[i] || 0) - (pb[i] || 0)
|
|
42
|
+
if (diff !== 0) return diff
|
|
43
|
+
}
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Check process liveness by PID */
|
|
48
|
+
export function isProcessAlive(pid) {
|
|
49
|
+
if (!pid || typeof pid !== 'number') return false
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0)
|
|
52
|
+
return true
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return e.code === 'EPERM'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 1. Validate room link (Q650) */
|
|
59
|
+
export function validateRoomLink(link) {
|
|
60
|
+
const raw = String(link || '').trim()
|
|
61
|
+
if (!raw) {
|
|
62
|
+
return { ok: false, code: 'Q650', why: 'Q650 room link is required (e.g. wss://relay|room-...)' }
|
|
63
|
+
}
|
|
64
|
+
const parsed = parseLink(raw)
|
|
65
|
+
if (!parsed || !parsed.room || parsed.room.trim() === '' || !parsed.relay) {
|
|
66
|
+
return { ok: false, code: 'Q650', why: `Q650 "${raw}" is not a valid room link (expected wss://relay|room-...)` }
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, relay: parsed.relay, room: parsed.room }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 2. Check git repository using git rev-parse --show-toplevel (Q651) */
|
|
72
|
+
export function checkGitRepo(repoPath) {
|
|
73
|
+
if (!repoPath || !fs.existsSync(repoPath)) {
|
|
74
|
+
return { ok: false, code: 'Q651', why: `Q651 "${repoPath}" does not exist.` }
|
|
75
|
+
}
|
|
76
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: path.resolve(repoPath), encoding: 'utf8' })
|
|
77
|
+
if (r.status !== 0 || !r.stdout || !r.stdout.trim()) {
|
|
78
|
+
return { ok: false, code: 'Q651', why: `Q651 "${repoPath}" is not inside a git repository. Run this in the repo whose branch the room is for.` }
|
|
79
|
+
}
|
|
80
|
+
const root = path.resolve(r.stdout.trim())
|
|
81
|
+
if (sameDir(root, os.homedir())) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'Q651',
|
|
85
|
+
why: `Q651 the git repository around ${repoPath} is your home directory (${root}), which is not a project: ${repoPath} is not a repository of its own. Run this in the project's own repository.`,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, root, repo: root }
|
|
89
|
+
}
|
|
90
|
+
export const getRepoRoot = checkGitRepo
|
|
91
|
+
|
|
92
|
+
/** 3. Check branch binding (Q652) */
|
|
93
|
+
export function checkBranchBinding(repoPath, target) {
|
|
94
|
+
const repoRes = checkGitRepo(repoPath)
|
|
95
|
+
const root = repoRes.ok ? repoRes.root : path.resolve(String(repoPath || '.'))
|
|
96
|
+
|
|
97
|
+
let targetRoom = target
|
|
98
|
+
try {
|
|
99
|
+
const p = parseLink(target)
|
|
100
|
+
if (p && p.room) targetRoom = p.room
|
|
101
|
+
} catch {}
|
|
102
|
+
|
|
103
|
+
const r = spawnSync('git', ['config', '--local', '--get-regexp', '^hivecode\\..*\\.room$'], { cwd: root, encoding: 'utf8' })
|
|
104
|
+
const matchedBranches = []
|
|
105
|
+
if (r.status === 0 && r.stdout) {
|
|
106
|
+
const lines = r.stdout.split(/\r?\n/)
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const match = line.match(/^hivecode\.(.*)\.room\s+(.*)$/)
|
|
109
|
+
if (match) {
|
|
110
|
+
const branch = match[1]
|
|
111
|
+
const val = match[2].trim()
|
|
112
|
+
let valRoom = val
|
|
113
|
+
try {
|
|
114
|
+
const vp = parseLink(val)
|
|
115
|
+
if (vp && vp.room) valRoom = vp.room
|
|
116
|
+
} catch {}
|
|
117
|
+
|
|
118
|
+
if (val === target || valRoom === targetRoom || val === targetRoom) {
|
|
119
|
+
matchedBranches.push(branch)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (matchedBranches.length > 1) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
code: 'Q652',
|
|
129
|
+
why: `Q652 multiple branches (${matchedBranches.join(', ')}) are bound to room "${target}". Specify which branch to run or re-bind.`,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (matchedBranches.length === 1) {
|
|
133
|
+
return { ok: true, branch: matchedBranches[0], room: targetRoom }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
code: 'Q652',
|
|
139
|
+
why: `Q652 room "${target}" is not bound to a git branch in this repository. Bind it first with: npx -y hivecode-mcp@latest branch <branch> --join "<link>"`,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 4. Check GitHub authentication (Q653) */
|
|
144
|
+
export function checkGitHubAuth(opts = {}) {
|
|
145
|
+
const runner = opts.ghRunner || (() => {
|
|
146
|
+
const bin = opts.gh || process.env.HIVE_GH || 'gh'
|
|
147
|
+
try {
|
|
148
|
+
const r = spawnSync(bin, ['auth', 'status'], { encoding: 'utf8', windowsHide: true })
|
|
149
|
+
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '', error: r.error }
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { status: null, error: e }
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const res = runner()
|
|
156
|
+
if (res.error || res.status === null) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
code: 'Q653',
|
|
160
|
+
why: 'Q653 GitHub CLI (gh) is not installed or not in PATH. Install it from https://cli.github.com, then run: gh auth login',
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (res.status !== 0) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
code: 'Q653',
|
|
167
|
+
why: 'Q653 GitHub login expired on this machine: run gh auth login.',
|
|
168
|
+
detail: (res.stderr || res.stdout || '').trim(),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { ok: true }
|
|
172
|
+
}
|
|
173
|
+
export const checkGhAuth = checkGitHubAuth
|
|
174
|
+
|
|
175
|
+
/** 5. Check member versions (Q654) */
|
|
176
|
+
export function checkMemberVersions(members = [], minVersion = MIN_ROOM_MODE_VERSION) {
|
|
177
|
+
let agentCount = 0
|
|
178
|
+
for (const m of members) {
|
|
179
|
+
if (!m) continue
|
|
180
|
+
// Skip humans, passive observers, control room / recorder, and probe connections
|
|
181
|
+
if (m.kind === 'human' || m.passive || /recorder|control[-_ ]?room|conn-check|github/i.test(m.name || '') || /control[-_ ]?room/i.test(m.app || '')) continue
|
|
182
|
+
|
|
183
|
+
if (m.kind === 'ai' || m.kind === undefined) {
|
|
184
|
+
agentCount++
|
|
185
|
+
const ver = m.version || m.hv
|
|
186
|
+
if (!ver) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
code: 'Q654',
|
|
190
|
+
member: m.name,
|
|
191
|
+
why: `Q654 ${m.name} runs an unversioned build (< 0.13.0), too old for room mode. Everyone in the room must run at least ${minVersion}. Update with: npx -y hivecode-mcp@latest setup`,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (compareSemver(ver, minVersion) < 0) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
code: 'Q654',
|
|
198
|
+
member: m.name,
|
|
199
|
+
version: ver,
|
|
200
|
+
why: `Q654 ${m.name} runs ${ver}, too old for room mode. Everyone in the room must run at least ${minVersion}. Update with: npx -y hivecode-mcp@latest setup`,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (agentCount === 0) {
|
|
207
|
+
return { ok: true, warn: 'Waiting: no agent is in room' }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { ok: true }
|
|
211
|
+
}
|
|
212
|
+
export const checkRoomMembers = checkMemberVersions
|
|
213
|
+
|
|
214
|
+
/** 6. Check watcher lock (Q655) */
|
|
215
|
+
export function checkWatcherLock(repoPath, room, opts = {}) {
|
|
216
|
+
const root = path.resolve(String(repoPath || '.'))
|
|
217
|
+
|
|
218
|
+
let gitDir = opts.gitDir
|
|
219
|
+
if (!gitDir) {
|
|
220
|
+
const r = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: root, encoding: 'utf8' })
|
|
221
|
+
if (r.status === 0 && r.stdout && r.stdout.trim()) {
|
|
222
|
+
gitDir = path.resolve(root, r.stdout.trim())
|
|
223
|
+
} else {
|
|
224
|
+
gitDir = path.join(root, '.git')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const lockDir = opts.lockDir || path.join(gitDir, 'hivecode')
|
|
229
|
+
fs.mkdirSync(lockDir, { recursive: true })
|
|
230
|
+
|
|
231
|
+
const lockFile = path.join(lockDir, `watcher-${room}.lock`)
|
|
232
|
+
const checkAlive = opts.isAlive || isProcessAlive
|
|
233
|
+
const myPid = opts.pid || process.pid
|
|
234
|
+
|
|
235
|
+
const payload = JSON.stringify({
|
|
236
|
+
pid: myPid,
|
|
237
|
+
room,
|
|
238
|
+
repo: root,
|
|
239
|
+
startedAt: new Date().toISOString(),
|
|
240
|
+
}, null, 2)
|
|
241
|
+
|
|
242
|
+
let acquired = false
|
|
243
|
+
try {
|
|
244
|
+
fs.writeFileSync(lockFile, payload, { flag: 'wx', encoding: 'utf8' })
|
|
245
|
+
acquired = true
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err.code !== 'EEXIST') throw err
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!acquired) {
|
|
251
|
+
// Lock file already exists: check if holder is alive
|
|
252
|
+
try {
|
|
253
|
+
const data = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
|
|
254
|
+
if (data && data.pid && checkAlive(data.pid)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
code: 'Q655',
|
|
258
|
+
pid: data.pid,
|
|
259
|
+
lockFile,
|
|
260
|
+
why: `Q655 Another room-mode watcher is already running for room "${room}" (process ${data.pid}, since ${data.startedAt}).`,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// corrupt lock file
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Holder is dead or corrupt: overwrite
|
|
268
|
+
const tmpLock = `${lockFile}.${myPid}.tmp`
|
|
269
|
+
fs.writeFileSync(tmpLock, payload, 'utf8')
|
|
270
|
+
fs.renameSync(tmpLock, lockFile)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
lockFile,
|
|
276
|
+
pid: myPid,
|
|
277
|
+
release: () => {
|
|
278
|
+
try {
|
|
279
|
+
if (fs.existsSync(lockFile)) {
|
|
280
|
+
const cur = JSON.parse(fs.readFileSync(lockFile, 'utf8'))
|
|
281
|
+
if (cur && cur.pid === myPid) {
|
|
282
|
+
fs.unlinkSync(lockFile)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {}
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export const takeRoomWatcherLock = checkWatcherLock
|
|
290
|
+
|
|
291
|
+
/** Connect lightweight peek to verify room connectivity and fetch members if needed */
|
|
292
|
+
export async function checkRoomConnection({ relay, room, token, timeoutMs = 60000 } = {}) {
|
|
293
|
+
const vdir = path.join(os.tmpdir(), `hive-conn-${crypto.randomBytes(6).toString('hex')}`)
|
|
294
|
+
fs.mkdirSync(vdir, { recursive: true })
|
|
295
|
+
let peek = null
|
|
296
|
+
try {
|
|
297
|
+
peek = startSync({
|
|
298
|
+
relay,
|
|
299
|
+
room,
|
|
300
|
+
dir: vdir,
|
|
301
|
+
name: 'conn-check',
|
|
302
|
+
kind: 'ai',
|
|
303
|
+
passive: true,
|
|
304
|
+
syncFiles: false,
|
|
305
|
+
token,
|
|
306
|
+
log: () => {},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
let synced = false
|
|
310
|
+
await new Promise((res) => {
|
|
311
|
+
const timer = setTimeout(res, timeoutMs)
|
|
312
|
+
if (peek.provider) {
|
|
313
|
+
peek.provider.on('sync', (s) => {
|
|
314
|
+
if (s) {
|
|
315
|
+
synced = true
|
|
316
|
+
clearTimeout(timer)
|
|
317
|
+
res()
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
if (!synced) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
code: 'Q656',
|
|
327
|
+
why: `Q656 could not connect to relay at ${relay} for room ${room}.`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const members = peek.members ? peek.members() : []
|
|
332
|
+
return { ok: true, members }
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
code: 'Q656',
|
|
337
|
+
why: `Q656 connection error to ${relay}: ${e.message}`,
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
if (peek && typeof peek.stop === 'function') {
|
|
341
|
+
try { peek.stop() } catch {}
|
|
342
|
+
}
|
|
343
|
+
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch {}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 7. Unified room-mode start check */
|
|
348
|
+
export async function checkRoomModeStart(opts = {}) {
|
|
349
|
+
// 1. Room link check (Q650)
|
|
350
|
+
const linkRes = validateRoomLink(opts.link || opts.room)
|
|
351
|
+
if (!linkRes.ok) return linkRes
|
|
352
|
+
|
|
353
|
+
// 2. Git repo check (Q651)
|
|
354
|
+
const repoRes = checkGitRepo(opts.repo || process.cwd())
|
|
355
|
+
if (!repoRes.ok) return repoRes
|
|
356
|
+
|
|
357
|
+
// 3. Branch binding check (Q652)
|
|
358
|
+
const bindRes = checkBranchBinding(repoRes.repo, linkRes.room)
|
|
359
|
+
if (!bindRes.ok) return bindRes
|
|
360
|
+
|
|
361
|
+
// 4. GitHub auth check (Q653)
|
|
362
|
+
const authRes = checkGitHubAuth(opts)
|
|
363
|
+
if (!authRes.ok) return authRes
|
|
364
|
+
|
|
365
|
+
// 5. Member versions check (Q654)
|
|
366
|
+
let members = opts.members
|
|
367
|
+
if (members === undefined) {
|
|
368
|
+
const connRes = await checkRoomConnection({
|
|
369
|
+
relay: linkRes.relay,
|
|
370
|
+
room: linkRes.room,
|
|
371
|
+
token: opts.token,
|
|
372
|
+
timeoutMs: opts.timeoutMs || 60000,
|
|
373
|
+
})
|
|
374
|
+
if (!connRes.ok) return connRes
|
|
375
|
+
members = connRes.members
|
|
376
|
+
}
|
|
377
|
+
const verRes = checkMemberVersions(members || [], opts.minVersion || MIN_ROOM_MODE_VERSION)
|
|
378
|
+
if (!verRes.ok) return verRes
|
|
379
|
+
|
|
380
|
+
// 6. Watcher lock check (Q655)
|
|
381
|
+
const lockRes = checkWatcherLock(repoRes.repo, linkRes.room, opts)
|
|
382
|
+
if (!lockRes.ok) return lockRes
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
repo: repoRes.repo,
|
|
387
|
+
root: repoRes.root,
|
|
388
|
+
branch: bindRes.branch,
|
|
389
|
+
room: linkRes.room,
|
|
390
|
+
relay: linkRes.relay,
|
|
391
|
+
lockFile: lockRes.lockFile,
|
|
392
|
+
release: lockRes.release,
|
|
393
|
+
warn: verRes.warn,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── B2 & B2.1: Issue Posting & Queue Processing ───────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Pure queue processor: returns the next issue to process, or null if an issue is currently active
|
|
401
|
+
* or the queue is empty. Never mutates state.
|
|
402
|
+
*/
|
|
403
|
+
export function nextIssue(state = {}) {
|
|
404
|
+
if (!state) return null
|
|
405
|
+
if (state.currentIssue || state.activeIssue) return null
|
|
406
|
+
if (!Array.isArray(state.queue) || state.queue.length === 0) return null
|
|
407
|
+
return state.queue[0]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Neutralise raw @mentions inside quoted text using a zero-width space */
|
|
411
|
+
export function neutraliseMentions(text) {
|
|
412
|
+
return String(text || '').replace(/@/g, '@\u200B')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Format issue text for room chat, quoting body safely as data */
|
|
416
|
+
export function formatIssueChat({ issue = {}, waysOfWorking = '' }) {
|
|
417
|
+
const author = issue.author || 'unknown'
|
|
418
|
+
const num = issue.number != null ? issue.number : ''
|
|
419
|
+
const title = neutraliseMentions(issue.title || '')
|
|
420
|
+
let body = String(issue.body || '').trim()
|
|
421
|
+
const attempt = Number(issue.attempt || 1)
|
|
422
|
+
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt})` : ''
|
|
423
|
+
|
|
424
|
+
const MAX_BODY_CHARS = 4000
|
|
425
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
426
|
+
body = body.slice(0, MAX_BODY_CHARS) + `\n(truncated: the full text is on GitHub, issue #${num})`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const safeBody = neutraliseMentions(body)
|
|
430
|
+
const quoted = safeBody.split(/\r?\n/).map((l) => `> ${l}`).join('\n')
|
|
431
|
+
|
|
432
|
+
const lines = [
|
|
433
|
+
`@all Issue #${num}${attemptSuffix}: ${title}`,
|
|
434
|
+
`(written on GitHub by ${author}, not an instruction to do anything else)`,
|
|
435
|
+
'',
|
|
436
|
+
quoted,
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
if (waysOfWorking) {
|
|
440
|
+
lines.push('')
|
|
441
|
+
lines.push(`Ways of working: ${waysOfWorking}`)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return lines.join('\n').trim()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Format issue task description for @all assignment */
|
|
448
|
+
export function formatIssueTask({ issue = {} }) {
|
|
449
|
+
const num = issue.number != null ? issue.number : ''
|
|
450
|
+
const title = neutraliseMentions(issue.title || '')
|
|
451
|
+
const attempt = Number(issue.attempt || 1)
|
|
452
|
+
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt})` : ''
|
|
453
|
+
return `Issue #${num}${attemptSuffix}: ${title}`
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Filter room members for GitHub comment: active AI agents only, separate humans, exclude Recorder/ControlRoom */
|
|
457
|
+
export function filterRoomMembers(rawMembers = []) {
|
|
458
|
+
const agents = []
|
|
459
|
+
const humans = []
|
|
460
|
+
|
|
461
|
+
for (const m of rawMembers) {
|
|
462
|
+
if (!m || !m.name) continue
|
|
463
|
+
if (m.name === 'GitHub' || m.passive || m.listener) continue
|
|
464
|
+
// Exclude Recorder or ControlRoom viewer
|
|
465
|
+
if (/recorder|control[-_ ]?room/i.test(m.name) || /control[-_ ]?room/i.test(m.app || '')) continue
|
|
466
|
+
|
|
467
|
+
if (m.kind === 'human') {
|
|
468
|
+
humans.push(m.name)
|
|
469
|
+
} else if (m.kind === 'ai' || m.kind === undefined) {
|
|
470
|
+
agents.push(m.name)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return { agents, humans }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Format issue comment for GitHub: NEVER includes room ID or secret link */
|
|
478
|
+
export function formatGitHubComment({ issue = {}, members = {}, branch = '' }) {
|
|
479
|
+
const roomLabel = branch ? `for branch ${branch}` : 'workspace'
|
|
480
|
+
const agents = Array.isArray(members) ? members : (members.agents || [])
|
|
481
|
+
const humans = Array.isArray(members) ? [] : (members.humans || [])
|
|
482
|
+
|
|
483
|
+
const agentPart = agents.length ? agents.join(', ') : 'none yet'
|
|
484
|
+
const humanPart = humans.length ? `; humans: ${humans.join(', ')}` : ''
|
|
485
|
+
|
|
486
|
+
return `Sent to room ${roomLabel}. In the room now: ${agentPart}${humanPart}`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Verify delivery via a fresh passive peek */
|
|
490
|
+
async function verifyDelivery({ relay, room, token, taskId, chatMarker, timeoutMs = 30000, verifierRunner }) {
|
|
491
|
+
if (verifierRunner) {
|
|
492
|
+
return verifierRunner({ relay, room, taskId, chatMarker })
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const vdir = fs.mkdtempSync(path.join(os.tmpdir(), `hive-verify-${crypto.randomBytes(6).toString('hex')}`))
|
|
496
|
+
let peek = null
|
|
497
|
+
try {
|
|
498
|
+
peek = startSync({
|
|
499
|
+
relay,
|
|
500
|
+
room,
|
|
501
|
+
dir: vdir,
|
|
502
|
+
name: 'GitHub-verify',
|
|
503
|
+
kind: 'ai',
|
|
504
|
+
passive: true,
|
|
505
|
+
syncFiles: false,
|
|
506
|
+
token,
|
|
507
|
+
log: () => {},
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
if (peek && peek.provider) {
|
|
511
|
+
await new Promise((res) => {
|
|
512
|
+
const timer = setTimeout(res, 20000)
|
|
513
|
+
if (peek.provider.synced) {
|
|
514
|
+
clearTimeout(timer)
|
|
515
|
+
return res()
|
|
516
|
+
}
|
|
517
|
+
peek.provider.on('sync', (isSynced) => {
|
|
518
|
+
if (isSynced) {
|
|
519
|
+
clearTimeout(timer)
|
|
520
|
+
res()
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const t0 = Date.now()
|
|
527
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
528
|
+
if (peek.doc) {
|
|
529
|
+
const tasks = peek.doc.getMap('tasks')
|
|
530
|
+
const chat = peek.doc.getArray('chat')
|
|
531
|
+
const hasTask = taskId ? tasks.has(taskId) : true
|
|
532
|
+
const hasChat = chatMarker
|
|
533
|
+
? chat.toArray().some((c) => c && c.text && c.text.includes(chatMarker))
|
|
534
|
+
: true
|
|
535
|
+
if (hasTask && hasChat) return true
|
|
536
|
+
}
|
|
537
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
538
|
+
}
|
|
539
|
+
return false
|
|
540
|
+
} catch {
|
|
541
|
+
return false
|
|
542
|
+
} finally {
|
|
543
|
+
if (peek && typeof peek.stop === 'function') {
|
|
544
|
+
try { peek.stop() } catch {}
|
|
545
|
+
}
|
|
546
|
+
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch {}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Post an issue into the room:
|
|
552
|
+
* - Joins as passive participant "GitHub"
|
|
553
|
+
* - Idempotency: checks for existing task by "GitHub" with marker `issue #<n>`
|
|
554
|
+
* - Dispatches exactly ONE @all task
|
|
555
|
+
* - Dispatches exactly ONE chat message with quoted text as data (neutralising @mentions)
|
|
556
|
+
* - Verifies delivery via fresh passive peek (Q657 on failure)
|
|
557
|
+
* - Returns structured error (Q656) if relay is unreachable
|
|
558
|
+
* - Reads members and returns comment for GitHub
|
|
559
|
+
* - Stops and cleans up passive sync session
|
|
560
|
+
*/
|
|
561
|
+
export async function postIssue(opts = {}) {
|
|
562
|
+
const {
|
|
563
|
+
link,
|
|
564
|
+
issue = {},
|
|
565
|
+
waysOfWorking = '',
|
|
566
|
+
branch = '',
|
|
567
|
+
syncRunner,
|
|
568
|
+
verifierRunner,
|
|
569
|
+
token,
|
|
570
|
+
} = opts
|
|
571
|
+
|
|
572
|
+
const parsed = parseLink(link)
|
|
573
|
+
if (!parsed || !parsed.room) {
|
|
574
|
+
return { ok: false, code: 'Q650', why: 'Q650 invalid room link' }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let tempDir = null
|
|
578
|
+
let hive = null
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
if (syncRunner) {
|
|
582
|
+
hive = await syncRunner({
|
|
583
|
+
relay: parsed.relay,
|
|
584
|
+
room: parsed.room,
|
|
585
|
+
name: 'GitHub',
|
|
586
|
+
kind: 'ai',
|
|
587
|
+
passive: true,
|
|
588
|
+
syncFiles: false,
|
|
589
|
+
token: parsed.token || token,
|
|
590
|
+
})
|
|
591
|
+
} else {
|
|
592
|
+
tempDir = path.join(os.tmpdir(), `hive-gh-${crypto.randomBytes(6).toString('hex')}`)
|
|
593
|
+
fs.mkdirSync(tempDir, { recursive: true })
|
|
594
|
+
hive = startSync({
|
|
595
|
+
relay: parsed.relay,
|
|
596
|
+
room: parsed.room,
|
|
597
|
+
dir: tempDir,
|
|
598
|
+
name: 'GitHub',
|
|
599
|
+
kind: 'ai',
|
|
600
|
+
passive: true,
|
|
601
|
+
syncFiles: false,
|
|
602
|
+
token: parsed.token || token,
|
|
603
|
+
log: () => {},
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
// Wait for sync to establish (up to 60s)
|
|
607
|
+
let isSynced = false
|
|
608
|
+
await new Promise((res) => {
|
|
609
|
+
const timer = setTimeout(res, 60000)
|
|
610
|
+
if (hive.provider) {
|
|
611
|
+
if (hive.provider.synced) {
|
|
612
|
+
isSynced = true
|
|
613
|
+
clearTimeout(timer)
|
|
614
|
+
return res()
|
|
615
|
+
}
|
|
616
|
+
hive.provider.on('sync', (synced) => {
|
|
617
|
+
if (synced) {
|
|
618
|
+
isSynced = true
|
|
619
|
+
clearTimeout(timer)
|
|
620
|
+
res()
|
|
621
|
+
}
|
|
622
|
+
})
|
|
623
|
+
} else {
|
|
624
|
+
isSynced = true
|
|
625
|
+
clearTimeout(timer)
|
|
626
|
+
res()
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
if (!isSynced) {
|
|
630
|
+
if (hive && typeof hive.stop === 'function') {
|
|
631
|
+
try { hive.stop() } catch {}
|
|
632
|
+
}
|
|
633
|
+
if (tempDir) {
|
|
634
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
ok: false,
|
|
638
|
+
code: 'Q656',
|
|
639
|
+
why: 'Q656 could not sync with room within 60s',
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
} catch (err) {
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
code: 'Q656',
|
|
647
|
+
why: `Q656 could not reach relay behind ${link}: ${err.message}`,
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
// 1. Idempotency: look for existing task by "GitHub" with marker `issue #<n>` and matching attempt
|
|
653
|
+
const issueNum = String(issue.number != null ? issue.number : '')
|
|
654
|
+
const attempt = Number(issue.attempt || 1)
|
|
655
|
+
const exactRegex = new RegExp(`(?:^|[\\s\\[(:])issue\\s*#${issueNum}(?:\\b|[:\\s\\])])`, 'i')
|
|
656
|
+
const attemptRegex = attempt > 1
|
|
657
|
+
? new RegExp(`\\(attempt\\s*${attempt}\\)`, 'i')
|
|
658
|
+
: null
|
|
659
|
+
const isMatch = (text) => {
|
|
660
|
+
const s = String(text || '')
|
|
661
|
+
if (!exactRegex.test(s)) return false
|
|
662
|
+
if (attempt > 1) {
|
|
663
|
+
return attemptRegex.test(s)
|
|
664
|
+
} else {
|
|
665
|
+
return !/\(attempt\s*\d+\)/i.test(s) || /\(attempt\s*1\)/i.test(s)
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
let existingTask = null
|
|
669
|
+
|
|
670
|
+
if (hive.tasks) {
|
|
671
|
+
const entries = hive.tasks instanceof Map ? hive.tasks.values() : Object.values(hive.tasks)
|
|
672
|
+
for (const t of entries) {
|
|
673
|
+
if (t && t.by === 'GitHub' && isMatch(t.text)) {
|
|
674
|
+
existingTask = t
|
|
675
|
+
break
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else if (hive.doc) {
|
|
679
|
+
const tasksMap = hive.doc.getMap('tasks')
|
|
680
|
+
for (const t of tasksMap.values()) {
|
|
681
|
+
if (t && t.by === 'GitHub' && isMatch(t.text)) {
|
|
682
|
+
existingTask = t
|
|
683
|
+
break
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (existingTask) {
|
|
689
|
+
const rawMembers = hive.members ? hive.members() : []
|
|
690
|
+
const members = filterRoomMembers(rawMembers)
|
|
691
|
+
const comment = formatGitHubComment({ issue, members, branch })
|
|
692
|
+
return {
|
|
693
|
+
ok: true,
|
|
694
|
+
taskId: existingTask.id,
|
|
695
|
+
already: true,
|
|
696
|
+
members: members.agents,
|
|
697
|
+
comment,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 2. Assign @all task
|
|
702
|
+
const taskText = formatIssueTask({ issue })
|
|
703
|
+
const taskId = hive.assign ? hive.assign('all', taskText) : null
|
|
704
|
+
|
|
705
|
+
// 3. Post chat message
|
|
706
|
+
const chatText = formatIssueChat({ issue, waysOfWorking })
|
|
707
|
+
if (hive.say) hive.say(chatText)
|
|
708
|
+
|
|
709
|
+
// 4. Verify delivery via fresh passive peek
|
|
710
|
+
const chatMarker = attempt > 1
|
|
711
|
+
? `Issue #${issue.number} (attempt ${attempt})`
|
|
712
|
+
: `Issue #${issue.number}`
|
|
713
|
+
const verified = verifierRunner
|
|
714
|
+
? await verifierRunner({ relay: parsed.relay, room: parsed.room, taskId, chatMarker })
|
|
715
|
+
: await verifyDelivery({ relay: parsed.relay, room: parsed.room, taskId, chatMarker, token: parsed.token || token })
|
|
716
|
+
|
|
717
|
+
if (!verified) {
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
code: 'Q657',
|
|
721
|
+
why: 'Q657 delivery verification failed: task or chat was not confirmed by the room.',
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 5. Read members
|
|
726
|
+
const rawMembers = hive.members ? hive.members() : []
|
|
727
|
+
const members = filterRoomMembers(rawMembers)
|
|
728
|
+
|
|
729
|
+
// 6. Generate GitHub comment
|
|
730
|
+
const comment = formatGitHubComment({ issue, members, branch })
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
taskId,
|
|
735
|
+
verified: true,
|
|
736
|
+
members: members.agents,
|
|
737
|
+
comment,
|
|
738
|
+
}
|
|
739
|
+
} finally {
|
|
740
|
+
if (hive && typeof hive.stop === 'function') {
|
|
741
|
+
try { hive.stop() } catch {}
|
|
742
|
+
}
|
|
743
|
+
if (tempDir) {
|
|
744
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ── B3: Done Detection & Landing ──────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Compute deterministic fingerprint of a plan: file + content hash for each planned file.
|
|
753
|
+
* Prevents retry loop when landing fails on an unchanged room.
|
|
754
|
+
*/
|
|
755
|
+
export function computePlanFingerprint(plan = [], files = [], hive = null, hashes = null) {
|
|
756
|
+
const allFiles = Array.isArray(files) && files.length
|
|
757
|
+
? files
|
|
758
|
+
: (Array.isArray(plan) ? plan.flatMap((g) => g.files || []) : [])
|
|
759
|
+
const sorted = [...new Set(allFiles)].sort()
|
|
760
|
+
const pieces = sorted.map((f) => {
|
|
761
|
+
let h = ''
|
|
762
|
+
if (hashes && typeof hashes === 'object' && hashes[f]) {
|
|
763
|
+
h = hashes[f]
|
|
764
|
+
} else if (hive && hive.headOf) {
|
|
765
|
+
const head = hive.headOf(f)
|
|
766
|
+
if (head && head.hash) h = head.hash
|
|
767
|
+
} else if (hive && hive.reviewStatus) {
|
|
768
|
+
const rs = hive.reviewStatus(f)
|
|
769
|
+
if (rs && rs.hash) h = rs.hash
|
|
770
|
+
}
|
|
771
|
+
return `${f}:${h}`
|
|
772
|
+
})
|
|
773
|
+
return crypto.createHash('sha256').update(pieces.join(';')).digest('hex')
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function readReviewsForFile(hive, relPath) {
|
|
777
|
+
if (hive && typeof hive.reviews === 'function') {
|
|
778
|
+
return hive.reviews(relPath) || []
|
|
779
|
+
}
|
|
780
|
+
if (hive && hive.doc) {
|
|
781
|
+
const reviewsMap = hive.doc.getMap('reviews')
|
|
782
|
+
const entries = []
|
|
783
|
+
for (const [k, r] of reviewsMap.entries()) {
|
|
784
|
+
if (r && !Array.isArray(r) && (r.file === relPath || k.startsWith(relPath + '::'))) {
|
|
785
|
+
entries.push(r)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (entries.length) return entries.sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
789
|
+
const raw = reviewsMap.get(relPath)
|
|
790
|
+
return Array.isArray(raw) ? raw : []
|
|
791
|
+
}
|
|
792
|
+
return []
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function evaluateFileReview(hive, rel, plan = [], hashes = null) {
|
|
796
|
+
// When dry landAndOpen returned authoritative room content hashes (res.hashes), compare reviews
|
|
797
|
+
// directly against hashes[rel] on the main doc so a fresh passive client with syncFiles:false
|
|
798
|
+
// never reads empty per-file subdocs as contentHash('').
|
|
799
|
+
if (hashes && typeof hashes === 'object' && hashes[rel]) {
|
|
800
|
+
const wantHash = hashes[rel]
|
|
801
|
+
const contributors = new Set(
|
|
802
|
+
(plan || [])
|
|
803
|
+
.filter((g) => g && Array.isArray(g.files) && g.files.includes(rel))
|
|
804
|
+
.map((g) => g.agent)
|
|
805
|
+
.filter(Boolean)
|
|
806
|
+
)
|
|
807
|
+
const allReviews = readReviewsForFile(hive, rel).filter((r) => r && !contributors.has(r.by))
|
|
808
|
+
const matching = allReviews.filter((r) => r.hash === wantHash)
|
|
809
|
+
if (!matching.length) {
|
|
810
|
+
const lastPrior = allReviews.length ? allReviews[allReviews.length - 1] : null
|
|
811
|
+
return {
|
|
812
|
+
verdict: 'unreviewed',
|
|
813
|
+
hash: wantHash,
|
|
814
|
+
stale: allReviews.length > 0,
|
|
815
|
+
priorFail: Boolean(lastPrior && lastPrior.verdict === 'fail'),
|
|
816
|
+
reviews: allReviews,
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
let lastFail = -1
|
|
820
|
+
let lastPass = -1
|
|
821
|
+
for (let i = 0; i < matching.length; i++) {
|
|
822
|
+
if (matching[i].verdict === 'fail') lastFail = i
|
|
823
|
+
if (matching[i].verdict === 'pass') lastPass = i
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
verdict: lastPass > lastFail ? 'pass' : 'fail',
|
|
827
|
+
hash: wantHash,
|
|
828
|
+
stale: false,
|
|
829
|
+
priorFail: false,
|
|
830
|
+
reviews: matching,
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const revStatus = hive && hive.reviewStatus
|
|
835
|
+
? hive.reviewStatus(rel)
|
|
836
|
+
: { verdict: 'unreviewed' }
|
|
837
|
+
const priorList = Array.isArray(revStatus.reviews) ? revStatus.reviews : []
|
|
838
|
+
const lastPrior = priorList.length ? priorList[priorList.length - 1] : null
|
|
839
|
+
return {
|
|
840
|
+
...revStatus,
|
|
841
|
+
priorFail: Boolean(revStatus.verdict === 'unreviewed' && revStatus.stale && lastPrior && lastPrior.verdict === 'fail'),
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* B3: Done Detection
|
|
847
|
+
* Evaluates whether an issue task in the room is ready for landing.
|
|
848
|
+
* Ready only when ALL of the following hold:
|
|
849
|
+
* 1. The issue task is done (status === 'done').
|
|
850
|
+
* 2. No live claims are currently held.
|
|
851
|
+
* 3. Every file that landing would change has an explicit PASS review at its current hash
|
|
852
|
+
* (unless allowUnreviewed is true and no file has a fail or stale-after-fail review).
|
|
853
|
+
* 4. No unresolved FAIL review exists on any planned file.
|
|
854
|
+
*/
|
|
855
|
+
export async function whenDone(opts = {}) {
|
|
856
|
+
const {
|
|
857
|
+
link,
|
|
858
|
+
taskId,
|
|
859
|
+
root,
|
|
860
|
+
branch,
|
|
861
|
+
syncRunner,
|
|
862
|
+
landRunner,
|
|
863
|
+
allowUnreviewed = false,
|
|
864
|
+
token,
|
|
865
|
+
} = opts
|
|
866
|
+
|
|
867
|
+
let hive = null
|
|
868
|
+
let tempDir = null
|
|
869
|
+
let ownedHive = false
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
if (syncRunner) {
|
|
873
|
+
hive = await syncRunner()
|
|
874
|
+
} else {
|
|
875
|
+
const parsed = parseLink(link)
|
|
876
|
+
if (!parsed || !parsed.room) {
|
|
877
|
+
return { ready: false, why: 'invalid room link', reasons: ['invalid room link'] }
|
|
878
|
+
}
|
|
879
|
+
tempDir = path.join(os.tmpdir(), `hive-done-${crypto.randomBytes(6).toString('hex')}`)
|
|
880
|
+
fs.mkdirSync(tempDir, { recursive: true })
|
|
881
|
+
hive = startSync({
|
|
882
|
+
relay: parsed.relay,
|
|
883
|
+
room: parsed.room,
|
|
884
|
+
dir: tempDir,
|
|
885
|
+
name: 'GitHub-done',
|
|
886
|
+
kind: 'ai',
|
|
887
|
+
passive: true,
|
|
888
|
+
syncFiles: false,
|
|
889
|
+
token: parsed.token || token,
|
|
890
|
+
log: () => {},
|
|
891
|
+
})
|
|
892
|
+
ownedHive = true
|
|
893
|
+
// Wait for sync to establish
|
|
894
|
+
await new Promise((res) => {
|
|
895
|
+
const timer = setTimeout(res, 15000)
|
|
896
|
+
if (hive.provider) {
|
|
897
|
+
if (hive.provider.synced) {
|
|
898
|
+
clearTimeout(timer)
|
|
899
|
+
return res()
|
|
900
|
+
}
|
|
901
|
+
hive.provider.on('sync', (synced) => {
|
|
902
|
+
if (synced) {
|
|
903
|
+
clearTimeout(timer)
|
|
904
|
+
res()
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
} else {
|
|
908
|
+
res()
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// 1. Task check
|
|
914
|
+
let task = null
|
|
915
|
+
if (hive.tasks) {
|
|
916
|
+
task = hive.tasks instanceof Map ? hive.tasks.get(taskId) : hive.tasks[taskId]
|
|
917
|
+
} else if (hive.doc) {
|
|
918
|
+
const tasksMap = hive.doc.getMap('tasks')
|
|
919
|
+
task = tasksMap.get(taskId)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!task) {
|
|
923
|
+
return { ready: false, why: `task ${taskId} not found in room`, reasons: [`task ${taskId} not found`] }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (task.status !== 'done') {
|
|
927
|
+
return {
|
|
928
|
+
ready: false,
|
|
929
|
+
why: `task ${taskId} is not done (status: ${task.status})`,
|
|
930
|
+
reasons: [`task is ${task.status}`],
|
|
931
|
+
task,
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// 2. Claims check
|
|
936
|
+
let activeClaims = []
|
|
937
|
+
if (hive.claimsBoard) {
|
|
938
|
+
activeClaims = hive.claimsBoard()
|
|
939
|
+
} else if (hive.doc) {
|
|
940
|
+
const claimsMap = hive.doc.getMap('claims')
|
|
941
|
+
const now = Date.now()
|
|
942
|
+
for (const [reg, c] of claimsMap.entries()) {
|
|
943
|
+
if (c && (!c.at || (now - c.at) < (c.ttl || 300000))) {
|
|
944
|
+
activeClaims.push({ region: reg, ...c })
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (activeClaims.length > 0) {
|
|
950
|
+
const claimDesc = activeClaims.map((c) => `@${c.by} holds ${c.region || c.file}`).join(', ')
|
|
951
|
+
return {
|
|
952
|
+
ready: false,
|
|
953
|
+
why: `active claim(s) still held: ${claimDesc}`,
|
|
954
|
+
reasons: activeClaims.map((c) => `claim held on ${c.region || c.file} by @${c.by}`),
|
|
955
|
+
task,
|
|
956
|
+
claims: activeClaims,
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// 3. Plan acquisition via dry landAndOpen (Claude addition b)
|
|
961
|
+
const landFn = landRunner || landAndOpen
|
|
962
|
+
let dryRes = null
|
|
963
|
+
try {
|
|
964
|
+
dryRes = await landFn({ cwd: root, branch, dry: true })
|
|
965
|
+
} catch (err) {
|
|
966
|
+
return {
|
|
967
|
+
ready: false,
|
|
968
|
+
why: `dry landing failed to compute plan: ${err.message}`,
|
|
969
|
+
reasons: [err.message],
|
|
970
|
+
task,
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!dryRes || dryRes.ok === false) {
|
|
975
|
+
const why = dryRes ? dryRes.why : 'dry landing returned false'
|
|
976
|
+
return {
|
|
977
|
+
ready: false,
|
|
978
|
+
why: `dry landing check refused: ${why}`,
|
|
979
|
+
reasons: [why],
|
|
980
|
+
task,
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const plannedFiles = (dryRes.plan || []).flatMap((g) => g.files || [])
|
|
985
|
+
const deletions = dryRes.deletions || []
|
|
986
|
+
const hashes = dryRes.hashes || null
|
|
987
|
+
|
|
988
|
+
// 4. Review status check for every planned file
|
|
989
|
+
const reasons = []
|
|
990
|
+
const unreviewedFiles = []
|
|
991
|
+
let hasHardBlock = false
|
|
992
|
+
|
|
993
|
+
for (const rel of plannedFiles) {
|
|
994
|
+
const revStatus = evaluateFileReview(hive, rel, dryRes.plan, hashes)
|
|
995
|
+
|
|
996
|
+
if (revStatus.verdict === 'unreviewed') {
|
|
997
|
+
if (revStatus.priorFail) {
|
|
998
|
+
hasHardBlock = true
|
|
999
|
+
reasons.push(`${rel} was modified after a fail review and must be re-reviewed`)
|
|
1000
|
+
} else if (revStatus.stale) {
|
|
1001
|
+
unreviewedFiles.push(rel)
|
|
1002
|
+
if (!allowUnreviewed) reasons.push(`${rel} was modified after review (stale review)`)
|
|
1003
|
+
} else {
|
|
1004
|
+
unreviewedFiles.push(rel)
|
|
1005
|
+
if (!allowUnreviewed) reasons.push(`${rel} has not been reviewed`)
|
|
1006
|
+
}
|
|
1007
|
+
} else if (revStatus.verdict === 'fail') {
|
|
1008
|
+
hasHardBlock = true
|
|
1009
|
+
reasons.push(`${rel} has an unresolved fail review`)
|
|
1010
|
+
} else if (revStatus.verdict !== 'pass') {
|
|
1011
|
+
hasHardBlock = true
|
|
1012
|
+
reasons.push(`${rel} review verdict is not pass (${revStatus.verdict})`)
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (hasHardBlock && allowUnreviewed && unreviewedFiles.length > 0) {
|
|
1017
|
+
for (const rel of unreviewedFiles) {
|
|
1018
|
+
reasons.push(`${rel} has not been reviewed`)
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (reasons.length > 0) {
|
|
1023
|
+
const onlyUnreviewed = !hasHardBlock && unreviewedFiles.length > 0
|
|
1024
|
+
return {
|
|
1025
|
+
ready: false,
|
|
1026
|
+
...(onlyUnreviewed ? { blocked: 'unreviewed', unreviewedFiles } : {}),
|
|
1027
|
+
why: reasons.join('; '),
|
|
1028
|
+
reasons,
|
|
1029
|
+
files: plannedFiles,
|
|
1030
|
+
deletions,
|
|
1031
|
+
task,
|
|
1032
|
+
plan: dryRes.plan,
|
|
1033
|
+
hashes,
|
|
1034
|
+
hive,
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return {
|
|
1039
|
+
ready: true,
|
|
1040
|
+
unreviewedFiles,
|
|
1041
|
+
files: plannedFiles,
|
|
1042
|
+
deletions,
|
|
1043
|
+
task,
|
|
1044
|
+
plan: dryRes.plan,
|
|
1045
|
+
hashes,
|
|
1046
|
+
hive,
|
|
1047
|
+
}
|
|
1048
|
+
} finally {
|
|
1049
|
+
if (ownedHive && hive && typeof hive.stop === 'function') {
|
|
1050
|
+
try { hive.stop() } catch {}
|
|
1051
|
+
}
|
|
1052
|
+
if (tempDir) {
|
|
1053
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* B3: Landing execution
|
|
1060
|
+
* When whenDone is ready, invokes landAndOpen from hive-land.js.
|
|
1061
|
+
* Handles test failure (Q576) by assigning a fix task back to the room.
|
|
1062
|
+
* Implements plan fingerprint caching to avoid retry loops on failing tests.
|
|
1063
|
+
*/
|
|
1064
|
+
export async function landIssue(opts = {}) {
|
|
1065
|
+
const {
|
|
1066
|
+
link,
|
|
1067
|
+
taskId,
|
|
1068
|
+
issue = {},
|
|
1069
|
+
root,
|
|
1070
|
+
branch,
|
|
1071
|
+
syncRunner,
|
|
1072
|
+
landRunner,
|
|
1073
|
+
allowUnreviewed = false,
|
|
1074
|
+
state = {},
|
|
1075
|
+
token,
|
|
1076
|
+
} = opts
|
|
1077
|
+
|
|
1078
|
+
// 1. Check readiness
|
|
1079
|
+
const wd = await whenDone({
|
|
1080
|
+
link,
|
|
1081
|
+
taskId,
|
|
1082
|
+
root,
|
|
1083
|
+
branch,
|
|
1084
|
+
syncRunner,
|
|
1085
|
+
landRunner,
|
|
1086
|
+
allowUnreviewed,
|
|
1087
|
+
token,
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
if (!wd.ready) {
|
|
1091
|
+
return {
|
|
1092
|
+
ok: false,
|
|
1093
|
+
...(wd.blocked === 'unreviewed' ? { blocked: 'unreviewed', files: wd.unreviewedFiles || wd.files || [] } : {}),
|
|
1094
|
+
why: `Not ready to land: ${wd.why}`,
|
|
1095
|
+
reasons: wd.reasons,
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// 2. Plan fingerprint (Claude addition a: NO RETRY LOOP)
|
|
1100
|
+
const hive = wd.hive
|
|
1101
|
+
const fingerprint = computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)
|
|
1102
|
+
|
|
1103
|
+
if (state.lastFailedFingerprint === fingerprint) {
|
|
1104
|
+
return {
|
|
1105
|
+
ok: false,
|
|
1106
|
+
skipped: true,
|
|
1107
|
+
why: 'No changes since last failed landing; skipping test re-run until room changes',
|
|
1108
|
+
fingerprint,
|
|
1109
|
+
state,
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// 3. Prepare parameters
|
|
1114
|
+
const testCmd = (typeof testCommandFor === 'function')
|
|
1115
|
+
? testCommandFor(root, branch).command
|
|
1116
|
+
: ''
|
|
1117
|
+
const issueNum = issue.number != null ? issue.number : ''
|
|
1118
|
+
const issueTitle = issue.title || (wd.task ? wd.task.text : '') || 'Issue'
|
|
1119
|
+
const title = issueNum ? `${issueTitle} (#${issueNum})` : issueTitle
|
|
1120
|
+
|
|
1121
|
+
let summary = issueTitle
|
|
1122
|
+
if (wd.task && wd.task.note) {
|
|
1123
|
+
summary += '\n\n' + wd.task.note
|
|
1124
|
+
}
|
|
1125
|
+
if (wd.deletions && wd.deletions.length > 0) {
|
|
1126
|
+
summary += '\n\nDeletions:\n' + wd.deletions.map((d) => `- ${d}`).join('\n')
|
|
1127
|
+
}
|
|
1128
|
+
if (wd.unreviewedFiles && wd.unreviewedFiles.length > 0) {
|
|
1129
|
+
summary += `\n\nNot reviewed by a second agent: ${wd.unreviewedFiles.join(', ')}`
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const landFn = landRunner || landAndOpen
|
|
1133
|
+
|
|
1134
|
+
const landRes = await landFn({
|
|
1135
|
+
cwd: root,
|
|
1136
|
+
branch,
|
|
1137
|
+
dry: false,
|
|
1138
|
+
pr: true,
|
|
1139
|
+
test: testCmd,
|
|
1140
|
+
by: 'Hivecode room mode',
|
|
1141
|
+
title,
|
|
1142
|
+
summary,
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
if (!landRes.ok) {
|
|
1146
|
+
const nextState = { ...state, lastFailedFingerprint: fingerprint }
|
|
1147
|
+
|
|
1148
|
+
if (landRes.code === 'Q576') {
|
|
1149
|
+
// Tests failed: post fix task back to the room (never a GitHub comment)
|
|
1150
|
+
if (hive && hive.assign) {
|
|
1151
|
+
hive.assign('all', `Fix failing tests on issue #${issueNum}: ${landRes.why}`)
|
|
1152
|
+
}
|
|
1153
|
+
if (hive && hive.say) {
|
|
1154
|
+
hive.say(`⚠ Tests failed on landing issue #${issueNum} (Q576): ${landRes.why}. Assigned fix task to room.`)
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
ok: false,
|
|
1158
|
+
code: 'Q576',
|
|
1159
|
+
why: landRes.why,
|
|
1160
|
+
fingerprint,
|
|
1161
|
+
state: nextState,
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return {
|
|
1166
|
+
ok: false,
|
|
1167
|
+
code: landRes.code,
|
|
1168
|
+
why: landRes.why,
|
|
1169
|
+
fingerprint,
|
|
1170
|
+
state: nextState,
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const nextState = { ...state }
|
|
1175
|
+
delete nextState.lastFailedFingerprint
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
ok: true,
|
|
1179
|
+
pr: landRes.pr,
|
|
1180
|
+
tip: landRes.tip,
|
|
1181
|
+
plan: landRes.plan,
|
|
1182
|
+
opts: { title, summary, test: testCmd },
|
|
1183
|
+
fingerprint,
|
|
1184
|
+
state: nextState,
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ── B4: PR Review Comments & /hivecode <text> Comments in Room Mode ───────────────
|
|
1189
|
+
|
|
1190
|
+
const PR_PUSHERS = new Set(['admin', 'maintain', 'write'])
|
|
1191
|
+
const PR_ASSOC = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
|
|
1192
|
+
const PR_COMMENT_MAX = 4000
|
|
1193
|
+
const isBotUser = (u) => !u || u.type === 'Bot' || /\[bot\]$/i.test(String(u.login || ''))
|
|
1194
|
+
const isOursComment = (body) => /<!-- hivecode:[a-z-]+/.test(String(body || ''))
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Resolve the git commit author of a commented line on a PR.
|
|
1198
|
+
* Blames against the review comment's commit_id / original_commit_id (or PR head sha) so line numbers
|
|
1199
|
+
* match the exact commit the reviewer looked at even if the local branch has moved ahead.
|
|
1200
|
+
*/
|
|
1201
|
+
export function blamePrComment(root, ref, filePath, line, { branch = '' } = {}) {
|
|
1202
|
+
const ln = Number(line)
|
|
1203
|
+
const rel = String(filePath || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
1204
|
+
if (!root || !rel || !Number.isInteger(ln) || ln <= 0) return null
|
|
1205
|
+
|
|
1206
|
+
const candidates = [ref, branch].map((x) => String(x || '').trim()).filter(Boolean)
|
|
1207
|
+
if (candidates.length === 0) return null
|
|
1208
|
+
|
|
1209
|
+
for (const targetRef of candidates) {
|
|
1210
|
+
const r = spawnSync('git', ['blame', '--porcelain', '-L', `${ln},${ln}`, targetRef, '--', rel], {
|
|
1211
|
+
cwd: path.resolve(root),
|
|
1212
|
+
encoding: 'utf8',
|
|
1213
|
+
windowsHide: true,
|
|
1214
|
+
})
|
|
1215
|
+
if (r.status !== 0 || !r.stdout) continue
|
|
1216
|
+
const firstLine = r.stdout.split(/\r?\n/)[0] || ''
|
|
1217
|
+
if (/^0{40}\b/.test(firstLine)) return null
|
|
1218
|
+
const m = r.stdout.match(/^author (.+)$/m)
|
|
1219
|
+
if (!m) return null
|
|
1220
|
+
const author = m[1].trim()
|
|
1221
|
+
if (!author || author === 'Not Committed Yet') return null
|
|
1222
|
+
return author
|
|
1223
|
+
}
|
|
1224
|
+
return null
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function askOfPrComment(key, c, state = {}) {
|
|
1228
|
+
const body = String((c && c.body) || '').trim()
|
|
1229
|
+
if (key === 'rc') return body
|
|
1230
|
+
if (key === 'rv') {
|
|
1231
|
+
return (c.state === 'CHANGES_REQUESTED' || c.state === 'COMMENTED') ? body : ''
|
|
1232
|
+
}
|
|
1233
|
+
const m = body.match(/^\/hivecode\b\s*([\s\S]*)$/i)
|
|
1234
|
+
if (m) return /^(stop|retry)\b/i.test(m[1].trim()) ? '' : m[1].trim()
|
|
1235
|
+
return key === 'ic' && state.question ? body : ''
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Poll and batch PR review comments, reviews, and /hivecode <text> comments on the PR/issue.
|
|
1240
|
+
* INTERNAL HELPER: do not persist its returned `state` directly before room delivery succeeds;
|
|
1241
|
+
* external callers (like the watcher) should call `postPrFeedback`, which only advances `seen`
|
|
1242
|
+
* after `verifyDelivery` confirms the task and chat reached the room.
|
|
1243
|
+
* - Ignores untrusted users (non-pushers, bots) and watcher's own <!-- hivecode:... --> comments.
|
|
1244
|
+
* - Ignores ambient conversation without `/hivecode <text>` and APPROVED / PENDING reviews.
|
|
1245
|
+
* - Waits `quietMs` so a multi-comment review settles into ONE batch.
|
|
1246
|
+
* - Includes comment IDs (`rc:<id>`, `rv:<id>`, `pc:<id>`, `ic:<id>`) in the batch signature.
|
|
1247
|
+
*/
|
|
1248
|
+
export function collectPrFeedback(opts = {}) {
|
|
1249
|
+
const {
|
|
1250
|
+
gh,
|
|
1251
|
+
issueNumber,
|
|
1252
|
+
prNumber,
|
|
1253
|
+
state = {},
|
|
1254
|
+
root = '',
|
|
1255
|
+
branch = '',
|
|
1256
|
+
headSha = '',
|
|
1257
|
+
quietMs = 90000,
|
|
1258
|
+
now = () => Date.now(),
|
|
1259
|
+
} = opts
|
|
1260
|
+
|
|
1261
|
+
const seen = { ic: 0, pc: 0, rc: 0, rv: 0, ...(state.seen || {}) }
|
|
1262
|
+
let cmdSeen = Array.isArray(state.cmdSeen) ? [...state.cmdSeen] : []
|
|
1263
|
+
const perms = new Map(Object.entries(state.perms || {}))
|
|
1264
|
+
|
|
1265
|
+
const canPush = (login) => {
|
|
1266
|
+
if (!login || !gh || typeof gh.permission !== 'function') return false
|
|
1267
|
+
const cached = perms.get(login)
|
|
1268
|
+
if (cached && now() - cached.at < 600000) return PR_PUSHERS.has(cached.perm)
|
|
1269
|
+
const perm = gh.permission(login)
|
|
1270
|
+
perms.set(login, { perm, at: now() })
|
|
1271
|
+
return PR_PUSHERS.has(perm)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const trusted = (item) =>
|
|
1275
|
+
Boolean(
|
|
1276
|
+
item &&
|
|
1277
|
+
!isBotUser(item.user) &&
|
|
1278
|
+
PR_ASSOC.has(String(item.author_association || '').toUpperCase()) &&
|
|
1279
|
+
canPush(item.user.login)
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
let resolvedHeadSha = headSha
|
|
1283
|
+
if (!resolvedHeadSha && prNumber && gh && typeof gh.pull === 'function') {
|
|
1284
|
+
try {
|
|
1285
|
+
const prObj = gh.pull(prNumber)
|
|
1286
|
+
if (prObj && prObj.head && prObj.head.sha) resolvedHeadSha = prObj.head.sha
|
|
1287
|
+
} catch {}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const streams = []
|
|
1291
|
+
if (prNumber) {
|
|
1292
|
+
streams.push(
|
|
1293
|
+
['rc', () => (gh && gh.reviewComments ? gh.reviewComments(prNumber) : []), 'review comment'],
|
|
1294
|
+
['rv', () => (gh && gh.reviews ? gh.reviews(prNumber) : []), 'review'],
|
|
1295
|
+
['pc', () => (gh && gh.comments ? gh.comments(prNumber) : []), 'comment on the pull request']
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
if (issueNumber) {
|
|
1299
|
+
streams.push(['ic', () => (gh && gh.comments ? gh.comments(issueNumber) : []), 'comment on the issue'])
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const fresh = []
|
|
1303
|
+
for (const [key, read, where] of streams) {
|
|
1304
|
+
const list = read() || []
|
|
1305
|
+
for (const c of list) {
|
|
1306
|
+
if (!c || !(c.id > (seen[key] || 0)) || isOursComment(c.body)) continue
|
|
1307
|
+
if (key === 'rv' && c.state === 'PENDING') continue
|
|
1308
|
+
fresh.push({
|
|
1309
|
+
key,
|
|
1310
|
+
id: c.id,
|
|
1311
|
+
c,
|
|
1312
|
+
where: key === 'rv' ? `${where}, ${String(c.state || '').toLowerCase().replace(/_/g, ' ')}` : where,
|
|
1313
|
+
})
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const nextSeen = { ...seen }
|
|
1318
|
+
for (const f of fresh) {
|
|
1319
|
+
nextSeen[f.key] = Math.max(nextSeen[f.key] || 0, f.id)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const result = { stop: false, retry: false, items: [] }
|
|
1323
|
+
const isCommand = (f) =>
|
|
1324
|
+
(f.key === 'ic' || f.key === 'pc') &&
|
|
1325
|
+
String((f.c && f.c.body) || '').trim().match(/^\/hivecode\s+(stop|retry)\b/i)
|
|
1326
|
+
|
|
1327
|
+
for (const f of fresh) {
|
|
1328
|
+
const cmd = isCommand(f)
|
|
1329
|
+
if (!cmd || cmdSeen.includes(`${f.key}:${f.id}`) || !trusted(f.c)) continue
|
|
1330
|
+
cmdSeen = [...cmdSeen.slice(-40), `${f.key}:${f.id}`]
|
|
1331
|
+
result[cmd[1].toLowerCase()] = true
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const actionable = fresh.filter((f) => !isCommand(f) && trusted(f.c) && askOfPrComment(f.key, f.c, state))
|
|
1335
|
+
const serializedPerms = Object.fromEntries(perms.entries())
|
|
1336
|
+
|
|
1337
|
+
if (actionable.length === 0) {
|
|
1338
|
+
return {
|
|
1339
|
+
settled: true,
|
|
1340
|
+
stop: result.stop,
|
|
1341
|
+
retry: result.retry,
|
|
1342
|
+
items: [],
|
|
1343
|
+
sig: '',
|
|
1344
|
+
nextSeen,
|
|
1345
|
+
state: { ...state, seen: nextSeen, cmdSeen, settle: null, perms: serializedPerms },
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const sig = actionable.map((f) => `${f.key}:${f.id}`).join(',')
|
|
1350
|
+
|
|
1351
|
+
if (quietMs > 0) {
|
|
1352
|
+
if (!state.settle || state.settle.sig !== sig) {
|
|
1353
|
+
return {
|
|
1354
|
+
settled: false,
|
|
1355
|
+
stop: result.stop,
|
|
1356
|
+
retry: result.retry,
|
|
1357
|
+
items: [],
|
|
1358
|
+
sig,
|
|
1359
|
+
nextSeen,
|
|
1360
|
+
state: { ...state, seen, cmdSeen, settle: { sig, at: now() }, perms: serializedPerms },
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (now() - state.settle.at < quietMs) {
|
|
1364
|
+
return {
|
|
1365
|
+
settled: false,
|
|
1366
|
+
stop: result.stop,
|
|
1367
|
+
retry: result.retry,
|
|
1368
|
+
items: [],
|
|
1369
|
+
sig,
|
|
1370
|
+
nextSeen,
|
|
1371
|
+
state: { ...state, seen, cmdSeen, settle: state.settle, perms: serializedPerms },
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const items = actionable.map((f) => {
|
|
1377
|
+
const rawBody = askOfPrComment(f.key, f.c, state)
|
|
1378
|
+
const body = rawBody.length > PR_COMMENT_MAX ? `${rawBody.slice(0, PR_COMMENT_MAX)}\n(cut)` : rawBody
|
|
1379
|
+
const filePath = f.c.path || ''
|
|
1380
|
+
// Pair (line, commit_id) on current comments vs (original_line, original_commit_id) on outdated comments
|
|
1381
|
+
// where GitHub sets line=null and commit_id to the new PR head sha.
|
|
1382
|
+
const [line, commitId] = f.c.line
|
|
1383
|
+
? [f.c.line, f.c.commit_id || f.c.original_commit_id || resolvedHeadSha || '']
|
|
1384
|
+
: [f.c.original_line || 0, f.c.original_commit_id || f.c.commit_id || resolvedHeadSha || '']
|
|
1385
|
+
const lineAuthor = (f.key === 'rc' && root && filePath && line > 0)
|
|
1386
|
+
? blamePrComment(root, commitId || branch, filePath, line, { branch })
|
|
1387
|
+
: null
|
|
1388
|
+
|
|
1389
|
+
return {
|
|
1390
|
+
id: f.id,
|
|
1391
|
+
key: f.key,
|
|
1392
|
+
commentKey: `${f.key}:${f.id}`,
|
|
1393
|
+
author: (f.c.user && f.c.user.login) || 'unknown',
|
|
1394
|
+
where: f.where,
|
|
1395
|
+
path: filePath,
|
|
1396
|
+
line,
|
|
1397
|
+
commitId,
|
|
1398
|
+
lineAuthor,
|
|
1399
|
+
body,
|
|
1400
|
+
}
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
return {
|
|
1404
|
+
settled: true,
|
|
1405
|
+
stop: result.stop,
|
|
1406
|
+
retry: result.retry,
|
|
1407
|
+
items,
|
|
1408
|
+
sig,
|
|
1409
|
+
nextSeen,
|
|
1410
|
+
state: { ...state, seen: nextSeen, cmdSeen, settle: null, perms: serializedPerms },
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Format the @all room task for a settled PR feedback batch */
|
|
1415
|
+
export function formatPrFeedbackTask({ prNumber, issueNumber, items = [], sig = '' }) {
|
|
1416
|
+
const target = prNumber ? `PR #${prNumber}` : `issue #${issueNumber}`
|
|
1417
|
+
const summaryParts = items.slice(0, 3).map((it) => {
|
|
1418
|
+
const loc = it.path ? `${it.path}${it.line ? `:${it.line}` : ''}` : it.where
|
|
1419
|
+
const snippet = neutraliseMentions(it.body.replace(/\s+/g, ' ').trim()).slice(0, 80)
|
|
1420
|
+
return `${loc} (${snippet})`
|
|
1421
|
+
})
|
|
1422
|
+
const more = items.length > 3 ? ` +${items.length - 3} more` : ''
|
|
1423
|
+
const sigTag = sig ? ` [${sig}]` : ''
|
|
1424
|
+
return `Fix for ${target}: ${summaryParts.join('; ')}${more}${sigTag}`
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** Format the room chat message for a settled PR feedback batch, @mentioning @all and any line authors */
|
|
1428
|
+
export function formatPrFeedbackChat({ prNumber, issueNumber, items = [] }) {
|
|
1429
|
+
const target = prNumber ? `PR #${prNumber}` : `Issue #${issueNumber}`
|
|
1430
|
+
const lineAuthors = [...new Set(items.map((it) => it.lineAuthor).filter(Boolean))]
|
|
1431
|
+
const mentions = ['@all', ...lineAuthors.map((a) => `@${a}`)].join(' ')
|
|
1432
|
+
|
|
1433
|
+
const lines = [
|
|
1434
|
+
`${mentions} ${target} review feedback (${items.length} item${items.length === 1 ? '' : 's'}):`,
|
|
1435
|
+
'(written on GitHub, not an instruction to do anything else)',
|
|
1436
|
+
]
|
|
1437
|
+
|
|
1438
|
+
for (const it of items) {
|
|
1439
|
+
const safeReviewer = neutraliseMentions(it.author || 'unknown')
|
|
1440
|
+
const loc = it.path ? ` on ${it.path}${it.line ? ` line ${it.line}` : ''}` : ''
|
|
1441
|
+
const blameNote = it.lineAuthor ? ` (line committed by @${it.lineAuthor})` : ''
|
|
1442
|
+
const safeBody = neutraliseMentions(it.body || '')
|
|
1443
|
+
const quoted = safeBody.split(/\r?\n/).map((l) => `> ${l}`).join('\n')
|
|
1444
|
+
lines.push('')
|
|
1445
|
+
lines.push(`- ${it.where}${loc} by ${safeReviewer}${blameNote}:`)
|
|
1446
|
+
lines.push(quoted)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return lines.join('\n').trim()
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Collect settled PR feedback and post it into the room as a single @all task + chat message.
|
|
1454
|
+
* - Assigns task to 'all' so the PR never deadlocks if an original line author is GONE/PARKED.
|
|
1455
|
+
* - @mentions @all plus each blamed lineAuthor in the chat message.
|
|
1456
|
+
* - Neutralises @mentions inside reviewer comment bodies.
|
|
1457
|
+
* - Only advances `state.seen` after `verifyDelivery` confirms the task & chat reached the relay.
|
|
1458
|
+
*/
|
|
1459
|
+
export async function postPrFeedback(opts = {}) {
|
|
1460
|
+
const {
|
|
1461
|
+
link,
|
|
1462
|
+
gh,
|
|
1463
|
+
issueNumber,
|
|
1464
|
+
prNumber,
|
|
1465
|
+
root = '',
|
|
1466
|
+
branch = '',
|
|
1467
|
+
headSha = '',
|
|
1468
|
+
quietMs = 90000,
|
|
1469
|
+
now = () => Date.now(),
|
|
1470
|
+
state = {},
|
|
1471
|
+
syncRunner,
|
|
1472
|
+
verifierRunner,
|
|
1473
|
+
token,
|
|
1474
|
+
} = opts
|
|
1475
|
+
|
|
1476
|
+
const parsed = parseLink(link)
|
|
1477
|
+
if (!parsed || !parsed.room) {
|
|
1478
|
+
return { ok: false, code: 'Q650', why: 'Q650 invalid room link', state }
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const preSeen = { ic: 0, pc: 0, rc: 0, rv: 0, ...(state.seen || {}) }
|
|
1482
|
+
const collected = opts.feedback || collectPrFeedback({
|
|
1483
|
+
gh,
|
|
1484
|
+
issueNumber,
|
|
1485
|
+
prNumber,
|
|
1486
|
+
state,
|
|
1487
|
+
root,
|
|
1488
|
+
branch,
|
|
1489
|
+
headSha,
|
|
1490
|
+
quietMs,
|
|
1491
|
+
now,
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
if (!collected.settled || !collected.items || collected.items.length === 0) {
|
|
1495
|
+
return {
|
|
1496
|
+
ok: true,
|
|
1497
|
+
noop: true,
|
|
1498
|
+
settled: collected.settled,
|
|
1499
|
+
stop: collected.stop,
|
|
1500
|
+
retry: collected.retry,
|
|
1501
|
+
items: [],
|
|
1502
|
+
state: collected.state,
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const { items, sig, nextSeen } = collected
|
|
1507
|
+
const postedBatches = Array.isArray(state.postedBatches) ? [...state.postedBatches] : []
|
|
1508
|
+
if (sig && postedBatches.includes(sig)) {
|
|
1509
|
+
return {
|
|
1510
|
+
ok: true,
|
|
1511
|
+
already: true,
|
|
1512
|
+
sig,
|
|
1513
|
+
items,
|
|
1514
|
+
state: { ...collected.state, seen: nextSeen, postedBatches },
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
let tempDir = null
|
|
1519
|
+
let hive = null
|
|
1520
|
+
|
|
1521
|
+
try {
|
|
1522
|
+
if (syncRunner) {
|
|
1523
|
+
hive = await syncRunner({
|
|
1524
|
+
relay: parsed.relay,
|
|
1525
|
+
room: parsed.room,
|
|
1526
|
+
name: 'GitHub',
|
|
1527
|
+
kind: 'ai',
|
|
1528
|
+
passive: true,
|
|
1529
|
+
syncFiles: false,
|
|
1530
|
+
token: parsed.token || token,
|
|
1531
|
+
})
|
|
1532
|
+
} else {
|
|
1533
|
+
tempDir = path.join(os.tmpdir(), `hive-prfb-${crypto.randomBytes(6).toString('hex')}`)
|
|
1534
|
+
fs.mkdirSync(tempDir, { recursive: true })
|
|
1535
|
+
hive = startSync({
|
|
1536
|
+
relay: parsed.relay,
|
|
1537
|
+
room: parsed.room,
|
|
1538
|
+
dir: tempDir,
|
|
1539
|
+
name: 'GitHub',
|
|
1540
|
+
kind: 'ai',
|
|
1541
|
+
passive: true,
|
|
1542
|
+
syncFiles: false,
|
|
1543
|
+
token: parsed.token || token,
|
|
1544
|
+
log: () => {},
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
let isSynced = false
|
|
1548
|
+
await new Promise((res) => {
|
|
1549
|
+
const timer = setTimeout(res, 60000)
|
|
1550
|
+
if (hive.provider) {
|
|
1551
|
+
if (hive.provider.synced) {
|
|
1552
|
+
isSynced = true
|
|
1553
|
+
clearTimeout(timer)
|
|
1554
|
+
return res()
|
|
1555
|
+
}
|
|
1556
|
+
hive.provider.on('sync', (synced) => {
|
|
1557
|
+
if (synced) {
|
|
1558
|
+
isSynced = true
|
|
1559
|
+
clearTimeout(timer)
|
|
1560
|
+
res()
|
|
1561
|
+
}
|
|
1562
|
+
})
|
|
1563
|
+
} else {
|
|
1564
|
+
isSynced = true
|
|
1565
|
+
clearTimeout(timer)
|
|
1566
|
+
res()
|
|
1567
|
+
}
|
|
1568
|
+
})
|
|
1569
|
+
|
|
1570
|
+
if (!isSynced) {
|
|
1571
|
+
return {
|
|
1572
|
+
ok: false,
|
|
1573
|
+
code: 'Q656',
|
|
1574
|
+
why: 'Q656 could not sync with room within 60s',
|
|
1575
|
+
state: { ...state, seen: preSeen, settle: state.settle || { sig, at: now() - quietMs } },
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Check room tasks for existing batch with same sig (crash/restart idempotency)
|
|
1581
|
+
let existingTask = null
|
|
1582
|
+
const matchBatch = (text) => sig && String(text || '').includes(`[${sig}]`)
|
|
1583
|
+
if (hive.tasks) {
|
|
1584
|
+
const entries = hive.tasks instanceof Map ? hive.tasks.values() : Object.values(hive.tasks)
|
|
1585
|
+
for (const t of entries) {
|
|
1586
|
+
if (t && t.by === 'GitHub' && matchBatch(t.text)) {
|
|
1587
|
+
existingTask = t
|
|
1588
|
+
break
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
} else if (hive.doc) {
|
|
1592
|
+
const tasksMap = hive.doc.getMap('tasks')
|
|
1593
|
+
for (const t of tasksMap.values()) {
|
|
1594
|
+
if (t && t.by === 'GitHub' && matchBatch(t.text)) {
|
|
1595
|
+
existingTask = t
|
|
1596
|
+
break
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (existingTask) {
|
|
1602
|
+
const updatedBatches = sig && !postedBatches.includes(sig) ? [...postedBatches, sig] : postedBatches
|
|
1603
|
+
return {
|
|
1604
|
+
ok: true,
|
|
1605
|
+
already: true,
|
|
1606
|
+
taskId: existingTask.id,
|
|
1607
|
+
sig,
|
|
1608
|
+
items,
|
|
1609
|
+
state: { ...collected.state, seen: nextSeen, postedBatches: updatedBatches },
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const taskText = formatPrFeedbackTask({ prNumber, issueNumber, items, sig })
|
|
1614
|
+
const chatText = formatPrFeedbackChat({ prNumber, issueNumber, items })
|
|
1615
|
+
const chatMarker = prNumber ? `PR #${prNumber} review feedback` : `Issue #${issueNumber} review feedback`
|
|
1616
|
+
|
|
1617
|
+
const taskId = hive.assign ? hive.assign('all', taskText) : null
|
|
1618
|
+
if (hive.say) hive.say(chatText)
|
|
1619
|
+
|
|
1620
|
+
const verified = verifierRunner
|
|
1621
|
+
? await verifierRunner({ relay: parsed.relay, room: parsed.room, taskId, chatMarker })
|
|
1622
|
+
: await verifyDelivery({
|
|
1623
|
+
relay: parsed.relay,
|
|
1624
|
+
room: parsed.room,
|
|
1625
|
+
taskId,
|
|
1626
|
+
chatMarker,
|
|
1627
|
+
token: parsed.token || token,
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1630
|
+
if (!verified) {
|
|
1631
|
+
return {
|
|
1632
|
+
ok: false,
|
|
1633
|
+
code: 'Q657',
|
|
1634
|
+
why: 'Q657 delivery verification failed: PR feedback task or chat was not confirmed by the room.',
|
|
1635
|
+
state: {
|
|
1636
|
+
...collected.state,
|
|
1637
|
+
seen: preSeen,
|
|
1638
|
+
settle: state.settle || { sig, at: now() - quietMs },
|
|
1639
|
+
},
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const updatedBatches = sig && !postedBatches.includes(sig) ? [...postedBatches, sig] : postedBatches
|
|
1644
|
+
const lineAuthors = [...new Set(items.map((it) => it.lineAuthor).filter(Boolean))]
|
|
1645
|
+
|
|
1646
|
+
return {
|
|
1647
|
+
ok: true,
|
|
1648
|
+
verified: true,
|
|
1649
|
+
taskId,
|
|
1650
|
+
sig,
|
|
1651
|
+
items,
|
|
1652
|
+
lineAuthors,
|
|
1653
|
+
state: {
|
|
1654
|
+
...collected.state,
|
|
1655
|
+
seen: nextSeen,
|
|
1656
|
+
settle: null,
|
|
1657
|
+
postedBatches: updatedBatches,
|
|
1658
|
+
},
|
|
1659
|
+
}
|
|
1660
|
+
} finally {
|
|
1661
|
+
if (hive && typeof hive.stop === 'function') {
|
|
1662
|
+
try { hive.stop() } catch {}
|
|
1663
|
+
}
|
|
1664
|
+
if (tempDir) {
|
|
1665
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|