@jonit-dev/night-watch-cli 1.7.25 → 1.7.29

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 (84) hide show
  1. package/dist/shared/types.d.ts +2 -0
  2. package/dist/shared/types.d.ts.map +1 -1
  3. package/dist/src/cli.js +3 -0
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/commands/audit.d.ts +19 -0
  6. package/dist/src/commands/audit.d.ts.map +1 -0
  7. package/dist/src/commands/audit.js +98 -0
  8. package/dist/src/commands/audit.js.map +1 -0
  9. package/dist/src/commands/dashboard.js +1 -1
  10. package/dist/src/commands/dashboard.js.map +1 -1
  11. package/dist/src/commands/init.d.ts.map +1 -1
  12. package/dist/src/commands/init.js +1 -6
  13. package/dist/src/commands/init.js.map +1 -1
  14. package/dist/src/commands/install.d.ts +4 -0
  15. package/dist/src/commands/install.d.ts.map +1 -1
  16. package/dist/src/commands/install.js +25 -20
  17. package/dist/src/commands/install.js.map +1 -1
  18. package/dist/src/commands/logs.js +3 -3
  19. package/dist/src/commands/logs.js.map +1 -1
  20. package/dist/src/commands/prs.js +2 -2
  21. package/dist/src/commands/prs.js.map +1 -1
  22. package/dist/src/commands/review.d.ts.map +1 -1
  23. package/dist/src/commands/review.js +13 -5
  24. package/dist/src/commands/review.js.map +1 -1
  25. package/dist/src/commands/uninstall.d.ts.map +1 -1
  26. package/dist/src/commands/uninstall.js +3 -22
  27. package/dist/src/commands/uninstall.js.map +1 -1
  28. package/dist/src/config.d.ts.map +1 -1
  29. package/dist/src/config.js +30 -1
  30. package/dist/src/config.js.map +1 -1
  31. package/dist/src/constants.d.ts +10 -3
  32. package/dist/src/constants.d.ts.map +1 -1
  33. package/dist/src/constants.js +15 -2
  34. package/dist/src/constants.js.map +1 -1
  35. package/dist/src/server/index.d.ts.map +1 -1
  36. package/dist/src/server/index.js +50 -3
  37. package/dist/src/server/index.js.map +1 -1
  38. package/dist/src/slack/client.d.ts +9 -1
  39. package/dist/src/slack/client.d.ts.map +1 -1
  40. package/dist/src/slack/client.js +18 -4
  41. package/dist/src/slack/client.js.map +1 -1
  42. package/dist/src/slack/deliberation.d.ts +22 -1
  43. package/dist/src/slack/deliberation.d.ts.map +1 -1
  44. package/dist/src/slack/deliberation.js +663 -51
  45. package/dist/src/slack/deliberation.js.map +1 -1
  46. package/dist/src/slack/interaction-listener.d.ts +33 -9
  47. package/dist/src/slack/interaction-listener.d.ts.map +1 -1
  48. package/dist/src/slack/interaction-listener.js +393 -197
  49. package/dist/src/slack/interaction-listener.js.map +1 -1
  50. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  51. package/dist/src/storage/repositories/index.js +2 -0
  52. package/dist/src/storage/repositories/index.js.map +1 -1
  53. package/dist/src/storage/repositories/interfaces.d.ts +1 -0
  54. package/dist/src/storage/repositories/interfaces.d.ts.map +1 -1
  55. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +6 -0
  56. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
  57. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +37 -1
  58. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
  59. package/dist/src/types.d.ts +13 -0
  60. package/dist/src/types.d.ts.map +1 -1
  61. package/dist/src/utils/avatar-generator.d.ts.map +1 -1
  62. package/dist/src/utils/avatar-generator.js +7 -2
  63. package/dist/src/utils/avatar-generator.js.map +1 -1
  64. package/dist/src/utils/notify.d.ts.map +1 -1
  65. package/dist/src/utils/notify.js +5 -1
  66. package/dist/src/utils/notify.js.map +1 -1
  67. package/dist/src/utils/status-data.d.ts +2 -2
  68. package/dist/src/utils/status-data.d.ts.map +1 -1
  69. package/dist/src/utils/status-data.js +78 -123
  70. package/dist/src/utils/status-data.js.map +1 -1
  71. package/package.json +3 -1
  72. package/scripts/night-watch-audit-cron.sh +149 -0
  73. package/scripts/night-watch-cron.sh +33 -14
  74. package/scripts/night-watch-helpers.sh +10 -2
  75. package/scripts/night-watch-pr-reviewer-cron.sh +224 -18
  76. package/web/dist/assets/index-BiJf9LFT.js +458 -0
  77. package/web/dist/assets/index-OpSgvsYu.css +1 -0
  78. package/web/dist/avatars/carlos.webp +0 -0
  79. package/web/dist/avatars/dev.webp +0 -0
  80. package/web/dist/avatars/maya.webp +0 -0
  81. package/web/dist/avatars/priya.webp +0 -0
  82. package/web/dist/index.html +2 -2
  83. package/web/dist/assets/index-CndIPm_F.js +0 -473
  84. package/web/dist/assets/index-w6Q6gxCS.css +0 -1
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Night Watch Audit Cron Runner (project-agnostic)
5
+ # Usage: night-watch-audit-cron.sh /path/to/project
6
+ #
7
+ # NOTE: This script expects environment variables to be set by the caller.
8
+ # The Node.js CLI will inject config values via environment variables.
9
+ # Required env vars (with defaults shown):
10
+ # NW_AUDIT_MAX_RUNTIME=1800 - Maximum runtime in seconds (30 minutes)
11
+ # NW_PROVIDER_CMD=claude - AI provider CLI to use (claude, codex, etc.)
12
+ # NW_DRY_RUN=0 - Set to 1 for dry-run mode (prints diagnostics only)
13
+
14
+ PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
15
+ PROJECT_NAME=$(basename "${PROJECT_DIR}")
16
+ LOG_DIR="${PROJECT_DIR}/logs"
17
+ LOG_FILE="${LOG_DIR}/audit.log"
18
+ REPORT_FILE="${PROJECT_DIR}/logs/audit-report.md"
19
+ MAX_RUNTIME="${NW_AUDIT_MAX_RUNTIME:-1800}" # 30 minutes
20
+ MAX_LOG_SIZE="524288" # 512 KB
21
+ PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
22
+
23
+ # Ensure NVM / Node / Claude are on PATH
24
+ export NVM_DIR="${HOME}/.nvm"
25
+ [ -s "${NVM_DIR}/nvm.sh" ] && . "${NVM_DIR}/nvm.sh"
26
+
27
+ mkdir -p "${LOG_DIR}"
28
+
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ # shellcheck source=night-watch-helpers.sh
31
+ source "${SCRIPT_DIR}/night-watch-helpers.sh"
32
+ PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
33
+ # NOTE: Lock file path must match auditLockPath() in src/utils/status-data.ts
34
+ LOCK_FILE="/tmp/night-watch-audit-${PROJECT_RUNTIME_KEY}.lock"
35
+
36
+ emit_result() {
37
+ local status="${1:?status required}"
38
+ local details="${2:-}"
39
+ if [ -n "${details}" ]; then
40
+ echo "NIGHT_WATCH_RESULT:${status}|${details}"
41
+ else
42
+ echo "NIGHT_WATCH_RESULT:${status}"
43
+ fi
44
+ }
45
+
46
+ # Validate provider
47
+ if ! validate_provider "${PROVIDER_CMD}"; then
48
+ echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
49
+ exit 1
50
+ fi
51
+
52
+ rotate_log
53
+
54
+ if ! acquire_lock "${LOCK_FILE}"; then
55
+ emit_result "skip_locked"
56
+ exit 0
57
+ fi
58
+
59
+ # Dry-run mode: print diagnostics and exit
60
+ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
61
+ echo "=== Dry Run: Code Auditor ==="
62
+ echo "Provider: ${PROVIDER_CMD}"
63
+ echo "Max Runtime: ${MAX_RUNTIME}s"
64
+ echo "Report File: ${REPORT_FILE}"
65
+ emit_result "skip_dry_run"
66
+ exit 0
67
+ fi
68
+
69
+ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
70
+ DEFAULT_BRANCH="${NW_DEFAULT_BRANCH}"
71
+ else
72
+ DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
73
+ fi
74
+
75
+ AUDIT_WORKTREE_BASENAME="${PROJECT_NAME}-nw-audit-runner"
76
+ AUDIT_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${AUDIT_WORKTREE_BASENAME}"
77
+
78
+ cleanup_worktrees "${PROJECT_DIR}" "${AUDIT_WORKTREE_BASENAME}"
79
+
80
+ if ! prepare_detached_worktree "${PROJECT_DIR}" "${AUDIT_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then
81
+ log "FAIL: Unable to create isolated audit worktree ${AUDIT_WORKTREE_DIR}"
82
+ exit 1
83
+ fi
84
+
85
+ # Ensure the logs dir exists inside the worktree so the provider can write the report
86
+ mkdir -p "${AUDIT_WORKTREE_DIR}/logs"
87
+
88
+ log "START: Running code audit for ${PROJECT_NAME} (provider: ${PROVIDER_CMD})"
89
+
90
+ EXIT_CODE=0
91
+
92
+ case "${PROVIDER_CMD}" in
93
+ claude)
94
+ if (
95
+ cd "${AUDIT_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
96
+ claude -p "/night-watch-audit" \
97
+ --dangerously-skip-permissions \
98
+ >> "${LOG_FILE}" 2>&1
99
+ ); then
100
+ EXIT_CODE=0
101
+ else
102
+ EXIT_CODE=$?
103
+ fi
104
+ ;;
105
+ codex)
106
+ if (
107
+ cd "${AUDIT_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
108
+ codex --quiet \
109
+ --yolo \
110
+ --prompt "$(cat "${AUDIT_WORKTREE_DIR}/.claude/commands/night-watch-audit.md")" \
111
+ >> "${LOG_FILE}" 2>&1
112
+ ); then
113
+ EXIT_CODE=0
114
+ else
115
+ EXIT_CODE=$?
116
+ fi
117
+ ;;
118
+ *)
119
+ log "ERROR: Unknown provider: ${PROVIDER_CMD}"
120
+ exit 1
121
+ ;;
122
+ esac
123
+
124
+ # Copy report back to project dir (if it was written in the worktree)
125
+ WORKTREE_REPORT="${AUDIT_WORKTREE_DIR}/logs/audit-report.md"
126
+ if [ -f "${WORKTREE_REPORT}" ]; then
127
+ cp "${WORKTREE_REPORT}" "${REPORT_FILE}"
128
+ log "INFO: Audit report copied to ${REPORT_FILE}"
129
+ fi
130
+
131
+ cleanup_worktrees "${PROJECT_DIR}" "${AUDIT_WORKTREE_BASENAME}"
132
+
133
+ if [ "${EXIT_CODE}" -eq 0 ]; then
134
+ if [ -f "${REPORT_FILE}" ] && grep -q "NO_ISSUES_FOUND" "${REPORT_FILE}" 2>/dev/null; then
135
+ log "DONE: Audit complete — no actionable issues found"
136
+ emit_result "skip_clean"
137
+ else
138
+ log "DONE: Audit complete — report written to ${REPORT_FILE}"
139
+ emit_result "success_audit"
140
+ fi
141
+ elif [ "${EXIT_CODE}" -eq 124 ]; then
142
+ log "TIMEOUT: Audit killed after ${MAX_RUNTIME}s"
143
+ emit_result "timeout"
144
+ else
145
+ log "FAIL: Audit exited with code ${EXIT_CODE}"
146
+ emit_result "failure"
147
+ fi
148
+
149
+ exit "${EXIT_CODE}"
@@ -21,7 +21,7 @@ else
21
21
  PRD_DIR="${PROJECT_DIR}/${PRD_DIR_REL}"
22
22
  fi
23
23
  LOG_DIR="${PROJECT_DIR}/logs"
24
- LOG_FILE="${LOG_DIR}/night-watch.log"
24
+ LOG_FILE="${LOG_DIR}/executor.log"
25
25
  MAX_RUNTIME="${NW_MAX_RUNTIME:-7200}" # 2 hours
26
26
  MAX_LOG_SIZE="524288" # 512 KB
27
27
  PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
@@ -96,25 +96,44 @@ if [ "${NW_BOARD_ENABLED:-}" = "true" ]; then
96
96
  log "ERROR: Cannot resolve night-watch CLI for board mode"
97
97
  exit 1
98
98
  fi
99
- ISSUE_JSON=$(find_eligible_board_issue)
100
- if [ -z "${ISSUE_JSON}" ]; then
101
- log "SKIP: No eligible issues in Ready column (board mode)"
102
- emit_result "skip_no_eligible_prd"
103
- exit 0
99
+
100
+ if [ -n "${NW_TARGET_ISSUE:-}" ]; then
101
+ # Targeted issue pickup: use specified issue directly (already "In Progress" from Slack trigger)
102
+ ISSUE_NUMBER="${NW_TARGET_ISSUE}"
103
+ log "BOARD: Using targeted issue #${ISSUE_NUMBER} (from NW_TARGET_ISSUE)"
104
+ ISSUE_JSON=$(gh issue view "${ISSUE_NUMBER}" --json number,title,body 2>/dev/null || true)
105
+ if [ -z "${ISSUE_JSON}" ]; then
106
+ log "ERROR: Cannot fetch issue #${ISSUE_NUMBER} via gh"
107
+ exit 1
108
+ fi
109
+ ISSUE_TITLE_RAW=$(printf '%s' "${ISSUE_JSON}" | jq -r '.title // empty' 2>/dev/null || true)
110
+ ISSUE_BODY=$(printf '%s' "${ISSUE_JSON}" | jq -r '.body // empty' 2>/dev/null || true)
111
+ else
112
+ ISSUE_JSON=$(find_eligible_board_issue)
113
+ if [ -z "${ISSUE_JSON}" ]; then
114
+ log "SKIP: No eligible issues in Ready column (board mode)"
115
+ emit_result "skip_no_eligible_prd"
116
+ exit 0
117
+ fi
118
+ ISSUE_NUMBER=$(printf '%s' "${ISSUE_JSON}" | jq -r '.number // empty' 2>/dev/null || true)
119
+ ISSUE_TITLE_RAW=$(printf '%s' "${ISSUE_JSON}" | jq -r '.title // empty' 2>/dev/null || true)
120
+ ISSUE_BODY=$(printf '%s' "${ISSUE_JSON}" | jq -r '.body // empty' 2>/dev/null || true)
121
+ if [ -z "${ISSUE_NUMBER}" ]; then
122
+ log "ERROR: Board mode: failed to parse issue number from JSON"
123
+ exit 1
124
+ fi
125
+ # Move issue to In Progress (claim it on the board)
126
+ "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "In Progress" 2>>"${LOG_FILE}" || \
127
+ log "WARN: Failed to move issue #${ISSUE_NUMBER} to In Progress"
104
128
  fi
105
- ISSUE_NUMBER=$(printf '%s' "${ISSUE_JSON}" | jq -r '.number // empty' 2>/dev/null || true)
106
- ISSUE_TITLE_RAW=$(printf '%s' "${ISSUE_JSON}" | jq -r '.title // empty' 2>/dev/null || true)
107
- ISSUE_BODY=$(printf '%s' "${ISSUE_JSON}" | jq -r '.body // empty' 2>/dev/null || true)
129
+
108
130
  if [ -z "${ISSUE_NUMBER}" ]; then
109
- log "ERROR: Board mode: failed to parse issue number from JSON"
131
+ log "ERROR: Board mode: no issue number resolved"
110
132
  exit 1
111
133
  fi
112
134
  # Slugify title for branch naming
113
135
  ELIGIBLE_PRD="${ISSUE_NUMBER}-$(printf '%s' "${ISSUE_TITLE_RAW}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-\|-$//g')"
114
- log "BOARD: Found ready issue #${ISSUE_NUMBER}: ${ISSUE_TITLE_RAW}"
115
- # Move issue to In Progress (claim it on the board)
116
- "${NW_CLI}" board move-issue "${ISSUE_NUMBER}" --column "In Progress" 2>>"${LOG_FILE}" || \
117
- log "WARN: Failed to move issue #${ISSUE_NUMBER} to In Progress"
136
+ log "BOARD: Processing issue #${ISSUE_NUMBER}: ${ISSUE_TITLE_RAW}"
118
137
  trap "rm -f '${LOCK_FILE}'" EXIT
119
138
  else
120
139
  # Filesystem mode: scan PRD directory
@@ -305,17 +305,25 @@ find_eligible_prd() {
305
305
  }
306
306
 
307
307
  # ── Clean up worktrees ───────────────────────────────────────────────────────
308
- # Removes any worktrees with "-nw-" in the path (night-watch worktrees).
308
+ # Removes night-watch worktrees for this project.
309
+ # Optional second argument narrows cleanup to worktrees containing that token.
310
+ # This prevents parallel reviewer workers from deleting each other's worktrees.
309
311
 
310
312
  cleanup_worktrees() {
311
313
  local project_dir="${1:?project_dir required}"
314
+ local scope="${2:-}"
312
315
  local project_name
313
316
  project_name=$(basename "${project_dir}")
314
317
 
318
+ local match_token="${project_name}-nw"
319
+ if [ -n "${scope}" ]; then
320
+ match_token="${scope}"
321
+ fi
322
+
315
323
  git -C "${project_dir}" worktree list --porcelain 2>/dev/null \
316
324
  | grep '^worktree ' \
317
325
  | awk '{print $2}' \
318
- | grep "${project_name}-nw" \
326
+ | grep -F "${match_token}" \
319
327
  | while read -r wt; do
320
328
  log "CLEANUP: Removing leftover worktree ${wt}"
321
329
  git -C "${project_dir}" worktree remove --force "${wt}" 2>/dev/null || true
@@ -16,7 +16,7 @@ set -euo pipefail
16
16
  PROJECT_DIR="${1:?Usage: $0 /path/to/project}"
17
17
  PROJECT_NAME=$(basename "${PROJECT_DIR}")
18
18
  LOG_DIR="${PROJECT_DIR}/logs"
19
- LOG_FILE="${LOG_DIR}/night-watch-pr-reviewer.log"
19
+ LOG_FILE="${LOG_DIR}/reviewer.log"
20
20
  MAX_RUNTIME="${NW_REVIEWER_MAX_RUNTIME:-3600}" # 1 hour
21
21
  MAX_LOG_SIZE="524288" # 512 KB
22
22
  PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}"
@@ -25,6 +25,8 @@ BRANCH_PATTERNS_RAW="${NW_BRANCH_PATTERNS:-feat/,night-watch/}"
25
25
  AUTO_MERGE="${NW_AUTO_MERGE:-0}"
26
26
  AUTO_MERGE_METHOD="${NW_AUTO_MERGE_METHOD:-squash}"
27
27
  TARGET_PR="${NW_TARGET_PR:-}"
28
+ PARALLEL_ENABLED="${NW_REVIEWER_PARALLEL:-1}"
29
+ WORKER_MODE="${NW_REVIEWER_WORKER_MODE:-0}"
28
30
 
29
31
  # Ensure NVM / Node / Claude are on PATH
30
32
  export NVM_DIR="${HOME}/.nvm"
@@ -39,8 +41,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
39
41
  # shellcheck source=night-watch-helpers.sh
40
42
  source "${SCRIPT_DIR}/night-watch-helpers.sh"
41
43
  PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}")
42
- # NOTE: Lock file path must match reviewerLockPath() in src/utils/status-data.ts
43
- LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}.lock"
44
+ GLOBAL_LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}.lock"
45
+ if [ "${WORKER_MODE}" = "1" ] && [ -n "${TARGET_PR}" ]; then
46
+ LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}-pr-${TARGET_PR}.lock"
47
+ else
48
+ # NOTE: Lock file path must match reviewerLockPath() in src/utils/status-data.ts
49
+ LOCK_FILE="${GLOBAL_LOCK_FILE}"
50
+ fi
44
51
 
45
52
  emit_result() {
46
53
  local status="${1:?status required}"
@@ -52,6 +59,38 @@ emit_result() {
52
59
  fi
53
60
  }
54
61
 
62
+ emit_final_status() {
63
+ local exit_code="${1:?exit code required}"
64
+ local prs_csv="${2:-}"
65
+ local auto_merged="${3:-}"
66
+ local auto_merge_failed="${4:-}"
67
+
68
+ if [ "${exit_code}" -eq 0 ]; then
69
+ log "DONE: PR reviewer completed successfully"
70
+ emit_result "success_reviewed" "prs=${prs_csv}|auto_merged=${auto_merged}|auto_merge_failed=${auto_merge_failed}"
71
+ elif [ "${exit_code}" -eq 124 ]; then
72
+ log "TIMEOUT: PR reviewer killed after ${MAX_RUNTIME}s"
73
+ emit_result "timeout" "prs=${prs_csv}"
74
+ else
75
+ log "FAIL: PR reviewer exited with code ${exit_code}"
76
+ emit_result "failure" "prs=${prs_csv}"
77
+ fi
78
+ }
79
+
80
+ append_csv() {
81
+ local current="${1:-}"
82
+ local incoming="${2:-}"
83
+ if [ -z "${incoming}" ]; then
84
+ printf "%s" "${current}"
85
+ return 0
86
+ fi
87
+ if [ -z "${current}" ]; then
88
+ printf "%s" "${incoming}"
89
+ else
90
+ printf "%s,%s" "${current}" "${incoming}"
91
+ fi
92
+ }
93
+
55
94
  # Validate provider
56
95
  if ! validate_provider "${PROVIDER_CMD}"; then
57
96
  echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2
@@ -159,6 +198,62 @@ done < <(gh pr list --state open --json number,headRefName --jq '.[] | [.number,
159
198
 
160
199
  if [ "${NEEDS_WORK}" -eq 0 ]; then
161
200
  log "SKIP: All ${OPEN_PRS} open PR(s) have passing CI and review score >= ${MIN_REVIEW_SCORE} (or no score yet)"
201
+
202
+ # ── Auto-merge eligible PRs ───────────────────────────────
203
+ if [ "${NW_AUTO_MERGE:-0}" = "1" ]; then
204
+ AUTO_MERGE_METHOD="${NW_AUTO_MERGE_METHOD:-squash}"
205
+ AUTO_MERGED_COUNT=0
206
+
207
+ log "AUTO-MERGE: Checking for merge-ready PRs (method: ${AUTO_MERGE_METHOD})"
208
+
209
+ while IFS=$'\t' read -r pr_number pr_branch; do
210
+ [ -z "${pr_number}" ] || [ -z "${pr_branch}" ] && continue
211
+ printf '%s\n' "${pr_branch}" | grep -Eq "${BRANCH_REGEX}" || continue
212
+
213
+ # Check CI status
214
+ FAILED_CHECKS=$(gh pr checks "${pr_number}" 2>/dev/null | grep -ci 'fail' || true)
215
+ [ "${FAILED_CHECKS}" -gt 0 ] && continue
216
+
217
+ # Check review score
218
+ PR_COMMENTS=$(
219
+ {
220
+ gh pr view "${pr_number}" --json comments --jq '.comments[].body' 2>/dev/null || true
221
+ if [ -n "${REPO}" ]; then
222
+ gh api "repos/${REPO}/issues/${pr_number}/comments" --jq '.[].body' 2>/dev/null || true
223
+ fi
224
+ } | sort -u
225
+ )
226
+ PR_SCORE=$(echo "${PR_COMMENTS}" \
227
+ | grep -oP 'Overall Score:\*?\*?\s*(\d+)/100' \
228
+ | tail -1 \
229
+ | grep -oP '\d+(?=/100)' || echo "")
230
+
231
+ # Skip PRs without a score or with score below threshold
232
+ [ -z "${PR_SCORE}" ] && continue
233
+ [ "${PR_SCORE}" -lt "${MIN_REVIEW_SCORE}" ] && continue
234
+
235
+ # PR is merge-ready
236
+ log "AUTO-MERGE: PR #${pr_number} (${pr_branch}) — score ${PR_SCORE}/100, CI passing"
237
+
238
+ # Dry-run mode: show what would be merged
239
+ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
240
+ log "AUTO-MERGE (dry-run): Would queue merge for PR #${pr_number} using ${AUTO_MERGE_METHOD}"
241
+ continue
242
+ fi
243
+
244
+ if gh pr merge "${pr_number}" --"${AUTO_MERGE_METHOD}" --auto --delete-branch 2>>"${LOG_FILE}"; then
245
+ log "AUTO-MERGE: Successfully queued merge for PR #${pr_number}"
246
+ AUTO_MERGED_COUNT=$((AUTO_MERGED_COUNT + 1))
247
+ else
248
+ log "WARN: Auto-merge failed for PR #${pr_number}"
249
+ fi
250
+ done < <(gh pr list --state open --json number,headRefName --jq '.[] | [.number, .headRefName] | @tsv' 2>/dev/null || true)
251
+
252
+ if [ "${AUTO_MERGED_COUNT}" -gt 0 ]; then
253
+ log "AUTO-MERGE: Queued ${AUTO_MERGED_COUNT} PR(s) for merge"
254
+ fi
255
+ fi
256
+
162
257
  emit_result "skip_all_passing"
163
258
  exit 0
164
259
  fi
@@ -172,11 +267,121 @@ if [ -n "${NW_DEFAULT_BRANCH:-}" ]; then
172
267
  else
173
268
  DEFAULT_BRANCH=$(detect_default_branch "${PROJECT_DIR}")
174
269
  fi
175
- REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${PROJECT_NAME}-nw-review-runner"
176
270
 
177
271
  log "START: Found PR(s) needing work:${PRS_NEEDING_WORK}"
178
272
 
179
- cleanup_worktrees "${PROJECT_DIR}"
273
+ # Convert "#12 #34" into ["12", "34"] for worker fan-out.
274
+ PR_NUMBER_ARRAY=()
275
+ for pr_token in ${PRS_NEEDING_WORK}; do
276
+ PR_NUMBER_ARRAY+=("${pr_token#\#}")
277
+ done
278
+
279
+ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED}" = "1" ] && [ "${#PR_NUMBER_ARRAY[@]}" -gt 1 ]; then
280
+ # Dry-run mode: print diagnostics and exit
281
+ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
282
+ echo "=== Dry Run: PR Reviewer ==="
283
+ echo "Provider: ${PROVIDER_CMD}"
284
+ echo "Branch Patterns: ${BRANCH_PATTERNS_RAW}"
285
+ echo "Min Review Score: ${MIN_REVIEW_SCORE}"
286
+ echo "Auto-merge: ${AUTO_MERGE}"
287
+ if [ "${AUTO_MERGE}" = "1" ]; then
288
+ echo "Auto-merge Method: ${AUTO_MERGE_METHOD}"
289
+ fi
290
+ echo "Open PRs needing work:${PRS_NEEDING_WORK}"
291
+ echo "Default Branch: ${DEFAULT_BRANCH}"
292
+ echo "Parallel Workers: ${#PR_NUMBER_ARRAY[@]}"
293
+ echo "Timeout: ${MAX_RUNTIME}s"
294
+ exit 0
295
+ fi
296
+
297
+ log "PARALLEL: Launching ${#PR_NUMBER_ARRAY[@]} reviewer worker(s)"
298
+
299
+ declare -a WORKER_PIDS=()
300
+ declare -a WORKER_PRS=()
301
+ declare -a WORKER_OUTPUTS=()
302
+
303
+ for pr_number in "${PR_NUMBER_ARRAY[@]}"; do
304
+ worker_output=$(mktemp "/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}-pr-${pr_number}.XXXXXX")
305
+ WORKER_OUTPUTS+=("${worker_output}")
306
+ WORKER_PRS+=("${pr_number}")
307
+
308
+ (
309
+ NW_TARGET_PR="${pr_number}" \
310
+ NW_REVIEWER_WORKER_MODE="1" \
311
+ NW_REVIEWER_PARALLEL="0" \
312
+ bash "${SCRIPT_DIR}/night-watch-pr-reviewer-cron.sh" "${PROJECT_DIR}" > "${worker_output}" 2>&1
313
+ ) &
314
+
315
+ worker_pid=$!
316
+ WORKER_PIDS+=("${worker_pid}")
317
+ log "PARALLEL: Worker PID ${worker_pid} started for PR #${pr_number}"
318
+ done
319
+
320
+ EXIT_CODE=0
321
+ AUTO_MERGED_PRS=""
322
+ AUTO_MERGE_FAILED_PRS=""
323
+
324
+ for idx in "${!WORKER_PIDS[@]}"; do
325
+ worker_pid="${WORKER_PIDS[$idx]}"
326
+ worker_pr="${WORKER_PRS[$idx]}"
327
+ worker_output="${WORKER_OUTPUTS[$idx]}"
328
+
329
+ worker_exit_code=0
330
+ if wait "${worker_pid}"; then
331
+ worker_exit_code=0
332
+ else
333
+ worker_exit_code=$?
334
+ fi
335
+
336
+ if [ -f "${worker_output}" ] && [ -s "${worker_output}" ]; then
337
+ cat "${worker_output}" >> "${LOG_FILE}"
338
+ fi
339
+
340
+ worker_result=$(grep -o 'NIGHT_WATCH_RESULT:.*' "${worker_output}" 2>/dev/null | tail -1 || true)
341
+ worker_status=$(printf '%s' "${worker_result}" | sed -n 's/^NIGHT_WATCH_RESULT:\([^|]*\).*$/\1/p')
342
+ worker_auto_merged=$(printf '%s' "${worker_result}" | grep -oP '(?<=auto_merged=)[^|]+' || true)
343
+ worker_auto_merge_failed=$(printf '%s' "${worker_result}" | grep -oP '(?<=auto_merge_failed=)[^|]+' || true)
344
+
345
+ AUTO_MERGED_PRS=$(append_csv "${AUTO_MERGED_PRS}" "${worker_auto_merged}")
346
+ AUTO_MERGE_FAILED_PRS=$(append_csv "${AUTO_MERGE_FAILED_PRS}" "${worker_auto_merge_failed}")
347
+
348
+ rm -f "${worker_output}"
349
+
350
+ if [ "${worker_status}" = "failure" ] || { [ -n "${worker_status}" ] && [ "${worker_status}" != "success_reviewed" ] && [ "${worker_status}" != "timeout" ] && [ "${worker_status#skip_}" = "${worker_status}" ]; }; then
351
+ if [ "${EXIT_CODE}" -eq 0 ] || [ "${EXIT_CODE}" -eq 124 ]; then
352
+ EXIT_CODE=1
353
+ fi
354
+ log "PARALLEL: Worker for PR #${worker_pr} reported status '${worker_status:-unknown}'"
355
+ elif [ "${worker_status}" = "timeout" ]; then
356
+ if [ "${EXIT_CODE}" -eq 0 ]; then
357
+ EXIT_CODE=124
358
+ fi
359
+ log "PARALLEL: Worker for PR #${worker_pr} timed out"
360
+ elif [ "${worker_exit_code}" -ne 0 ]; then
361
+ if [ "${worker_exit_code}" -eq 124 ]; then
362
+ if [ "${EXIT_CODE}" -eq 0 ]; then
363
+ EXIT_CODE=124
364
+ fi
365
+ elif [ "${EXIT_CODE}" -eq 0 ] || [ "${EXIT_CODE}" -eq 124 ]; then
366
+ EXIT_CODE="${worker_exit_code}"
367
+ fi
368
+ log "PARALLEL: Worker for PR #${worker_pr} exited with code ${worker_exit_code}"
369
+ else
370
+ log "PARALLEL: Worker for PR #${worker_pr} completed"
371
+ fi
372
+ done
373
+
374
+ emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}"
375
+ exit 0
376
+ fi
377
+
378
+ REVIEW_WORKTREE_BASENAME="${PROJECT_NAME}-nw-review-runner"
379
+ if [ -n "${TARGET_PR}" ]; then
380
+ REVIEW_WORKTREE_BASENAME="${REVIEW_WORKTREE_BASENAME}-pr-${TARGET_PR}"
381
+ fi
382
+ REVIEW_WORKTREE_DIR="$(dirname "${PROJECT_DIR}")/${REVIEW_WORKTREE_BASENAME}"
383
+
384
+ cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
180
385
 
181
386
  # Dry-run mode: print diagnostics and exit
182
387
  if [ "${NW_DRY_RUN:-0}" = "1" ]; then
@@ -191,6 +396,10 @@ if [ "${NW_DRY_RUN:-0}" = "1" ]; then
191
396
  echo "Open PRs needing work:${PRS_NEEDING_WORK}"
192
397
  echo "Default Branch: ${DEFAULT_BRANCH}"
193
398
  echo "Review Worktree: ${REVIEW_WORKTREE_DIR}"
399
+ echo "Target PR: ${TARGET_PR:-all}"
400
+ if [ -n "${TARGET_PR}" ]; then
401
+ echo "Worker Mode: ${WORKER_MODE}"
402
+ fi
194
403
  echo "Timeout: ${MAX_RUNTIME}s"
195
404
  exit 0
196
405
  fi
@@ -201,12 +410,17 @@ if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFA
201
410
  fi
202
411
 
203
412
  EXIT_CODE=0
413
+ TARGET_SCOPE_PROMPT=""
414
+ if [ -n "${TARGET_PR}" ]; then
415
+ 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'
416
+ fi
204
417
 
205
418
  case "${PROVIDER_CMD}" in
206
419
  claude)
420
+ CLAUDE_PROMPT="/night-watch-pr-reviewer${TARGET_SCOPE_PROMPT}"
207
421
  if (
208
422
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
209
- claude -p "/night-watch-pr-reviewer" \
423
+ claude -p "${CLAUDE_PROMPT}" \
210
424
  --dangerously-skip-permissions \
211
425
  >> "${LOG_FILE}" 2>&1
212
426
  ); then
@@ -216,11 +430,12 @@ case "${PROVIDER_CMD}" in
216
430
  fi
217
431
  ;;
218
432
  codex)
433
+ CODEX_PROMPT="$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")${TARGET_SCOPE_PROMPT}"
219
434
  if (
220
435
  cd "${REVIEW_WORKTREE_DIR}" && timeout "${MAX_RUNTIME}" \
221
436
  codex --quiet \
222
437
  --yolo \
223
- --prompt "$(cat "${REVIEW_WORKTREE_DIR}/.claude/commands/night-watch-pr-reviewer.md")" \
438
+ --prompt "${CODEX_PROMPT}" \
224
439
  >> "${LOG_FILE}" 2>&1
225
440
  ); then
226
441
  EXIT_CODE=0
@@ -234,7 +449,7 @@ case "${PROVIDER_CMD}" in
234
449
  ;;
235
450
  esac
236
451
 
237
- cleanup_worktrees "${PROJECT_DIR}"
452
+ cleanup_worktrees "${PROJECT_DIR}" "${REVIEW_WORKTREE_BASENAME}"
238
453
 
239
454
  # ── Auto-merge eligible PRs ─────────────────────────────────────────────────────
240
455
  # After the reviewer completes, check for PRs that are merge-ready and queue them
@@ -310,13 +525,4 @@ if [ "${AUTO_MERGE}" = "1" ] && [ ${EXIT_CODE} -eq 0 ]; then
310
525
  done < <(gh pr list --state open --json number,headRefName --jq '.[] | [.number, .headRefName] | @tsv' 2>/dev/null || true)
311
526
  fi
312
527
 
313
- if [ ${EXIT_CODE} -eq 0 ]; then
314
- log "DONE: PR reviewer completed successfully"
315
- emit_result "success_reviewed" "prs=${PRS_NEEDING_WORK_CSV}|auto_merged=${AUTO_MERGED_PRS}|auto_merge_failed=${AUTO_MERGE_FAILED_PRS}"
316
- elif [ ${EXIT_CODE} -eq 124 ]; then
317
- log "TIMEOUT: PR reviewer killed after ${MAX_RUNTIME}s"
318
- emit_result "timeout" "prs=${PRS_NEEDING_WORK_CSV}"
319
- else
320
- log "FAIL: PR reviewer exited with code ${EXIT_CODE}"
321
- emit_result "failure" "prs=${PRS_NEEDING_WORK_CSV}"
322
- fi
528
+ emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}"