@jonit-dev/night-watch-cli 1.8.12-beta.1 → 1.8.12-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/cli.js +3729 -2027
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/agent.d.ts +12 -0
  4. package/dist/commands/agent.d.ts.map +1 -0
  5. package/dist/commands/agent.js +307 -0
  6. package/dist/commands/agent.js.map +1 -0
  7. package/dist/commands/analytics.d.ts.map +1 -1
  8. package/dist/commands/analytics.js +60 -0
  9. package/dist/commands/analytics.js.map +1 -1
  10. package/dist/commands/audit.d.ts.map +1 -1
  11. package/dist/commands/audit.js +45 -0
  12. package/dist/commands/audit.js.map +1 -1
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +13 -0
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/merge.d.ts.map +1 -1
  17. package/dist/commands/merge.js +30 -3
  18. package/dist/commands/merge.js.map +1 -1
  19. package/dist/commands/plan.d.ts.map +1 -1
  20. package/dist/commands/plan.js +45 -0
  21. package/dist/commands/plan.js.map +1 -1
  22. package/dist/commands/qa.d.ts.map +1 -1
  23. package/dist/commands/qa.js +24 -0
  24. package/dist/commands/qa.js.map +1 -1
  25. package/dist/commands/queue.d.ts.map +1 -1
  26. package/dist/commands/queue.js +20 -0
  27. package/dist/commands/queue.js.map +1 -1
  28. package/dist/commands/resolve.d.ts.map +1 -1
  29. package/dist/commands/resolve.js +26 -0
  30. package/dist/commands/resolve.js.map +1 -1
  31. package/dist/commands/review.d.ts +2 -0
  32. package/dist/commands/review.d.ts.map +1 -1
  33. package/dist/commands/review.js +46 -1
  34. package/dist/commands/review.js.map +1 -1
  35. package/dist/commands/run.d.ts +16 -1
  36. package/dist/commands/run.d.ts.map +1 -1
  37. package/dist/commands/run.js +85 -1
  38. package/dist/commands/run.js.map +1 -1
  39. package/dist/commands/shared/env-builder.d.ts.map +1 -1
  40. package/dist/commands/shared/env-builder.js +1 -0
  41. package/dist/commands/shared/env-builder.js.map +1 -1
  42. package/dist/commands/shared/feedback.d.ts +24 -0
  43. package/dist/commands/shared/feedback.d.ts.map +1 -0
  44. package/dist/commands/shared/feedback.js +38 -0
  45. package/dist/commands/shared/feedback.js.map +1 -0
  46. package/dist/commands/slice.d.ts.map +1 -1
  47. package/dist/commands/slice.js +48 -1
  48. package/dist/commands/slice.js.map +1 -1
  49. package/dist/scripts/night-watch-audit-cron.sh +1 -0
  50. package/dist/scripts/night-watch-cron.sh +186 -23
  51. package/dist/scripts/night-watch-helpers.sh +65 -2
  52. package/dist/scripts/night-watch-merger-cron.sh +210 -36
  53. package/dist/scripts/night-watch-plan-cron.sh +2 -0
  54. package/dist/scripts/night-watch-pr-resolver-cron.sh +6 -0
  55. package/dist/scripts/night-watch-pr-reviewer-cron.sh +96 -5
  56. package/dist/scripts/night-watch-qa-cron.sh +8 -1
  57. package/dist/scripts/night-watch-slicer-cron.sh +3 -0
  58. package/dist/templates/night-watch-pr-reviewer.md +7 -6
  59. package/dist/templates/night-watch.config.json +21 -0
  60. package/dist/templates/pr-reviewer.md +7 -6
  61. package/dist/templates/slicer.md +3 -0
  62. package/dist/web/assets/index-B6E6kOoR.js +406 -0
  63. package/dist/web/assets/index-C-xpWpS8.css +1 -0
  64. package/dist/web/assets/index-CEYe-290.js +412 -0
  65. package/dist/web/assets/index-DIMUXIP8.css +1 -0
  66. package/dist/web/assets/index-DpvzoXEv.js +442 -0
  67. package/dist/web/assets/index-DyME41HV.css +1 -0
  68. package/dist/web/assets/index-NR27JE3b.js +406 -0
  69. package/dist/web/index.html +2 -2
  70. package/package.json +3 -1
@@ -14,6 +14,8 @@ set -euo pipefail
14
14
  # NW_MERGER_BRANCH_PATTERNS= - Comma-separated branch prefixes (empty = all)
15
15
  # NW_MERGER_REBASE_BEFORE_MERGE=1 - Set to 1 to rebase before merging
16
16
  # NW_MERGER_MAX_PRS_PER_RUN=0 - Max PRs to merge per run (0 = unlimited)
17
+ # NW_MERGER_CI_MAX_WAIT=300 - Max seconds to wait for checks after rebase
18
+ # NW_MERGER_CI_POLL_INTERVAL=15 - Seconds between check polls after rebase
17
19
  # NW_DRY_RUN=0 - Set to 1 for dry-run mode
18
20
 
19
21
  PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
@@ -26,7 +28,10 @@ MERGE_METHOD="${NW_MERGER_MERGE_METHOD:-squash}"
26
28
  MIN_REVIEW_SCORE="${NW_MERGER_MIN_REVIEW_SCORE:-80}"
27
29
  REBASE_BEFORE_MERGE="${NW_MERGER_REBASE_BEFORE_MERGE:-1}"
28
30
  MAX_PRS_PER_RUN="${NW_MERGER_MAX_PRS_PER_RUN:-0}"
31
+ CI_MAX_WAIT="${NW_MERGER_CI_MAX_WAIT:-300}"
32
+ CI_POLL_INTERVAL="${NW_MERGER_CI_POLL_INTERVAL:-15}"
29
33
  BRANCH_PATTERNS_RAW="${NW_MERGER_BRANCH_PATTERNS:-}"
34
+ READY_TO_MERGE_LABEL="${NW_PR_RESOLVER_READY_LABEL:-ready-to-merge}"
30
35
  SCRIPT_START_TIME=$(date +%s)
31
36
  DRY_RUN="${NW_DRY_RUN:-0}"
32
37
  PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
@@ -39,6 +44,12 @@ fi
39
44
  if ! [[ "${MIN_REVIEW_SCORE}" =~ ^[0-9]+$ ]]; then
40
45
  MIN_REVIEW_SCORE="80"
41
46
  fi
47
+ if ! [[ "${CI_MAX_WAIT}" =~ ^[0-9]+$ ]]; then
48
+ CI_MAX_WAIT="300"
49
+ fi
50
+ if ! [[ "${CI_POLL_INTERVAL}" =~ ^[0-9]+$ ]] || [ "${CI_POLL_INTERVAL}" = "0" ]; then
51
+ CI_POLL_INTERVAL="15"
52
+ fi
42
53
  # Clamp merge method to valid values
43
54
  case "${MERGE_METHOD}" in
44
55
  squash|merge|rebase) ;;
@@ -55,6 +66,7 @@ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
55
66
  # NOTE: Lock file path must match mergerLockPath() in src/utils/status-data.ts
56
67
  LOCK_FILE="/tmp/night-watch-merger-${PROJECT_RUNTIME_KEY}.lock"
57
68
  SCRIPT_TYPE="merger"
69
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
58
70
 
59
71
  MERGED_PRS=0
60
72
  FAILED_PRS=0
@@ -103,29 +115,150 @@ get_review_score() {
103
115
  # Look for review score comment from night-watch
104
116
  local score
105
117
  score=$(gh pr view "${pr_number}" --json comments \
106
- --jq '[.comments[].body | select(test("review score|score:? [0-9]+/100"; "i")) | capture("(?i)score:? *(?<s>[0-9]+)/100") | .s] | last | tonumber // -1' \
118
+ --jq '[.comments[].body | select(test("score[^0-9]{0,30}[0-9]+/100"; "i")) | capture("(?i)score[^0-9]{0,30}(?<s>[0-9]+)/100") | .s] | last | tonumber // -1' \
107
119
  2>/dev/null || echo "-1")
108
120
  echo "${score}"
109
121
  }
110
122
 
111
- # Check if CI is passing for a PR (all checks must be complete and none failing)
112
- ci_passing() {
123
+ # Count pending review feedback that should send the PR back through reviewer
124
+ # instead of allowing the merger to land it. We combine GitHub's review decision
125
+ # with unresolved review threads because some providers leave actionable feedback
126
+ # as thread comments without lowering the score.
127
+ get_pending_review_feedback_count() {
113
128
  local pr_number="${1}"
114
- local checks_json
115
- checks_json=$(gh pr checks "${pr_number}" --json name,state,conclusion 2>/dev/null || echo "[]")
116
- # Fail if any checks have explicit failures
117
- local fail_count
118
- fail_count=$(echo "${checks_json}" | jq '[.[] | select(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "CANCELLED" or .state == "FAILURE")] | length' 2>/dev/null || echo "999")
119
- if [ "${fail_count}" != "0" ]; then
120
- return 1
129
+ local review_decision=""
130
+ local change_request_count="0"
131
+ local unresolved_thread_count="0"
132
+ local repo=""
133
+ local owner=""
134
+ local name=""
135
+
136
+ review_decision=$(gh pr view "${pr_number}" --json reviewDecision --jq '.reviewDecision // ""' 2>/dev/null || echo "")
137
+
138
+ change_request_count=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}/reviews" \
139
+ --jq '[.[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0")
140
+ if ! [[ "${change_request_count}" =~ ^[0-9]+$ ]]; then
141
+ change_request_count="0"
142
+ fi
143
+
144
+ repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
145
+ if [[ "${repo}" == */* ]]; then
146
+ owner="${repo%%/*}"
147
+ name="${repo#*/}"
148
+ unresolved_thread_count=$(gh api graphql \
149
+ -F owner="${owner}" \
150
+ -F name="${name}" \
151
+ -F number="${pr_number}" \
152
+ -f query='
153
+ query($owner: String!, $name: String!, $number: Int!) {
154
+ repository(owner: $owner, name: $name) {
155
+ pullRequest(number: $number) {
156
+ reviewThreads(first: 100) {
157
+ nodes {
158
+ isResolved
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ ' \
165
+ --jq '[.data.repository.pullRequest.reviewThreads.nodes[]? | select(.isResolved == false)] | length' \
166
+ 2>/dev/null || echo "0")
167
+ if ! [[ "${unresolved_thread_count}" =~ ^[0-9]+$ ]]; then
168
+ unresolved_thread_count="0"
169
+ fi
170
+ fi
171
+
172
+ if [ "${unresolved_thread_count}" -gt 0 ]; then
173
+ echo "${unresolved_thread_count}"
174
+ return 0
121
175
  fi
122
- # Fail if any checks are still pending/in-progress (not yet concluded)
123
- local pending_count
124
- pending_count=$(echo "${checks_json}" | jq '[.[] | select(.state == "PENDING" or .state == "IN_PROGRESS" or (.conclusion == null and .state != "SUCCESS"))] | length' 2>/dev/null || echo "999")
125
- if [ "${pending_count}" != "0" ]; then
126
- return 1
176
+
177
+ if [ "${change_request_count}" -gt 0 ]; then
178
+ echo "${change_request_count}"
179
+ return 0
127
180
  fi
128
- return 0
181
+
182
+ if [ "${review_decision}" = "CHANGES_REQUESTED" ]; then
183
+ echo "1"
184
+ return 0
185
+ fi
186
+
187
+ echo "0"
188
+ }
189
+
190
+ # Get the current head OID for a PR.
191
+ get_pr_head_oid() {
192
+ local pr_number="${1}"
193
+ gh pr view "${pr_number}" --json headRefOid --jq '.headRefOid // ""' 2>/dev/null || echo ""
194
+ }
195
+
196
+ # Return the CI state for the PR's status rollup on the expected head OID.
197
+ ci_status_for_head() {
198
+ local pr_number="${1}"
199
+ local expected_head="${2:-}"
200
+ local status_json
201
+
202
+ status_json=$(gh pr view "${pr_number}" --json headRefOid,statusCheckRollup 2>/dev/null || echo "")
203
+ if [ -z "${status_json}" ]; then
204
+ echo "unknown"
205
+ return 0
206
+ fi
207
+
208
+ echo "${status_json}" | jq -r --arg expected_head "${expected_head}" '
209
+ def in_list($values): . as $value | $values | index($value);
210
+ def check_run_failure:
211
+ .__typename == "CheckRun"
212
+ and ((.conclusion // "") | in_list(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STALE", "STARTUP_FAILURE"]));
213
+ def status_context_failure:
214
+ .__typename == "StatusContext"
215
+ and ((.state // "") | in_list(["FAILURE", "ERROR"]));
216
+ def check_run_pending:
217
+ .__typename == "CheckRun"
218
+ and ((.status // "") != "COMPLETED" or .conclusion == null);
219
+ def status_context_pending:
220
+ .__typename == "StatusContext"
221
+ and ((.state // "") | in_list(["PENDING", "EXPECTED"]));
222
+ def check_run_nonpassing:
223
+ .__typename == "CheckRun"
224
+ and ((.conclusion // "") | in_list(["SUCCESS", "NEUTRAL", "SKIPPED"]) | not);
225
+ def status_context_nonpassing:
226
+ .__typename == "StatusContext"
227
+ and (.state // "") != "SUCCESS";
228
+ if ($expected_head != "" and (.headRefOid // "") != $expected_head) then
229
+ "head_mismatch"
230
+ elif ((.statusCheckRollup // []) | length) == 0 then
231
+ "absent"
232
+ elif any((.statusCheckRollup // [])[]; check_run_failure or status_context_failure) then
233
+ "failed"
234
+ elif any((.statusCheckRollup // [])[]; check_run_pending or status_context_pending) then
235
+ "pending"
236
+ elif any((.statusCheckRollup // [])[]; check_run_nonpassing or status_context_nonpassing) then
237
+ "failed"
238
+ else
239
+ "passing"
240
+ end
241
+ ' 2>/dev/null || echo "unknown"
242
+ }
243
+
244
+ wait_for_ci_passing_on_head() {
245
+ local pr_number="${1}"
246
+ local expected_head="${2}"
247
+ local ci_waited=0
248
+ LAST_CI_STATUS="unknown"
249
+
250
+ while [ "${ci_waited}" -lt "${CI_MAX_WAIT}" ]; do
251
+ LAST_CI_STATUS=$(ci_status_for_head "${pr_number}" "${expected_head}")
252
+ if [ "${LAST_CI_STATUS}" = "passing" ]; then
253
+ return 0
254
+ fi
255
+ log "INFO: PR #${pr_number}: Waiting for fresh CI on head ${expected_head} (${LAST_CI_STATUS}, ${ci_waited}s/${CI_MAX_WAIT}s)..."
256
+ sleep "${CI_POLL_INTERVAL}"
257
+ ci_waited=$((ci_waited + CI_POLL_INTERVAL))
258
+ done
259
+
260
+ LAST_CI_STATUS=$(ci_status_for_head "${pr_number}" "${expected_head}")
261
+ [ "${LAST_CI_STATUS}" = "passing" ]
129
262
  }
130
263
 
131
264
  # Rebase a PR against its base branch
@@ -140,6 +273,23 @@ rebase_pr() {
140
273
  return $?
141
274
  }
142
275
 
276
+ cleanup_watchdog() {
277
+ local pid="${1:-}"
278
+ local child_pids=""
279
+
280
+ if [ -z "${pid}" ]; then
281
+ return 0
282
+ fi
283
+
284
+ child_pids=$(pgrep -P "${pid}" 2>/dev/null || true)
285
+ if [ -n "${child_pids}" ]; then
286
+ kill ${child_pids} 2>/dev/null || true
287
+ fi
288
+
289
+ kill "${pid}" 2>/dev/null || true
290
+ wait "${pid}" 2>/dev/null || true
291
+ }
292
+
143
293
  log() {
144
294
  echo "[$(date '+%Y-%m-%dT%H:%M:%S')] $*" | tee -a "${LOG_FILE}"
145
295
  }
@@ -158,7 +308,7 @@ cd "${PROJECT_DIR}"
158
308
 
159
309
  log "========================================"
160
310
  log "RUN-START: merger invoked project=${PROJECT_DIR} dry_run=${DRY_RUN}"
161
- log "CONFIG: merge_method=${MERGE_METHOD} min_review_score=${MIN_REVIEW_SCORE} rebase_before_merge=${REBASE_BEFORE_MERGE} max_prs=${MAX_PRS_PER_RUN} max_runtime=${MAX_RUNTIME}s branch_patterns=${BRANCH_PATTERNS_RAW:-<all>}"
311
+ log "CONFIG: merge_method=${MERGE_METHOD} min_review_score=${MIN_REVIEW_SCORE} rebase_before_merge=${REBASE_BEFORE_MERGE} max_prs=${MAX_PRS_PER_RUN} max_runtime=${MAX_RUNTIME}s ready_label=${READY_TO_MERGE_LABEL} branch_patterns=${BRANCH_PATTERNS_RAW:-<all>}"
162
312
  log "========================================"
163
313
 
164
314
  if ! acquire_lock "${LOCK_FILE}"; then
@@ -187,7 +337,7 @@ fi
187
337
  kill -TERM $$ 2>/dev/null || true
188
338
  ) &
189
339
  WATCHDOG_PID=$!
190
- append_exit_trap "kill ${WATCHDOG_PID} 2>/dev/null || true"
340
+ append_exit_trap "cleanup_watchdog ${WATCHDOG_PID}"
191
341
 
192
342
  # Discover open PRs sorted by creation date (oldest first = FIFO)
193
343
  log "INFO: Scanning open PRs..."
@@ -224,15 +374,27 @@ while IFS= read -r pr_json; do
224
374
  continue
225
375
  fi
226
376
 
377
+ if csv_has_label "${pr_labels:-}" "${READY_TO_MERGE_LABEL}"; then
378
+ log "INFO: PR #${pr_number} (${pr_branch}): Skipping PR labeled ${READY_TO_MERGE_LABEL}"
379
+ continue
380
+ fi
381
+
227
382
  # Check branch pattern filter
228
383
  if ! matches_branch_patterns "${pr_branch}"; then
229
384
  log "DEBUG: PR #${pr_number} (${pr_branch}): Branch pattern mismatch, skipping"
230
385
  continue
231
386
  fi
232
387
 
388
+ pr_head_oid=$(get_pr_head_oid "${pr_number}")
389
+ if [ -z "${pr_head_oid}" ]; then
390
+ log "INFO: PR #${pr_number} (${pr_branch}): Unable to determine PR head, skipping"
391
+ continue
392
+ fi
393
+
233
394
  # Check CI status
234
- if ! ci_passing "${pr_number}"; then
235
- log "INFO: PR #${pr_number} (${pr_branch}): CI not passing, skipping"
395
+ ci_status=$(ci_status_for_head "${pr_number}" "${pr_head_oid}")
396
+ if [ "${ci_status}" != "passing" ]; then
397
+ log "INFO: PR #${pr_number} (${pr_branch}): CI not passing on head ${pr_head_oid} (${ci_status}), skipping"
236
398
  continue
237
399
  fi
238
400
 
@@ -240,15 +402,22 @@ while IFS= read -r pr_json; do
240
402
  if [ "${MIN_REVIEW_SCORE}" -gt "0" ]; then
241
403
  score=$(get_review_score "${pr_number}")
242
404
  if [ "${score}" -lt "0" ] || [ "${score}" -lt "${MIN_REVIEW_SCORE}" ]; then
243
- log "INFO: PR #${pr_number} (${pr_branch}): Review score ${score} < ${MIN_REVIEW_SCORE} (or no score found), skipping"
405
+ log "INFO: PR #${pr_number} (${pr_branch}): Review score ${score} < ${MIN_REVIEW_SCORE} (or no score found), reviewer job required before merge"
244
406
  continue
245
407
  fi
246
408
  fi
247
409
 
410
+ pending_review_feedback_count=$(get_pending_review_feedback_count "${pr_number}")
411
+ if [ "${pending_review_feedback_count}" -gt "0" ]; then
412
+ log "INFO: PR #${pr_number} (${pr_branch}): ${pending_review_feedback_count} pending review feedback item(s), reviewer job required before merge"
413
+ continue
414
+ fi
415
+
248
416
  log "INFO: PR #${pr_number} (${pr_branch}): Eligible for merge"
249
417
 
250
418
  # Rebase before merge if configured
251
419
  if [ "${REBASE_BEFORE_MERGE}" = "1" ]; then
420
+ pr_head_before_rebase="${pr_head_oid}"
252
421
  if ! rebase_pr "${pr_number}"; then
253
422
  log "WARN: PR #${pr_number}: Rebase failed, skipping"
254
423
  FAILED_PRS=$((FAILED_PRS + 1))
@@ -256,20 +425,20 @@ while IFS= read -r pr_json; do
256
425
  fi
257
426
  log "INFO: PR #${pr_number}: Rebase successful"
258
427
 
259
- # Poll CI until all checks complete after rebase (up to 5 minutes)
260
- local ci_max_wait=300
261
- local ci_waited=0
262
- local ci_poll=15
263
- while [ "${ci_waited}" -lt "${ci_max_wait}" ]; do
264
- sleep "${ci_poll}"
265
- ci_waited=$((ci_waited + ci_poll))
266
- if ci_passing "${pr_number}"; then
267
- break
268
- fi
269
- log "INFO: PR #${pr_number}: Waiting for CI after rebase (${ci_waited}s/${ci_max_wait}s)..."
270
- done
271
- if ! ci_passing "${pr_number}"; then
272
- log "INFO: PR #${pr_number}: CI not passing after rebase (waited ${ci_waited}s), skipping"
428
+ pr_head_after_rebase=$(get_pr_head_oid "${pr_number}")
429
+ if [ -z "${pr_head_after_rebase}" ]; then
430
+ log "INFO: PR #${pr_number}: Unable to determine PR head after rebase, skipping"
431
+ continue
432
+ fi
433
+ if [ "${pr_head_after_rebase}" != "${pr_head_before_rebase}" ]; then
434
+ log "INFO: PR #${pr_number}: Head changed after rebase ${pr_head_before_rebase} -> ${pr_head_after_rebase}; waiting for fresh CI"
435
+ else
436
+ log "INFO: PR #${pr_number}: Head unchanged after rebase (${pr_head_after_rebase}); confirming CI"
437
+ fi
438
+
439
+ # Poll CI until all checks attached to the post-rebase head are complete and passing.
440
+ if ! wait_for_ci_passing_on_head "${pr_number}" "${pr_head_after_rebase}"; then
441
+ log "INFO: PR #${pr_number}: Fresh CI not passing on head ${pr_head_after_rebase} after rebase (${LAST_CI_STATUS}, waited ${CI_MAX_WAIT}s), skipping"
273
442
  continue
274
443
  fi
275
444
  fi
@@ -284,12 +453,17 @@ while IFS= read -r pr_json; do
284
453
  # Rebase remaining PRs after each successful merge
285
454
  log "INFO: Rebasing remaining open PRs after merging #${pr_number}..."
286
455
  REMAINING_JSON=$(gh pr list --state open \
287
- --json number,headRefName \
456
+ --json number,headRefName,labels \
288
457
  2>/dev/null || echo "[]")
289
458
  while IFS= read -r remaining_pr; do
290
459
  remaining_number=$(echo "${remaining_pr}" | jq -r '.number')
291
460
  remaining_branch=$(echo "${remaining_pr}" | jq -r '.headRefName')
461
+ remaining_labels=$(echo "${remaining_pr}" | jq -r '[.labels[]?.name] | join(",")')
292
462
  if [ "${remaining_number}" != "${pr_number}" ]; then
463
+ if csv_has_label "${remaining_labels:-}" "${READY_TO_MERGE_LABEL}"; then
464
+ log "INFO: Skipping post-merge rebase for PR #${remaining_number} (${remaining_branch}) because it is labeled ${READY_TO_MERGE_LABEL}"
465
+ continue
466
+ fi
293
467
  log "INFO: Rebasing remaining PR #${remaining_number} (${remaining_branch})"
294
468
  gh pr update-branch --rebase "${remaining_number}" 2>/dev/null || \
295
469
  log "WARN: PR #${remaining_number}: Rebase failed (continuing)"
@@ -61,6 +61,8 @@ PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PRO
61
61
  PRD_DIR="${NW_PRD_DIR:-docs/PRDs}"
62
62
  PLAN_TASK="${NW_PLAN_TASK:-}"
63
63
 
64
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
65
+
64
66
  rotate_log
65
67
  log_separator
66
68
  log "RUN-START: planner invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}"
@@ -63,6 +63,7 @@ PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PRO
63
63
  # NOTE: Lock file path must match resolverLockPath() in src/utils/status-data.ts
64
64
  LOCK_FILE="/tmp/night-watch-pr-resolver-${PROJECT_RUNTIME_KEY}.lock"
65
65
  SCRIPT_TYPE="pr-resolver"
66
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
66
67
 
67
68
  emit_result() {
68
69
  local status="${1:?status required}"
@@ -359,6 +360,11 @@ while IFS= read -r pr_line; do
359
360
  continue
360
361
  fi
361
362
 
363
+ if csv_has_label "${labels:-}" "${READY_LABEL}"; then
364
+ log "INFO: Skipping PR #${pr_number} (${pr_branch}) because it is labeled ${READY_LABEL}"
365
+ continue
366
+ fi
367
+
362
368
  # Apply branch pattern filter
363
369
  if ! matches_branch_patterns "${pr_branch}"; then
364
370
  log "DEBUG: Skipping PR #${pr_number} — branch '${pr_branch}' does not match patterns" "patterns=${BRANCH_PATTERNS_RAW}"
@@ -63,11 +63,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
63
63
  # shellcheck source=night-watch-helpers.sh
64
64
  source "${SCRIPT_DIR}/night-watch-helpers.sh"
65
65
 
66
- # Ensure provider CLI is on PATH (nvm, fnm, volta, common bin dirs)
67
- if ! ensure_provider_on_path "${PROVIDER_CMD}"; then
68
- echo "ERROR: Provider '${PROVIDER_CMD}' not found in PATH or common installation locations" >&2
69
- exit 127
70
- fi
71
66
  PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
72
67
  PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PROVIDER_LABEL}")
73
68
  GLOBAL_LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}.lock"
@@ -79,6 +74,7 @@ else
79
74
  fi
80
75
 
81
76
  SCRIPT_TYPE="reviewer"
77
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
82
78
  READY_FOR_REVIEW_LABEL="${NW_READY_FOR_REVIEW_LABEL:-ready-for-review}"
83
79
  READY_FOR_REVIEW_MARKER_NAME="night-watch-ready-for-review"
84
80
  READY_TO_MERGE_LABEL="${NW_PR_RESOLVER_READY_LABEL:-ready-to-merge}"
@@ -93,6 +89,13 @@ emit_result() {
93
89
  fi
94
90
  }
95
91
 
92
+ require_provider_on_path() {
93
+ if ! ensure_provider_on_path "${PROVIDER_CMD}"; then
94
+ echo "ERROR: Provider '${PROVIDER_CMD}' not found in PATH or common installation locations" >&2
95
+ exit 127
96
+ fi
97
+ }
98
+
96
99
  extract_review_score_from_text() {
97
100
  local review_text="${1:-}"
98
101
  printf '%s' "${review_text}" \
@@ -129,6 +132,72 @@ get_pr_comments() {
129
132
  } | awk '!seen[$0]++'
130
133
  }
131
134
 
135
+ get_pr_pending_review_feedback_count() {
136
+ local pr_number="${1:?PR number required}"
137
+ local review_decision=""
138
+ local change_request_count="0"
139
+ local unresolved_thread_count="0"
140
+ local repo="${REPO:-}"
141
+ local owner=""
142
+ local name=""
143
+
144
+ review_decision=$(gh pr view "${pr_number}" --json reviewDecision --jq '.reviewDecision // ""' 2>/dev/null || echo "")
145
+
146
+ change_request_count=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}/reviews" \
147
+ --jq '[.[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0")
148
+ if ! [[ "${change_request_count}" =~ ^[0-9]+$ ]]; then
149
+ change_request_count="0"
150
+ fi
151
+
152
+ if [ -z "${repo}" ]; then
153
+ repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
154
+ fi
155
+
156
+ if [[ "${repo}" == */* ]]; then
157
+ owner="${repo%%/*}"
158
+ name="${repo#*/}"
159
+ unresolved_thread_count=$(gh api graphql \
160
+ -F owner="${owner}" \
161
+ -F name="${name}" \
162
+ -F number="${pr_number}" \
163
+ -f query='
164
+ query($owner: String!, $name: String!, $number: Int!) {
165
+ repository(owner: $owner, name: $name) {
166
+ pullRequest(number: $number) {
167
+ reviewThreads(first: 100) {
168
+ nodes {
169
+ isResolved
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ ' \
176
+ --jq '[.data.repository.pullRequest.reviewThreads.nodes[]? | select(.isResolved == false)] | length' \
177
+ 2>/dev/null || echo "0")
178
+ if ! [[ "${unresolved_thread_count}" =~ ^[0-9]+$ ]]; then
179
+ unresolved_thread_count="0"
180
+ fi
181
+ fi
182
+
183
+ if [ "${unresolved_thread_count}" -gt 0 ]; then
184
+ echo "${unresolved_thread_count}"
185
+ return 0
186
+ fi
187
+
188
+ if [ "${change_request_count}" -gt 0 ]; then
189
+ echo "${change_request_count}"
190
+ return 0
191
+ fi
192
+
193
+ if [ "${review_decision}" = "CHANGES_REQUESTED" ]; then
194
+ echo "1"
195
+ return 0
196
+ fi
197
+
198
+ echo "0"
199
+ }
200
+
132
201
  get_pr_head_ref_oid() {
133
202
  local pr_number="${1:?PR number required}"
134
203
  gh pr view "${pr_number}" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo ""
@@ -757,6 +826,18 @@ while IFS=$'\t' read -r pr_number pr_branch pr_labels; do
757
826
  continue
758
827
  fi
759
828
 
829
+ PENDING_REVIEW_FEEDBACK_COUNT=$(get_pr_pending_review_feedback_count "${pr_number}")
830
+ if [ "${PENDING_REVIEW_FEEDBACK_COUNT}" -gt 0 ]; then
831
+ if [ "${local_ready_for_review_label_present}" -eq 1 ]; then
832
+ log "INFO: PR #${pr_number} (${pr_branch}) has pending review feedback; removing stale ${READY_FOR_REVIEW_LABEL} label"
833
+ clear_ready_for_human_review_label "${pr_number}"
834
+ fi
835
+ log "INFO: PR #${pr_number} (${pr_branch}) has ${PENDING_REVIEW_FEEDBACK_COUNT} pending review feedback item(s)"
836
+ NEEDS_WORK=1
837
+ PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}"
838
+ continue
839
+ fi
840
+
760
841
  if has_ready_for_human_review_marker "${all_comments}" "${current_head_sha}"; then
761
842
  SKIPPED_ALREADY_REVIEWED_CURRENT_HEAD=1
762
843
  log "INFO: PR #${pr_number} (${pr_branch}) is already marked ready for human review at head ${current_head_sha:0:12}; skipping repeat automated review"
@@ -853,6 +934,8 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED
853
934
  exit 0
854
935
  fi
855
936
 
937
+ require_provider_on_path
938
+
856
939
  log "PARALLEL: Launching ${#PR_NUMBER_ARRAY[@]} reviewer worker(s)"
857
940
 
858
941
  declare -a WORKER_PIDS=()
@@ -1022,6 +1105,8 @@ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
1022
1105
  exit 0
1023
1106
  fi
1024
1107
 
1108
+ require_provider_on_path
1109
+
1025
1110
  if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
1026
1111
  log "FAIL: Unable to create isolated reviewer worktree ${REVIEW_WORKTREE_DIR}"
1027
1112
  exit 1
@@ -1070,6 +1155,7 @@ if [ -n "${TARGET_PR}" ]; then
1070
1155
  TARGET_MERGE_STATE=$(gh pr view "${TARGET_PR}" --json mergeStateStatus --jq '.mergeStateStatus' 2>/dev/null || echo "UNKNOWN")
1071
1156
  TARGET_FAILED_CHECKS=$(get_pr_failed_ci_summary "${TARGET_PR}")
1072
1157
  TARGET_SCORE=$(get_pr_score "${TARGET_PR}")
1158
+ TARGET_PENDING_REVIEW_FEEDBACK=$(get_pr_pending_review_feedback_count "${TARGET_PR}")
1073
1159
 
1074
1160
  TARGET_SCOPE_PROMPT+=$'\n## Preflight Data (from CLI)\n- mergeStateStatus: '"${TARGET_MERGE_STATE}"$'\n'
1075
1161
  if [ -n "${TARGET_FAILED_CHECKS}" ]; then
@@ -1077,6 +1163,7 @@ if [ -n "${TARGET_PR}" ]; then
1077
1163
  else
1078
1164
  TARGET_SCOPE_PROMPT+=$'- failing checks: none detected\n'
1079
1165
  fi
1166
+ TARGET_SCOPE_PROMPT+=$'- pending review feedback: '"${TARGET_PENDING_REVIEW_FEEDBACK}"$' item(s)\n'
1080
1167
  if [ -n "${TARGET_SCORE}" ]; then
1081
1168
  TARGET_SCOPE_PROMPT+=$'- latest review score: '"${TARGET_SCORE}"$'/100\n'
1082
1169
  TARGET_SCOPE_PROMPT+=$'- action: fix\n'
@@ -1226,6 +1313,10 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do
1226
1313
  LOG_LINE_BEFORE=$(wc -l < "${LOG_FILE}" 2>/dev/null || echo 0)
1227
1314
  REVIEWER_ATTEMPT_START=$(date +%s)
1228
1315
  REVIEWER_PROMPT="${REVIEWER_PROMPT_BASE}${TARGET_SCOPE_PROMPT}${PRD_CONTEXT_PROMPT}"
1316
+ if [ -n "${NW_PROJECT_FEEDBACK_PROMPT:-}" ]; then
1317
+ REVIEWER_PROMPT="${REVIEWER_PROMPT}"$'\n\n'"${NW_PROJECT_FEEDBACK_PROMPT}"
1318
+ log "INFO: Added project feedback prompt context"
1319
+ fi
1229
1320
 
1230
1321
  # Build provider command array using generic helper
1231
1322
  mapfile -d '' -t PROVIDER_CMD_PARTS < <(build_provider_cmd "${REVIEW_WORKTREE_DIR}" "${REVIEWER_PROMPT}")
@@ -26,6 +26,7 @@ PROVIDER_LABEL="${NW_PROVIDER_LABEL:-}"
26
26
  BRANCH_PATTERNS_RAW="${NW_BRANCH_PATTERNS:-feat/,night-watch/}"
27
27
  SKIP_LABEL="${NW_QA_SKIP_LABEL:-skip-qa}"
28
28
  VALIDATED_LABEL="${NW_QA_VALIDATED_LABEL:-e2e-validated}"
29
+ READY_TO_MERGE_LABEL="${NW_PR_RESOLVER_READY_LABEL:-ready-to-merge}"
29
30
  QA_ARTIFACTS="${NW_QA_ARTIFACTS:-both}"
30
31
  QA_AUTO_INSTALL_PLAYWRIGHT="${NW_QA_AUTO_INSTALL_PLAYWRIGHT:-1}"
31
32
  SCRIPT_START_TIME=$(date +%s)
@@ -45,6 +46,7 @@ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
45
46
  # NOTE: Lock file path must match qaLockPath() in src/utils/status-data.ts
46
47
  LOCK_FILE="/tmp/night-watch-qa-${PROJECT_RUNTIME_KEY}.lock"
47
48
  SCRIPT_TYPE="qa"
49
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
48
50
 
49
51
  emit_result() {
50
52
  local status="${1:?status required}"
@@ -366,7 +368,7 @@ fi
366
368
  rotate_log
367
369
  log_separator
368
370
  log "RUN-START: qa invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}"
369
- log "CONFIG: max_runtime=${MAX_RUNTIME}s artifacts=${QA_ARTIFACTS} skip_label=${SKIP_LABEL} branch_patterns=${BRANCH_PATTERNS_RAW}"
371
+ log "CONFIG: max_runtime=${MAX_RUNTIME}s artifacts=${QA_ARTIFACTS} skip_label=${SKIP_LABEL} ready_label=${READY_TO_MERGE_LABEL} branch_patterns=${BRANCH_PATTERNS_RAW}"
370
372
 
371
373
  if ! acquire_lock "${LOCK_FILE}"; then
372
374
  emit_result "skip_locked"
@@ -431,6 +433,11 @@ while IFS=$'\t' read -r pr_number pr_branch pr_title pr_labels; do
431
433
  continue
432
434
  fi
433
435
 
436
+ if csv_has_label "${pr_labels:-}" "${READY_TO_MERGE_LABEL}"; then
437
+ log "SKIP-QA: PR #${pr_number} (${pr_branch}) is labeled ${READY_TO_MERGE_LABEL}"
438
+ continue
439
+ fi
440
+
434
441
  # Skip PRs with the skip label
435
442
  if echo "${pr_labels}" | grep -q "${SKIP_LABEL}"; then
436
443
  log "SKIP-QA: PR #${pr_number} (${pr_branch}) has '${SKIP_LABEL}' label"
@@ -35,6 +35,7 @@ source "${SCRIPT_DIR}/night-watch-helpers.sh"
35
35
  ensure_provider_on_path "${PROVIDER_CMD}" || true
36
36
  PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
37
37
  LOCK_FILE="/tmp/night-watch-slicer-${PROJECT_RUNTIME_KEY}.lock"
38
+ SCRIPT_TYPE="slicer"
38
39
  PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PROVIDER_LABEL}")
39
40
 
40
41
  emit_result() {
@@ -53,6 +54,8 @@ if ! validate_provider "${PROVIDER_CMD}"; then
53
54
  exit 1
54
55
  fi
55
56
 
57
+ skip_if_job_paused "${SCRIPT_TYPE}" "${PROJECT_DIR}"
58
+
56
59
  rotate_log
57
60
  log_separator
58
61
  log "RUN-START: slicer invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}"
@@ -1,7 +1,7 @@
1
1
  You are the Night Watch PR Reviewer agent. Your job is to check open PRs for three things:
2
2
 
3
3
  1. Merge conflicts -- rebase onto the base branch and resolve them.
4
- 2. Review comments with a score below 80 -- address the feedback.
4
+ 2. Review comments with a score below 80 or unresolved review feedback -- address the feedback.
5
5
  3. Failed CI jobs -- diagnose and fix the failures.
6
6
 
7
7
  ## Context
@@ -10,7 +10,7 @@ The repo can have multiple PR checks/workflows (project CI plus Night Watch auto
10
10
  Common examples include `typecheck`, `lint`, `test`, `build`, `verify`, `executor`, `qa`, and `audit`.
11
11
  Treat `gh pr checks <number> --json name,state,conclusion` as the source of truth for which checks failed.
12
12
 
13
- A PR needs attention if **any** of the following: merge conflicts present, review score below 80, or any CI job failed.
13
+ A PR needs attention if **any** of the following: merge conflicts present, no review score yet, review score below 80, unresolved review feedback, or any CI job failed.
14
14
 
15
15
  ## PRD Context
16
16
 
@@ -21,7 +21,7 @@ If current PR code or review feedback conflicts with the PRD context, call out t
21
21
  ## Important: Early Exit
22
22
 
23
23
  - If there are **no open PRs** on `night-watch/` or `feat/` branches, **stop immediately** and report "No PRs to review."
24
- - If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80**, **stop immediately** and report "All PRs are in good shape."
24
+ - If all open PRs have **no merge conflicts**, **passing CI**, **review score >= 80**, and **no unresolved review feedback**, **stop immediately** and report "All PRs are in good shape."
25
25
  - If a PR has no review score yet, it needs a first review — do NOT skip it.
26
26
  - Do **NOT** loop or retry. Process each PR **once** per run. After processing all PRs, stop.
27
27
  - Do **NOT** re-check PRs after pushing fixes -- the CI will re-run automatically on the next push.
@@ -90,8 +90,8 @@ Parse the review score from the comment body. Look for patterns like:
90
90
  Extract the numeric score. If multiple comments have scores, use the **most recent** one.
91
91
 
92
92
  3. **Determine if PR needs work**:
93
- - If no merge conflicts **AND** score >= 80 **AND** all CI checks pass --> skip this PR.
94
- - If merge conflicts present **OR** score < 80 **OR** any CI check failed --> fix the issues.
93
+ - If no merge conflicts **AND** score >= 80 **AND** no unresolved review feedback **AND** all CI checks pass --> skip this PR.
94
+ - If merge conflicts present **OR** score < 80 **OR** unresolved review feedback exists **OR** any CI check failed --> fix the issues.
95
95
 
96
96
  4. **Fix the PR**:
97
97
 
@@ -122,8 +122,9 @@ Parse the review score from the comment body. Look for patterns like:
122
122
  - Push the clean branch: `git push --force-with-lease origin <branch-name>`
123
123
  - **Do NOT leave any conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) in any file.**
124
124
 
125
- c. **Address review feedback** (if score < 80):
125
+ c. **Address review feedback** (if score < 80 or pending review feedback exists):
126
126
  - Read the review comments carefully. Extract areas for improvement, bugs found, issues found, and specific file/line suggestions.
127
+ - If `pending review feedback` is greater than 0 in the cron-provided preflight data, inspect review conversations with `gh pr view <number> --comments` and the GitHub PR review UI/API as needed.
127
128
  - For each review suggestion:
128
129
  - If you agree, implement the change.
129
130
  - If you do not agree, do not implement it blindly. Capture a short technical reason and include that reason in the PR comment.