@jonit-dev/night-watch-cli 1.7.49 → 1.7.51
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/dist/cli.js +2817 -981
- package/dist/commands/cron.d.ts +8 -0
- package/dist/commands/cron.d.ts.map +1 -0
- package/dist/commands/cron.js +214 -0
- package/dist/commands/cron.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +28 -31
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/qa.d.ts.map +1 -1
- package/dist/commands/qa.js +3 -27
- package/dist/commands/qa.js.map +1 -1
- package/dist/commands/review.d.ts +20 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +98 -18
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +3 -18
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/shared/env-builder.d.ts +25 -0
- package/dist/commands/shared/env-builder.d.ts.map +1 -0
- package/dist/commands/shared/env-builder.js +48 -0
- package/dist/commands/shared/env-builder.js.map +1 -0
- package/dist/commands/slice.d.ts.map +1 -1
- package/dist/commands/slice.js +3 -23
- package/dist/commands/slice.js.map +1 -1
- package/dist/scripts/night-watch-audit-cron.sh +56 -33
- package/dist/scripts/night-watch-cron.sh +12 -2
- package/dist/scripts/night-watch-helpers.sh +84 -0
- package/dist/scripts/night-watch-pr-reviewer-cron.sh +389 -9
- package/dist/scripts/night-watch-qa-cron.sh +116 -4
- package/dist/templates/audit.md +87 -0
- package/dist/templates/executor.md +67 -0
- package/dist/templates/night-watch-pr-reviewer.md +153 -135
- package/dist/templates/night-watch-slicer.md +1 -1
- package/dist/templates/night-watch.md +1 -1
- package/dist/templates/pr-reviewer.md +203 -0
- package/dist/templates/qa.md +157 -0
- package/dist/templates/slicer.md +234 -0
- package/package.json +1 -1
|
@@ -27,6 +27,12 @@ AUTO_MERGE_METHOD="${NW_AUTO_MERGE_METHOD:-squash}"
|
|
|
27
27
|
TARGET_PR="${NW_TARGET_PR:-}"
|
|
28
28
|
PARALLEL_ENABLED="${NW_REVIEWER_PARALLEL:-1}"
|
|
29
29
|
WORKER_MODE="${NW_REVIEWER_WORKER_MODE:-0}"
|
|
30
|
+
PRD_DIR_REL="${NW_PRD_DIR:-docs/PRDs/night-watch}"
|
|
31
|
+
if [[ "${PRD_DIR_REL}" = /* ]]; then
|
|
32
|
+
PRD_DIR="${PRD_DIR_REL}"
|
|
33
|
+
else
|
|
34
|
+
PRD_DIR="${PROJECT_DIR}/${PRD_DIR_REL}"
|
|
35
|
+
fi
|
|
30
36
|
|
|
31
37
|
# Retry configuration
|
|
32
38
|
REVIEWER_MAX_RETRIES="${NW_REVIEWER_MAX_RETRIES:-2}"
|
|
@@ -124,6 +130,175 @@ append_csv() {
|
|
|
124
130
|
fi
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
truncate_for_prompt() {
|
|
134
|
+
local text="${1:-}"
|
|
135
|
+
local limit="${2:-7000}"
|
|
136
|
+
if [ "${#text}" -le "${limit}" ]; then
|
|
137
|
+
printf "%s" "${text}"
|
|
138
|
+
else
|
|
139
|
+
printf '%s\n\n[truncated to %s chars]' "${text:0:${limit}}" "${limit}"
|
|
140
|
+
fi
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
extract_linked_issue_numbers() {
|
|
144
|
+
local body="${1:-}"
|
|
145
|
+
printf '%s\n' "${body}" \
|
|
146
|
+
| grep -Eoi '(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*#[0-9]+' \
|
|
147
|
+
| grep -Eo '[0-9]+' \
|
|
148
|
+
| awk '!seen[$0]++' || true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
find_prd_file_by_branch() {
|
|
152
|
+
local branch_name="${1:-}"
|
|
153
|
+
local branch_slug="${branch_name#*/}"
|
|
154
|
+
local branch_number=""
|
|
155
|
+
local candidate_dirs=()
|
|
156
|
+
local candidate=""
|
|
157
|
+
local base_name=""
|
|
158
|
+
local dir=""
|
|
159
|
+
|
|
160
|
+
if [ -z "${branch_slug}" ]; then
|
|
161
|
+
branch_slug="${branch_name}"
|
|
162
|
+
fi
|
|
163
|
+
[ -z "${branch_slug}" ] && return 1
|
|
164
|
+
|
|
165
|
+
if [ -d "${PRD_DIR}" ]; then
|
|
166
|
+
candidate_dirs+=("${PRD_DIR}")
|
|
167
|
+
fi
|
|
168
|
+
if [ -d "${PRD_DIR}/done" ]; then
|
|
169
|
+
candidate_dirs+=("${PRD_DIR}/done")
|
|
170
|
+
fi
|
|
171
|
+
[ "${#candidate_dirs[@]}" -eq 0 ] && return 1
|
|
172
|
+
|
|
173
|
+
for dir in "${candidate_dirs[@]}"; do
|
|
174
|
+
if [ -f "${dir}/${branch_slug}.md" ]; then
|
|
175
|
+
printf "%s" "${dir}/${branch_slug}.md"
|
|
176
|
+
return 0
|
|
177
|
+
fi
|
|
178
|
+
done
|
|
179
|
+
|
|
180
|
+
branch_number=$(printf '%s' "${branch_slug}" | grep -oE '^[0-9]+' || true)
|
|
181
|
+
for dir in "${candidate_dirs[@]}"; do
|
|
182
|
+
while IFS= read -r candidate; do
|
|
183
|
+
[ -z "${candidate}" ] && continue
|
|
184
|
+
base_name=$(basename "${candidate}" .md)
|
|
185
|
+
if [[ "${base_name}" == "${branch_slug}"* ]] || [[ "${branch_slug}" == "${base_name}"* ]]; then
|
|
186
|
+
printf "%s" "${candidate}"
|
|
187
|
+
return 0
|
|
188
|
+
fi
|
|
189
|
+
if [ -n "${branch_number}" ] && [[ "${base_name}" == "${branch_number}-"* ]]; then
|
|
190
|
+
printf "%s" "${candidate}"
|
|
191
|
+
return 0
|
|
192
|
+
fi
|
|
193
|
+
done < <(find "${dir}" -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort)
|
|
194
|
+
done
|
|
195
|
+
|
|
196
|
+
return 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
build_prd_context_for_pr() {
|
|
200
|
+
local pr_number="${1:?PR number required}"
|
|
201
|
+
local pr_payload=""
|
|
202
|
+
local pr_title=""
|
|
203
|
+
local pr_branch=""
|
|
204
|
+
local pr_body=""
|
|
205
|
+
local pr_url=""
|
|
206
|
+
local issue_context=""
|
|
207
|
+
local issue_count=0
|
|
208
|
+
local issue_number=""
|
|
209
|
+
local issue_payload=""
|
|
210
|
+
local issue_title=""
|
|
211
|
+
local issue_body=""
|
|
212
|
+
local issue_excerpt=""
|
|
213
|
+
local prd_file=""
|
|
214
|
+
local prd_payload=""
|
|
215
|
+
local prd_excerpt=""
|
|
216
|
+
local prd_rel_path=""
|
|
217
|
+
local section=""
|
|
218
|
+
|
|
219
|
+
pr_payload=$(gh pr view "${pr_number}" --json title,headRefName,body,url 2>/dev/null || true)
|
|
220
|
+
pr_title=$(printf '%s' "${pr_payload}" | jq -r '.title // ""' 2>/dev/null || echo "")
|
|
221
|
+
pr_branch=$(printf '%s' "${pr_payload}" | jq -r '.headRefName // ""' 2>/dev/null || echo "")
|
|
222
|
+
pr_body=$(printf '%s' "${pr_payload}" | jq -r '.body // ""' 2>/dev/null || echo "")
|
|
223
|
+
pr_url=$(printf '%s' "${pr_payload}" | jq -r '.url // ""' 2>/dev/null || echo "")
|
|
224
|
+
|
|
225
|
+
if [ -n "${pr_body}" ]; then
|
|
226
|
+
while IFS= read -r issue_number; do
|
|
227
|
+
[ -z "${issue_number}" ] && continue
|
|
228
|
+
issue_count=$((issue_count + 1))
|
|
229
|
+
if [ "${issue_count}" -gt 2 ]; then
|
|
230
|
+
break
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
issue_payload=$(gh issue view "${issue_number}" --json title,body,url 2>/dev/null || true)
|
|
234
|
+
issue_title=$(printf '%s' "${issue_payload}" | jq -r '.title // ""' 2>/dev/null || echo "")
|
|
235
|
+
issue_body=$(printf '%s' "${issue_payload}" | jq -r '.body // ""' 2>/dev/null || echo "")
|
|
236
|
+
[ -z "${issue_body}" ] && continue
|
|
237
|
+
|
|
238
|
+
issue_excerpt=$(truncate_for_prompt "${issue_body}" 4500)
|
|
239
|
+
issue_context="${issue_context}${issue_context:+$'\n\n'}Issue #${issue_number}: ${issue_title}
|
|
240
|
+
${issue_excerpt}"
|
|
241
|
+
done < <(extract_linked_issue_numbers "${pr_body}")
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
if [ -z "${issue_context}" ] && [ -n "${pr_branch}" ]; then
|
|
245
|
+
prd_file=$(find_prd_file_by_branch "${pr_branch}" || true)
|
|
246
|
+
if [ -n "${prd_file}" ] && [ -f "${prd_file}" ]; then
|
|
247
|
+
prd_payload=$(cat "${prd_file}" 2>/dev/null || true)
|
|
248
|
+
if [ -n "${prd_payload}" ]; then
|
|
249
|
+
prd_excerpt=$(truncate_for_prompt "${prd_payload}" 4500)
|
|
250
|
+
if [[ "${prd_file}" == "${PROJECT_DIR}/"* ]]; then
|
|
251
|
+
prd_rel_path="${prd_file#${PROJECT_DIR}/}"
|
|
252
|
+
else
|
|
253
|
+
prd_rel_path="${prd_file}"
|
|
254
|
+
fi
|
|
255
|
+
fi
|
|
256
|
+
fi
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
section="### PR #${pr_number}"
|
|
260
|
+
if [ -n "${pr_title}" ]; then
|
|
261
|
+
section="${section} — ${pr_title}"
|
|
262
|
+
fi
|
|
263
|
+
section="${section}
|
|
264
|
+
- branch: ${pr_branch:-unknown}"
|
|
265
|
+
if [ -n "${pr_url}" ]; then
|
|
266
|
+
section="${section}
|
|
267
|
+
- url: ${pr_url}"
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
if [ -n "${issue_context}" ]; then
|
|
271
|
+
section="${section}
|
|
272
|
+
- context source: linked GitHub issue body
|
|
273
|
+
${issue_context}"
|
|
274
|
+
elif [ -n "${prd_excerpt}" ]; then
|
|
275
|
+
section="${section}
|
|
276
|
+
- context source: ${prd_rel_path}
|
|
277
|
+
${prd_excerpt}"
|
|
278
|
+
else
|
|
279
|
+
section="${section}
|
|
280
|
+
- context source: not found"
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
printf "%s" "${section}"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
build_prd_context_prompt() {
|
|
287
|
+
local pr_number=""
|
|
288
|
+
local entry=""
|
|
289
|
+
local combined=""
|
|
290
|
+
|
|
291
|
+
for pr_number in "$@"; do
|
|
292
|
+
[ -z "${pr_number}" ] && continue
|
|
293
|
+
entry=$(build_prd_context_for_pr "${pr_number}")
|
|
294
|
+
[ -z "${entry}" ] && continue
|
|
295
|
+
combined="${combined}${combined:+$'\n\n'}${entry}"
|
|
296
|
+
done
|
|
297
|
+
|
|
298
|
+
[ -z "${combined}" ] && return 0
|
|
299
|
+
printf '\n\n## PRD Context\nUse this product context while reviewing and fixing PRs.\n%s\n' "${combined}"
|
|
300
|
+
}
|
|
301
|
+
|
|
127
302
|
# Extract the latest review score from PR comments
|
|
128
303
|
# Returns empty string if no score found
|
|
129
304
|
get_pr_score() {
|
|
@@ -143,6 +318,135 @@ get_pr_score() {
|
|
|
143
318
|
| grep -oP '\d+(?=/100)' || echo ""
|
|
144
319
|
}
|
|
145
320
|
|
|
321
|
+
# Count failed CI checks for a PR.
|
|
322
|
+
# Uses JSON fields when available (more reliable across check name/status formats),
|
|
323
|
+
# then falls back to text parsing for older/mocked gh outputs.
|
|
324
|
+
get_pr_failed_ci_checks() {
|
|
325
|
+
local pr_number="${1:?PR number required}"
|
|
326
|
+
local failed_count=""
|
|
327
|
+
|
|
328
|
+
failed_count="$(
|
|
329
|
+
gh pr checks "${pr_number}" --json bucket,state,conclusion --jq '
|
|
330
|
+
[ .[]
|
|
331
|
+
| (.bucket // "" | ascii_downcase) as $bucket
|
|
332
|
+
| (.state // "" | ascii_downcase) as $state
|
|
333
|
+
| (.conclusion // "" | ascii_downcase) as $conclusion
|
|
334
|
+
| select(
|
|
335
|
+
$bucket == "fail" or
|
|
336
|
+
$bucket == "cancel" or
|
|
337
|
+
$state == "failure" or
|
|
338
|
+
$state == "error" or
|
|
339
|
+
$state == "cancelled" or
|
|
340
|
+
$conclusion == "failure" or
|
|
341
|
+
$conclusion == "error" or
|
|
342
|
+
$conclusion == "cancelled" or
|
|
343
|
+
$conclusion == "timed_out" or
|
|
344
|
+
$conclusion == "action_required" or
|
|
345
|
+
$conclusion == "startup_failure" or
|
|
346
|
+
$conclusion == "stale"
|
|
347
|
+
)
|
|
348
|
+
] | length
|
|
349
|
+
' 2>/dev/null || true
|
|
350
|
+
)"
|
|
351
|
+
|
|
352
|
+
if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
|
|
353
|
+
echo "${failed_count}"
|
|
354
|
+
return 0
|
|
355
|
+
fi
|
|
356
|
+
|
|
357
|
+
failed_count=$(
|
|
358
|
+
gh pr checks "${pr_number}" 2>/dev/null \
|
|
359
|
+
| grep -Eci 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' || true
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if [[ "${failed_count}" =~ ^[0-9]+$ ]]; then
|
|
363
|
+
echo "${failed_count}"
|
|
364
|
+
else
|
|
365
|
+
echo "0"
|
|
366
|
+
fi
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
# Return a semicolon-separated summary of failing CI checks for a PR.
|
|
370
|
+
# Format: "<check name> [state=<state>, conclusion=<conclusion>]"
|
|
371
|
+
get_pr_failed_ci_summary() {
|
|
372
|
+
local pr_number="${1:?PR number required}"
|
|
373
|
+
local failed_summary=""
|
|
374
|
+
|
|
375
|
+
failed_summary="$(
|
|
376
|
+
gh pr checks "${pr_number}" --json name,bucket,state,conclusion --jq '
|
|
377
|
+
[ .[]
|
|
378
|
+
| (.bucket // "" | ascii_downcase) as $bucket
|
|
379
|
+
| (.state // "" | ascii_downcase) as $state
|
|
380
|
+
| (.conclusion // "" | ascii_downcase) as $conclusion
|
|
381
|
+
| select(
|
|
382
|
+
$bucket == "fail" or
|
|
383
|
+
$bucket == "cancel" or
|
|
384
|
+
$state == "failure" or
|
|
385
|
+
$state == "error" or
|
|
386
|
+
$state == "cancelled" or
|
|
387
|
+
$conclusion == "failure" or
|
|
388
|
+
$conclusion == "error" or
|
|
389
|
+
$conclusion == "cancelled" or
|
|
390
|
+
$conclusion == "timed_out" or
|
|
391
|
+
$conclusion == "action_required" or
|
|
392
|
+
$conclusion == "startup_failure" or
|
|
393
|
+
$conclusion == "stale"
|
|
394
|
+
)
|
|
395
|
+
| "\(.name // "unknown") [state=\(.state // "unknown"), conclusion=\(.conclusion // "unknown")]"
|
|
396
|
+
] | join("; ")
|
|
397
|
+
' 2>/dev/null || true
|
|
398
|
+
)"
|
|
399
|
+
|
|
400
|
+
if [ -n "${failed_summary}" ]; then
|
|
401
|
+
echo "${failed_summary}"
|
|
402
|
+
return 0
|
|
403
|
+
fi
|
|
404
|
+
|
|
405
|
+
# Fallback for older/mocked outputs where JSON fields aren't available.
|
|
406
|
+
failed_summary=$(
|
|
407
|
+
gh pr checks "${pr_number}" 2>/dev/null \
|
|
408
|
+
| grep -Ei 'fail|error|cancel|timed[_ -]?out|action_required|startup_failure|stale' \
|
|
409
|
+
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]][[:space:]]*/ /g' \
|
|
410
|
+
| paste -sd '; ' - || true
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
echo "${failed_summary}"
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
# Clean up reviewer-managed worktrees.
|
|
417
|
+
# - Always removes the caller's runner worktree when runner_scope is provided.
|
|
418
|
+
# - Only non-worker/controller processes perform broad cleanup to avoid
|
|
419
|
+
# parallel workers deleting each other's active worktrees.
|
|
420
|
+
cleanup_reviewer_worktrees() {
|
|
421
|
+
local runner_scope="${1:-}"
|
|
422
|
+
|
|
423
|
+
if [ -n "${runner_scope}" ]; then
|
|
424
|
+
cleanup_worktrees "${PROJECT_DIR}" "${runner_scope}"
|
|
425
|
+
fi
|
|
426
|
+
|
|
427
|
+
if [ "${WORKER_MODE}" = "1" ]; then
|
|
428
|
+
return 0
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
# Remove per-PR reviewer worktrees created by prompts from older runs.
|
|
432
|
+
cleanup_worktrees "${PROJECT_DIR}" "${PROJECT_NAME}-nw-review-"
|
|
433
|
+
|
|
434
|
+
# Remove legacy reviewer worktree naming used in some older prompt variants.
|
|
435
|
+
local escaped_project_name
|
|
436
|
+
escaped_project_name=$(printf '%s\n' "${PROJECT_NAME}" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')
|
|
437
|
+
git -C "${PROJECT_DIR}" worktree list --porcelain 2>/dev/null \
|
|
438
|
+
| grep '^worktree ' \
|
|
439
|
+
| awk '{print $2}' \
|
|
440
|
+
| while read -r wt; do
|
|
441
|
+
local wt_basename
|
|
442
|
+
wt_basename=$(basename "${wt}")
|
|
443
|
+
if printf '%s\n' "${wt_basename}" | grep -Eq "^${escaped_project_name}-pr-?[0-9]+$"; then
|
|
444
|
+
log "CLEANUP: Removing legacy reviewer worktree ${wt}"
|
|
445
|
+
git -C "${PROJECT_DIR}" worktree remove --force "${wt}" 2>/dev/null || true
|
|
446
|
+
fi
|
|
447
|
+
done || true
|
|
448
|
+
}
|
|
449
|
+
|
|
146
450
|
# Validate provider
|
|
147
451
|
if ! validate_provider "${PROVIDER_CMD}"; then
|
|
148
452
|
echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
|
|
@@ -221,9 +525,14 @@ while IFS=$'\t' read -r pr_number pr_branch; do
|
|
|
221
525
|
continue
|
|
222
526
|
fi
|
|
223
527
|
|
|
224
|
-
FAILED_CHECKS=$(
|
|
528
|
+
FAILED_CHECKS=$(get_pr_failed_ci_checks "${pr_number}")
|
|
225
529
|
if [ "${FAILED_CHECKS}" -gt 0 ]; then
|
|
226
|
-
|
|
530
|
+
FAILED_SUMMARY=$(get_pr_failed_ci_summary "${pr_number}")
|
|
531
|
+
if [ -n "${FAILED_SUMMARY}" ]; then
|
|
532
|
+
log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s): ${FAILED_SUMMARY}"
|
|
533
|
+
else
|
|
534
|
+
log "INFO: PR #${pr_number} (${pr_branch}) has ${FAILED_CHECKS} failed CI check(s)"
|
|
535
|
+
fi
|
|
227
536
|
NEEDS_WORK=1
|
|
228
537
|
PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}"
|
|
229
538
|
continue
|
|
@@ -325,6 +634,10 @@ fi
|
|
|
325
634
|
|
|
326
635
|
log "START: Found PR(s) needing work:${PRS_NEEDING_WORK}"
|
|
327
636
|
|
|
637
|
+
# Remove stale reviewer worktrees from previous interrupted runs.
|
|
638
|
+
# Worker processes skip broad cleanup to avoid parallel interference.
|
|
639
|
+
cleanup_reviewer_worktrees
|
|
640
|
+
|
|
328
641
|
# Convert "#12 #34" into ["12", "34"] for worker fan-out.
|
|
329
642
|
PR_NUMBER_ARRAY=()
|
|
330
643
|
for pr_token in ${PRS_NEEDING_WORK}; do
|
|
@@ -355,7 +668,14 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
|
|
|
355
668
|
declare -a WORKER_PRS=()
|
|
356
669
|
declare -a WORKER_OUTPUTS=()
|
|
357
670
|
|
|
671
|
+
WORKER_IDX=0
|
|
672
|
+
WORKER_STAGGER_DELAY="${NW_REVIEWER_WORKER_STAGGER:-60}"
|
|
358
673
|
for pr_number in "${PR_NUMBER_ARRAY[@]}"; do
|
|
674
|
+
if [ "${WORKER_IDX}" -gt 0 ]; then
|
|
675
|
+
log "PARALLEL: Staggering worker launch by ${WORKER_STAGGER_DELAY}s (worker $((WORKER_IDX + 1))/${#PR_NUMBER_ARRAY[@]})"
|
|
676
|
+
sleep "${WORKER_STAGGER_DELAY}"
|
|
677
|
+
fi
|
|
678
|
+
|
|
359
679
|
worker_output=$(mktemp "/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}-pr-${pr_number}.XXXXXX")
|
|
360
680
|
WORKER_OUTPUTS+=("${worker_output}")
|
|
361
681
|
WORKER_PRS+=("${pr_number}")
|
|
@@ -370,6 +690,7 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
|
|
|
370
690
|
worker_pid=$!
|
|
371
691
|
WORKER_PIDS+=("${worker_pid}")
|
|
372
692
|
log "PARALLEL: Worker PID ${worker_pid} started for PR #${pr_number}"
|
|
693
|
+
WORKER_IDX=$((WORKER_IDX + 1))
|
|
373
694
|
done
|
|
374
695
|
|
|
375
696
|
EXIT_CODE=0
|
|
@@ -439,6 +760,10 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
|
|
|
439
760
|
fi
|
|
440
761
|
done
|
|
441
762
|
|
|
763
|
+
# Parent/controller process cleans up any per-PR reviewer worktrees that
|
|
764
|
+
# worker runs may have left behind.
|
|
765
|
+
cleanup_reviewer_worktrees
|
|
766
|
+
|
|
442
767
|
emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}" "${MAX_WORKER_ATTEMPTS}" "${MAX_WORKER_FINAL_SCORE}"
|
|
443
768
|
exit 0
|
|
444
769
|
fi
|
|
@@ -449,7 +774,7 @@ if [ -n "${TARGET_PR}" ]; then
|
|
|
449
774
|
fi
|
|
450
775
|
REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${REVIEW_WORKTREE_BASENAME}"
|
|
451
776
|
|
|
452
|
-
|
|
777
|
+
cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
|
|
453
778
|
|
|
454
779
|
# Dry-run mode: print diagnostics and exit
|
|
455
780
|
if [ "${NW_DRY_RUN:-0}" = "1" ]; then
|
|
@@ -479,12 +804,59 @@ if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFA
|
|
|
479
804
|
exit 1
|
|
480
805
|
fi
|
|
481
806
|
|
|
807
|
+
REVIEWER_PROMPT_PATH=$(resolve_instruction_path_with_fallback "${REVIEW_WORKTREE_DIR}" "pr-reviewer.md" "night-watch-pr-reviewer.md" || true)
|
|
808
|
+
if [ -z "${REVIEWER_PROMPT_PATH}" ]; then
|
|
809
|
+
log "FAIL: Missing reviewer prompt file. Checked pr-reviewer.md/night-watch-pr-reviewer.md in instructions/, .claude/commands/, and bundled templates/"
|
|
810
|
+
emit_result "failure" "reason=missing_reviewer_prompt"
|
|
811
|
+
exit 1
|
|
812
|
+
fi
|
|
813
|
+
REVIEWER_PROMPT_BUNDLED_NAME="pr-reviewer.md"
|
|
814
|
+
if [[ "${REVIEWER_PROMPT_PATH}" == */night-watch-pr-reviewer.md ]]; then
|
|
815
|
+
REVIEWER_PROMPT_BUNDLED_NAME="night-watch-pr-reviewer.md"
|
|
816
|
+
fi
|
|
817
|
+
REVIEWER_PROMPT_PATH=$(prefer_bundled_prompt_if_legacy_command "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT_PATH}" "${REVIEWER_PROMPT_BUNDLED_NAME}")
|
|
818
|
+
REVIEWER_PROMPT_BASE=$(cat "${REVIEWER_PROMPT_PATH}")
|
|
819
|
+
REVIEWER_PROMPT_REF=$(instruction_ref_for_prompt "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT_PATH}")
|
|
820
|
+
log "INFO: Using reviewer prompt from ${REVIEWER_PROMPT_REF}"
|
|
821
|
+
|
|
482
822
|
EXIT_CODE=0
|
|
483
823
|
ATTEMPTS_MADE=1
|
|
484
824
|
FINAL_SCORE=""
|
|
485
825
|
TARGET_SCOPE_PROMPT=""
|
|
486
826
|
if [ -n "${TARGET_PR}" ]; then
|
|
487
827
|
TARGET_SCOPE_PROMPT=$'\n\n## Target Scope\n- Only process PR #'"${TARGET_PR}"$'.\n- Ignore all other PRs.\n- If this PR no longer needs work, stop immediately.\n'
|
|
828
|
+
|
|
829
|
+
TARGET_MERGE_STATE=$(gh pr view "${TARGET_PR}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo "UNKNOWN")
|
|
830
|
+
TARGET_FAILED_CHECKS=$(get_pr_failed_ci_summary "${TARGET_PR}")
|
|
831
|
+
TARGET_SCORE=$(get_pr_score "${TARGET_PR}")
|
|
832
|
+
|
|
833
|
+
TARGET_SCOPE_PROMPT+=$'\n## Preflight Data (from CLI)\n- mergeStateStatus: '"${TARGET_MERGE_STATE}"$'\n'
|
|
834
|
+
if [ -n "${TARGET_FAILED_CHECKS}" ]; then
|
|
835
|
+
TARGET_SCOPE_PROMPT+=$'- failing checks: '"${TARGET_FAILED_CHECKS}"$'\n'
|
|
836
|
+
else
|
|
837
|
+
TARGET_SCOPE_PROMPT+=$'- failing checks: none detected\n'
|
|
838
|
+
fi
|
|
839
|
+
if [ -n "${TARGET_SCORE}" ]; then
|
|
840
|
+
TARGET_SCOPE_PROMPT+=$'- latest review score: '"${TARGET_SCORE}"$'/100\n'
|
|
841
|
+
else
|
|
842
|
+
TARGET_SCOPE_PROMPT+=$'- latest review score: not found\n'
|
|
843
|
+
fi
|
|
844
|
+
fi
|
|
845
|
+
|
|
846
|
+
PRD_CONTEXT_PROMPT=""
|
|
847
|
+
if [ -n "${TARGET_PR}" ]; then
|
|
848
|
+
PRD_CONTEXT_PROMPT=$(build_prd_context_prompt "${TARGET_PR}")
|
|
849
|
+
elif [ "${#PR_NUMBER_ARRAY[@]}" -gt 0 ]; then
|
|
850
|
+
PRD_CONTEXT_PROMPT=$(build_prd_context_prompt "${PR_NUMBER_ARRAY[@]}")
|
|
851
|
+
fi
|
|
852
|
+
if [ -n "${PRD_CONTEXT_PROMPT}" ]; then
|
|
853
|
+
if [ -n "${TARGET_PR}" ]; then
|
|
854
|
+
log "INFO: Added PRD context for PR #${TARGET_PR}"
|
|
855
|
+
else
|
|
856
|
+
log "INFO: Added PRD context for ${#PR_NUMBER_ARRAY[@]} PR(s)"
|
|
857
|
+
fi
|
|
858
|
+
else
|
|
859
|
+
log "WARN: No PRD context found for current reviewer scope"
|
|
488
860
|
fi
|
|
489
861
|
|
|
490
862
|
# ── Retry Loop for Targeted PR Review ──────────────────────────────────────────
|
|
@@ -518,13 +890,14 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
|
|
|
518
890
|
fi
|
|
519
891
|
|
|
520
892
|
log "RETRY: Starting attempt ${ATTEMPT}/${TOTAL_ATTEMPTS} (timeout: ${ATTEMPT_TIMEOUT}s)"
|
|
893
|
+
LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
|
|
894
|
+
REVIEWER_PROMPT="${REVIEWER_PROMPT_BASE}${TARGET_SCOPE_PROMPT}${PRD_CONTEXT_PROMPT}"
|
|
521
895
|
|
|
522
896
|
case "${PROVIDER_CMD}" in
|
|
523
897
|
claude)
|
|
524
|
-
CLAUDE_PROMPT="/night-watch-pr-reviewer${TARGET_SCOPE_PROMPT}"
|
|
525
898
|
if (
|
|
526
899
|
cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
|
|
527
|
-
claude -p "${
|
|
900
|
+
claude -p "${REVIEWER_PROMPT}" \
|
|
528
901
|
--dangerously-skip-permissions \
|
|
529
902
|
>> "${LOG_FILE}" 2>&1
|
|
530
903
|
); then
|
|
@@ -534,12 +907,11 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
|
|
|
534
907
|
fi
|
|
535
908
|
;;
|
|
536
909
|
codex)
|
|
537
|
-
CODEX_PROMPT="$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")${TARGET_SCOPE_PROMPT}"
|
|
538
910
|
if (
|
|
539
911
|
cd "${REVIEW_WORKTREE_DIR}" && timeout "${ATTEMPT_TIMEOUT}" \
|
|
540
912
|
codex --quiet \
|
|
541
913
|
--yolo \
|
|
542
|
-
--prompt "${
|
|
914
|
+
--prompt "${REVIEWER_PROMPT}" \
|
|
543
915
|
>> "${LOG_FILE}" 2>&1
|
|
544
916
|
); then
|
|
545
917
|
EXIT_CODE=0
|
|
@@ -553,8 +925,16 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
|
|
|
553
925
|
;;
|
|
554
926
|
esac
|
|
555
927
|
|
|
556
|
-
# If provider failed (non-zero exit),
|
|
928
|
+
# If provider failed (non-zero exit), check for rate limit before giving up
|
|
557
929
|
if [ "${EXIT_CODE}" -ne 0 ]; then
|
|
930
|
+
if [ "${EXIT_CODE}" -ne 124 ] && \
|
|
931
|
+
check_rate_limited "${LOG_FILE}" "${LOG_LINE_BEFORE}" && \
|
|
932
|
+
[ -n "${TARGET_PR}" ] && \
|
|
933
|
+
[ "${ATTEMPT}" -lt "${TOTAL_ATTEMPTS}" ]; then
|
|
934
|
+
log "RATE-LIMITED: 429 detected for PR #${TARGET_PR} (attempt ${ATTEMPT}/${TOTAL_ATTEMPTS}), retrying in 120s..."
|
|
935
|
+
sleep 120
|
|
936
|
+
continue
|
|
937
|
+
fi
|
|
558
938
|
log "RETRY: Provider exited with code ${EXIT_CODE}, not retrying"
|
|
559
939
|
break
|
|
560
940
|
fi
|
|
@@ -585,7 +965,7 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
|
|
|
585
965
|
fi
|
|
586
966
|
done
|
|
587
967
|
|
|
588
|
-
|
|
968
|
+
cleanup_reviewer_worktrees "${REVIEW_WORKTREE_BASENAME}"
|
|
589
969
|
|
|
590
970
|
# ── Auto-merge eligible PRs ─────────────────────────────────────────────────────
|
|
591
971
|
# After the reviewer completes, check for PRs that are merge-ready and queue them
|
|
@@ -53,6 +53,90 @@ emit_result() {
|
|
|
53
53
|
fi
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
decode_base64_value() {
|
|
57
|
+
local value="${1:-}"
|
|
58
|
+
if [ -z "${value}" ]; then
|
|
59
|
+
return 0
|
|
60
|
+
fi
|
|
61
|
+
if printf '%s' "${value}" | base64 --decode >/dev/null 2>&1; then
|
|
62
|
+
printf '%s' "${value}" | base64 --decode
|
|
63
|
+
else
|
|
64
|
+
printf '%s' "${value}" | base64 -d 2>/dev/null || true
|
|
65
|
+
fi
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get_pr_comment_bodies_base64() {
|
|
69
|
+
local pr_number="${1:?PR number required}"
|
|
70
|
+
gh pr view "${pr_number}" --json comments --jq '.comments[]?.body | @base64' 2>/dev/null || true
|
|
71
|
+
if [ -n "${REPO:-}" ]; then
|
|
72
|
+
gh api "repos/${REPO}/issues/${pr_number}/comments" --jq '.[].body | @base64' 2>/dev/null || true
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get_latest_qa_comment_body() {
|
|
77
|
+
local pr_number="${1:?PR number required}"
|
|
78
|
+
local latest=""
|
|
79
|
+
local encoded=""
|
|
80
|
+
local decoded=""
|
|
81
|
+
|
|
82
|
+
while IFS= read -r encoded; do
|
|
83
|
+
[ -z "${encoded}" ] && continue
|
|
84
|
+
decoded=$(decode_base64_value "${encoded}")
|
|
85
|
+
if printf '%s' "${decoded}" | grep -q '<!-- night-watch-qa-marker -->'; then
|
|
86
|
+
latest="${decoded}"
|
|
87
|
+
fi
|
|
88
|
+
done < <(get_pr_comment_bodies_base64 "${pr_number}")
|
|
89
|
+
|
|
90
|
+
printf "%s" "${latest}"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pr_has_qa_generated_files() {
|
|
94
|
+
local pr_number="${1:?PR number required}"
|
|
95
|
+
gh pr view "${pr_number}" --json files --jq '.files[]?.path' 2>/dev/null \
|
|
96
|
+
| grep -Eq '^(qa-artifacts/|tests/.*/qa/)'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
provider_output_looks_invalid() {
|
|
100
|
+
local from_line="${1:-0}"
|
|
101
|
+
if [ ! -f "${LOG_FILE}" ]; then
|
|
102
|
+
return 1
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
tail -n "+$((from_line + 1))" "${LOG_FILE}" 2>/dev/null \
|
|
106
|
+
| grep -Eqi 'Unknown skill:|session is in a broken state|working directory .* no longer exists|Please restart this session'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
validate_qa_evidence() {
|
|
110
|
+
local pr_number="${1:?PR number required}"
|
|
111
|
+
local qa_comment=""
|
|
112
|
+
|
|
113
|
+
qa_comment=$(get_latest_qa_comment_body "${pr_number}")
|
|
114
|
+
if [ -z "${qa_comment}" ]; then
|
|
115
|
+
log "FAIL-QA-EVIDENCE: PR #${pr_number} has no QA marker comment (<!-- night-watch-qa-marker -->)"
|
|
116
|
+
return 1
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
if printf '%s' "${qa_comment}" | grep -Eqi 'QA: No tests needed for this PR|No tests needed'; then
|
|
120
|
+
return 0
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
if ! pr_has_qa_generated_files "${pr_number}"; then
|
|
124
|
+
log "FAIL-QA-EVIDENCE: PR #${pr_number} has QA marker comment but no qa-artifacts/ or tests/*/qa/ files"
|
|
125
|
+
return 1
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
if [ "${QA_ARTIFACTS}" = "screenshot" ] || [ "${QA_ARTIFACTS}" = "both" ]; then
|
|
129
|
+
if printf '%s' "${qa_comment}" | grep -q '#### UI Tests (Playwright)'; then
|
|
130
|
+
if ! printf '%s' "${qa_comment}" | grep -Eq '!\[[^]]*\]\([^)]*qa-artifacts/[^)]*\)'; then
|
|
131
|
+
log "FAIL-QA-EVIDENCE: PR #${pr_number} reports UI tests but comment lacks screenshot links to qa-artifacts/"
|
|
132
|
+
return 1
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
return 0
|
|
138
|
+
}
|
|
139
|
+
|
|
56
140
|
# Validate provider
|
|
57
141
|
if ! validate_provider "${PROVIDER_CMD}"; then
|
|
58
142
|
echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
|
|
@@ -217,15 +301,32 @@ Artifacts: ${QA_ARTIFACTS}"
|
|
|
217
301
|
continue
|
|
218
302
|
fi
|
|
219
303
|
|
|
304
|
+
QA_PROMPT_PATH=$(resolve_instruction_path_with_fallback "${QA_WORKTREE_DIR}" "qa.md" "night-watch-qa.md" || true)
|
|
305
|
+
if [ -z "${QA_PROMPT_PATH}" ]; then
|
|
306
|
+
log "FAIL: Missing QA prompt file for PR #${pr_num}. Checked qa.md/night-watch-qa.md in instructions/, .claude/commands/, and bundled templates/"
|
|
307
|
+
EXIT_CODE=1
|
|
308
|
+
break
|
|
309
|
+
fi
|
|
310
|
+
QA_PROMPT_BUNDLED_NAME="qa.md"
|
|
311
|
+
if [[ "${QA_PROMPT_PATH}" == */night-watch-qa.md ]]; then
|
|
312
|
+
QA_PROMPT_BUNDLED_NAME="night-watch-qa.md"
|
|
313
|
+
fi
|
|
314
|
+
QA_PROMPT_PATH=$(prefer_bundled_prompt_if_legacy_command "${QA_WORKTREE_DIR}" "${QA_PROMPT_PATH}" "${QA_PROMPT_BUNDLED_NAME}")
|
|
315
|
+
QA_PROMPT=$(cat "${QA_PROMPT_PATH}")
|
|
316
|
+
QA_PROMPT_REF=$(instruction_ref_for_prompt "${QA_WORKTREE_DIR}" "${QA_PROMPT_PATH}")
|
|
317
|
+
log "QA: PR #${pr_num} — using prompt from ${QA_PROMPT_REF}"
|
|
318
|
+
|
|
319
|
+
LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
|
|
320
|
+
PROVIDER_OK=0
|
|
220
321
|
case "${PROVIDER_CMD}" in
|
|
221
322
|
claude)
|
|
222
323
|
if (
|
|
223
324
|
cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
|
|
224
|
-
claude -p "
|
|
325
|
+
claude -p "${QA_PROMPT}" \
|
|
225
326
|
--dangerously-skip-permissions \
|
|
226
327
|
>> "${LOG_FILE}" 2>&1
|
|
227
328
|
); then
|
|
228
|
-
|
|
329
|
+
PROVIDER_OK=1
|
|
229
330
|
else
|
|
230
331
|
local_exit=$?
|
|
231
332
|
log "QA: PR #${pr_num} — provider exited with code ${local_exit}"
|
|
@@ -241,10 +342,10 @@ Artifacts: ${QA_ARTIFACTS}"
|
|
|
241
342
|
cd "${QA_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
|
|
242
343
|
codex --quiet \
|
|
243
344
|
--yolo \
|
|
244
|
-
--prompt "$
|
|
345
|
+
--prompt "${QA_PROMPT}" \
|
|
245
346
|
>> "${LOG_FILE}" 2>&1
|
|
246
347
|
); then
|
|
247
|
-
|
|
348
|
+
PROVIDER_OK=1
|
|
248
349
|
else
|
|
249
350
|
local_exit=$?
|
|
250
351
|
log "QA: PR #${pr_num} — provider exited with code ${local_exit}"
|
|
@@ -261,6 +362,17 @@ Artifacts: ${QA_ARTIFACTS}"
|
|
|
261
362
|
;;
|
|
262
363
|
esac
|
|
263
364
|
|
|
365
|
+
if [ "${PROVIDER_OK}" -eq 1 ]; then
|
|
366
|
+
if provider_output_looks_invalid "${LOG_LINE_BEFORE}"; then
|
|
367
|
+
log "FAIL-QA-EVIDENCE: PR #${pr_num} provider output indicates an invalid automation run"
|
|
368
|
+
EXIT_CODE=1
|
|
369
|
+
elif ! validate_qa_evidence "${pr_num}"; then
|
|
370
|
+
EXIT_CODE=1
|
|
371
|
+
else
|
|
372
|
+
log "QA: PR #${pr_num} — provider completed with verifiable QA evidence"
|
|
373
|
+
fi
|
|
374
|
+
fi
|
|
375
|
+
|
|
264
376
|
cleanup_worktrees "${PROJECT_DIR}"
|
|
265
377
|
done
|
|
266
378
|
|