diff --git a/.gitignore b/.gitignore index 96e191d5..fded760a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ qa-artifacts/ .npmrc night-watch.config.json +.worktrees diff --git a/docs/PRDs/analytics-job.md b/docs/PRDs/done/analytics-job.md similarity index 100% rename from docs/PRDs/analytics-job.md rename to docs/PRDs/done/analytics-job.md diff --git a/docs/PRDs/fix-executor-streaming-output.md b/docs/PRDs/done/fix-executor-streaming-output.md similarity index 90% rename from docs/PRDs/fix-executor-streaming-output.md rename to docs/PRDs/done/fix-executor-streaming-output.md index 221ca2cb..e1754938 100644 --- a/docs/PRDs/fix-executor-streaming-output.md +++ b/docs/PRDs/done/fix-executor-streaming-output.md @@ -9,6 +9,7 @@ **Problem:** When the executor launches `claude -p`, it logs "output will stream below" but no output actually streams to the terminal — all output is silently redirected to the log file via `>> "${LOG_FILE}" 2>&1`. **Files Analyzed:** + - `scripts/night-watch-helpers.sh` — `log()` function (writes ONLY to file) - `scripts/night-watch-cron.sh` — provider dispatch (lines 545-577, 624-637) - `scripts/night-watch-audit-cron.sh` — provider dispatch (lines 163-189) @@ -17,6 +18,7 @@ - `packages/core/src/utils/shell.ts` — `executeScriptWithOutput()` (already streams child stdout/stderr to terminal) **Current Behavior:** + - `log()` writes ONLY to `LOG_FILE` (`echo ... >> "${log_file}"`) — not to stdout or stderr - Provider commands redirect ALL output to file: `claude -p ... >> "${LOG_FILE}" 2>&1` - Node's `executeScriptWithOutput()` listens on the bash child's stdout/stderr pipes but receives nothing because the bash script sends everything to the file @@ -25,11 +27,13 @@ ## 2. Solution **Approach:** + - Replace `>> "${LOG_FILE}" 2>&1` with `2>&1 | tee -a "${LOG_FILE}"` for provider dispatch — output goes to both the log file AND stdout (which propagates through Node's pipe to the terminal) - Modify `log()` to also write to stderr so diagnostic messages are visible in the terminal during interactive `night-watch run` - All scripts already use `set -euo pipefail`, so pipe exit codes propagate correctly (if `claude` fails with code 1 and `tee` succeeds with 0, pipefail returns 1) **Key Decisions:** + - `tee -a` (append mode) preserves the existing log file behavior - Provider output goes to stdout via tee; diagnostic messages go to stderr via log — keeps them on separate channels - No changes to `executeScriptWithOutput()` needed — it already streams both pipes to the terminal @@ -41,6 +45,7 @@ ### Phase 1: Fix `log()` to also write to stderr + use `tee` for provider output **Files (5):** + - `scripts/night-watch-helpers.sh` — make `log()` also write to stderr - `scripts/night-watch-cron.sh` — replace `>> "${LOG_FILE}" 2>&1` with `2>&1 | tee -a "${LOG_FILE}"` (3 occurrences: main dispatch, codex dispatch, fallback) - `scripts/night-watch-audit-cron.sh` — same replacement (2 occurrences) @@ -50,6 +55,7 @@ **Implementation:** - [ ] In `night-watch-helpers.sh`, modify `log()` to also echo to stderr: + ```bash log() { local log_file="${LOG_FILE:?LOG_FILE not set}" @@ -82,6 +88,7 @@ **Pattern for each replacement:** Before: + ```bash if ( cd "${WORKTREE_DIR}" && timeout "${SESSION_MAX_RUNTIME}" \ @@ -92,6 +99,7 @@ if ( ``` After: + ```bash if ( cd "${WORKTREE_DIR}" && timeout "${SESSION_MAX_RUNTIME}" \ @@ -102,6 +110,7 @@ if ( ``` **Exit code behavior with `pipefail`:** + - All scripts use `set -euo pipefail` (line 2) - If `timeout ... claude` exits 124 (timeout) and `tee` exits 0 → pipe returns 124 ✓ - If `timeout ... claude` exits 1 (failure) and `tee` exits 0 → pipe returns 1 ✓ @@ -109,6 +118,7 @@ if ( - The `if (...); then` construct disables `set -e` for the condition, so non-zero exits are captured correctly **Rate-limit detection still works:** + - `check_rate_limited` greps the LOG_FILE — `tee -a` still writes everything to the file, so this is unchanged **Tests Required:** @@ -120,6 +130,7 @@ if ( | Manual | Smoke test: `bash -n scripts/night-watch-pr-reviewer-cron.sh` | No syntax errors | **User Verification:** + - Action: Run `night-watch run` (or trigger executor) - Expected: Diagnostic log messages AND claude's streaming output visible in the terminal in real time @@ -139,10 +150,10 @@ if ( ## Files to Modify -| File | Change | -|------|--------| -| `scripts/night-watch-helpers.sh` | `log()` also writes to stderr | -| `scripts/night-watch-cron.sh` | 3× replace `>> LOG 2>&1` with `2>&1 \| tee -a LOG` | -| `scripts/night-watch-audit-cron.sh` | 2× same replacement | -| `scripts/night-watch-qa-cron.sh` | 2× same replacement | -| `scripts/night-watch-pr-reviewer-cron.sh` | 2× same replacement | +| File | Change | +| ----------------------------------------- | -------------------------------------------------- | +| `scripts/night-watch-helpers.sh` | `log()` also writes to stderr | +| `scripts/night-watch-cron.sh` | 3× replace `>> LOG 2>&1` with `2>&1 \| tee -a LOG` | +| `scripts/night-watch-audit-cron.sh` | 2× same replacement | +| `scripts/night-watch-qa-cron.sh` | 2× same replacement | +| `scripts/night-watch-pr-reviewer-cron.sh` | 2× same replacement | diff --git a/docs/PRDs/fix-prd-execution-failures.md b/docs/PRDs/done/fix-prd-execution-failures.md similarity index 97% rename from docs/PRDs/fix-prd-execution-failures.md rename to docs/PRDs/done/fix-prd-execution-failures.md index 4a2d154e..289ce044 100644 --- a/docs/PRDs/fix-prd-execution-failures.md +++ b/docs/PRDs/done/fix-prd-execution-failures.md @@ -14,6 +14,7 @@ PRD execution is failing consistently across projects (night-watch-cli, autopilo When the proxy returns 429, the system correctly triggers a native Claude fallback. **However, if native Claude is also rate-limited**, the fallback exits with code 1 and the system records `provider_exit` instead of `rate_limited`. **Evidence from `logs/executor.log`:** + ``` API Error: 429 {"error":{"code":"1308","message":"Usage limit reached for 5 hour..."}} RATE-LIMITED: Proxy quota exhausted — triggering native Claude fallback @@ -24,6 +25,7 @@ FAIL: Night watch exited with code 1 while processing 69-ux-revamp... ``` **Impact:** The system records a `failure` with `reason=provider_exit` instead of `rate_limited`, which: + - Triggers a long cooldown (max_runtime-based) instead of a rate-limit-appropriate retry - Sends misleading failure notifications - Prevents the PRD from being retried once the rate limit resets @@ -33,6 +35,7 @@ FAIL: Night watch exited with code 1 while processing 69-ux-revamp... The function scans `tail -50` of the shared `executor.log` file, but log entries from **previous runs** can bleed into the current run's error detail. **Evidence:** Issue #70's failure detail contains issue #69's error message: + ``` detail=[2026-03-07 00:40:59] [PID:75449] FAIL: Night watch exited with code 1 while processing 69-ux-revamp... ``` @@ -44,6 +47,7 @@ This happens because the log is append-only and `latest_failure_detail()` doesn' In filesystem mode, `code-cleanup-q1-2026.md` was selected and executed despite the work already being merged to master. Claude correctly identified the work was done but didn't create a PR. The cron script then recorded `failure_no_pr_after_success`. **Evidence:** + ``` OUTCOME: exit_code=0 total_elapsed=363s prd=code-cleanup-q1-2026.md WARN: claude exited 0 but no open/merged PR found on night-watch/code-cleanup-q1-2026 @@ -62,6 +66,7 @@ This is a pre-existing filesystem mode issue (stale PRDs not moved to `done/`). After the native Claude fallback runs (line ~626), check if the fallback also hit a rate limit before falling through to the generic failure handler. **Implementation:** + 1. After `RATE_LIMIT_FALLBACK_TRIGGERED` block (lines 603-632), if `EXIT_CODE != 0`, scan fallback output for rate-limit indicators (`"hit your limit"`, `429`, `"Usage limit"`) 2. If detected, set a new flag `DOUBLE_RATE_LIMITED=1` 3. In the outcome handler (lines 711-726), when `DOUBLE_RATE_LIMITED=1`: @@ -72,6 +77,7 @@ After the native Claude fallback runs (line ~626), check if the fallback also hi **Specific changes in `night-watch-cron.sh`:** After line 632 (`fi` closing the fallback block), add: + ```bash # Detect double rate-limit: both proxy AND native Claude exhausted DOUBLE_RATE_LIMITED=0 @@ -84,6 +90,7 @@ fi ``` In the outcome handler, add a new branch before the generic `else` on line 711: + ```bash elif [ "${DOUBLE_RATE_LIMITED}" = "1" ]; then if [ -n "${ISSUE_NUMBER}" ]; then @@ -102,6 +109,7 @@ elif [ "${DOUBLE_RATE_LIMITED}" = "1" ]; then Modify `latest_failure_detail()` to accept an optional `since_line` parameter that filters to only lines written during the current run. **Implementation:** + 1. Change `latest_failure_detail()` (lines 79-92) to accept a second parameter `since_line` 2. Use `tail -n +${since_line}` instead of `tail -50` when `since_line` is provided 3. At the call site (line 712), pass the `LOG_LINE_BEFORE` captured at the start of the current attempt @@ -109,6 +117,7 @@ Modify `latest_failure_detail()` to accept an optional `since_line` parameter th **Specific changes:** Replace `latest_failure_detail()`: + ```bash latest_failure_detail() { local log_file="${1:?log_file required}" @@ -138,6 +147,7 @@ latest_failure_detail() { ``` Update call site at line 712: + ```bash PROVIDER_ERROR_DETAIL=$(latest_failure_detail "${LOG_FILE}" "${LOG_LINE_BEFORE}") ``` @@ -182,6 +192,6 @@ This is already handled — the `code-cleanup-q1-2026.md` issue was a one-time s ## Files to Modify -| File | Change | -|------|--------| +| File | Change | +| ----------------------------- | -------------------------------------------------------------------- | | `scripts/night-watch-cron.sh` | Add double-rate-limit detection, scope failure detail to current run | diff --git a/docs/PRDs/job-registry.md b/docs/PRDs/done/job-registry.md similarity index 95% rename from docs/PRDs/job-registry.md rename to docs/PRDs/done/job-registry.md index dad44ede..700c3ad5 100644 --- a/docs/PRDs/job-registry.md +++ b/docs/PRDs/done/job-registry.md @@ -7,6 +7,7 @@ **Problem:** Adding a new job type (e.g., analytics) requires touching 15+ files across 4 packages — types, constants, config normalization, env parsing, CLI command, server routes, API client, Scheduling UI, Settings UI, schedule templates, and more. Each job's state shape is inconsistent (executor/reviewer use top-level flat fields, qa/audit/analytics use nested config objects, slicer lives inside `roadmapScanner`). **Files Analyzed:** + - `packages/core/src/types.ts` — `JobType`, `IJobProviders`, `INightWatchConfig`, `IQaConfig`, `IAuditConfig`, `IAnalyticsConfig` - `packages/core/src/shared/types.ts` — duplicated type definitions for web contract - `packages/core/src/constants.ts` — `DEFAULT_*` per job, `VALID_JOB_TYPES`, `DEFAULT_QUEUE_PRIORITY`, `LOG_FILE_NAMES` @@ -22,6 +23,7 @@ - `web/store/useStore.ts` — minimal Zustand, no job-specific state **Current Behavior:** + - 6 job types exist: `executor`, `reviewer`, `qa`, `audit`, `slicer`, `analytics` - Executor/reviewer use flat top-level config fields (`cronSchedule`, `reviewerSchedule`, `executorEnabled`, `reviewerEnabled`) - QA/audit/analytics use nested config objects (`config.qa`, `config.audit`, `config.analytics`) with common shape: `{ enabled, schedule, maxRuntime, ...extras }` @@ -32,6 +34,7 @@ ## 2. Solution **Approach:** + 1. Create a **Job Registry** in `packages/core/src/jobs/` that defines each job's metadata, defaults, config access patterns, and env parsing rules in a single object 2. Extract a **`IBaseJobConfig`** interface (`{ enabled, schedule, maxRuntime }`) that all job configs extend 3. Replace per-job boilerplate in `config-normalize.ts` and `config-env.ts` with generic registry-driven loops @@ -39,6 +42,7 @@ 5. Add a **Zustand `jobs` slice** that provides computed job state derived from `status.config` so components don't need to know each job's config shape **Architecture Diagram:** + ```mermaid flowchart TB subgraph Core["@night-watch/core"] @@ -63,12 +67,14 @@ flowchart TB ``` **Key Decisions:** + - **Migrate executor/reviewer to nested config**: All jobs will use `config.jobs.{id}: { enabled, schedule, maxRuntime, ...extras }`. Auto-detect legacy flat format and migrate on load. This is a breaking config change but gives uniform access patterns. - **Registry is a const array, not DI**: Simple, testable, no runtime overhead - **Web job registry stores React components directly** for icons (type-safe, tree-shakeable) - **Generic `triggerJob(jobId)`** replaces per-job `triggerRun()`, `triggerReview()` etc. (keep old functions as thin wrappers for backward compat) **Data Changes:** + - `INightWatchConfig` gains `jobs: Record` — replaces flat executor/reviewer fields and nested qa/audit/analytics objects - Legacy flat fields (`cronSchedule`, `reviewerSchedule`, `executorEnabled`, `reviewerEnabled`) and nested objects (`qa`, `audit`, `analytics`, `roadmapScanner.slicerSchedule`) auto-detected and migrated on config load - Config file rewritten in new format on first save after migration @@ -100,6 +106,7 @@ sequenceDiagram **User-visible outcome:** Job registry exists and is the single source of truth for job metadata. All constants derived from it. Tests prove registry drives normalization. **Files (5):** + - `packages/core/src/jobs/job-registry.ts` — **NEW** — `IJobDefinition` interface + `JOB_REGISTRY` array + accessor utilities - `packages/core/src/jobs/index.ts` — **NEW** — barrel exports - `packages/core/src/types.ts` — add `IBaseJobConfig` interface @@ -110,21 +117,22 @@ sequenceDiagram - [ ] Define `IBaseJobConfig` interface: `{ enabled: boolean; schedule: string; maxRuntime: number }` - [ ] Define `IJobDefinition` interface with: + ```typescript interface IJobDefinition { id: JobType; - name: string; // "Executor", "QA", "Auditor" - description: string; // "Creates implementation PRs from PRDs" - cliCommand: string; // "run", "review", "qa", "audit", "planner", "analytics" - logName: string; // "executor", "reviewer", "night-watch-qa", etc. - lockSuffix: string; // ".lock", "-r.lock", "-qa.lock", etc. - queuePriority: number; // 50, 40, 30, 20, 10 + name: string; // "Executor", "QA", "Auditor" + description: string; // "Creates implementation PRs from PRDs" + cliCommand: string; // "run", "review", "qa", "audit", "planner", "analytics" + logName: string; // "executor", "reviewer", "night-watch-qa", etc. + lockSuffix: string; // ".lock", "-r.lock", "-qa.lock", etc. + queuePriority: number; // 50, 40, 30, 20, 10 // Env var prefix for NW_* overrides - envPrefix: string; // "NW_EXECUTOR", "NW_QA", "NW_AUDIT", etc. + envPrefix: string; // "NW_EXECUTOR", "NW_QA", "NW_AUDIT", etc. // Extra config field normalizers (beyond enabled/schedule/maxRuntime) - extraFields?: IExtraFieldDef[]; // e.g., QA's branchPatterns, artifacts, etc. + extraFields?: IExtraFieldDef[]; // e.g., QA's branchPatterns, artifacts, etc. // Defaults defaultConfig: TConfig; @@ -133,6 +141,7 @@ sequenceDiagram migrateLegacy?: (raw: Record) => Partial | undefined; } ``` + - [ ] Create `JOB_REGISTRY` const array with entries for all 6 job types - [ ] Create utility functions: `getJobDef(id)`, `getAllJobDefs()`, `getJobDefByCommand(cmd)` - [ ] Derive `VALID_JOB_TYPES`, `DEFAULT_QUEUE_PRIORITY`, `LOG_FILE_NAMES` from registry (keep exports stable) @@ -154,6 +163,7 @@ sequenceDiagram **User-visible outcome:** `config-normalize.ts` and `config-env.ts` use generic loops. Legacy flat config auto-migrated. Adding a job config section no longer requires per-job blocks. **Files (5):** + - `packages/core/src/jobs/job-registry.ts` — add `normalizeJobConfig()` and `buildJobEnvOverrides()` generic helpers - `packages/core/src/config-normalize.ts` — replace per-job normalization blocks with registry loop + legacy migration - `packages/core/src/config-env.ts` — replace per-job env blocks with registry loop @@ -161,6 +171,7 @@ sequenceDiagram - `packages/core/src/__tests__/config-normalize.test.ts` — verify normalization + migration **Implementation:** + - [ ] Add `normalizeJobConfig(rawConfig, jobDef)` that reads raw object, applies defaults, validates fields - [ ] Each `IJobDefinition` declares `extraFields` for job-specific fields beyond `{ enabled, schedule, maxRuntime }` - [ ] Add `migrateLegacyConfig(raw)` that detects old format (e.g., `cronSchedule` exists at top level) and transforms to new `jobs: { executor: { ... }, ... }` shape @@ -184,17 +195,19 @@ sequenceDiagram **User-visible outcome:** Scheduling and Settings pages read job definitions from a registry instead of hardcoded arrays. Zustand provides computed job state. **Files (4):** + - `web/utils/jobs.ts` — **NEW** — Web-side job registry with UI metadata - `web/store/useStore.ts` — add `jobs` computed slice derived from `status.config` - `web/api.ts` — add generic `triggerJob(jobId)` function - `web/utils/cron.ts` — derive schedule template keys from registry **Implementation:** + - [ ] Create `IWebJobDefinition` extending core `IJobDefinition` with UI fields: ```typescript interface IWebJobDefinition extends IJobDefinition { - icon: string; // lucide icon component name - triggerEndpoint: string; // '/api/actions/qa' + icon: string; // lucide icon component name + triggerEndpoint: string; // '/api/actions/qa' scheduleTemplateKey: string; // key in IScheduleTemplate.schedules settingsSection?: 'general' | 'advanced'; // where in Settings to show } @@ -223,11 +236,13 @@ sequenceDiagram **User-visible outcome:** Scheduling page renders job cards from the registry. Adding a new job automatically shows it in Scheduling. **Files (3):** + - `web/pages/Scheduling.tsx` — replace hardcoded `agents` array with registry-driven rendering - `web/components/scheduling/ScheduleConfig.tsx` — use registry for form fields - `web/utils/cron.ts` — update `IScheduleTemplate` to be extensible **Implementation:** + - [ ] Replace the hardcoded `agents: IAgentInfo[]` array with `WEB_JOB_REGISTRY.map(job => ...)` - [ ] Replace `handleJobToggle` if/else chain with generic `job.buildEnabledPatch(enabled, config)` - [ ] Replace `handleTriggerJob` map with generic `triggerJob(job.id)` @@ -247,11 +262,13 @@ sequenceDiagram **User-visible outcome:** Settings page job config sections rendered from registry. Adding a new job auto-shows its settings. **Files (3):** + - `web/pages/Settings.tsx` — replace per-job settings JSX blocks with registry loop - `web/components/dashboard/AgentStatusBar.tsx` — use registry for process status - `packages/server/src/routes/action.routes.ts` — generate routes from registry **Implementation:** + - [ ] Settings: iterate `WEB_JOB_REGISTRY` to render job config sections - [ ] Each `IWebJobDefinition` can declare its settings fields: `settingsFields: ISettingsField[]` - [ ] `AgentStatusBar`: derive process list from registry instead of hardcoded diff --git a/docs/PRDs/open-source-readiness.md b/docs/PRDs/done/open-source-readiness.md similarity index 99% rename from docs/PRDs/open-source-readiness.md rename to docs/PRDs/done/open-source-readiness.md index f451d5cc..1bfa98b8 100644 --- a/docs/PRDs/open-source-readiness.md +++ b/docs/PRDs/done/open-source-readiness.md @@ -7,6 +7,7 @@ **Problem:** The project is missing standard OSS community files and has several bugs in README/CI that would hurt a public launch. **Files Analyzed:** + - `README.md` — install command wrong, broken doc link, From Source uses npm not yarn - `.github/workflows/ci.yml` — targets `main` but default branch is `master` - `docs/contributing.md` — exists but not auto-discovered by GitHub (needs root copy) @@ -15,6 +16,7 @@ - No `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CHANGELOG.md` at root **Current Behavior:** + - `npm install -g night-watch-cli` installs wrong/nonexistent package (real npm package is `@jonit-dev/night-watch-cli`) - `npm` badge links to wrong package name - CI push trigger never fires (wrong branch `main` vs `master`) @@ -25,6 +27,7 @@ ## 2. Solution **Approach:** + - Fix README bugs (install command, badge, broken link, From Source yarn command) - Fix CI workflow branch targets - Create community files: CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, CHANGELOG.md @@ -32,6 +35,7 @@ - Clean up web/README.md **Key Decisions:** + - CONTRIBUTING.md goes in `.github/` so GitHub auto-links it; also keep `docs/contributing.md` unchanged - Use Contributor Covenant v2.1 for CODE_OF_CONDUCT.md - CHANGELOG.md starts from current version with a brief history @@ -44,6 +48,7 @@ ### Phase 1: Fix README and CI bugs **Files (4):** + - `README.md` — fix install command, badge, broken link, From Source yarn - `.github/workflows/ci.yml` — fix branch target main → master @@ -79,6 +84,7 @@ | Manual: `docs/architecture-overview.md` exists | File accessible | **Verification:** + - `yarn verify` passes (no TS/lint changes, but ensures nothing broken) --- @@ -86,6 +92,7 @@ ### Phase 2: Community files — CONTRIBUTING, CODE_OF_CONDUCT, SECURITY **Files (3):** + - `.github/CONTRIBUTING.md` — auto-discovered by GitHub - `CODE_OF_CONDUCT.md` — project root - `SECURITY.md` — project root @@ -99,6 +106,7 @@ - [ ] Create `SECURITY.md` at project root — vulnerability reporting via GitHub private security advisory **Content for `.github/CONTRIBUTING.md`:** + ```markdown # Contributing to Night Watch CLI @@ -113,6 +121,7 @@ Thank you for your interest in contributing! ## Reporting Bugs Open an issue using the **Bug Report** template. Include: + - Night Watch version (`night-watch --version`) - OS and Node.js version - Your `night-watch.config.json` (redact any API keys or tokens) @@ -121,6 +130,7 @@ Open an issue using the **Bug Report** template. Include: ## Requesting Features Open an issue using the **Feature Request** template. Describe: + - The problem you're solving - Your proposed solution - Alternatives you considered @@ -145,6 +155,7 @@ This project follows the [Contributor Covenant Code of Conduct](../CODE_OF_CONDU **Content for `CODE_OF_CONDUCT.md`:** Use Contributor Covenant v2.1 standard text with contact email placeholder. **Content for `SECURITY.md`:** + ```markdown # Security Policy @@ -162,6 +173,7 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf ``` **Verification:** + - `yarn verify` passes - GitHub shows CONTRIBUTING link on Issues/PRs page after pushing @@ -170,6 +182,7 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf ### Phase 3: GitHub templates and CHANGELOG **Files (4):** + - `.github/ISSUE_TEMPLATE/bug_report.md` - `.github/ISSUE_TEMPLATE/feature_request.md` - `.github/pull_request_template.md` @@ -183,6 +196,7 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf - [ ] Create `CHANGELOG.md` starting from current version `1.7.94` with brief history **Bug report template fields:** + - Night Watch version - OS + Node.js version - Config (redacted) @@ -191,12 +205,14 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf - Relevant logs **Feature request template fields:** + - Problem statement - Proposed solution - Alternatives considered - Additional context **PR template fields:** + - Summary of changes - Related issue (closes #) - Type of change (bug fix / feature / docs / refactor) @@ -205,6 +221,7 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf **CHANGELOG.md:** Start with current version block, note it covers changes from initial public release. Keep it brief — one block per recent milestone, not per patch. **Verification:** + - `yarn verify` passes - New issue on GitHub shows template selector - New PR on GitHub shows template pre-filled @@ -214,12 +231,15 @@ You should expect an acknowledgement within 48 hours. If a vulnerability is conf ### Phase 4: Cleanup web/README.md **Files (1):** + - `web/README.md` **Implementation:** + - [ ] Replace Google AI Studio boilerplate content with a brief description of the Night Watch web dashboard: what it is, how to start it (`yarn dev` from `web/`), and a link to `docs/WEB-UI.md` for full docs. **Verification:** + - `yarn verify` passes - `web/README.md` no longer references Google AI Studio or unrelated tooling diff --git a/docs/PRDs/prd-provider-schedule-overrides.md b/docs/PRDs/done/prd-provider-schedule-overrides.md similarity index 100% rename from docs/PRDs/prd-provider-schedule-overrides.md rename to docs/PRDs/done/prd-provider-schedule-overrides.md diff --git a/docs/PRDs/provider-agnostic-instructions.md b/docs/PRDs/done/provider-agnostic-instructions.md similarity index 100% rename from docs/PRDs/provider-agnostic-instructions.md rename to docs/PRDs/done/provider-agnostic-instructions.md diff --git a/docs/PRDs/remove-legacy-personas-and-filesystem-prd.md b/docs/PRDs/done/remove-legacy-personas-and-filesystem-prd.md similarity index 100% rename from docs/PRDs/remove-legacy-personas-and-filesystem-prd.md rename to docs/PRDs/done/remove-legacy-personas-and-filesystem-prd.md diff --git a/docs/PRDs/e2e-validated-label.md b/docs/PRDs/e2e-validated-label.md new file mode 100644 index 00000000..ae8831bb --- /dev/null +++ b/docs/PRDs/e2e-validated-label.md @@ -0,0 +1,321 @@ +# PRD: E2E Validated Label — Automated Acceptance Proof for PRs + +**Complexity: 4 → MEDIUM mode** (+1 touches 5 files, +2 multi-package, +1 external API integration) + +## 1. Context + +**Problem:** The QA job generates and runs e2e/integration tests on PRs, but there is no way to prove at a glance that a PR's acceptance requirements have been validated. Developers and reviewers must manually inspect QA comments to know if tests passed. There is no GitHub label signaling "this PR's e2e tests prove the work is done." + +**Files Analyzed:** + +- `packages/cli/scripts/night-watch-qa-cron.sh` — QA bash script, classifies QA outcomes (passing, issues_found, no_tests_needed, unclassified) +- `packages/core/src/board/labels.ts` — label taxonomy (priority, category, horizon, operational) +- `packages/core/src/constants.ts` — `DEFAULT_QA_SKIP_LABEL`, other QA defaults +- `packages/core/src/types.ts` — `IQaConfig`, `INightWatchConfig` +- `packages/cli/src/commands/init.ts` — initialization flow, label creation not currently done +- `packages/cli/src/commands/qa.ts` — QA command, parses script results + +**Current Behavior:** + +- QA job runs Playwright tests, posts a `` comment on each PR with results +- `classify_qa_comment_outcome()` already classifies outcomes as `passing`, `issues_found`, `no_tests_needed`, `unclassified` +- `validate_qa_evidence()` already validates QA evidence quality (marker exists, artifacts present) +- No label is applied to PRs that pass QA — the only QA-related label is `skip-qa` (to skip QA) +- The reviewer job adds `needs-human-review` label but nothing signals e2e validation +- Labels are statically defined in `labels.ts` but never auto-created on GitHub during `init` + +## 2. Solution + +**Approach:** + +1. Add an `e2e-validated` label definition to the label taxonomy in `labels.ts` +2. After QA processes each PR, if the outcome is `passing` → apply the `e2e-validated` label via `gh pr edit --add-label`; if outcome is `issues_found` or `no_tests_needed` → remove the label (idempotent) +3. Ensure the label exists on GitHub before applying: add `gh label create` in the QA script (idempotent, `--force` updates if exists) +4. Wire label creation into `night-watch init` so all Night Watch labels (including `e2e-validated`) are synced to GitHub during project initialization +5. Make the label name configurable via `IQaConfig.validatedLabel` with default `e2e-validated` + +**Architecture Diagram:** + +```mermaid +flowchart LR + QA[QA Script] --> CLASSIFY[classify_qa_comment_outcome] + CLASSIFY -->|passing| ADD[gh pr edit --add-label e2e-validated] + CLASSIFY -->|issues_found| REMOVE[gh pr edit --remove-label e2e-validated] + CLASSIFY -->|no_tests_needed| REMOVE + INIT[night-watch init] --> SYNC[gh label create for all NW labels] +``` + +**Key Decisions:** + +- **Reuse existing `classify_qa_comment_outcome()`** — no new classification logic needed; the infrastructure already tells us if tests pass +- **Idempotent label operations** — `--add-label` and `--remove-label` are no-ops if already present/absent; `gh label create --force` updates existing +- **Configurable label name** — `config.qa.validatedLabel` (default: `e2e-validated`) allows customization +- **Label auto-creation** — `gh label create` is called in the QA script before first use (one-time, cached per run) so it works even without `init` +- **Init syncs all labels** — `night-watch init` step now creates all `NIGHT_WATCH_LABELS` on GitHub (including e2e-validated) + +**Data Changes:** + +- `IQaConfig` gains `validatedLabel: string` field (default: `e2e-validated`) +- `NIGHT_WATCH_LABELS` array gains `e2e-validated` entry +- No database changes + +## 3. Sequence Flow + +```mermaid +sequenceDiagram + participant QA as QA Script + participant GH as GitHub (gh CLI) + + Note over QA: After provider completes for PR #N + + QA->>QA: classify_qa_comment_outcome(#N) + alt outcome = passing + QA->>GH: gh label create e2e-validated --force (idempotent) + QA->>GH: gh pr edit #N --add-label e2e-validated + QA->>QA: log "PR #N marked as e2e-validated" + else outcome = issues_found | no_tests_needed + QA->>GH: gh pr edit #N --remove-label e2e-validated + QA->>QA: log "PR #N e2e-validated label removed" + end +``` + +## 4. Execution Phases + +### Phase 1: Label Definition & Config — Add `e2e-validated` to the system + +**User-visible outcome:** `e2e-validated` label appears in `NIGHT_WATCH_LABELS`, `IQaConfig` has a `validatedLabel` field, and config normalizes correctly. + +**Files (5):** + +- `packages/core/src/board/labels.ts` — add `e2e-validated` to `NIGHT_WATCH_LABELS` +- `packages/core/src/types.ts` — add `validatedLabel: string` to `IQaConfig` +- `packages/core/src/constants.ts` — add `DEFAULT_QA_VALIDATED_LABEL` constant, update `DEFAULT_QA` +- `packages/core/src/jobs/job-registry.ts` — add `validatedLabel` to QA extra fields +- `packages/core/src/config-normalize.ts` — normalize `validatedLabel` (fallback to default) + +**Implementation:** + +- [ ] In `labels.ts`, add to `NIGHT_WATCH_LABELS` array: + ```typescript + { + name: 'e2e-validated', + description: 'PR acceptance requirements validated by e2e/integration tests', + color: '0e8a16', // green + }, + ``` +- [ ] In `types.ts`, add to `IQaConfig`: + ```typescript + /** GitHub label to apply when e2e tests pass (proves acceptance requirements met) */ + validatedLabel: string; + ``` +- [ ] In `constants.ts`: + ```typescript + export const DEFAULT_QA_VALIDATED_LABEL = 'e2e-validated'; + ``` + Update `DEFAULT_QA` to include `validatedLabel: DEFAULT_QA_VALIDATED_LABEL` +- [ ] In `job-registry.ts`, add to the QA job's `extraFields`: + ```typescript + { name: 'validatedLabel', type: 'string', defaultValue: 'e2e-validated' }, + ``` +- [ ] In `config-normalize.ts`, ensure `validatedLabel` is normalized with string fallback (follow `skipLabel` pattern) + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/core/src/__tests__/jobs/job-registry.test.ts` | `qa job has validatedLabel extra field` | `expect(getJobDef('qa').extraFields).toContainEqual(expect.objectContaining({ name: 'validatedLabel' }))` | +| `packages/core/src/__tests__/board/labels.test.ts` | `NIGHT_WATCH_LABELS includes e2e-validated` | `expect(NIGHT_WATCH_LABELS.map(l => l.name)).toContain('e2e-validated')` | + +**Verification Plan:** + +1. **Unit Tests:** Registry field check, label presence check, config normalization +2. **Evidence:** `yarn verify` passes, `yarn test` passes + +--- + +### Phase 2: QA Script — Apply/Remove Label After Test Classification + +**User-visible outcome:** After QA runs on a PR, the `e2e-validated` label is automatically added if tests pass, or removed if tests fail/are not needed. + +**Files (2):** + +- `packages/cli/scripts/night-watch-qa-cron.sh` — add label application logic after classification +- `packages/cli/src/commands/qa.ts` — pass `validatedLabel` to env vars + +**Implementation:** + +- [ ] In `qa.ts` `buildEnvVars()`, add: + ```typescript + env.NW_QA_VALIDATED_LABEL = config.qa.validatedLabel; + ``` +- [ ] In `night-watch-qa-cron.sh`, read the env var: + ```bash + VALIDATED_LABEL="${NW_QA_VALIDATED_LABEL:-e2e-validated}" + ``` +- [ ] Add a helper function `ensure_label_exists()` near the top of the script: + ```bash + LABEL_ENSURED=0 + ensure_validated_label() { + if [ "${LABEL_ENSURED}" -eq 1 ]; then return 0; fi + gh label create "${VALIDATED_LABEL}" \ + --description "PR acceptance requirements validated by e2e/integration tests" \ + --color "0e8a16" \ + --force 2>/dev/null || true + LABEL_ENSURED=1 + } + ``` +- [ ] In the per-PR processing loop, after `classify_qa_comment_outcome`, add label logic: + ```bash + case "${QA_OUTCOME}" in + passing) + PASSING_PRS_CSV=$(append_csv "${PASSING_PRS_CSV}" "#${pr_num}") + # Apply e2e-validated label + ensure_validated_label + gh pr edit "${pr_num}" --add-label "${VALIDATED_LABEL}" 2>/dev/null || true + log "QA: PR #${pr_num} — added '${VALIDATED_LABEL}' label (tests passing)" + ;; + issues_found) + ISSUES_FOUND_PRS_CSV=$(append_csv "${ISSUES_FOUND_PRS_CSV}" "#${pr_num}") + # Remove e2e-validated label if present + gh pr edit "${pr_num}" --remove-label "${VALIDATED_LABEL}" 2>/dev/null || true + ;; + no_tests_needed) + NO_TESTS_PRS_CSV=$(append_csv "${NO_TESTS_PRS_CSV}" "#${pr_num}") + # Remove e2e-validated label — no tests doesn't prove acceptance + gh pr edit "${pr_num}" --remove-label "${VALIDATED_LABEL}" 2>/dev/null || true + ;; + *) + UNCLASSIFIED_PRS_CSV=$(append_csv "${UNCLASSIFIED_PRS_CSV}" "#${pr_num}") + ;; + esac + ``` +- [ ] In dry-run output, include the validated label setting + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/qa.test.ts` | `buildEnvVars includes NW_QA_VALIDATED_LABEL` | `expect(env.NW_QA_VALIDATED_LABEL).toBe('e2e-validated')` | +| `packages/cli/src/__tests__/commands/qa.test.ts` | `buildEnvVars uses custom validatedLabel from config` | custom label value passed through | + +**Verification Plan:** + +1. **Unit Tests:** Env var presence, custom label passthrough +2. **Manual test:** Run `night-watch qa --dry-run` and verify `NW_QA_VALIDATED_LABEL` appears in env vars +3. **Manual test:** Run `night-watch qa` on a repo with a PR that has passing tests → label applied +4. **Evidence:** `yarn verify` passes, label visible on GitHub PR + +--- + +### Phase 3: Init Label Sync — Create All Night Watch Labels on GitHub + +**User-visible outcome:** `night-watch init` creates all Night Watch labels (including `e2e-validated`) on GitHub when the repo has a GitHub remote and `gh` is authenticated. + +**Files (2):** + +- `packages/cli/src/commands/init.ts` — add label sync step +- `packages/core/src/board/labels.ts` — export exists, no changes needed (consumed by init) + +**Implementation:** + +- [ ] In `init.ts`, add a new step between the board setup step and the global registry step (renumber subsequent steps, update `totalSteps`): + ```typescript + // Step 11: Sync Night Watch labels to GitHub + step(11, totalSteps, 'Syncing Night Watch labels to GitHub...'); + if (!remoteStatus.hasGitHubRemote || !ghAuthenticated) { + info('Skipping label sync (no GitHub remote or gh not authenticated).'); + } else { + try { + const { NIGHT_WATCH_LABELS } = await import('@night-watch/core'); + let created = 0; + for (const label of NIGHT_WATCH_LABELS) { + try { + execSync( + `gh label create "${label.name}" --description "${label.description}" --color "${label.color}" --force`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + created++; + } catch { + // Label creation is best-effort + } + } + success(`Synced ${created}/${NIGHT_WATCH_LABELS.length} labels to GitHub`); + } catch (labelErr) { + warn( + `Could not sync labels: ${labelErr instanceof Error ? labelErr.message : String(labelErr)}`, + ); + } + } + ``` +- [ ] Update `totalSteps` from 13 to 14, renumber steps 11+ (global registry becomes 12, skills becomes 13, summary becomes 14) +- [ ] Add label sync status to the summary table + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/init.test.ts` | `init syncs Night Watch labels when gh is authenticated` | execSync called with `gh label create` for each label | +| `packages/cli/src/__tests__/commands/init.test.ts` | `init skips label sync when no GitHub remote` | no label creation calls | + +**Verification Plan:** + +1. **Unit Tests:** Label sync invocation, skip conditions +2. **Manual test:** Run `night-watch init --force` in a project with GitHub remote → labels visible on GitHub +3. **Evidence:** `yarn verify` passes, labels visible at `https://github.com/{owner}/{repo}/labels` + +--- + +### Phase 4: Dry-Run & Summary Integration + +**User-visible outcome:** `night-watch qa --dry-run` shows the validated label config. The emit_result output includes the label information for downstream notification consumers. The QA script idempotency check skips re-processing PRs that already have the label only when the QA comment is also present. + +**Files (2):** + +- `packages/cli/src/commands/qa.ts` — show validated label in dry-run config table +- `packages/cli/scripts/night-watch-qa-cron.sh` — include validated label stats in emit_result + +**Implementation:** + +- [ ] In `qa.ts` dry-run section, add a row to the config table: + ```typescript + configTable.push(['Validated Label', config.qa.validatedLabel]); + ``` +- [ ] In `night-watch-qa-cron.sh`, add `VALIDATED_PRS_CSV` tracker alongside other CSV trackers: + ```bash + VALIDATED_PRS_CSV="" + ``` +- [ ] After applying the `e2e-validated` label for passing PRs, append to `VALIDATED_PRS_CSV`: + ```bash + VALIDATED_PRS_CSV=$(append_csv "${VALIDATED_PRS_CSV}" "#${pr_num}") + ``` +- [ ] In `emit_result` calls, include `validated=` field: + ```bash + emit_result "success_qa" "prs=...|passing=...|validated=${VALIDATED_PRS_SUMMARY}|..." + ``` +- [ ] Add `VALIDATED_PRS_SUMMARY=$(csv_or_none "${VALIDATED_PRS_CSV}")` alongside other summaries +- [ ] In Telegram status messages, include "E2E validated: ${VALIDATED_PRS_SUMMARY}" line + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/qa.test.ts` | `dry-run config table includes validatedLabel` | config output contains label name | + +**Verification Plan:** + +1. **Unit Tests:** Dry-run output includes validated label +2. **Manual test:** `night-watch qa --dry-run` shows "Validated Label: e2e-validated" +3. **Manual test:** After QA run, Telegram message includes "E2E validated" line +4. **Evidence:** `yarn verify` passes + +## 5. Acceptance Criteria + +- [ ] All 4 phases complete +- [ ] All specified tests pass +- [ ] `yarn verify` passes +- [ ] All automated checkpoint reviews passed +- [ ] `e2e-validated` label is defined in `NIGHT_WATCH_LABELS` with green color (#0e8a16) +- [ ] `IQaConfig.validatedLabel` is configurable with default `e2e-validated` +- [ ] QA script applies label when `classify_qa_comment_outcome()` returns `passing` +- [ ] QA script removes label when outcome is `issues_found` or `no_tests_needed` +- [ ] Label operations are idempotent (no error if label exists/doesn't exist) +- [ ] `night-watch init` creates all Night Watch labels on GitHub (including `e2e-validated`) +- [ ] `night-watch qa --dry-run` shows the validated label in config +- [ ] Label name is configurable via `NW_QA_VALIDATED_LABEL` env var +- [ ] `gh label create --force` ensures label exists before first use in QA script diff --git a/docs/PRDs/enhance-planner-prd-quality.md b/docs/PRDs/enhance-planner-prd-quality.md new file mode 100644 index 00000000..d61f25da --- /dev/null +++ b/docs/PRDs/enhance-planner-prd-quality.md @@ -0,0 +1,137 @@ +# PRD: Enhance Planner to Create Proper PRD-Quality GitHub Issues + +**Complexity: 3 → LOW mode** + +- +1 Touches 4 files +- +2 Multi-package changes (core template + cli command) + +--- + +## 1. Context + +**Problem:** The planner job creates GitHub issues with the raw roadmap item title as the issue title and a loosely formatted body that just dumps whatever the AI slicer produced. The issue should be a properly structured PRD following the prd-creator template so the executor agent can implement it without ambiguity. + +**Files Analyzed:** + +- `packages/cli/src/commands/slice.ts` — planner CLI command, `buildPlannerIssueBody()`, `createPlannerIssue()` +- `packages/core/src/templates/slicer-prompt.ts` — slicer prompt template rendering +- `templates/slicer.md` — the prompt template the AI provider receives +- `packages/core/src/utils/roadmap-scanner.ts` — roadmap parsing and slicing orchestration +- `packages/core/src/board/types.ts` — `ICreateIssueInput` interface +- `instructions/prd-creator.md` — the prd-creator skill instructions +- `docs/PRDs/prd-format.md` — expected PRD/ticket format guide + +**Current Behavior:** + +- `sliceNextItem()` spawns an AI provider with a slicer prompt to generate a local PRD file +- `createPlannerIssue()` creates a GitHub issue with `title: result.item.title` (raw roadmap title, e.g., "Structured Execution Telemetry") +- `buildPlannerIssueBody()` wraps the generated PRD file content in a "## Planner Generated PRD" header with metadata +- The issue title is a generic one-liner with no "PRD:" prefix or structure +- The issue body quality depends entirely on what the AI wrote — no validation or enforcement of PRD structure +- The slicer prompt template (`templates/slicer.md`) references `instructions/prd-creator.md` but doesn't embed the template structure inline, so the AI may not follow it consistently + +--- + +## 2. Solution + +**Approach:** + +- Prefix issue titles with `PRD:` so they're clearly identifiable as PRDs on the board +- Improve `buildPlannerIssueBody()` to extract structured sections from the generated PRD and format them as a clean GitHub issue body (not wrapped in a "Planner Generated PRD" meta-section) +- Embed the prd-creator PRD template structure directly into `templates/slicer.md` so the AI always has the full template available (rather than relying on it reading an `instructions/` file that may not exist in the target repo) +- Add a lightweight PRD structure validator that checks the generated PRD file contains the required sections before creating the issue +- If the PRD file is malformed, fall back to the current behavior (dump the raw content) so we don't break the pipeline + +**Key Decisions:** + +- No new dependencies — pure string manipulation +- PRD validation is advisory, not blocking — a missing section logs a warning but still creates the issue +- The `ICreateIssueInput` interface is unchanged; only the values passed to it change +- The slicer template already has the PRD structure inline (as a fallback); we just need to make it the primary path and ensure it matches the prd-creator skill exactly + +**Data Changes:** None + +--- + +## 4. Execution Phases + +### Phase 1: Improve issue title and body formatting — GitHub issues created by planner have "PRD:" prefix and clean body structure + +**Files (max 5):** + +- `packages/cli/src/commands/slice.ts` — update `buildPlannerIssueBody()` and `createPlannerIssue()` +- `packages/cli/src/__tests__/commands/slice.test.ts` — add tests for new formatting + +**Implementation:** + +- [ ] In `createPlannerIssue()`, change issue title from `result.item.title` to `PRD: ${result.item.title}` +- [ ] Also update the duplicate detection to normalize both sides (strip "PRD:" prefix before comparing) +- [ ] Rewrite `buildPlannerIssueBody()` to: + 1. Read the generated PRD file content + 2. Use it directly as the issue body (no "## Planner Generated PRD" wrapper, no source metadata preamble) + 3. Append a collapsible `
` section at the bottom with source metadata (section, item title, PRD file path) for traceability +- [ ] Keep the existing 60k char truncation logic + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/slice.test.ts` | `should prefix issue title with PRD:` | `expect(input.title).toBe('PRD: Test Feature')` | +| `packages/cli/src/__tests__/commands/slice.test.ts` | `should use PRD content directly as issue body` | `expect(input.body).not.toContain('## Planner Generated PRD')` | +| `packages/cli/src/__tests__/commands/slice.test.ts` | `should include source metadata in details section` | `expect(input.body).toContain('
')` | +| `packages/cli/src/__tests__/commands/slice.test.ts` | `should detect duplicates ignoring PRD: prefix` | `expect(result.skippedReason).toBe('already-exists')` | + +**Verification Plan:** + +1. **Unit Tests:** `packages/cli/src/__tests__/commands/slice.test.ts` — all new tests pass +2. **User Verification:** + - Action: Run `night-watch slice --dry-run` then manually inspect a created issue + - Expected: Issue title starts with "PRD:", body is clean PRD content + +**Checkpoint:** Run automated review after this phase completes. + +--- + +### Phase 2: Embed full prd-creator template in slicer prompt — AI provider always has the complete PRD structure available + +**Files (max 5):** + +- `templates/slicer.md` — embed the full prd-creator PRD template structure inline +- `packages/core/src/templates/slicer-prompt.ts` — update `DEFAULT_SLICER_TEMPLATE` fallback to match + +**Implementation:** + +- [ ] Update `templates/slicer.md` to embed the full PRD template structure from `instructions/prd-creator.md` inline (the critical sections: Complexity Scoring, PRD Template Structure with all subsections, Critical Instructions) +- [ ] Remove the "Load Planner Skill - Read and apply `instructions/prd-creator.md`" step — the instructions are now embedded, so the AI doesn't need to find a file +- [ ] Keep the `{{SECTION}}`, `{{TITLE}}`, `{{DESCRIPTION}}`, `{{OUTPUT_FILE_PATH}}`, `{{PRD_DIR}}` placeholders +- [ ] Add explicit instruction: "The PRD MUST include these sections: Context (with Problem, Files Analyzed, Current Behavior, Integration Points), Solution (with Approach, Key Decisions), Execution Phases (with Files, Implementation steps, Tests), and Acceptance Criteria" +- [ ] Update `DEFAULT_SLICER_TEMPLATE` in `slicer-prompt.ts` to match the new `templates/slicer.md` content +- [ ] Add instruction to the template that emphasizes: "Write the PRD so it can be used directly as a GitHub issue body — it must be self-contained and actionable" + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/core/src/__tests__/slicer-prompt.test.ts` | `should not reference external prd-creator file` | `expect(rendered).not.toContain('Read and apply')` | +| `packages/core/src/__tests__/slicer-prompt.test.ts` | `should contain PRD template sections inline` | `expect(rendered).toContain('## 1. Context')` | + +**Verification Plan:** + +1. **Unit Tests:** `packages/core/src/__tests__/slicer-prompt.test.ts` — template rendering tests pass +2. **User Verification:** + - Action: Run `night-watch slice` on a project with a ROADMAP.md + - Expected: Generated PRD file follows the full prd-creator structure with all sections filled in + +**Checkpoint:** Run automated review after this phase completes. + +--- + +## 5. Acceptance Criteria + +- [ ] All phases complete +- [ ] All specified tests pass +- [ ] `yarn verify` passes +- [ ] GitHub issues created by planner have "PRD:" prefixed titles +- [ ] GitHub issue body is the PRD content directly (not wrapped in metadata) +- [ ] Source metadata is preserved in a collapsible `
` section +- [ ] Duplicate detection works correctly with "PRD:" prefix +- [ ] Slicer prompt template embeds full PRD structure inline +- [ ] AI provider no longer needs to read an external `instructions/prd-creator.md` file diff --git a/docs/PRDs/migrate-bash-to-typescript.md b/docs/PRDs/migrate-bash-to-typescript.md deleted file mode 100644 index df468b9a..00000000 --- a/docs/PRDs/migrate-bash-to-typescript.md +++ /dev/null @@ -1,213 +0,0 @@ -# PRD: Migrate Bash Business Logic to TypeScript - -## Context - -`scripts/night-watch-helpers.sh` (603 lines) contains business logic (claim management, PRD discovery, lock acquisition, git branch detection, worktree orchestration) that is hard to test — only a small bats test file covers claims. The rest of the codebase is TypeScript with comprehensive vitest coverage. The project already established a migration pattern: `night-watch history` and `night-watch board` subcommands moved bash logic to TypeScript and the bash scripts now call CLI commands. This PRD continues that pattern for the remaining helpers. - -**Complexity: 9 (HIGH)** — 10+ files, new module, multi-package, concurrency logic. - ---- - -## Integration Points - -- **Entry point**: New `night-watch cron ` CLI group -- **Caller**: Bash cron scripts call via `"${NW_CLI}" cron ` (same as `night_watch_history`) -- **Registration**: `cronCommand(program)` in `packages/cli/src/cli.ts` -- **Internal-only**: These subcommands are called by bash scripts, not directly by users - ---- - -## Solution - -1. Create core utility modules with pure business logic (testable with vitest) -2. Create `night-watch cron` CLI subcommand group as thin wrappers (exit-code signaling for bash) -3. Replace bash helper calls with CLI calls in cron scripts -4. Delete migrated bash functions and bats tests - -**Key decisions:** - -- **Batched `find-eligible` command** — internalizes the PRD scanning loop (calls `isInCooldown()`, `isClaimed()` directly instead of N subprocess calls) -- **Git ops via `execFileSync('git', ...)`** — testable via real temp git repos (pattern from `execution-history.test.ts`) -- **Claims stay file-based** — SQLite would be over-engineering for cron-level concurrency -- **`validate_provider()` not migrated** — already handled by TS caller via Commander `.choices()` -- **Telegram calls not migrated as CLI subcommands** — move to TS callers (`run.ts`, `audit.ts`, etc.) using existing `notify.ts` - ---- - -## Phases - -### Phase 1: Git Utilities - -**Files:** - -- `packages/core/src/utils/git-utils.ts` (NEW) -- `packages/core/src/__tests__/utils/git-utils.test.ts` (NEW) -- `packages/core/src/index.ts` (add export) - -**Implementation:** - -- `getBranchTipTimestamp(projectDir, branch): number | null` — replaces `get_branch_tip_timestamp()` -- `detectDefaultBranch(projectDir): string` — replaces `detect_default_branch()` -- `resolveWorktreeBaseRef(projectDir, defaultBranch): string | null` — replaces `resolve_worktree_base_ref()` -- All use `execFileSync('git', [...], { cwd })` with try/catch - -**Tests:** Create temp git repos with `git init`, make commits on main/master, verify detection. Test local-only repos, repos with only master, detached HEAD. - ---- - -### Phase 2: Worktree Management - -**Files:** - -- `packages/core/src/utils/worktree-manager.ts` (NEW) -- `packages/core/src/__tests__/utils/worktree-manager.test.ts` (NEW) -- `packages/core/src/index.ts` (add export) - -**Implementation:** - -- `prepareBranchWorktree({ projectDir, worktreeDir, branchName, defaultBranch }): IPrepareWorktreeResult` -- `prepareDetachedWorktree({ projectDir, worktreeDir, defaultBranch }): IPrepareWorktreeResult` -- `cleanupWorktrees(projectDir, scope?): string[]` — returns removed paths - -**Tests:** Create bare repo + clone, test worktree creation/cleanup, stale directory handling. - ---- - -### Phase 3: Lock & Claim Management - -**Files:** - -- `packages/core/src/utils/status-data.ts` (extend with `acquireLock`, `releaseLock`) -- `packages/core/src/utils/claim-manager.ts` (NEW) -- `packages/core/src/__tests__/utils/claim-manager.test.ts` (NEW) -- `packages/core/src/__tests__/utils/status-data.test.ts` (add lock tests) -- `packages/core/src/index.ts` (add export) - -**Implementation:** - -- `acquireLock(lockPath, pid?): boolean` — extends existing `checkLockFile()`, writes PID -- `releaseLock(lockPath): void` -- `claimPrd(prdDir, prdFile, pid?): void` — writes JSON claim file -- `releaseClaim(prdDir, prdFile): void` -- `isClaimed(prdDir, prdFile, maxRuntime): boolean` — removes stale claims -- `readClaimInfo(prdDir, prdFile, maxRuntime): IClaimInfo | null` - -**Tests:** Temp directories, verify claim JSON format, stale claim expiry, lock acquisition/release. - ---- - -### Phase 4: PRD Discovery - -**Files:** - -- `packages/core/src/utils/prd-discovery.ts` (NEW) -- `packages/core/src/__tests__/utils/prd-discovery.test.ts` (NEW) -- `packages/core/src/index.ts` (add export) - -**Implementation:** - -- `findEligiblePrd({ prdDir, projectDir, maxRuntime, prdPriority? }): string | null` - - Scans PRD files, applies priority ordering - - Calls `isClaimed()` from Phase 3 directly (no subprocess) - - Calls `isInCooldown()` from `execution-history.ts` directly - - Calls `parsePrdDependencies()` from `status-data.ts` directly - - Queries open branches via `execFileSync('gh', ['pr', 'list', ...])` - - Returns first eligible PRD filename or null -- `findEligibleBoardIssue({ projectDir, maxRuntime }): IEligibleBoardIssue | null` - -**Tests:** Temp PRD dirs with claim files, mock `gh pr list`, execution history records for cooldown. - ---- - -### Phase 5: Remaining Helpers - -**Files:** - -- `packages/core/src/utils/log-utils.ts` (NEW) -- `packages/core/src/__tests__/utils/log-utils.test.ts` (NEW) -- `packages/core/src/utils/prd-utils.ts` (add `markPrdDone`) -- `packages/core/src/__tests__/utils/prd-utils.test.ts` (extend) -- `packages/core/src/index.ts` (add export) - -**Implementation:** - -- `rotateLog(logFile, maxSize?): boolean` — replaces `rotate_log()` -- `checkRateLimited(logFile, startLine?): boolean` — replaces `check_rate_limited()` -- `markPrdDone(prdDir, prdFile): boolean` — replaces `mark_prd_done()` - ---- - -### Phase 6: CLI Subcommands - -**Files:** - -- `packages/cli/src/commands/cron.ts` (NEW) -- `packages/cli/src/__tests__/commands/cron.test.ts` (NEW) -- `packages/cli/src/cli.ts` (register `cronCommand`) - -**Subcommands** (following `history.ts` exit-code signaling pattern): - -``` -cron detect-branch [projectDir] → stdout: branch name -cron acquire-lock → exit 0=acquired, 1=locked -cron find-eligible → stdout: PRD filename or JSON -cron claim → exit 0=claimed -cron release-claim → exit 0=released -cron is-claimed → exit 0=claimed, 1=not -cron mark-done → exit 0=done, 1=failed -cron prepare-worktree → exit 0=created, 1=failed -cron cleanup-worktrees → exit 0=cleaned -cron check-rate-limit → exit 0=limited, 1=not -cron rotate-log → exit 0=rotated -``` - ---- - -### Phase 7: Bash Script Migration - -Replace `source night-watch-helpers.sh` function calls with `"${NW_CLI}" cron ` calls. - -**Order** (simplest to most complex): - -1. `night-watch-slicer-cron.sh` — only uses `rotate_log`, `acquire_lock` -2. `night-watch-audit-cron.sh` — adds `detect_default_branch`, worktrees -3. `night-watch-qa-cron.sh` — similar to audit -4. `night-watch-cron.sh` — full: `find_eligible_prd`, claims, worktrees, rate limit -5. `night-watch-pr-reviewer-cron.sh` — parallel workers, worktrees - -Keep `resolve_night_watch_cli()`, `log()`, `emit_result()` in bash (inherently shell-native). Keep `night-watch-helpers.sh` as a minimal file with only those functions. - ---- - -### Phase 8: Cleanup - -- Slim `scripts/night-watch-helpers.sh` to only remaining bash-native functions -- Delete `scripts/test-helpers.bats` (replaced by vitest tests) -- Verify all cron scripts work end-to-end -- `yarn verify` passes - ---- - -## Dependency Graph - -``` -Phase 1 (git-utils) ──┐ -Phase 3 (lock+claim) ─┤── Phase 4 (prd-discovery) -Phase 5 (log/misc) ───┘ │ -Phase 2 (worktree) ─────────────┤ - ├── Phase 6 (CLI commands) → Phase 7 (bash migration) → Phase 8 (cleanup) -``` - -Phases 1, 2, 3, 5 can run in parallel. Phase 4 depends on Phase 3. Phase 6 depends on 1-5. Phase 7 depends on 6. - ---- - -## Verification - -After each phase: - -1. `yarn verify` (typecheck + lint) -2. `yarn test` for changed packages -3. Phase 6: test CLI subcommands via `node dist/cli.js cron ` with temp dirs -4. Phase 7: dry-run each cron script (`NW_DRY_RUN=1`) to verify no bash errors -5. Phase 8: full integration — run executor/reviewer with `--dry-run` against a test project diff --git a/docs/PRDs/night-watch/provider-aware-queue.md b/docs/PRDs/night-watch/done/provider-aware-queue.md similarity index 100% rename from docs/PRDs/night-watch/provider-aware-queue.md rename to docs/PRDs/night-watch/done/provider-aware-queue.md diff --git a/docs/PRDs/pr-merge-keeper.md b/docs/PRDs/pr-merge-keeper.md new file mode 100644 index 00000000..d60ac94e --- /dev/null +++ b/docs/PRDs/pr-merge-keeper.md @@ -0,0 +1,428 @@ +# PRD: PR Conflict Solver — Automated Conflict Resolution & Ready-to-Merge Management + +**Complexity: 8 → HIGH mode** (+3 touches 10+ files, +2 new module from scratch, +2 multi-package, +1 external API integration) + +## 1. Context + +**Problem:** Open PRs drift out of date as the default branch evolves. Merge conflicts accumulate and block PRs from being pushed or merged. Currently, there is no automated job to keep PRs rebased and conflict-free. The existing reviewer job reviews PRs and posts comments — it does not resolve conflicts or make any git changes. + +**Files Analyzed:** + +- `packages/core/src/jobs/job-registry.ts` — existing job definitions (6 types) +- `packages/core/src/types.ts` — `JobType`, `INightWatchConfig`, `IJobProviders`, `NotificationEvent` +- `packages/core/src/constants.ts` — defaults, queue priority +- `packages/core/src/utils/github.ts` — `gh` CLI wrappers for PR operations +- `packages/core/src/utils/git-utils.ts` — branch detection, timestamps +- `packages/core/src/utils/worktree-manager.ts` — worktree creation/cleanup +- `packages/cli/src/commands/review.ts` — reviewer pattern (closest analogue) +- `packages/cli/src/commands/shared/env-builder.ts` — shared env building +- `packages/cli/scripts/night-watch-helpers.sh` — `build_provider_cmd()`, `ensure_provider_on_path()` +- `packages/cli/scripts/night-watch-pr-reviewer-cron.sh` — reviewer bash script pattern + +**Current Behavior:** + +- PRs accumulate merge conflicts silently; developers must manually rebase +- No label/signal to indicate a PR is conflict-free and ready-to-merge +- The reviewer job only reviews PRs and posts comments — does not touch git or resolve anything +- Auto-merge exists but only triggers after reviewer score threshold; no conflict resolution step + +## 2. Solution + +**Approach:** + +1. Register a new **`pr-resolver`** job type in the job registry with its own cron schedule (3x daily), CLI command, lock file, and queue priority +2. The job iterates **all open PRs** in the repo, with **merge conflict resolution as the primary goal**: check out the PR branch in a worktree, attempt `git rebase` on default branch. If git auto-merge fails, invoke the configured AI provider to resolve conflicts intelligently, then force-push the rebased branch +3. **Secondarily** (when `aiReviewResolution` is enabled): after resolving conflicts, address unresolved GitHub review comments using the AI provider — implementing the suggested changes, committing, and pushing +4. When a PR is conflict-free, add a **`ready-to-merge`** GitHub label + +**Architecture Diagram:** + +```mermaid +flowchart TB + subgraph CLI["night-watch resolve"] + CMD[resolve command] --> ENV[buildEnvVars] + ENV --> SCRIPT[night-watch-pr-resolver-cron.sh] + end + + subgraph Script["Bash Script"] + SCRIPT --> FETCH[gh pr list --state open] + FETCH --> LOOP[For each PR] + LOOP --> CONFLICT{Has conflicts?} + CONFLICT -->|Yes| REBASE[git rebase default branch] + REBASE --> AUTO{Auto-resolved?} + AUTO -->|No| AI_RESOLVE[AI provider resolves conflicts] + AUTO -->|Yes| PUSH[force-push rebased branch] + AI_RESOLVE --> PUSH + PUSH --> REVIEW{aiReviewResolution?} + CONFLICT -->|No| REVIEW + REVIEW -->|Yes| UNRESOLVED{Unresolved reviews?} + UNRESOLVED -->|Yes| AI_FIX[AI provider implements suggestions] + AI_FIX --> RE_REVIEW[Request re-review] + UNRESOLVED -->|No| LABEL + REVIEW -->|No| LABEL[Add ready-to-merge label] + RE_REVIEW --> LABEL + end + + subgraph Core["@night-watch/core"] + REG[Job Registry] --> CMD + CONFIG[INightWatchConfig] --> CMD + end +``` + +**Key Decisions:** + +- **Primary goal: conflict resolution** — the job exists to resolve merge conflicts that block PRs; review comment resolution is opt-in secondary behavior +- **No redundancy with reviewer job** — reviewer reads code and posts comments; pr-resolver fixes conflicts and optionally implements those comments +- **Uses configured AI provider** via `build_provider_cmd()` (same as all other jobs) — respects presets, schedule overrides, fallback chains +- **Scope: all open PRs** — not limited to night-watch branches; configurable via `branchPatterns` extra field if user wants to narrow scope +- **Force-push after rebase** — necessary for rebased branches; the job only force-pushes branches it has actively rebased (never the default branch) +- **`ready-to-merge` label** — added when PR is conflict-free; removed if new conflicts appear +- **Bash script pattern** — follows the same `night-watch-*-cron.sh` pattern as all other jobs for consistency + +**Data Changes:** + +- `JobType` union gains `'pr-resolver'` value +- `IJobProviders` gains optional `prResolver?: Provider` +- New `IPrResolverConfig` interface extending `IBaseJobConfig` with extra fields +- New notification events: `pr_resolver_completed`, `pr_resolver_conflict_resolved`, `pr_resolver_failed` +- Job registry gains `pr-resolver` entry + +## 3. Sequence Flow + +```mermaid +sequenceDiagram + participant Cron as Cron / CLI + participant Script as pr-resolver-cron.sh + participant GH as GitHub (gh CLI) + participant AI as AI Provider + participant Git as Git + + Cron->>Script: night-watch resolve + Script->>GH: gh pr list --state open --json ... + GH-->>Script: [PR1, PR2, ...] + + loop Each PR + Script->>GH: gh pr view PR --json mergeable,reviewThreads + alt Has merge conflicts (PRIMARY) + Script->>Git: git worktree add + git rebase + alt Rebase succeeds (auto-merge) + Script->>Git: git push --force-with-lease + else Rebase fails (conflicts) + Script->>AI: "Resolve merge conflicts in these files: ..." + AI-->>Script: Conflict resolution applied + Script->>Git: git add + git rebase --continue + Script->>Git: git push --force-with-lease + end + end + + alt aiReviewResolution=true AND has unresolved review comments (SECONDARY) + Script->>GH: gh api /repos/.../pulls/N/reviews + Script->>AI: "Implement these review suggestions: ..." + AI-->>Script: Changes applied + Script->>Git: git commit + git push + Script->>GH: gh pr edit --add-reviewer (request re-review) + end + + alt Conflict-free + Script->>GH: gh pr edit --add-label ready-to-merge + Script->>Script: log "PR #N is ready to merge" + else Still has conflicts + Script->>GH: gh pr edit --remove-label ready-to-merge + end + end + + Script-->>Cron: emit_result with summary +``` + +## 4. Execution Phases + +### Phase 1: Core Registration — Job Type & Config + +**User-visible outcome:** `pr-resolver` job type exists in the registry, config normalizes correctly, and `night-watch resolve --dry-run` shows configuration. + +**Files (5):** + +- `packages/core/src/types.ts` — add `'pr-resolver'` to `JobType`, `IPrResolverConfig` interface, new notification events, `prResolver` to `IJobProviders` +- `packages/core/src/jobs/job-registry.ts` — add `pr-resolver` entry to `JOB_REGISTRY` +- `packages/core/src/constants.ts` — add `DEFAULT_PR_RESOLVER_*` constants +- `packages/core/src/config.ts` — wire `pr-resolver` config normalization (follows existing pattern) +- `packages/core/src/index.ts` — export new types if needed + +**Implementation:** + +- [ ] Add `'pr-resolver'` to the `JobType` union type +- [ ] Add `prResolver?: Provider` to `IJobProviders` +- [ ] Define `IPrResolverConfig` extending `IBaseJobConfig`: + ```typescript + interface IPrResolverConfig extends IBaseJobConfig { + /** Branch patterns to match (empty = all open PRs) */ + branchPatterns: string[]; + /** Max PRs to process per run (0 = unlimited) */ + maxPrsPerRun: number; + /** Max runtime per individual PR in seconds */ + perPrTimeout: number; + /** Whether to attempt AI conflict resolution (vs skip conflicted PRs) */ + aiConflictResolution: boolean; + /** Whether to also address unresolved review comments (secondary behavior) */ + aiReviewResolution: boolean; + /** Label to add when PR is conflict-free and ready to merge */ + readyLabel: string; + } + ``` +- [ ] Add default constants: + ```typescript + DEFAULT_PR_RESOLVER_ENABLED = true; + DEFAULT_PR_RESOLVER_SCHEDULE = '15 6,14,22 * * *'; // 3x daily: 6:15, 14:15, 22:15 + DEFAULT_PR_RESOLVER_MAX_RUNTIME = 3600; // 1 hour + DEFAULT_PR_RESOLVER_MAX_PRS_PER_RUN = 0; // unlimited + DEFAULT_PR_RESOLVER_PER_PR_TIMEOUT = 600; // 10 min per PR + DEFAULT_PR_RESOLVER_AI_CONFLICT_RESOLUTION = true; + DEFAULT_PR_RESOLVER_AI_REVIEW_RESOLUTION = false; // opt-in; reviewer job handles comments + DEFAULT_PR_RESOLVER_READY_LABEL = 'ready-to-merge'; + ``` +- [ ] Add `pr-resolver` entry to `JOB_REGISTRY`: + ```typescript + { + id: 'pr-resolver', + name: 'PR Conflict Solver', + description: 'Resolves merge conflicts via AI rebase; optionally addresses review comments and labels PRs ready-to-merge', + cliCommand: 'resolve', + logName: 'pr-resolver', + lockSuffix: '-pr-resolver.lock', + queuePriority: 35, // between reviewer (40) and slicer (30) + envPrefix: 'NW_PR_RESOLVER', + extraFields: [ + { name: 'branchPatterns', type: 'string[]', defaultValue: [] }, + { name: 'maxPrsPerRun', type: 'number', defaultValue: 0 }, + { name: 'perPrTimeout', type: 'number', defaultValue: 600 }, + { name: 'aiConflictResolution', type: 'boolean', defaultValue: true }, + { name: 'aiReviewResolution', type: 'boolean', defaultValue: false }, + { name: 'readyLabel', type: 'string', defaultValue: 'ready-to-merge' }, + ], + defaultConfig: { enabled: true, schedule: '15 6,14,22 * * *', maxRuntime: 3600, ... } + } + ``` +- [ ] Add `'pr_resolver_completed' | 'pr_resolver_conflict_resolved' | 'pr_resolver_failed'` to `NotificationEvent` union + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/core/src/__tests__/jobs/job-registry.test.ts` | `should include pr-resolver in job registry` | `expect(getJobDef('pr-resolver')).toBeDefined()` | +| `packages/core/src/__tests__/jobs/job-registry.test.ts` | `pr-resolver has correct defaults` | schedule, maxRuntime, queuePriority checks | +| `packages/core/src/__tests__/jobs/job-registry.test.ts` | `normalizeJobConfig handles pr-resolver extra fields` | all extra fields normalized with defaults | + +**Verification Plan:** + +1. **Unit Tests:** Registry lookup, config normalization for pr-resolver +2. **Evidence:** `yarn verify` passes, `yarn test` passes + +--- + +### Phase 2: CLI Command — `night-watch resolve` + +**User-visible outcome:** Running `night-watch resolve --dry-run` displays pr-resolver configuration, open PRs with conflict status, and provider info. Running `night-watch resolve` executes the bash script. + +**Files (4):** + +- `packages/cli/src/commands/resolve.ts` — **NEW** — CLI command implementation (follows review.ts pattern) +- `packages/cli/src/cli.ts` — register `resolveCommand` +- `packages/cli/src/commands/shared/env-builder.ts` — no changes needed (generic `buildBaseEnvVars` handles new job type) +- `packages/cli/src/commands/install.ts` — add `--no-pr-resolver` / `--pr-resolver` flags + +**Implementation:** + +- [ ] Create `resolve.ts` following the reviewer command pattern: + - `IResolveOptions`: `{ dryRun, timeout, provider }` + - `buildEnvVars(config, options)`: calls `buildBaseEnvVars(config, 'pr-resolver', options.dryRun)` + resolver-specific env vars: + - `NW_PR_RESOLVER_MAX_RUNTIME` + - `NW_PR_RESOLVER_MAX_PRS_PER_RUN` + - `NW_PR_RESOLVER_PER_PR_TIMEOUT` + - `NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION` + - `NW_PR_RESOLVER_AI_REVIEW_RESOLUTION` + - `NW_PR_RESOLVER_READY_LABEL` + - `NW_PR_RESOLVER_BRANCH_PATTERNS` + - `applyCliOverrides(config, options)`: timeout + provider overrides + - Dry-run mode: show config table, list open PRs with conflict status, env vars, command + - Execute mode: spinner + `executeScriptWithOutput` calling `night-watch-pr-resolver-cron.sh` + - Notification sending after completion +- [ ] Register in `cli.ts`: `resolveCommand(program)` +- [ ] Add `--no-pr-resolver` / `--pr-resolver` flags to install command for cron schedule control + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `buildEnvVars includes pr-resolver-specific vars` | env var keys present | +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `applyCliOverrides applies timeout override` | config mutated | + +**Verification Plan:** + +1. **Unit Tests:** env var building, config overrides +2. **Manual:** `night-watch resolve --dry-run` outputs valid config +3. **Evidence:** `yarn verify` passes + +--- + +### Phase 3: Bash Script — Core Resolver Logic + +**User-visible outcome:** `night-watch resolve` iterates open PRs, detects conflicts, attempts git rebase, and invokes AI provider for unresolvable conflicts. Optionally addresses review comments. Adds/removes `ready-to-merge` label. + +**Files (2):** + +- `packages/cli/scripts/night-watch-pr-resolver-cron.sh` — **NEW** — main resolver bash script +- `packages/cli/scripts/night-watch-helpers.sh` — add any shared helper functions if needed (likely none) + +**Implementation:** + +- [ ] Script structure (following reviewer pattern): + ```bash + #!/usr/bin/env bash + set -euo pipefail + # Usage: night-watch-pr-resolver-cron.sh /path/to/project + ``` +- [ ] Parse env vars: + - `NW_PR_RESOLVER_MAX_RUNTIME`, `NW_PR_RESOLVER_MAX_PRS_PER_RUN`, `NW_PR_RESOLVER_PER_PR_TIMEOUT` + - `NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION`, `NW_PR_RESOLVER_AI_REVIEW_RESOLUTION` + - `NW_PR_RESOLVER_READY_LABEL`, `NW_PR_RESOLVER_BRANCH_PATTERNS` + - Standard provider vars via `NW_PROVIDER_CMD`, etc. +- [ ] Source `night-watch-helpers.sh` for `build_provider_cmd`, `log`, `emit_result`, `acquire_lock`, `release_lock`, `rotate_log`, `ensure_provider_on_path` +- [ ] Lock file acquisition: `/tmp/night-watch-pr-resolver-${PROJECT_RUNTIME_KEY}.lock` +- [ ] **PR Discovery:** + ```bash + gh pr list --state open --json number,title,headRefName,mergeable,reviewDecision,statusCheckRollup + ``` + + - Filter by `NW_PR_RESOLVER_BRANCH_PATTERNS` if set (comma-separated) + - Respect `NW_PR_RESOLVER_MAX_PRS_PER_RUN` +- [ ] **Per-PR processing loop:** + 1. **Conflict detection (PRIMARY):** Check `mergeable` status from `gh pr view` + 2. **Rebase attempt:** + - Create worktree on the PR branch via `prepare_branch_worktree` or manual `git worktree add` + - `git fetch origin ${DEFAULT_BRANCH}` then `git rebase origin/${DEFAULT_BRANCH}` + - If rebase succeeds cleanly: `git push --force-with-lease origin ${BRANCH}` + - If rebase fails with conflicts and `NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION=1`: + - Abort rebase: `git rebase --abort` + - Build AI prompt: "You are in a git repository. The branch `{branch}` has merge conflicts with `{default_branch}`. Please rebase this branch onto `origin/{default_branch}` and resolve all merge conflicts. After resolving, ensure the code compiles and tests pass. Use `git rebase origin/{default_branch}` and resolve conflicts, then `git push --force-with-lease origin {branch}`." + - Invoke AI via `build_provider_cmd` + `timeout` + - If rebase fails and AI resolution is disabled: skip PR, log warning + 3. **Review comment resolution — SECONDARY (only if `NW_PR_RESOLVER_AI_REVIEW_RESOLUTION=1`):** + - Check for unresolved review threads: `gh api repos/{owner}/{repo}/pulls/{number}/reviews` + - If unresolved threads exist: + - Build AI prompt: "You are in a git repository on branch `{branch}`. This PR has unresolved review comments from GitHub reviewers. Please read the review comments using `gh pr view {number} --comments`, understand the requested changes, implement them, commit with a descriptive message, and push." + - Invoke AI via `build_provider_cmd` + `timeout` + 4. **Ready-to-merge labeling:** + - After processing, re-check: `gh pr view {number} --json mergeable` + - If conflict-free: + - `gh pr edit {number} --add-label ${READY_LABEL}` + - Log: "PR #{number} marked as ready-to-merge" + - Else: + - `gh pr edit {number} --remove-label ${READY_LABEL}` (ignore error if label not present) + 5. **Worktree cleanup** after each PR +- [ ] **Result emission:** + - Track: `prs_processed`, `conflicts_resolved`, `reviews_addressed`, `prs_ready`, `prs_failed` + - `emit_result "success" "prs_processed=${PROCESSED} conflicts_resolved=${CONFLICTS} reviews_addressed=${REVIEWS} prs_ready=${READY}"` +- [ ] **Timeout handling:** per-PR timeout via `NW_PR_RESOLVER_PER_PR_TIMEOUT`, global timeout via `NW_PR_RESOLVER_MAX_RUNTIME` + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/scripts/test-helpers.bats` | `pr-resolver lock acquisition` | lock file created/released | + +**Verification Plan:** + +1. **Manual test:** Run `night-watch resolve --dry-run` in a project with open PRs +2. **Manual test:** Run `night-watch resolve` on a repo with a known conflicted PR +3. **Evidence:** Log output shows PR iteration, conflict detection, resolution attempt + +--- + +### Phase 4: Notifications & Install Integration + +**User-visible outcome:** PR resolver job sends notifications on completion/failure. `night-watch install` includes resolver cron schedule. Summary command includes resolver data. + +**Files (5):** + +- `packages/core/src/utils/notify.ts` — add pr-resolver notification event formatting +- `packages/cli/src/commands/install.ts` — add pr-resolver cron entry generation +- `packages/cli/src/commands/uninstall.ts` — handle pr-resolver cron removal +- `packages/cli/src/commands/resolve.ts` — wire notification sending in execute flow +- `packages/core/src/utils/summary.ts` — include resolver stats in morning briefing + +**Implementation:** + +- [ ] Add notification message formatting for `pr_resolver_completed`, `pr_resolver_conflict_resolved`, `pr_resolver_failed` events +- [ ] `install.ts`: + - Add `--no-pr-resolver` flag to `IInstallOptions` + - Generate cron entry for pr-resolver using config schedule (same pattern as reviewer/qa) + - Format: `{schedule} cd {projectDir} && {nightWatchBin} resolve >> {logDir}/pr-resolver.log 2>&1` +- [ ] `uninstall.ts`: remove pr-resolver cron entries in cleanup +- [ ] `resolve.ts`: after script execution, build notification context and call `sendNotifications()` + - Include `prsProcessed`, `conflictsResolved`, `reviewsAddressed`, `prsReady` in context +- [ ] `summary.ts`: resolver stats in the action items / summary data: + - "N PRs are ready to merge" or "N PRs have unresolved conflicts" + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `sends pr_resolver_completed notification on success` | notification mock called | +| `packages/cli/src/__tests__/commands/install.test.ts` | `includes pr-resolver cron entry` | crontab contains resolver schedule | + +**Verification Plan:** + +1. **Unit Tests:** notification formatting, install output +2. **Manual:** `night-watch install` shows pr-resolver in crontab, `night-watch summary` includes resolver data +3. **Evidence:** `yarn verify` + `yarn test` pass + +--- + +### Phase 5: Edge Cases, Idempotency & Hardening + +**User-visible outcome:** PR resolver handles edge cases gracefully — protected branches, draft PRs, concurrent runs, AI resolution failures — without breaking existing PRs. + +**Files (3):** + +- `packages/cli/scripts/night-watch-pr-resolver-cron.sh` — edge case handling +- `packages/cli/src/commands/resolve.ts` — preflight checks +- `packages/core/src/__tests__/commands/resolve.test.ts` — edge case tests + +**Implementation:** + +- [ ] **Skip draft PRs:** filter out PRs with `isDraft: true` +- [ ] **Skip PRs with `skip-resolver` label:** configurable skip label +- [ ] **Protected branch safety:** never force-push to the default branch; verify branch name before push +- [ ] **Idempotent label management:** don't fail if `ready-to-merge` label doesn't exist yet (auto-create via `gh label create` if missing, ignore errors) +- [ ] **AI resolution failure handling:** if AI provider fails or times out, skip the PR, log error, continue to next PR +- [ ] **Force-with-lease safety:** always use `--force-with-lease` instead of `--force` to avoid overwriting concurrent pushes +- [ ] **Rate limiting:** respect `NW_PR_RESOLVER_MAX_PRS_PER_RUN` and global timeout +- [ ] **Re-check after rebase:** after force-pushing, wait briefly for GitHub to update mergeable status before labeling +- [ ] **Concurrent run protection:** lock file prevents multiple resolver instances + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `skips draft PRs` | draft PRs excluded from processing | +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `skips PRs with skip-resolver label` | labeled PRs excluded | +| `packages/cli/src/__tests__/commands/resolve.test.ts` | `handles AI resolution failure gracefully` | continues to next PR | + +**Verification Plan:** + +1. **Unit Tests:** edge case filtering +2. **Integration test:** Run against a repo with draft PRs, labeled PRs, and conflicted PRs +3. **Evidence:** `yarn verify` + `yarn test` pass, no data loss in any scenario + +## 5. Acceptance Criteria + +- [ ] All 5 phases complete +- [ ] All specified tests pass +- [ ] `yarn verify` passes +- [ ] All automated checkpoint reviews passed +- [ ] `night-watch resolve --dry-run` shows configuration and PR conflict status +- [ ] `night-watch resolve` processes open PRs and resolves merge conflicts via AI rebase +- [ ] `ready-to-merge` label added to PRs that are conflict-free +- [ ] `night-watch install` includes pr-resolver cron schedule +- [ ] Notifications sent on completion/failure +- [ ] `night-watch summary` includes resolver stats +- [ ] No force-push to protected/default branches +- [ ] Draft PRs and skip-labeled PRs are excluded +- [ ] AI resolution failures are handled gracefully (skip + continue) +- [ ] Job integrates with global queue system +- [ ] Review comment resolution is opt-in (`aiReviewResolution: false` by default) — does not duplicate reviewer job behavior diff --git a/docs/PRDs/provider-presets.md b/docs/PRDs/provider-presets.md deleted file mode 100644 index 94187972..00000000 --- a/docs/PRDs/provider-presets.md +++ /dev/null @@ -1,405 +0,0 @@ -# PRD: Provider Presets - -**Complexity: 7 → HIGH mode** - -``` -+3 Touches 10+ files -+2 Multi-package changes (core, cli, server, web) -+2 Complex state logic (preset resolution, backward compat) -``` - ---- - -## 1. Context - -**Problem:** Providers are a hardcoded union type (`'claude' | 'codex'`). Adding a new CLI, customizing models, or creating purpose-specific provider configurations (e.g. "Architect" = Claude Opus, "Grunt" = Claude Sonnet) requires code changes. Users can't preconfigure named provider bundles and assign them to jobs from the UI. - -**Files Analyzed:** - -- `packages/core/src/types.ts` — `Provider`, `IJobProviders`, `INightWatchConfig` -- `packages/core/src/shared/types.ts` — duplicate `Provider` type -- `packages/core/src/constants.ts` — `VALID_PROVIDERS`, `PROVIDER_COMMANDS`, model constants -- `packages/core/src/config.ts` — `loadConfig()`, `resolveJobProvider()` -- `packages/core/src/config-normalize.ts` — `normalizeConfig()`, `validateProvider()` -- `packages/core/src/config-env.ts` — env var overrides -- `packages/cli/src/commands/shared/env-builder.ts` — `buildBaseEnvVars()`, `deriveProviderLabel()` -- `packages/server/src/routes/config.routes.ts` — `validateConfigChanges()` -- `web/pages/Settings.tsx` — Providers tab (lines 1055-1178) -- Bash scripts: `night-watch-cron.sh`, `night-watch-pr-reviewer-cron.sh`, `night-watch-qa-cron.sh`, `night-watch-audit-cron.sh` - -**Current Behavior:** - -- `Provider = 'claude' | 'codex'` — only two hardcoded options -- `PROVIDER_COMMANDS` maps provider name → CLI binary -- `jobProviders` assigns a Provider per job type (executor, reviewer, qa, audit, slicer) -- Bash scripts use `case "${PROVIDER_CMD}"` with hardcoded invocation patterns per provider -- UI shows two dropdowns (Claude/Codex) and a shared env var editor - ---- - -## 2. Solution - -**Approach:** - -- Introduce **provider presets** — named, fully-configured provider bundles stored in `night-watch.config.json` under `providerPresets` -- Each preset bundles: CLI command, subcommand, prompt flag, auto-approve flag, workdir flag, model, env vars -- Two built-in presets (`claude`, `codex`) serve as defaults and are editable but not deletable -- `provider` and `jobProviders` reference preset IDs instead of the old union type -- Full backward compatibility: old configs with `provider: 'claude'` still work (resolves to built-in preset) -- Bash scripts switch from `case` statements to generic env-var-driven invocation - -**Architecture:** - -```mermaid -flowchart LR - subgraph Config["night-watch.config.json"] - PP["providerPresets: { architect: {...}, grunt: {...} }"] - P["provider: 'grunt' (default preset ID)"] - JP["jobProviders: { executor: 'architect', reviewer: 'codex' }"] - end - subgraph Core["@night-watch/core"] - RP["resolvePreset(id) → IProviderPreset"] - RJP["resolveJobProvider(config, jobType) → presetId"] - end - subgraph CLI["@night-watch/cli"] - EB["buildBaseEnvVars() → NW_PROVIDER_* env vars"] - end - subgraph Bash["bash scripts"] - GI["Generic invocation from env vars"] - end - subgraph UI["web (Settings)"] - PL["Preset list with cards"] - PM["Add/Edit modal"] - JA["Job assignment dropdowns"] - end - - Config --> Core --> CLI --> Bash - UI -->|PUT /api/config| Config -``` - -**Key Decisions:** - -- [x] Per-project storage in config JSON (not global DB) -- [x] Built-in presets are code defaults, overridable but not deletable -- [x] Rate-limit fallback remains global & Claude-specific (not per-preset) -- [x] Backward compat: bare `'claude'`/`'codex'` strings resolve to built-in presets -- [x] Delete protection: block deletion if preset is assigned to any job - -**Data Changes:** - -New config field `providerPresets: Record`. No database schema changes. - ---- - -## 3. Type Definitions - -```typescript -/** A fully-configured provider preset */ -interface IProviderPreset { - /** Display name (e.g. "Architect", "Grunt") */ - name: string; - /** CLI binary to invoke (e.g. "claude", "codex", "aider") */ - command: string; - /** Optional subcommand (e.g. "exec" for codex) */ - subcommand?: string; - /** Flag for passing the prompt (e.g. "-p"). Empty/undefined = prompt is positional (last arg) */ - promptFlag?: string; - /** Flag for auto-approve/skip permissions (e.g. "--dangerously-skip-permissions", "--yolo") */ - autoApproveFlag?: string; - /** Flag for working directory (e.g. "-C"). Empty/undefined = use cd */ - workdirFlag?: string; - /** Flag name for model selection (e.g. "--model") */ - modelFlag?: string; - /** Model value to pass (e.g. "claude-opus-4-6") */ - model?: string; - /** Extra environment variables for this preset */ - envVars?: Record; -} -``` - -Built-in defaults (code constants, not stored in config): - -```typescript -const BUILT_IN_PRESETS: Record = { - claude: { - name: 'Claude', - command: 'claude', - promptFlag: '-p', - autoApproveFlag: '--dangerously-skip-permissions', - }, - codex: { - name: 'Codex', - command: 'codex', - subcommand: 'exec', - autoApproveFlag: '--yolo', - workdirFlag: '-C', - }, -}; -``` - ---- - -## 4. Sequence Flow - -```mermaid -sequenceDiagram - participant UI as Settings UI - participant API as Server API - participant Config as config.json - participant CLI as CLI Command - participant Bash as Bash Script - - Note over UI: User creates preset "Architect" - UI->>API: PUT /api/config { providerPresets: { architect: {...} } } - API->>API: validateConfigChanges() - API->>Config: saveConfig() - API-->>UI: 200 OK - - Note over UI: User assigns "Architect" to executor - UI->>API: PUT /api/config { jobProviders: { executor: "architect" } } - API->>Config: saveConfig() - - Note over CLI: night-watch run - CLI->>Config: loadConfig() - CLI->>CLI: resolveJobProvider(config, 'executor') → 'architect' - CLI->>CLI: resolvePreset(config, 'architect') → IProviderPreset - CLI->>CLI: buildBaseEnvVars() → NW_PROVIDER_CMD, NW_PROVIDER_PROMPT_FLAG, etc. - CLI->>Bash: spawn with env vars - Bash->>Bash: Build command from NW_PROVIDER_* env vars - Bash->>Bash: Execute: claude -p "$PROMPT" --dangerously-skip-permissions --model claude-opus-4-6 -``` - ---- - -## 5. Execution Phases - -### Phase 1: Core Types & Preset Resolution - -**User-visible outcome:** `resolvePreset()` resolves preset IDs to full `IProviderPreset` objects; old configs still work. - -**Files (4):** - -- `packages/core/src/types.ts` — Add `IProviderPreset` interface; widen `Provider` to `string`; update `IJobProviders` values to `string`; add `providerPresets` to `INightWatchConfig` -- `packages/core/src/shared/types.ts` — Sync `Provider` type change -- `packages/core/src/constants.ts` — Add `BUILT_IN_PRESETS`; keep `PROVIDER_COMMANDS` derived from presets; keep `VALID_PROVIDERS` for backward compat but also export `BUILT_IN_PRESET_IDS` -- `packages/core/src/config.ts` — Add `resolvePreset(config, presetId): IProviderPreset`; update `resolveJobProvider()` to return preset IDs - -**Implementation:** - -- [ ] Add `IProviderPreset` interface to `types.ts` -- [ ] Change `Provider` from union to `string` (backward compat: 'claude'|'codex' are still valid strings) -- [ ] Change `IJobProviders` values from `Provider` to `string | undefined` -- [ ] Add `providerPresets?: Record` to `INightWatchConfig` -- [ ] Add `BUILT_IN_PRESETS` to `constants.ts` with claude and codex defaults -- [ ] Add `resolvePreset(config, presetId)` to `config.ts`: looks up `config.providerPresets[id]` first, then `BUILT_IN_PRESETS[id]`, throws if not found -- [ ] Ensure `resolveJobProvider()` still returns a string (preset ID) -- [ ] Deprecate `providerLabel` at config root level — preset's `name` is the label now - -**Tests Required:** - -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| `packages/core/src/__tests__/config.test.ts` | `should resolve built-in claude preset` | `resolvePreset(config, 'claude').command === 'claude'` | -| `packages/core/src/__tests__/config.test.ts` | `should resolve custom preset from config` | `resolvePreset(config, 'architect').model === 'claude-opus-4-6'` | -| `packages/core/src/__tests__/config.test.ts` | `should throw for unknown preset` | `expect(() => resolvePreset(config, 'invalid')).toThrow()` | -| `packages/core/src/__tests__/config.test.ts` | `should allow overriding built-in preset` | custom 'claude' preset overrides built-in | -| `packages/core/src/__tests__/config.test.ts` | `should resolve job provider as preset ID` | `resolveJobProvider(config, 'executor') === 'architect'` | - -**User Verification:** - -- Action: Run `yarn test packages/core` -- Expected: All new and existing tests pass - ---- - -### Phase 2: Config Normalization & Validation - -**User-visible outcome:** Config loading validates presets, normalizes them, and maintains full backward compat with old configs. - -**Files (3):** - -- `packages/core/src/config-normalize.ts` — Normalize `providerPresets` entries; update `validateProvider()` to accept preset IDs -- `packages/core/src/config-env.ts` — Add `NW_PROVIDER_PRESET_*` env var overrides (stretch: may defer) -- `packages/server/src/routes/config.routes.ts` — Validate `providerPresets` in `validateConfigChanges()`; update `provider`/`jobProviders` validation to accept preset IDs - -**Implementation:** - -- [ ] In `normalizeConfig()`: read `providerPresets` from raw config, validate each entry has at minimum `name` and `command` -- [ ] Update `validateProvider()` to accept any string (preset ID) not just 'claude'|'codex'; actual existence check happens at resolution time -- [ ] In `validateConfigChanges()`: validate `providerPresets` entries (name required, command required, envVars must be string-valued if present, no reserved IDs conflict) -- [ ] Update `jobProviders` validation: accept any string (preset ID), not just `VALID_PROVIDERS` -- [ ] Add delete protection validation: if a preset ID is removed from `providerPresets` but still referenced by `provider` or `jobProviders`, return error listing which jobs reference it - -**Tests Required:** - -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| `packages/core/src/__tests__/config-normalize.test.ts` | `should normalize providerPresets` | presets are preserved after normalization | -| `packages/core/src/__tests__/config-normalize.test.ts` | `should reject preset without command` | validation error | -| `packages/server/src/__tests__/config.routes.test.ts` | `should accept custom preset ID in jobProviders` | 200 OK | -| `packages/server/src/__tests__/config.routes.test.ts` | `should block deletion of in-use preset` | 400 with job references | - -**User Verification:** - -- Action: Run `yarn test` and `yarn verify` -- Expected: All tests pass, type checking passes - ---- - -### Phase 3: Env Builder Update - -**User-visible outcome:** `buildBaseEnvVars()` outputs preset-specific env vars (`NW_PROVIDER_PROMPT_FLAG`, `NW_PROVIDER_APPROVE_FLAG`, etc.) that bash scripts will consume. - -**Files (2):** - -- `packages/cli/src/commands/shared/env-builder.ts` — Resolve preset and emit `NW_PROVIDER_*` env vars; update `deriveProviderLabel()` to use preset name -- `packages/core/src/constants.ts` — Update `resolveProviderBucketKey()` to work with presets - -**Implementation:** - -- [ ] In `buildBaseEnvVars()`: call `resolvePreset(config, presetId)` to get the full preset -- [ ] Emit new env vars from preset fields: - - `NW_PROVIDER_CMD` = `preset.command` (already exists, now from preset) - - `NW_PROVIDER_SUBCOMMAND` = `preset.subcommand ?? ''` - - `NW_PROVIDER_PROMPT_FLAG` = `preset.promptFlag ?? ''` - - `NW_PROVIDER_APPROVE_FLAG` = `preset.autoApproveFlag ?? ''` - - `NW_PROVIDER_WORKDIR_FLAG` = `preset.workdirFlag ?? ''` - - `NW_PROVIDER_MODEL_FLAG` = `preset.modelFlag ?? ''` - - `NW_PROVIDER_MODEL` = `preset.model ?? ''` - - `NW_PROVIDER_LABEL` = `preset.name` -- [ ] Merge `preset.envVars` into the env (in addition to `config.providerEnv` for backward compat; preset-level env vars take precedence) -- [ ] Update `deriveProviderLabel()`: use preset name as primary source -- [ ] Update `resolveProviderBucketKey()`: use preset command + envVars to derive bucket key - -**Tests Required:** - -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| `packages/cli/src/__tests__/env-builder.test.ts` | `should emit NW_PROVIDER_PROMPT_FLAG for claude preset` | env.NW_PROVIDER_PROMPT_FLAG === '-p' | -| `packages/cli/src/__tests__/env-builder.test.ts` | `should emit NW_PROVIDER_MODEL for preset with model` | env.NW_PROVIDER_MODEL === 'claude-opus-4-6' | -| `packages/cli/src/__tests__/env-builder.test.ts` | `should merge preset envVars` | preset env vars appear in output | -| `packages/cli/src/__tests__/env-builder.test.ts` | `should use preset name as provider label` | env.NW_PROVIDER_LABEL === 'Architect' | - -**User Verification:** - -- Action: `night-watch run --dry-run` with a config that has custom presets -- Expected: Dry-run output shows correct provider command, model, and label from the preset - ---- - -### Phase 4: Bash Script Generic Invocation - -**User-visible outcome:** Bash scripts build the provider command dynamically from `NW_PROVIDER_*` env vars instead of hardcoded `case` statements. Any custom CLI works without code changes. - -**Files (4):** - -- `packages/cli/scripts/night-watch-cron.sh` — Replace case statement with generic invocation helper -- `packages/cli/scripts/night-watch-pr-reviewer-cron.sh` — Same -- `packages/cli/scripts/night-watch-qa-cron.sh` — Same -- `packages/cli/scripts/night-watch-audit-cron.sh` — Same - -**Implementation:** - -- [ ] Add a shared helper function `build_provider_cmd()` that constructs the command array from `NW_PROVIDER_*` env vars: - ```bash - build_provider_cmd() { - local workdir="$1" prompt="$2" - local cmd_parts=("${NW_PROVIDER_CMD}") - [ -n "${NW_PROVIDER_SUBCOMMAND:-}" ] && cmd_parts+=("${NW_PROVIDER_SUBCOMMAND}") - [ -n "${NW_PROVIDER_WORKDIR_FLAG:-}" ] && cmd_parts+=("${NW_PROVIDER_WORKDIR_FLAG}" "${workdir}") - [ -n "${NW_PROVIDER_APPROVE_FLAG:-}" ] && cmd_parts+=("${NW_PROVIDER_APPROVE_FLAG}") - [ -n "${NW_PROVIDER_MODEL_FLAG:-}" ] && [ -n "${NW_PROVIDER_MODEL:-}" ] && \ - cmd_parts+=("${NW_PROVIDER_MODEL_FLAG}" "${NW_PROVIDER_MODEL}") - if [ -n "${NW_PROVIDER_PROMPT_FLAG:-}" ]; then - cmd_parts+=("${NW_PROVIDER_PROMPT_FLAG}" "${prompt}") - else - cmd_parts+=("${prompt}") - fi - echo "${cmd_parts[@]}" - } - ``` -- [ ] Replace `case "${PROVIDER_CMD}"` blocks with the generic helper in all four scripts -- [ ] For the working directory: if `NW_PROVIDER_WORKDIR_FLAG` is empty, `cd` into the workdir before execution -- [ ] Keep rate-limit fallback logic as-is (it checks `NW_PROVIDER_CMD === 'claude'` and uses hardcoded claude syntax for native fallback — this is intentional, the fallback is always native Claude) - -**Tests Required:** - -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| Manual | Claude preset invocation | `night-watch run --dry-run` shows `claude -p ... --dangerously-skip-permissions` | -| Manual | Codex preset invocation | dry-run shows `codex exec -C ... --yolo` | -| Manual | Custom preset invocation | dry-run shows custom command with correct flags | - -**User Verification:** - -- Action: Run `night-watch run --dry-run` with different presets assigned -- Expected: Dry-run output shows the correct full command for each preset - ---- - -### Phase 5: UI — Provider Presets List & Job Assignment - -**User-visible outcome:** Settings > Providers tab shows a card list of all presets with add/edit/delete actions and a redesigned job assignment section using preset names. - -**Files (4):** - -- `web/pages/Settings.tsx` — Redesign Providers tab: preset card list + job assignment dropdowns using preset names -- `web/components/providers/PresetCard.tsx` — New: card component for a single preset (name, command, model badge, env var count, edit/delete actions) -- `web/components/providers/PresetFormModal.tsx` — New: modal for adding/editing a preset (all fields from `IProviderPreset`) -- `web/components/providers/ProviderEnvEditor.tsx` — Move existing `ProviderEnvEditor` from Settings.tsx inline to its own component file (if not already) - -**Implementation:** - -- [ ] **PresetCard**: displays preset name, command pill (e.g. `claude`), model badge (e.g. `opus-4-6`), env var count, edit/delete buttons. Built-in presets show a "Built-in" badge and "Reset" instead of "Delete" -- [ ] **PresetFormModal**: form fields for name, command, subcommand, promptFlag, autoApproveFlag, workdirFlag, modelFlag, model, envVars. Built-in presets have a "Template" dropdown to auto-fill claude/codex defaults. Advanced fields (subcommand, promptFlag, workdirFlag) in a collapsible "Advanced" section -- [ ] **Providers tab layout**: - 1. **Provider Presets** card: grid of PresetCards + "Add Provider" button - 2. **Job Assignments** card: 5 rows (Executor, Reviewer, QA, Audit, Planner) each with a Select dropdown listing all preset names + "Use Global (default)" - 3. **Rate Limit Fallback** card: keep existing fallback settings (primary/secondary model, toggle) — these are global & Claude-specific -- [ ] **Delete protection**: when delete is clicked, check if preset ID is in `provider` or any `jobProviders` value. If so, show a warning listing which jobs reference it and prevent deletion. -- [ ] **Global provider**: the "Global Provider" select changes from Claude/Codex dropdown to a select listing all preset names -- [ ] Wire all changes through the existing `updateConfig()` API call - -**Tests Required:** - -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| Manual | Add custom preset | Click "Add Provider" → fill form → save → card appears | -| Manual | Edit preset | Click edit on a preset → modify model → save → card updates | -| Manual | Delete protection | Try to delete preset assigned to a job → warning shown | -| Manual | Job assignment | Change executor to custom preset → save → config updated | -| Manual | Built-in preset | Claude/Codex cards show "Built-in" badge, no delete button | - -**User Verification:** - -- Action: Open Settings > Providers in the web UI -- Expected: See preset cards for Claude and Codex (built-in), ability to add new presets, job assignment dropdowns show preset names - ---- - -## 6. Backward Compatibility - -| Old Config | New Behavior | -|---|---| -| `provider: 'claude'` | Resolves to built-in `claude` preset | -| `provider: 'codex'` | Resolves to built-in `codex` preset | -| `jobProviders: { executor: 'claude' }` | Resolves to built-in `claude` preset | -| `providerEnv: { ANTHROPIC_BASE_URL: '...' }` | Still merged into env; preset-level `envVars` take precedence | -| `providerLabel: 'GLM-5'` | Still used if set; overridden by preset `name` when presets are configured | -| No `providerPresets` key | System uses built-in presets only, everything works as before | - ---- - -## 7. Acceptance Criteria - -- [ ] All phases complete -- [ ] All specified tests pass -- [ ] `yarn verify` passes -- [ ] All automated checkpoint reviews passed -- [ ] Users can create, edit, and delete custom provider presets from the UI -- [ ] Built-in presets (Claude, Codex) are editable but not deletable -- [ ] Job assignments use preset names instead of hardcoded provider types -- [ ] Custom CLI commands (not just claude/codex) work end-to-end -- [ ] Old configs without `providerPresets` continue to work unchanged -- [ ] Deleting a preset assigned to a job is blocked with a clear warning -- [ ] Bash scripts invoke any provider generically from env vars diff --git a/docs/PRDs/refactor-interaction-listener.md b/docs/PRDs/refactor-interaction-listener.md deleted file mode 100644 index 5240cf0a..00000000 --- a/docs/PRDs/refactor-interaction-listener.md +++ /dev/null @@ -1,285 +0,0 @@ -# PRD: Refactor SlackInteractionListener God Class - -**Complexity: 5 → MEDIUM mode** - ---- - -## 1. Context - -**Problem:** `packages/slack/src/interaction-listener.ts` is a 1954-line god class with ~8 distinct responsibilities violating SRP. - -**Files Analyzed:** - -- `packages/slack/src/interaction-listener.ts` (1954 lines) -- `packages/slack/src/__tests__/slack/interaction-listener.test.ts` (433 lines) -- `packages/slack/src/index.ts` (barrel exports) -- `packages/slack/src/factory.ts` (composition root) -- `packages/slack/src/personas.ts` (prior extraction example) -- `packages/slack/src/utils.ts` (prior extraction example) -- `packages/slack/src/deliberation.ts` (similar class structure) - -**Current Behavior:** - -- The class handles Socket Mode lifecycle, message parsing, URL context fetching, job/process spawning, proactive messaging loops, human-like timing simulation, persona intros, and a ~300-line message router — all in one file -- Standalone parsing functions are already pure but live alongside the class -- Re-exports from `personas.ts` exist for backward compatibility -- Tests import parsing helpers directly from `interaction-listener.js` -- `index.ts` exports `SlackInteractionListener` and `shouldIgnoreInboundSlackEvent` - ---- - -## 2. Solution - -**Approach:** - -- Extract standalone parsing functions + types into `message-parser.ts` -- Extract URL/GitHub context-fetching into `context-fetcher.ts` -- Extract process spawning (NW jobs, providers, audits) into `job-spawner.ts` as a class -- Extract proactive messaging loop into `proactive-loop.ts` as a class -- Keep `interaction-listener.ts` as a thin orchestrator (~700 lines) composing the extracted modules -- Maintain all existing exports via re-exports — zero breaking changes for consumers - -**Architecture Diagram:** - -```mermaid -flowchart TD - IL[SlackInteractionListener
~700 lines
Orchestrator] - MP[message-parser.ts
Pure parsing functions] - CF[context-fetcher.ts
URL/GitHub fetching] - JS[JobSpawner
Process spawning] - PL[ProactiveLoop
Timed proactive messages] - - IL -->|imports| MP - IL -->|imports| CF - IL -->|delegates| JS - IL -->|delegates| PL - JS -->|uses| SlackClient - JS -->|uses| DeliberationEngine - PL -->|uses| SlackClient - PL -->|uses| DeliberationEngine -``` - -**Key Decisions:** - -- Pure functions (parsing, URL fetching) extracted as standalone modules — no class needed -- `JobSpawner` and `ProactiveLoop` are classes receiving `SlackClient`, `DeliberationEngine`, and `INightWatchConfig` via constructor — same pattern as existing `DeliberationEngine` -- State maps (`_lastChannelActivityAt`, `_lastProactiveAt`, etc.) shared via injection where needed -- All existing `interaction-listener.js` exports preserved via re-exports — tests and barrel untouched unless imports need updating -- Remove the re-export block for personas (lines 36-43) and update test imports to import from `personas.js` and `message-parser.js` directly - -**Data Changes:** None - ---- - -## 3. Sequence Flow - -```mermaid -sequenceDiagram - participant SM as SocketMode - participant IL as InteractionListener - participant MP as MessageParser - participant CF as ContextFetcher - participant JS as JobSpawner - participant PL as ProactiveLoop - - SM->>IL: inbound event - IL->>MP: parse job/provider/issue request - MP-->>IL: parsed request or null - IL->>CF: fetch GitHub/URL context - CF-->>IL: enriched context string - alt Job/Provider/Issue request - IL->>JS: spawn job/provider - JS-->>IL: (async child process) - else Persona reply - IL->>IL: resolve persona, reply via engine - end - - Note over PL: Runs on interval timer - PL->>PL: check idle channels - PL->>JS: spawn code-watch audit -``` - ---- - -## 4. Execution Phases - -### Integration Points Checklist - -``` -How will this feature be reached? -- [x] Entry point: Socket Mode events (unchanged) -- [x] Caller file: factory.ts creates SlackInteractionListener (unchanged) -- [x] Registration/wiring: new classes instantiated inside InteractionListener constructor - -Is this user-facing? -- [x] NO → Internal refactoring, no behavior changes - -Full user flow: -1. Slack message arrives via Socket Mode (unchanged) -2. InteractionListener routes to parsers/spawners (same logic, different files) -3. Result displayed in Slack (unchanged) -``` - ---- - -#### Phase 1: Extract `message-parser.ts` — Pure parsing functions moved to dedicated module - -**Files (4):** - -- `packages/slack/src/message-parser.ts` — NEW: all parsing functions, types, constants -- `packages/slack/src/interaction-listener.ts` — remove standalone functions, import from `message-parser.ts` -- `packages/slack/src/__tests__/slack/interaction-listener.test.ts` — update imports -- `packages/slack/src/index.ts` — add `shouldIgnoreInboundSlackEvent` export from `message-parser.ts` - -**Implementation:** - -- [ ] Create `message-parser.ts` with: - - Constants: `JOB_STOPWORDS` - - Types: `TSlackJobName`, `TSlackProviderName`, `ISlackJobRequest`, `ISlackProviderRequest`, `ISlackIssuePickupRequest`, `IInboundSlackEvent`, `IEventsApiPayload`, `IAdHocThreadState` - - Functions: `normalizeForParsing`, `extractInboundEvent`, `buildInboundMessageKey`, `isAmbientTeamMessage`, `parseSlackJobRequest`, `parseSlackIssuePickupRequest`, `parseSlackProviderRequest`, `shouldIgnoreInboundSlackEvent`, `extractGitHubIssueUrls`, `extractGenericUrls` -- [ ] In `interaction-listener.ts`: replace standalone functions with imports from `message-parser.js` -- [ ] Remove the persona re-export block (lines 36-43) — these are already exported from `personas.ts` and `index.ts` -- [ ] Update test imports to use `message-parser.js` for parsers and `personas.js` for persona helpers -- [ ] Update `index.ts` to export `shouldIgnoreInboundSlackEvent` from `message-parser.js` - -**Tests Required:** - -| Test File | Test Name | Assertion | -| ------------------------------ | ------------------------- | ----------------------------- | -| `interaction-listener.test.ts` | All existing parser tests | All pass with updated imports | - -**Verification:** - -- `yarn verify` passes -- `yarn workspace @night-watch/slack test` passes -- All 25+ existing parser tests continue passing - ---- - -#### Phase 2: Extract `context-fetcher.ts` — URL/GitHub context enrichment - -**Files (2):** - -- `packages/slack/src/context-fetcher.ts` — NEW: `fetchUrlSummaries`, `fetchGitHubIssueContext` -- `packages/slack/src/interaction-listener.ts` — import from `context-fetcher.js` - -**Implementation:** - -- [ ] Create `context-fetcher.ts` with `fetchUrlSummaries` and `fetchGitHubIssueContext` -- [ ] In `interaction-listener.ts`: import these two functions from `context-fetcher.js` - -**Tests Required:** - -| Test File | Test Name | Assertion | -| -------------- | ------------------ | --------------------------------------- | -| existing tests | All existing tests | Continue passing (no public API change) | - -**Verification:** - -- `yarn verify` passes -- `yarn workspace @night-watch/slack test` passes - ---- - -#### Phase 3: Extract `job-spawner.ts` — Process spawning for NW jobs, providers, audits - -**Files (2):** - -- `packages/slack/src/job-spawner.ts` — NEW: `JobSpawner` class -- `packages/slack/src/interaction-listener.ts` — delegate to `JobSpawner` - -**Implementation:** - -- [ ] Create `JobSpawner` class with constructor `(slackClient, engine, config)` -- [ ] Move `extractLastMeaningfulLines` helper (used only by spawner) -- [ ] Move constants: `MAX_JOB_OUTPUT_CHARS` -- [ ] Move methods: - - `_spawnNightWatchJob` → `spawnNightWatchJob(job, project, channel, threadTs, persona, opts, callbacks)` - - `_spawnDirectProviderRequest` → `spawnDirectProviderRequest(request, project, channel, threadTs, persona, callbacks)` - - `_spawnCodeWatchAudit` → `spawnCodeWatchAudit(project, channel, callbacks)` -- [ ] Callbacks interface `IJobSpawnerCallbacks` for `markChannelActivity` and `markPersonaReply` to avoid circular coupling -- [ ] In `interaction-listener.ts`: instantiate `JobSpawner` in constructor, delegate calls - -**Tests Required:** - -| Test File | Test Name | Assertion | -| -------------- | ---------------------------- | ---------------- | -| existing tests | Lifecycle + all parser tests | Continue passing | - -**Verification:** - -- `yarn verify` passes -- `yarn workspace @night-watch/slack test` passes - ---- - -#### Phase 4: Extract `proactive-loop.ts` — Proactive messaging timer and code-watch - -**Files (2):** - -- `packages/slack/src/proactive-loop.ts` — NEW: `ProactiveLoop` class -- `packages/slack/src/interaction-listener.ts` — delegate to `ProactiveLoop` - -**Implementation:** - -- [ ] Create `ProactiveLoop` class with constructor `(engine, config, jobSpawner, stateAccessors)` -- [ ] Move constants: `PROACTIVE_IDLE_MS`, `PROACTIVE_MIN_INTERVAL_MS`, `PROACTIVE_SWEEP_INTERVAL_MS`, `PROACTIVE_CODEWATCH_MIN_INTERVAL_MS` -- [ ] Move methods: - - `_startProactiveLoop` → `start()` - - `_stopProactiveLoop` → `stop()` - - `_sendProactiveMessages` → `sendProactiveMessages()` - - `_runProactiveCodeWatch` → `runProactiveCodeWatch()` - - `_resolveProactiveChannelForProject` → `resolveProactiveChannelForProject()` -- [ ] State accessors interface for reading/writing `_lastChannelActivityAt`, `_lastProactiveAt`, `_lastCodeWatchAt` -- [ ] `ProactiveLoop` receives project/persona resolution helpers or accesses repos directly -- [ ] In `interaction-listener.ts`: instantiate `ProactiveLoop` in constructor, delegate `start()/stop()` - -**Tests Required:** - -| Test File | Test Name | Assertion | -| -------------- | ------------------- | ---------------- | -| existing tests | Lifecycle stop test | Continue passing | - -**Verification:** - -- `yarn verify` passes -- `yarn workspace @night-watch/slack test` passes - ---- - -#### Phase 5: Final cleanup and comprehensive verification - -**Files (2):** - -- `packages/slack/src/interaction-listener.ts` — final review, remove dead imports -- `packages/slack/src/index.ts` — verify all exports still work - -**Implementation:** - -- [ ] Audit `interaction-listener.ts` for unused imports after all extractions -- [ ] Verify barrel exports in `index.ts` cover all previously exported symbols -- [ ] Run full workspace verification - -**Tests Required:** - -| Test File | Test Name | Assertion | -| -------------- | --------------- | --------- | -| All test files | Full test suite | All pass | - -**Verification:** - -- `yarn verify` passes (full workspace) -- `yarn workspace @night-watch/slack test` passes -- Manual check: `interaction-listener.ts` is ~700 lines or less (down from 1954) - ---- - -## 5. Acceptance Criteria - -- [ ] All 5 phases complete -- [ ] All existing tests pass without modification (or with only import path updates) -- [ ] `yarn verify` passes -- [ ] `interaction-listener.ts` reduced to ~700 lines (orchestrator only) -- [ ] No new public API — all existing exports preserved via re-exports -- [ ] No behavior changes — pure structural refactoring -- [ ] Each extracted module has a single clear responsibility diff --git a/docs/PRDs/ux-revamp.md b/docs/PRDs/ux-revamp.md index aa7c0a95..83920b93 100644 --- a/docs/PRDs/ux-revamp.md +++ b/docs/PRDs/ux-revamp.md @@ -8,353 +8,324 @@ Score breakdown: +3 (10+ files) +2 (new components) +2 (complex state/real-time ## 1. Context -**Problem:** The Dashboard is overwhelming (too many widgets, duplicate info) and the Scheduling page is hard to understand (3 tabs with unclear purpose distinctions). +**Problem:** The Dashboard is overwhelming (too many widgets, duplicate info) and the Scheduling page is hard to understand (5 tabs with unclear purpose distinctions). **Files Analyzed:** + - `web/pages/Dashboard.tsx` - `web/pages/Scheduling.tsx` +- `web/pages/Logs.tsx` - `web/components/Sidebar.tsx` - `web/components/TopBar.tsx` - `web/App.tsx` +- `web/components/scheduling/ScheduleConfig.tsx` - `web/components/scheduling/ScheduleTimeline.tsx` +- `web/components/dashboard/AgentStatusBar.tsx` +- `web/store/useStore.ts` +- `web/hooks/useStatusSync.ts` - `web/components/ui/{Button,Card,Badge,Tabs,Switch}.tsx` -**Current Behavior:** -- Dashboard has 7 distinct sections (stat cards, System Status card, Process Status card with 5 rows, Scheduling summary card with 5 rows, Board widget) — all visible simultaneously -- "System Status" card shows Project/Provider/Last Updated — info redundant with the TopBar -- "Scheduling" summary widget on Dashboard duplicates the Scheduling page -- Scheduling page uses 3 tabs named "Overview", "Schedules", "Jobs" — "Overview" and "Schedules" sound nearly identical, causing confusion -- "Jobs" tab is a tiny page (just 5 toggles) that doesn't warrant its own tab -- Sidebar has 7 flat items with no grouping or labels — everything appears equally important -- **Process state is not shared:** Dashboard owns the only SSE subscription (`useStatusStream`) and a local `streamedStatus` state. `Logs.tsx` makes its own independent `useApi(fetchStatus)` HTTP poll. These two can show contradictory process status (e.g., Executor running on Dashboard, Idle in Logs). Any future page that needs process status adds yet another independent poll. +**Current Behavior (as of 2026-03-21):** + +- Dashboard: 4 stat cards + AgentStatusBar (compact 6-agent grid) + next automation teaser + GitHub Board widget — already cleaned up +- Scheduling: 5-tab layout (Overview, Schedules, Crontab, Parallelism, individual job tabs) — still complex and hard to navigate +- Sidebar: section labels (Overview / Work / Automation / Config) — already grouped +- TopBar: Bell icon with red dot but no implementation behind it; no Settings icon — mostly clean +- Shared state: `useStatusSync` in `App.tsx` writes to Zustand store; all pages read from store — already unified +- Logs: reads `status` from store, independent log polling — correct --- -## 2. Solution - -**Approach:** -- **Shared state (Phase 0):** Lift `IStatusSnapshot` into the Zustand store. Single SSE subscription in `App.tsx` via `useStatusSync` hook. All pages read from the store — no contradictory process status across pages. -- **Dashboard**: Remove low-value widgets (System Status card, Scheduling summary), compress the 5-process list into a compact Agent Status bar with inline controls. Make the page answer: "What's happening right now?" -- **Scheduling**: Replace 3 ambiguous tabs with a single scrollable page. Extract the schedule editor from Settings into a shared `ScheduleConfig` component used by both pages (DRY). No more hidden redirect to Settings to edit schedules. -- **Sidebar**: Add section labels to group nav items visually, rename "Scheduling" to "Automation" (it controls enablement too, not just timing) -- **TopBar**: Remove the redundant Settings icon (Settings is already in the sidebar) - -**Architecture Diagram:** -```mermaid -flowchart LR - Dashboard["Dashboard\n(What's running now)"] - Scheduling["Scheduling / Automation\n(Configure & timeline)"] - Sidebar["Sidebar\n(Grouped nav)"] - TopBar["TopBar\n(Project + status only)"] -``` +## 2. Completed Phases -**Key Decisions:** -- Keep all existing data-fetching logic (`useApi`, `useStatusStream`, SSE) — only restructure the render layer -- No new API calls needed; all data is already fetched -- Reuse all existing `ui/` components (Card, Button, Switch, etc.) -- Move Job toggles into the Agent cards on the Scheduling page (inline toggle = enable/disable right next to schedule) +### ✅ Phase 0: Shared status state -**Data Changes:** None — store gains `status: IStatusSnapshot | null`, no schema changes +- `useStatusSync` hook exists at `web/hooks/useStatusSync.ts` +- `status: IStatusSnapshot | null` in `web/store/useStore.ts` +- `useStatusSync()` called in `App.tsx` — single SSE subscription app-wide +- Dashboard and Logs both read from store ---- +### ✅ Phase 1: Dashboard Cleanup -## 3. Integration Points +- 4 stat cards: Board Ready, In Progress, Open PRs, Automation Status +- `AgentStatusBar` (`web/components/dashboard/AgentStatusBar.tsx`) — 6-agent compact grid +- "System Status" card removed +- "Scheduling summary" card removed +- "Next automation" teaser line added -``` -How will this feature be reached? -✅ Entry point: existing routes (/, /scheduling, sidebar) -✅ No new routes needed -✅ No new API surface needed -✅ User-facing: YES — all changes are visual -``` +### ✅ Phase 3: Sidebar Navigation Grouping ---- +- Section labels: OVERVIEW / WORK / AUTOMATION / CONFIG +- Labels hidden when collapsed, visible when expanded +- "Scheduling" nav item present under AUTOMATION -## 4. Execution Phases +### ✅ Phase 4: TopBar Cleanup + +- Settings icon already removed from TopBar +- Bell icon present (no Settings duplication) --- -### Phase 0: Shared status state — single source of truth for process/system status +## 3. Remaining Work -**Why first:** Phases 1 and 2 both render process status. If the state is not unified before those rewrites, each page will continue managing its own poll/SSE independently and showing different values. +--- -**Files (max 5):** -- `web/store/useStore.ts` — add `status` + `setStatus` to the store -- `web/hooks/useStatusSync.ts` — new hook: owns SSE subscription + polling, writes to store -- `web/App.tsx` — call `useStatusSync()` at app root (single subscription for the whole app) -- `web/pages/Dashboard.tsx` — remove local SSE/polling, read `status` from store -- `web/pages/Logs.tsx` — remove independent `fetchStatus` call, read `status` from store +### Phase 2 (Incomplete): Flatten Scheduling Page -**Implementation:** +**Status:** `ScheduleConfig` component extracted ✅ — but `Scheduling.tsx` still uses 5 tabs ❌ -- [ ] `useStore.ts`: add two fields to `AppState`: - ```ts - status: IStatusSnapshot | null; - setStatus: (s: IStatusSnapshot) => void; - ``` - Initialize `status: null`. `setStatus` uses `pickLatestSnapshot` to only update when the incoming snapshot is newer than the stored one (prevents stale SSE payload overwriting a fresher poll result). Import `IStatusSnapshot` from `@shared/types`. - -- [ ] `useStatusSync.ts`: new hook that extracts the logic currently in `Dashboard.tsx`: - ```ts - export const useStatusSync = () => { - const { setStatus, setProjectName, selectedProjectId, globalModeLoading } = useStore(); - // 1. useApi(fetchStatus) with 30s polling + window focus refetch → calls setStatus - // 2. useStatusStream(snapshot => setStatus(snapshot)) for SSE fast path - // 3. useEffect on status: update projectName when status.projectName changes - }; - ``` - This is a direct move of the relevant `useEffect`/`useApi`/`useStatusStream` calls from Dashboard — no logic changes. - -- [ ] `App.tsx`: add `useStatusSync()` call at the top of the `App` component (after `useGlobalMode()`). This ensures the subscription is alive regardless of which route is active. - -- [ ] `Dashboard.tsx`: - - Remove `useApi(fetchStatus, ...)`, the `streamedStatus` local state, `useStatusStream`, `pickLatestSnapshot`, the 30s polling `useEffect`, and the window-focus `useEffect` - - Replace with: `const currentStatus = useStore((s) => s.status);` - - Remove `loading`/`error` from fetchStatus (status is now always available from store after first load; handle `status === null` with a loading state) - - Keep all handlers (`handleCancelProcess`, `handleForceClear`, `handleStartProcess`) — they call `refetch` after; replace that refetch with a store read (status auto-updates via SSE) - -- [ ] `Logs.tsx`: - - Remove `useApi(fetchStatus, ...)` - - Replace with: `const status = useStore((s) => s.status);` - - The `isProcessRunning` check reads from the same shared state +**Problem:** Scheduling has tabs: Overview, Schedules, Crontab, Parallelism, plus per-job sub-tabs. "Overview" and "Schedules" are still confusing. "Crontab" and "Parallelism" are obscure labels for advanced config that buries the primary use case (seeing what runs when). -**Tests Required:** -| Test File | Test Name | Assertion | -|-----------|-----------|-----------| -| `web/src/__tests__/useStatusSync.test.ts` | `writes SSE snapshot to store` | `setStatus` called with snapshot payload | -| `web/src/__tests__/useStatusSync.test.ts` | `does not downgrade to older snapshot` | Store retains newer timestamp when stale SSE arrives | -| `web/src/__tests__/Dashboard.test.tsx` | `reads process status from store, not local state` | No `useState(streamedStatus)` — uses store value | +**Files (max 4):** -**User Verification:** -- Action: Open Dashboard (Executor shows "Running"), then navigate to Logs tab -- Expected: Logs page shows Executor as "Running" — same state, no flicker or contradiction +- `web/pages/Scheduling.tsx` — replace Tabs with flat scrollable sections -**Checkpoint:** Run `prd-work-reviewer` agent after this phase. +**Implementation:** ---- +Delete the `` component and `activeTab` state. New flat page structure: -### Phase 1: Dashboard Cleanup — Focused overview of what's happening right now +``` +Section A: Global Controls + [Automation: Active/Paused] [Schedule bundle name] [Pause/Resume button] -**Files (max 5):** -- `web/pages/Dashboard.tsx` — major rewrite of render section only -- `web/components/dashboard/AgentStatusBar.tsx` — new compact component +Section B: Agent Schedule Cards + 6 cards (2-col grid): icon + name + Switch toggle + "every 3h" desc + next run countdown + start delay badge -**Implementation:** +Section C: Configure Schedules ← was hidden in "Schedules" tab + with template picker / custom cron inputs + [Save & Install] button -- [ ] Create `web/components/dashboard/AgentStatusBar.tsx`: - - Props: `processes: IStatusSnapshot['processes']`, `activePrd: string | null`, handlers for run/stop/clear - - Renders a horizontal grid (2-col on mobile, 5-col on desktop) of agent pills - - Each pill: colored status dot (pulsing if running) + agent name + either "Idle" or truncated PID/PRD info - - Action on click: shows a small inline popover/dropdown with "Run" or "Stop" + "View Log" links - - Or simpler: each pill has a tiny Run/Stop icon button directly inline (no popover) - - Keep all existing handler logic, just change the visual layout - -- [ ] Rewrite `Dashboard.tsx` render section: - - **Keep:** 4 stat cards (top row) — but rename "Cron Status" → "Automation" and show Active/Paused status - - **Remove:** "System Status" card (project/provider/lastUpdated) — this info is in TopBar and is low-value - - **Replace:** The 5-row verbose "Process Status" card → `` (compact, same data) - - **Remove:** The "Scheduling" summary card (5-row list of next run times) — users go to /scheduling for this - - **Keep:** "GitHub Board" widget (already compact, useful at-a-glance) - - **Add:** Under stat cards, a single "Next Automation Run" line (1 line: "Next: Executor in 12 min") as a teaser linking to /scheduling +Section D: Cron Entries ← was "Crontab" tab + Installed crontab entries table (collapsible, default collapsed) + Enable/disable/remove per entry -**Result layout:** -``` -[Board Ready] [In Progress] [Open PRs] [Automation: Active] -[AgentStatusBar: Executor ● Reviewer ● QA ● Auditor ● Planner ●] -[Next automation: Executor in 12 min → Manage Schedules] -[GitHub Board widget] +Section E: Parallelism & Queue ← was "Parallelism" tab + Queue mode (Auto/Manual), global max concurrency, provider buckets + (collapsible, default collapsed) ``` +- [ ] Remove `activeTab` state and `` import +- [ ] Move Overview tab content → inline Sections A + B +- [ ] Move Schedules tab content → Section C (already has ``) +- [ ] Move Crontab tab content → Section D (collapsible `
` or state toggle) +- [ ] Move Parallelism tab content → Section E (collapsible) +- [ ] Replace `navigate('/settings?tab=schedules...')` redirect with `scrollIntoView` to Section C anchor +- [ ] `expandedCrontab` + `expandedParallelism` local state (default `false`) for collapsible sections +- [ ] `ScheduleTimeline` moves into Section B as a visual beneath the agent cards + **Tests Required:** | Test File | Test Name | Assertion | |-----------|-----------|-----------| -| `web/src/__tests__/AgentStatusBar.test.tsx` | `renders idle state for all agents when none running` | All pills show idle state | -| `web/src/__tests__/AgentStatusBar.test.tsx` | `shows running state with pulse when process is running` | Pulse class present, PID shown | -| `web/src/__tests__/AgentStatusBar.test.tsx` | `calls onStop handler when stop button clicked` | Mock called once | +| `web/src/__tests__/Scheduling.test.tsx` | `renders all 6 agent cards without tabs` | 6 agent name elements, no Tabs component | +| `web/src/__tests__/Scheduling.test.tsx` | `ScheduleConfig section visible without clicking tabs` | Schedule config rendered directly | +| `web/src/__tests__/Scheduling.test.tsx` | `crontab section collapsed by default` | Crontab entries not visible initially | +| `web/src/__tests__/Scheduling.test.tsx` | `expanding crontab section shows entries` | Entries visible after expand click | **User Verification:** -- Action: Open `/` (Dashboard) -- Expected: Page no longer shows "System Status" card or "Scheduling" summary card. Agent status is compact (one row or small grid, not 5 separate full-width cards). Cron status card now shows "Active" or "Paused". -**Checkpoint:** Run `prd-work-reviewer` agent after this phase. +- Action: Open `/scheduling` +- Expected: No tabs. Scroll down past agents → see "Configure Schedules" inline. Crontab and Parallelism are collapsed advanced sections at the bottom. No redirect to Settings needed. + +**Checkpoint:** Run `prd-work-reviewer` after this phase. --- -### Phase 2: Extract `ScheduleConfig` component (DRY) — shared schedule editor for Scheduling + Settings - -**Root problem:** Settings has a fully-featured schedule editing UI (Template/Custom mode, `CronScheduleInput` for all 5 jobs, Scheduling Priority, Global Queue toggle, Extra Start Delay). The Scheduling page's "Edit" buttons silently redirect to `Settings?tab=schedules` — a hidden, jarring flow. Users on the Scheduling page have no obvious way to know where schedule editing lives. +### Phase 5: Command Palette (Cmd+K) -**Solution:** Extract the schedule editor from `Settings.tsx` into a standalone `ScheduleConfig` component. Both the Scheduling page and Settings use it — same component, no duplication. +**Why:** Power users trigger agents, navigate pages, and manage schedules frequently. Every action currently requires 2–4 clicks through the UI. A command palette eliminates navigation overhead and makes the tool feel fast. **Files (max 5):** -- `web/components/scheduling/ScheduleConfig.tsx` — new shared component (extracted from Settings) -- `web/pages/Settings.tsx` — replace schedule tab inline JSX with `` -- `web/pages/Scheduling.tsx` — embed `` directly as a page section (no redirect) - -**Component interface for `ScheduleConfig`:** -```tsx -interface IScheduleConfigProps { - form: ConfigForm; // the editable config form state - scheduleMode: 'template' | 'custom'; - selectedTemplateId: string | null; - onFieldChange: (field: keyof ConfigForm, value: unknown) => void; - onSwitchToTemplate: () => void; - onSwitchToCustom: () => void; - onApplyTemplate: (tpl: IScheduleTemplate) => void; - allProjectConfigs: Array<{ projectId: string; config: INightWatchConfig }>; - currentProjectId: string | null; - onEditJob: (projectId: string, jobType: string) => void; // for ScheduleTimeline click-through -} -``` - -**What to extract into `ScheduleConfig`:** -- The Template / Custom toggle buttons -- The `SCHEDULE_TEMPLATES` grid (template picker) -- The `CronScheduleInput` grid (custom cron editors for all 5 jobs) -- The Scheduling Priority `` -- The `` (currently duplicated — Settings has it in schedules tab, Scheduling has it in the Overview tab) -**Settings.tsx changes:** -- Replace the inline JSX inside the `schedules` tab `content:` with `` passing existing state/handlers -- No logic changes, just moves JSX into the component +- `web/components/CommandPalette.tsx` — new component +- `web/hooks/useCommandPalette.ts` — keyboard shortcut registration + open/close state +- `web/App.tsx` — render `` at root + `useCommandPalette()` +- `web/store/useStore.ts` — add `commandPaletteOpen: boolean` + `setCommandPaletteOpen` -**Scheduling.tsx changes (flat layout):** - -Replace the `` component entirely. New page structure: +**Component design:** ``` -Section A: Global Controls - [Automation: Active/Paused] [Schedule Bundle label] [Pause/Resume button] - -Section B: Agents (merged Schedules + Jobs) - 5 cards (2-col grid): icon + name + Switch toggle + schedule desc + next run + delay note - -Section C: Configure Schedules ← NEW — was hidden in Settings - — shows template picker or custom cron inputs + priority/queue/delay settings - With a "Save & Install" button below it - -Section D: Queue Analytics (collapsible, hidden by default) - [Running] [Pending] [Avg Wait] [Throttled] - +┌─────────────────────────────────────────────────┐ +│ 🔍 Search commands or navigate... │ +├─────────────────────────────────────────────────┤ +│ ── NAVIGATE ── │ +│ → Dashboard ⌘1 │ +│ → Logs ⌘2 │ +│ → Board ⌘3 │ +│ → Scheduling ⌘4 │ +│ → Settings ⌘, │ +├─────────────────────────────────────────────────┤ +│ ── AGENTS ── │ +│ ▶ Run Executor [only if idle] │ +│ ■ Stop Executor [only if running] │ +│ ▶ Run Reviewer │ +│ ▶ Run QA │ +│ ▶ Run Auditor │ +│ ▶ Run Planner │ +├─────────────────────────────────────────────────┤ +│ ── SCHEDULING ── │ +│ ⏸ Pause Automation [only if active] │ +│ ▶ Resume Automation [only if paused] │ +└─────────────────────────────────────────────────┘ ``` -**Form state in Scheduling.tsx:** -- The Scheduling page needs its own local `form` state (copy from current config on load, same pattern as Settings) -- On "Save & Install": call `updateConfig(form)` then `triggerInstallCron()` (same as Settings save flow) -- Reuse `scheduleMode`, `selectedTemplateId`, `applyTemplate`, `switchToTemplateMode`, `switchToCustomMode` logic — copy the same state shape from Settings +**Implementation:** -**Implementation steps:** -- [ ] Create `web/components/scheduling/ScheduleConfig.tsx` with the interface above; move JSX from Settings `schedules` tab content into it -- [ ] Update `Settings.tsx`: replace the inlined schedule tab JSX with ``; verify Settings still works identically -- [ ] In `Scheduling.tsx`: add `form` state (mirror Settings pattern), add `scheduleMode`/`selectedTemplateId` state -- [ ] Delete `overviewTab`, `schedulesTab`, `jobsTab` variables -- [ ] Write new flat `return` JSX: Section A (global controls) → Section B (agent cards with Switch+schedule+nextRun) → Section C (`` + Save button) → Section D (collapsible queue) -- [ ] Remove the `navigate('/settings?tab=schedules...')` redirect from `handleEditJobOnTimeline` — it now scrolls to Section C instead -- [ ] Add `expandedQueue` local state (default `false`) for the collapsible queue section +- [ ] `useCommandPalette.ts`: registers `keydown` listener for `Cmd+K` / `Ctrl+K`; writes `commandPaletteOpen` to store +- [ ] `CommandPalette.tsx`: modal overlay (semi-transparent backdrop), search input, grouped command list + - Filter commands by search term (fuzzy or substring match) + - Keyboard navigation: `↑`/`↓` to move, `Enter` to execute, `Esc` to close + - Commands: navigate (uses `useNavigate`), trigger agent (calls `triggerJob` API), toggle automation + - Agent trigger commands conditionally shown based on `useStore(s => s.status)` — only show "Run X" if X is idle +- [ ] `App.tsx`: add `` after routes, call `useCommandPalette()` +- [ ] `useStore.ts`: add `commandPaletteOpen` + `setCommandPaletteOpen` to store **Tests Required:** | Test File | Test Name | Assertion | |-----------|-----------|-----------| -| `web/src/__tests__/ScheduleConfig.test.tsx` | `renders template picker by default` | Template cards visible | -| `web/src/__tests__/ScheduleConfig.test.tsx` | `switches to custom cron inputs on Custom click` | CronScheduleInput fields visible | -| `web/src/__tests__/Scheduling.test.tsx` | `shows all 5 agent cards` | 5 agent name elements rendered | -| `web/src/__tests__/Scheduling.test.tsx` | `agent toggle calls handleJobToggle` | Mock API called with correct job | -| `web/src/__tests__/Scheduling.test.tsx` | `ScheduleConfig section visible without clicking tabs` | Schedule config rendered directly on page | +| `web/src/__tests__/CommandPalette.test.tsx` | `opens on Cmd+K` | Palette visible after keydown event | +| `web/src/__tests__/CommandPalette.test.tsx` | `closes on Escape` | Palette hidden after Escape | +| `web/src/__tests__/CommandPalette.test.tsx` | `filters commands by search term` | Only matching commands shown | +| `web/src/__tests__/CommandPalette.test.tsx` | `navigates to page on Enter` | navigate called with correct path | +| `web/src/__tests__/CommandPalette.test.tsx` | `shows Run agent only when idle` | Run command absent when process running | **User Verification:** -- Action: Open `/scheduling` -- Expected: No tabs. Scroll down past agents → see "Configure Schedules" section inline. Can edit schedules without going to Settings. Settings → Schedules tab still works identically (same component). -**Checkpoint:** Run `prd-work-reviewer` agent after this phase. +- Action: Press `Cmd+K` on any page +- Expected: Palette opens. Type "exec" → "Run Executor" appears. Press Enter → executor triggered. Press Escape → closes. + +**Checkpoint:** Run `prd-work-reviewer` after this phase. --- -### Phase 3: Sidebar Navigation Grouping — Visual hierarchy for nav items +### Phase 6: Notification / Activity Center + +**Why:** The TopBar Bell icon has a red dot but no implementation. Users have no way to see what happened recently (which PRDs ran, which failed, when schedules last fired) without digging through Logs. An activity feed surfaces this at a glance. **Files (max 5):** -- `web/components/Sidebar.tsx` — add section labels, rename one item -**Implementation:** +- `web/components/ActivityCenter.tsx` — slide-out panel +- `web/hooks/useActivityFeed.ts` — assembles activity events from status + logs API +- `web/components/TopBar.tsx` — wire Bell button to open panel +- `web/store/useStore.ts` — add `activityCenterOpen: boolean` + `setActivityCenterOpen` -- [ ] Rename "Scheduling" nav item label to **"Automation"** and update its `path` to `/scheduling` (path stays the same, only label changes) -- [ ] Add section labels between nav groups. Current flat list becomes: +**Activity event types (derive from existing data, no new API needed):** +```ts +type IActivityEvent = + | { type: 'agent_completed'; agent: string; duration: string; prd?: string; ts: Date } + | { type: 'agent_failed'; agent: string; error: string; ts: Date } + | { type: 'schedule_fired'; agent: string; ts: Date } + | { type: 'automation_paused' | 'automation_resumed'; ts: Date } + | { type: 'pr_opened'; number: number; title: string; ts: Date }; ``` -── OVERVIEW ── - Home (Dashboard) - Logs -── WORK ── - Board - Pull Requests - Roadmap +**Panel design (slide-out from right, 360px wide):** -── AUTOMATION ── - Automation (was: Scheduling) - -── CONFIG ── - Settings +``` +┌─ Activity ─────────────────── [×] ─┐ +│ Today │ +│ ● Executor completed PRD-42 2m ago│ +│ ● PR #18 opened 5m ago │ +│ ● Reviewer completed 12m ago │ +│ ─ Yesterday ─ │ +│ ● Automation paused 3h ago │ +│ ● QA failed: exit code 1 5h ago │ +└────────────────────────────────────┘ ``` -- [ ] In `navItems` array, add a `section?: string` field to mark where section headers appear: - ```ts - { icon: Home, label: 'Dashboard', path: '/', section: 'Overview' }, - { icon: Terminal, label: 'Logs', path: '/logs' }, - { icon: Kanban, label: 'Board', path: '/board', section: 'Work' }, - { icon: GitPullRequest, label: 'Pull Requests', path: '/prs', badge: openPrCount }, - { icon: Map, label: 'Roadmap', path: '/roadmap' }, - { icon: Calendar, label: 'Automation', path: '/scheduling', section: 'Automation' }, - { icon: Settings, label: 'Settings', path: '/settings', section: 'Config' }, - ``` -- [ ] Render section labels as `
  • ` with `text-[10px] font-bold text-slate-600 uppercase tracking-widest px-3.5 pt-4 pb-1` only when sidebar is NOT collapsed -- [ ] Section labels hidden when sidebar is collapsed (collapsed mode shows only icons) +**Implementation:** + +- [ ] `useStore.ts`: add `activityCenterOpen` + `setActivityCenterOpen` +- [ ] `useActivityFeed.ts`: builds `IActivityEvent[]` by watching `status` changes in store (compare previous vs next — if a process transitions running→idle, it "completed") + fetching recent log entries on mount +- [ ] `ActivityCenter.tsx`: fixed right-side panel, `translate-x-full` when closed, `translate-x-0` when open; grouped by day; each event is an icon + description + relative time +- [ ] `TopBar.tsx`: Bell button calls `setActivityCenterOpen(true)`; red dot shows only when `activityEvents.length > 0` and panel is closed (i.e., unread events) +- [ ] Clear unread count when panel is opened **Tests Required:** | Test File | Test Name | Assertion | |-----------|-----------|-----------| -| `web/src/__tests__/Sidebar.test.tsx` | `shows section labels when expanded` | "OVERVIEW", "WORK", "AUTOMATION", "CONFIG" visible | -| `web/src/__tests__/Sidebar.test.tsx` | `hides section labels when collapsed` | No section label text visible in collapsed state | -| `web/src/__tests__/Sidebar.test.tsx` | `Scheduling item label is now Automation` | Nav link text is "Automation" | +| `web/src/__tests__/ActivityCenter.test.tsx` | `slides in when open` | Panel has translate-x-0 class | +| `web/src/__tests__/ActivityCenter.test.tsx` | `shows completed event when process transitions running→idle` | Completed event rendered | +| `web/src/__tests__/ActivityCenter.test.tsx` | `bell dot hidden when panel open` | Red dot not visible with panel open | **User Verification:** -- Action: Open any page, look at the sidebar -- Expected: 4 clear group labels visible. "Scheduling" is now labeled "Automation". Groups make it immediately obvious where Board/PRs live vs configuration vs logs. -**Checkpoint:** Run `prd-work-reviewer` agent after this phase. +- Action: Click the Bell icon +- Expected: Right panel slides in showing recent agent completions and PR events. Bell dot disappears after opening. + +**Checkpoint:** Run `prd-work-reviewer` after this phase. --- -### Phase 4: TopBar Cleanup — Remove redundant icons +### Phase 7: Log Page UX — Filter Bar + Agent Tabs -**Files (max 5):** -- `web/components/TopBar.tsx` +**Why:** Logs page currently shows a raw log dump with no way to filter by agent. When multiple agents run, logs interleave and become hard to read. Users must manually scan for the agent they care about. + +**Files (max 4):** + +- `web/pages/Logs.tsx` — add filter bar + per-agent view +- `web/components/logs/LogFilterBar.tsx` — new component **Implementation:** -- [ ] Read `TopBar.tsx` first to confirm what the Settings icon currently does (likely just `navigate('/settings')`) -- [ ] Remove the Settings icon button from TopBar (Settings is already the bottom nav item in sidebar — duplicate) -- [ ] If the Bell (notifications) icon has no implementation behind it, remove it too (or keep as placeholder — confirm with codebase) -- [ ] Keep: Project name + connection status (left) + search box (center) -- [ ] The search box can remain as a placeholder (do not implement search — YAGNI) +- [ ] `LogFilterBar.tsx`: horizontal pill bar showing all 6 agents + "All" option + - Active pill: filled background (agent color); inactive: ghost + - When an agent is "running" (from store status), show a pulsing green dot on its pill + - Second row: search input (filter lines containing text) + "Errors only" toggle + +- [ ] `Logs.tsx` changes: + - Add `selectedAgent: string | null` state (null = "All") + - Add `searchTerm: string` state + - Add `errorsOnly: boolean` state + - Filter `logLines` before render: by agent prefix (log lines are prefixed with `[executor]`, `[reviewer]` etc.), by `searchTerm`, by error keywords if `errorsOnly` + - Render `` above the log output + - Keep existing auto-scroll behavior + +- [ ] Log line parsing: each line starts with `[agent-name]` prefix — extract agent name from this prefix to drive per-agent filtering (no API change needed) + +**Result layout:** + +``` +[All] [Executor ●] [Reviewer] [QA] [Auditor] [Planner] [Analytics] +🔍 Search logs... [Errors only ○] +───────────────────────────────────────────────────────── +2026-03-21 14:32:01 [executor] Starting PRD execution... +2026-03-21 14:32:05 [executor] Reading board... +``` **Tests Required:** | Test File | Test Name | Assertion | |-----------|-----------|-----------| -| `web/src/__tests__/TopBar.test.tsx` | `does not render Settings icon button` | No button with Settings icon | +| `web/src/__tests__/LogFilterBar.test.tsx` | `renders all 6 agent pills plus All` | 7 pills visible | +| `web/src/__tests__/LogFilterBar.test.tsx` | `shows running dot on active agent pill` | Pulse element present for running agent | +| `web/src/__tests__/Logs.test.tsx` | `filters log lines by selected agent` | Only executor lines shown when executor pill active | +| `web/src/__tests__/Logs.test.tsx` | `filters by search term` | Only matching lines shown | +| `web/src/__tests__/Logs.test.tsx` | `errors only toggle filters non-error lines` | Only error lines shown | **User Verification:** -- Action: Look at the TopBar on any page -- Expected: Cleaner header. No redundant Settings icon. Project name + connection status clearly visible on the left. -**Checkpoint:** Run `prd-work-reviewer` agent after this phase. +- Action: Open `/logs`, click "Executor" pill +- Expected: Only lines prefixed with `[executor]` shown. Other agents' lines hidden. Running agent has a pulsing dot on its pill. + +**Checkpoint:** Run `prd-work-reviewer` after this phase. + +--- + +## 4. Integration Points + +``` +Entry points: existing routes (/, /scheduling, /logs, sidebar, TopBar bell) +No new routes needed +No new API surface needed (all data derived from existing status + logs APIs) +User-facing: YES — all changes are visual +``` --- ## 5. Verification Strategy -Each phase runs `yarn verify` and the phase-specific tests. The prd-work-reviewer agent is spawned after each phase. +Each phase: `yarn verify` + phase-specific tests + `prd-work-reviewer` checkpoint. -**Running tests:** ```bash cd /home/joao/projects/night-watch-cli yarn verify @@ -365,11 +336,19 @@ yarn workspace night-watch-web test --run ## 6. Acceptance Criteria -- [ ] Phase 0: Single SSE subscription in App; `status` in Zustand store; Dashboard + Logs read from store — process state is consistent across all pages -- [ ] Phase 1: Dashboard no longer has "System Status" card or "Scheduling summary" card; Process Status is compact -- [ ] Phase 2: `ScheduleConfig` component extracted; Scheduling page has no tabs; schedule editing is inline (no redirect to Settings) -- [ ] Phase 3: Sidebar shows section group labels; "Scheduling" renamed to "Automation" -- [ ] Phase 4: TopBar Settings icon removed +### Original phases (all complete) + +- [x] Phase 0: Single SSE subscription; `status` in Zustand; Dashboard + Logs read from store +- [x] Phase 1: Dashboard has no "System Status" or "Scheduling summary" card; AgentStatusBar is compact +- [x] Phase 3: Sidebar shows section labels (OVERVIEW / WORK / AUTOMATION / CONFIG) +- [x] Phase 4: TopBar Settings icon removed + +### Remaining + +- [ ] Phase 2: Scheduling page has no Tabs; flat scroll layout with collapsible advanced sections +- [ ] Phase 5: `Cmd+K` opens command palette; can trigger agents + navigate without mouse +- [ ] Phase 6: Bell icon opens Activity Center slide-out with recent agent completions +- [ ] Phase 7: Logs page has agent filter pills + search bar + errors-only toggle - [ ] All `yarn verify` passes after each phase - [ ] All specified tests pass -- [ ] No regressions: existing process start/stop/cancel, schedule edit, job toggle, SSE streaming all still work +- [ ] No regressions: process start/stop/cancel, schedule edit, job toggle, SSE streaming all still work diff --git a/docs/architecture/analytics-job.md b/docs/architecture/analytics-job.md new file mode 100644 index 00000000..1a08778a --- /dev/null +++ b/docs/architecture/analytics-job.md @@ -0,0 +1,169 @@ +# Analytics Job Architecture + +The Analytics job analyzes product analytics data (Amplitude) and creates GitHub issues for trending patterns or anomalies. It runs on a weekly schedule by default. + +--- + +## Component Overview + +```mermaid +graph TD + subgraph Core["@night-watch/core"] + AR["analytics/analytics-runner.ts\nAnalyticsRunner.run()\nfetchEvents()\nanalyzeTrends()\ncreateIssues()"] + AAC["analytics/amplitude-client.ts\nAmplitudeClient\nfetchEvents()\nfetchUsers()"] + T["types.ts\nIAnalyticsConfig\nIAnalyticsEvent"] + end + + subgraph CLI["@night-watch/cli"] + CMD["commands/analytics.ts\nnw analytics command"] + end + + subgraph Server["@night-watch/server"] + ROUTES["analytics.routes.ts\nGET /api/analytics/config\nPOST /api/analytics/run"] + end + + subgraph External["External Services"] + AMP["Amplitude API\nEvents, Users"] + GH["GitHub API\nCreate issues"] + end + + CMD --> AR + AR --> AAC + AAC --> AMP + AR --> GH + ROUTES --> AR +``` + +--- + +## Job Configuration + +```typescript +interface IAnalyticsConfig extends IBaseJobConfig { + enabled: boolean; + schedule: string; // Default: "0 6 * * 1" (weekly Monday 6am) + maxRuntime: number; // Default: 900 (15 minutes) + + // Analytics-specific fields + lookbackDays: number; // Default: 7 + targetColumn: string; // Default: "Draft" (GitHub Projects column) + analysisPrompt: string; // Optional custom analysis prompt +} +``` + +Env override examples: + +- `NW_ANALYTICS_ENABLED=true` +- `NW_ANALYTICS_LOOKBACK_DAYS=14` +- `NW_ANALYTICS_TARGET_COLUMN=Ready` + +--- + +## Analytics Flow + +```mermaid +flowchart TD + Start([Job triggered]) --> LoadConfig["Load analytics config"] + LoadConfig --> Fetch["Fetch Amplitude events\nlast N days"] + Fetch --> Analyze["Analyze trends\nusing Claude API"] + Analyze --> Trends["Identify patterns:\n- User drops\n- Funnel breaks\n- Feature adoption"] + Trends --> Issues["Create GitHub issues\nfor findings"] + Issues --> Notify["Send notifications"] + Notify --> Done([Job complete]) +``` + +--- + +## Amplitude Client + +```typescript +class AmplitudeClient { + constructor(apiKey: string, apiSecret: string); + + fetchEvents(options: { + startDate: string; + endDate: string; + events?: string[]; + }): Promise; + + fetchUsers(userIds: string[]): Promise; +} +``` + +Event structure: + +```typescript +interface IAnalyticsEvent { + event_type: string; + user_id: string; + time: number; + event_properties?: Record; + user_properties?: Record; +} +``` + +--- + +## Analysis Patterns + +The job uses Claude to analyze events and detect: + +1. **User Drop-offs**: Sudden decreases in active users +2. **Funnel Breaks**: Conversion rate drops in key flows +3. **Feature Adoption**: New feature usage trends +4. **Anomalies**: Statistical outliers in metrics + +Example issue created: + +``` +Title: [Analytics] User drop-off detected - Week of 2026-03-15 + +Body: +- Active users dropped 23% vs previous week +- Key event `checkout_completed` down 31% +- Possible cause: Deploy on 2026-03-14 introduced regression +- Recommendation: Investigate recent checkout flow changes +``` + +--- + +## Key File Locations + +| File | Purpose | +| ------------------------------------------------- | ---------------------------------------------- | +| `packages/core/src/analytics/analytics-runner.ts` | Main analytics job runner | +| `packages/core/src/analytics/amplitude-client.ts` | Amplitude API client | +| `packages/core/src/analytics/amplitude-types.ts` | Analytics type definitions | +| `packages/core/src/analytics/index.ts` | Barrel exports | +| `packages/cli/src/commands/analytics.ts` | CLI command: `nw analytics` | +| `packages/server/src/routes/analytics.routes.ts` | Analytics API routes | +| `packages/core/src/jobs/job-registry.ts` | Analytics job registry entry | +| `web/api.ts` | `fetchAnalyticsConfig()`, `triggerAnalytics()` | +| `web/pages/Settings.tsx` | Analytics job settings UI | + +--- + +## CLI Commands + +```bash +# Run analytics job manually +nw analytics run + +# Show analytics configuration +nw analytics config + +# Test Amplitude connection +nw analytics test +``` + +--- + +## Web Integration + +The Analytics job appears in: + +- **Scheduling page**: Enable/disable, set schedule +- **Settings page**: Configure lookback days, target column, analysis prompt +- **Dashboard**: Shows last run status and any created issues + +The job is **disabled by default** (`enabled: false`) since it requires Amplitude credentials. diff --git a/docs/architecture/job-registry.md b/docs/architecture/job-registry.md new file mode 100644 index 00000000..29e70c13 --- /dev/null +++ b/docs/architecture/job-registry.md @@ -0,0 +1,212 @@ +# Job Registry Architecture + +The Job Registry is the single source of truth for all job type metadata, defaults, and configuration patterns. It reduces the cost of adding a new job type from touching 15+ files to just 2-3 files. + +--- + +## Component Overview + +```mermaid +graph TD + subgraph Core["@night-watch/core"] + JR["job-registry.ts\nJOB_REGISTRY[]\ngetJobDef(id)\nnormalizeJobConfig()\nbuildJobEnvOverrides()"] + CONSTS["constants.ts\nVALID_JOB_TYPES\nDEFAULT_QUEUE_PRIORITY\nLOG_FILE_NAMES"] + CN["config-normalize.ts\nRegistry-driven loop"] + CE["config-env.ts\nRegistry-driven loop"] + end + + subgraph Web["web/"] + WJR["web/utils/jobs.ts\nWEB_JOB_REGISTRY[]\ntriggerJob(id)"] + ZUSTAND["useStore.ts\njobs computed slice"] + SCHED["Scheduling.tsx\nRegistry.map() rendering"] + SETT["Settings.tsx\nRegistry.map() rendering"] + end + + subgraph Server["@night-watch/server"] + ROUTES["action.routes.ts\nRegistry loop route generation"] + end + + JR --> CONSTS + JR --> CN + JR --> CE + JR -.->|shared types| WJR + WJR --> SCHED + WJR --> SETT + WJR --> ROUTES + ZUSTAND --> SCHED + ZUSTAND --> SETT +``` + +--- + +## Job Definition Interface + +```typescript +interface IJobDefinition { + id: JobType; + name: string; + description: string; + cliCommand: string; + logName: string; + lockSuffix: string; + queuePriority: number; + envPrefix: string; + extraFields?: IExtraFieldDef[]; + defaultConfig: TConfig; +} +``` + +All jobs extend `IBaseJobConfig`: + +```typescript +interface IBaseJobConfig { + enabled: boolean; + schedule: string; + maxRuntime: number; +} +``` + +--- + +## Registered Jobs + +| Job Type | Name | CLI Command | Priority | Env Prefix | +| --------- | --------- | ----------- | -------- | -------------- | +| executor | Executor | `run` | 50 | `NW_EXECUTOR` | +| reviewer | Reviewer | `review` | 40 | `NW_REVIEWER` | +| slicer | Slicer | `planner` | 30 | `NW_SLICER` | +| qa | QA | `qa` | 20 | `NW_QA` | +| audit | Auditor | `audit` | 10 | `NW_AUDIT` | +| analytics | Analytics | `analytics` | 10 | `NW_ANALYTICS` | + +--- + +## Config Normalization Flow + +```mermaid +flowchart LR + Raw["Raw config object"] --> NR["normalizeJobConfig()\nregistry.get(id)"] + NR --> Base["Apply IBaseJobConfig defaults\nenabled, schedule, maxRuntime"] + Base --> Extra["Apply extraFields defaults\nif defined"] + Extra --> Result["Normalized config"] +``` + +The registry-driven normalization replaces per-job blocks in `config-normalize.ts`: + +**Before (per-job blocks):** + +```typescript +// QA job normalization +if (raw.qa) { + normalized.qa = { + enabled: raw.qa.enabled ?? true, + schedule: raw.qa.schedule ?? '45 2,10,18 * * *', + maxRuntime: raw.qa.maxRuntime ?? 3600, + branchPatterns: raw.qa.branchPatterns ?? [], + artifacts: raw.qa.artifacts ?? 'both', + // ... more fields + }; +} +``` + +**After (registry-driven):** + +```typescript +for (const jobDef of JOB_REGISTRY) { + const rawJob = raw[jobDef.id]; + if (rawJob) { + normalized[jobDef.id] = normalizeJobConfig(rawJob, jobDef); + } +} +``` + +--- + +## Env Override Flow + +```mermaid +flowchart LR + EnvVars["Process env\nNW_* vars"] --> BE["buildJobEnvOverrides()\nregistry entry"] + BE --> Parse["Parse by field type\nboolean, string, number,\nstring[], enum"] + Parse --> Merge["Merge with base config"] + Merge --> Result["Job config with env overrides"] +``` + +Env var naming: `{envPrefix}_{FIELD_UPPER_SNAKE}` + +Examples: + +- `NW_QA_ENABLED` → `qa.enabled` +- `NW_QA_BRANCH_PATTERNS` → `qa.branchPatterns` +- `NW_QA_ARTIFACTS` → `qa.artifacts` (enum validation) + +--- + +## Web Job Registry + +The web-side registry extends the core registry with UI-specific fields: + +```typescript +interface IWebJobDefinition extends IJobDefinition { + icon: string; // lucide icon name + triggerEndpoint: string; // '/api/actions/qa' + scheduleTemplateKey: string; + settingsSection?: 'general' | 'advanced'; +} +``` + +This enables: + +- Generic `triggerJob(jobId)` instead of per-job `triggerRun()`, `triggerReview()`, etc. +- Dynamic rendering of Scheduling and Settings pages via `WEB_JOB_REGISTRY.map()` + +--- + +## Key File Locations + +| File | Purpose | +| --------------------------------------------- | --------------------------------------------------------------------- | +| `packages/core/src/jobs/job-registry.ts` | Core registry, `JOB_REGISTRY`, utility functions | +| `packages/core/src/jobs/index.ts` | Barrel exports | +| `packages/core/src/config-normalize.ts` | Registry-driven normalization loop | +| `packages/core/src/config-env.ts` | Registry-driven env override loop | +| `packages/core/src/constants.ts` | Derives `VALID_JOB_TYPES`, `DEFAULT_QUEUE_PRIORITY`, `LOG_FILE_NAMES` | +| `packages/cli/src/commands/run.ts` | Uses `getJobDef()` for executor dispatch | +| `packages/server/src/routes/action.routes.ts` | Generates routes from registry | +| `web/utils/jobs.ts` | Web-side job registry | +| `web/api.ts` | Generic `triggerJob(jobId)` function | +| `web/pages/Scheduling.tsx` | Renders job cards from `WEB_JOB_REGISTRY` | +| `web/pages/Settings.tsx` | Renders job settings from `WEB_JOB_REGISTRY` | +| `web/store/useStore.ts` | Zustand `jobs` computed slice | + +--- + +## Adding a New Job Type + +With the registry, adding a new job requires only: + +1. **Core registry entry** (`packages/core/src/jobs/job-registry.ts`): + +```typescript +{ + id: 'metrics', + name: 'Metrics', + description: 'Generates metrics reports', + cliCommand: 'metrics', + logName: 'metrics', + lockSuffix: '-metrics.lock', + queuePriority: 10, + envPrefix: 'NW_METRICS', + defaultConfig: { + enabled: false, + schedule: '0 6 * * 1', + maxRuntime: 900, + }, +} +``` + +2. **CLI command** (`packages/cli/src/commands/metrics.ts`) + +3. **Web registry entry** (`web/utils/jobs.ts`) + +All other layers (normalization, env parsing, UI rendering, API routes) are automatically handled by the registry. diff --git a/docs/audits/CLI_ARCHITECTURE_EFFICIENCY_AUDIT_2026-03-24.md b/docs/audits/CLI_ARCHITECTURE_EFFICIENCY_AUDIT_2026-03-24.md new file mode 100644 index 00000000..8d0799f5 --- /dev/null +++ b/docs/audits/CLI_ARCHITECTURE_EFFICIENCY_AUDIT_2026-03-24.md @@ -0,0 +1,222 @@ +# Night Watch CLI Audit: Execution Inefficiencies and Architectural Gaps + +Date: 2026-03-24 + +## Scope + +- `packages/cli` +- `packages/core` +- `scripts` +- `docs` + +## Method + +- Static audit of CLI entrypoints, queue orchestration, reviewer flow, and scheduler behavior. +- Focus on wasted schedules, control-flow dead ends, and configuration/architecture mismatches. +- No code changes were made. + +## Direct Answer to the Reviewer Example + +The reviewer does **not** stop at the first PR that needs no work. It scans all matching open PRs first, accumulates the ones needing action, and only exits with `skip_all_passing` after the full scan: + +- `scripts/night-watch-pr-reviewer-cron.sh:620-677` +- `scripts/night-watch-pr-reviewer-cron.sh:679-742` + +However, there is a more important waste case: a PR with **no review score yet** is currently treated as "good enough", so a review run can burn its schedule and exit with `skip_all_passing` without creating the initial review: + +- `scripts/night-watch-pr-reviewer-cron.sh:668-680` +- `instructions/night-watch-pr-reviewer.md:24` +- `docs/prds/reviewer-review-first-fix-later.md:7-33` + +## Findings + +### ✅ H3. Reviewer runs still waste schedules on unrated PRs because "no score yet" is treated as "nothing to do" — ⭐⭐⭐⭐⭐ + +Why it matters: + +- The reviewer only marks a PR as needing work when there are merge conflicts, failed CI checks, or a review score below threshold. +- A PR with no review score falls through as non-actionable. +- If all matching PRs are clean-but-unrated, the entire review run exits `skip_all_passing`. + +Evidence: + +- Scan loop only flips `NEEDS_WORK` when score exists and is below threshold: `scripts/night-watch-pr-reviewer-cron.sh:660-673` +- Early exit explicitly treats "or no score yet" as all-good: `scripts/night-watch-pr-reviewer-cron.sh:679-742` +- Prompt repeats the same rule: `instructions/night-watch-pr-reviewer.md:24` +- This gap is already acknowledged in an open PRD: `docs/prds/reviewer-review-first-fix-later.md:7-33` + +Recommendation: + +- Implement the open PRD. +- First-run behavior should be "review-first", not "skip". +- Add a smoke test for "no score exists" so this case cannot silently regress again. + +### ✅ M2. Queued jobs do not preserve CLI override semantics consistently — ⭐⭐⭐⭐ + +Why it matters: + +- The docs say CLI flags override everything. +- That is only true for immediate execution. +- When a run is queued, the dispatcher rebuilds env from disk config and only replays a narrow allowlist of persisted `NW_*` markers. +- Result: queued execution can differ from the command the user actually invoked. + +Evidence: + +- Docs claim CLI flags are highest precedence: `docs/reference/configuration.md:5-10` +- Commands apply overrides in-memory before building env: + - Executor: `packages/cli/src/commands/run.ts:393-403` + - Reviewer: `packages/cli/src/commands/review.ts:218-232` + - QA: `packages/cli/src/commands/qa.ts:129-139` +- Dispatcher rebuilds queued env from the saved project config: `packages/cli/src/commands/queue.ts:254-272` +- `buildQueuedJobEnv()` reloads config and does not know about CLI overrides: `packages/cli/src/commands/shared/env-builder.ts:168-176` +- Only a small marker allowlist is replayed later: `packages/cli/src/commands/queue.ts:385-420` + +Concrete examples: + +- `night-watch run --timeout ...` loses the timeout when queued because `NW_MAX_RUNTIME` is not replayed. +- `night-watch qa --timeout ...` loses the timeout when queued because `NW_QA_MAX_RUNTIME` is not replayed. +- `--provider` overrides are rebuilt from config and therefore lost for queued runs. +- Reviewer timeout survives because `NW_REVIEWER_MAX_RUNTIME` happens to be in the allowlist, but reviewer provider override still does not. + +Recommendation: + +- Persist a structured "effective runtime overrides" object with the queue entry. +- Reconstruct the queued env from that object, not from a partial `NW_*` allowlist. +- Add explicit tests for queued `--provider` and `--timeout` behavior. + +### ✅ H2. The "global" queue policy is not actually global; dispatch behavior depends on whichever project happens to call `queue dispatch` — ⭐⭐⭐⭐ + +Why it matters: + +- The queue database is shared across projects, but the queue policy is loaded from the caller's current project at dispatch time. +- This makes `maxConcurrency`, `mode`, and `providerBuckets` effectively nondeterministic in multi-project setups. +- Two projects with different queue configs can observe different dispatch behavior depending on who finished last. + +Evidence: + +- `queue dispatch` loads config from `process.cwd()`: `packages/cli/src/commands/queue.ts:232-239` +- `queue can-start` does the same: `packages/cli/src/commands/queue.ts:350-355` +- `queue claim` instead loads config from the target project path: `packages/cli/src/commands/queue.ts:304-326` + +Impact: + +- Project A can enqueue/claim under one queue policy. +- Project B can later dispatch the shared queue under a different policy. +- That breaks the core mental model of a single global scheduler. + +Recommendation: + +- Move queue policy to one canonical source for the global DB, not `cwd`. +- If per-project queue configs are required, define a merge rule explicitly and persist the effective policy with the queue entry. +- Document the policy source clearly; today the behavior is implicit and unstable. + +### ✅ H1. The global queue allows duplicate pending rows for the same project/job, so repeated cron triggers can create a backlog of redundant no-op runs — ⭐⭐⭐⭐ + +Why it matters: + +- When a job cannot claim a slot, the bash layer always enqueues it. +- The queue insert path does not dedupe by `project_path` + `job_type`. +- The dispatcher only considers the first pending row per project, so duplicates sit behind the head entry and drain later one by one. +- Result: if cron keeps firing while a project is busy, the queue can accumulate stale reruns that later consume slots just to no-op. + +Evidence: + +- Claim failure always falls through to enqueue: `scripts/night-watch-helpers.sh:1069-1090` +- Enqueue path is an unconditional `INSERT`: `packages/core/src/utils/job-queue.ts:249-280` +- Dispatcher only keeps one pending head per project: `packages/core/src/utils/job-queue.ts:171-227` + +Example failure mode: + +1. Project A reviewer is running. +2. The next 2-3 reviewer crons for Project A fire before the first one finishes. +3. Each failed claim inserts another pending row. +4. After the first run completes, the queue dispatches the next stale reviewer entry anyway. + +Recommendation: + +- Add idempotent enqueue semantics for active states (`pending`, `dispatched`, `running`) per `project_path + job_type`. +- Prefer "refresh existing pending row" over creating a new one. +- Add a regression test for repeated `claim_or_enqueue` on the same project/job under contention. + +### ✅ M1. Cross-project scheduling delay ignores actual cron equivalence and can delay unrelated jobs — ⭐⭐⭐ + +Why it matters: + +- The docs say balancing applies when projects share the same job type and cron schedule. +- The implementation only checks whether the job type is enabled, not whether the schedules actually collide. +- That means a project can be delayed by unrelated peers even when their cron expressions are different. + +Evidence: + +- Docs claim "same job type and cron schedule": `docs/architecture/scheduler-architecture.md:117-125` +- Peer collection only checks `isJobTypeEnabled(...)`: `packages/core/src/utils/scheduling.ts:67-109` +- Delay is computed from total enabled peers, regardless of schedule: `packages/core/src/utils/scheduling.ts:111-137` + +Secondary issue: + +- The delay is applied in the CLI before the bash script reaches queue claim/enqueue, so the queue cannot see or manage that waiting time: + - `packages/cli/src/commands/shared/env-builder.ts:138-154` + +Recommendation: + +- Filter peers by normalized schedule, not just job type. +- If the intention is to balance all same-job peers globally, update the docs to say that explicitly. +- Consider moving the delay to enqueue time or install-time cron generation instead of a long-lived pre-claim sleep. + +### ✅ M3. The reviewer wrapper duplicates GitHub work and its "needs work" preview is not the same logic as the real script — ⭐⭐⭐ + +Why it matters: + +- The TypeScript wrapper performs its own PR listing and preflight checks before launching the real bash flow. +- That adds extra GitHub CLI calls on every review run. +- The wrapper's "Open PRs Needing Work" label is misleading because the helper does not actually evaluate merge state, labels, review score, or the full bash selection logic. + +Evidence: + +- `getOpenPrsNeedingWork()` only lists open PRs and returns them directly: `packages/cli/src/commands/review.ts:316-344` +- Dry-run presents that list as "Open PRs Needing Work": `packages/cli/src/commands/review.ts:407-418` +- Non-dry-run preflight then calls `gh pr checks` for each listed PR: `packages/cli/src/commands/review.ts:442-460` +- The bash script performs its own full scan again, including label skip, merge state, failed checks, and review-score analysis: `scripts/night-watch-pr-reviewer-cron.sh:620-742` + +Additional note: + +- Local `gh pr list --help` describes `--head` as filtering by head branch, so the wrapper's repeated `--head ` usage is not obviously equivalent to the bash script's regex-based branch-prefix filtering. + +Recommendation: + +- Make the bash script the single source of truth for dry-run selection output. +- If preflight visibility is still desired, extract shared selection logic into one reusable layer instead of maintaining parallel heuristics. + +### ✅ L1. Reference docs drift on scheduling defaults and queue semantics — ⭐⭐ + +Why it matters: + +- The docs still communicate behaviors that no longer line up with the current code. +- This raises operator error risk because schedules and override precedence are operational, not cosmetic. + +Evidence: + +- `commands.md` still documents old install defaults: + - Docs: `docs/reference/commands.md:124-138` + - Current defaults: `packages/core/src/constants.ts:39-41` + - Install uses config schedules, not the documented legacy values: `packages/cli/src/commands/install.ts:201-210` +- `configuration.md` says CLI flags override everything, but queued jobs do not preserve that guarantee: `docs/reference/configuration.md:5-10` plus the evidence in M2 above. + +Recommendation: + +- Update the docs after fixing M2. +- Treat queue/dispatch behavior as architecture docs, not incidental implementation detail. + +## Priority Remediation Order + +1. ⭐⭐⭐⭐⭐ Fix reviewer "no score yet" behavior so review schedules do real work. +2. ⭐⭐⭐⭐ Preserve CLI overrides in queued jobs explicitly. +3. ⭐⭐⭐⭐ Define a single authoritative source for global queue policy. +4. ⭐⭐⭐⭐ Add queue dedupe for active entries by project/job. +5. ⭐⭐⭐ Fix scheduler peer filtering to check schedule equivalence. +6. ⭐⭐⭐ Make dry-run/preflight reuse the real reviewer selection logic. +7. ⭐⭐ Align scheduler docs with implementation. + +## Bottom Line + +The CLI generally avoids hard-stop failures on no-op work, and the reviewer does continue scanning past a clean PR. The bigger efficiency problems are architectural: queue entries are not idempotent, queue policy is not truly global, unrated PRs are skipped instead of reviewed, and the scheduler/dry-run layers each have their own version of reality. Those are the places where schedules and operator intent are currently being lost. diff --git a/instructions/night-watch-pr-reviewer.md b/instructions/night-watch-pr-reviewer.md index 0d9d6253..3b5e95ac 100644 --- a/instructions/night-watch-pr-reviewer.md +++ b/instructions/night-watch-pr-reviewer.md @@ -21,7 +21,8 @@ If current PR code or review feedback conflicts with the PRD context, call out t ## Important: Early Exit - If there are **no open PRs** on `night-watch/` or `feat/` branches, **stop immediately** and report "No PRs to review." -- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80** (or no review score yet), **stop immediately** and report "All PRs are in good shape." +- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80**, **stop immediately** and report "All PRs are in good shape." +- If a PR has no review score yet, it needs a first review — do NOT skip it. - Do **NOT** loop or retry. Process each PR **once** per run. After processing all PRs, stop. - Do **NOT** re-check PRs after pushing fixes -- the CI will re-run automatically on the next push. @@ -90,7 +91,48 @@ Parse the review score from the comment body. Look for patterns like: 3. **Determine if PR needs work**: - If no merge conflicts **AND** score >= 80 **AND** all CI checks pass --> skip this PR. - - If merge conflicts present **OR** score < 80 **OR** any CI check failed --> fix the issues. + - If **no review score exists yet** --> this PR needs its first review (see Mode: Review below). + - If merge conflicts present **OR** score < 80 **OR** any CI check failed --> fix the issues (see Mode: Fix below). + +## Mode: Review (when no review score exists yet) + +When a PR has no review score, post an initial review instead of fixing issues: + +1. Fetch the PR diff: `gh pr diff ` +2. Review the code using these criteria: + - **Correctness**: logic errors, edge cases, off-by-one errors + - **Code quality**: readability, naming, dead code, complexity + - **Tests**: missing tests, inadequate coverage + - **Performance**: obvious bottlenecks, unnecessary work + - **Security**: injection, XSS, secrets in code, unsafe patterns + - **Conventions**: follows project CLAUDE.md / coding standards +3. Post a review comment in this exact format (so the score can be parsed): + +``` +gh pr comment --body "## PR Review + +### Summary +<1-2 sentence summary of what the PR does> + +### Issues Found + +| Category | Confidence | Issue | +|----------|-----------|-------| +| | High/Medium/Low | | + +### Strengths +- + +**Overall Score:** /100 + +> 🔍 Reviewed by " +``` + +4. **Do NOT fix anything** — just post the review and stop. The next reviewer run will address the issues. + +## Mode: Fix (when review score < threshold) + +When the cron script injects `- action: fix` in the ## Target Scope section, follow the fix steps in section 4 below. Read the injected review body from `## Latest Review Feedback` to know what to address. 4. **Fix the PR**: diff --git a/packages/cli/package.json b/packages/cli/package.json index bb0a50c8..8557087b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@jonit-dev/night-watch-cli", - "version": "1.8.8-beta.3", + "version": "1.8.8-beta.10", "description": "AI agent that implements your specs, opens PRs, and reviews code overnight. Queue GitHub issues or PRDs, wake up to pull requests.", "type": "module", "bin": { diff --git a/packages/cli/src/__tests__/commands/dashboard.test.ts b/packages/cli/src/__tests__/commands/dashboard.test.ts index 2670eb2b..8fa7f5c2 100644 --- a/packages/cli/src/__tests__/commands/dashboard.test.ts +++ b/packages/cli/src/__tests__/commands/dashboard.test.ts @@ -151,6 +151,7 @@ describe('dashboard command', () => { branch: 'feat/new-feature', ciStatus: 'pass' as const, reviewScore: 100, + labels: [], }, { number: 2, @@ -158,6 +159,7 @@ describe('dashboard command', () => { branch: 'night-watch/phase-1', ciStatus: 'fail' as const, reviewScore: 0, + labels: [], }, { number: 3, @@ -165,6 +167,7 @@ describe('dashboard command', () => { branch: 'feat/wip', ciStatus: 'pending' as const, reviewScore: null, + labels: [], }, { number: 4, @@ -172,6 +175,7 @@ describe('dashboard command', () => { branch: 'feat/unknown', ciStatus: 'unknown' as const, reviewScore: null, + labels: [], }, ]; @@ -289,7 +293,14 @@ describe('dashboard command', () => { expect(processResult).toContain('executor: Not running'); const prResult = renderPrPane([ - { number: 1, title: 'Solo PR', branch: 'feat/solo', ciStatus: 'pass', reviewScore: null }, + { + number: 1, + title: 'Solo PR', + branch: 'feat/solo', + ciStatus: 'pass', + reviewScore: null, + labels: [], + }, ]); expect(prResult).toContain('#1 Solo PR'); expect(prResult).toContain('feat/solo'); diff --git a/packages/cli/src/__tests__/commands/dashboard/tab-status.test.ts b/packages/cli/src/__tests__/commands/dashboard/tab-status.test.ts index 7bf38b92..e6dfa0a4 100644 --- a/packages/cli/src/__tests__/commands/dashboard/tab-status.test.ts +++ b/packages/cli/src/__tests__/commands/dashboard/tab-status.test.ts @@ -2,156 +2,173 @@ * Tests for Status tab render functions */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; import { renderPrdPane, renderProcessPane, renderPrPane, renderLogPane, sortPrdsByPriority, -} from "@/cli/commands/dashboard/tab-status.js"; -import * as fs from "fs"; -import * as path from "path"; -import * as os from "os"; - -describe("tab-status render functions", () => { - describe("renderPrdPane", () => { - it("should return empty message when no PRDs", () => { - expect(renderPrdPane([])).toBe("No PRD files found"); +} from '@/cli/commands/dashboard/tab-status.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +describe('tab-status render functions', () => { + describe('renderPrdPane', () => { + it('should return empty message when no PRDs', () => { + expect(renderPrdPane([])).toBe('No PRD files found'); }); - it("should render PRDs with status indicators", () => { + it('should render PRDs with status indicators', () => { const prds = [ - { name: "phase0", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "phase1", status: "blocked" as const, dependencies: ["phase0"], unmetDependencies: ["phase0"] }, - { name: "phase2", status: "in-progress" as const, dependencies: [], unmetDependencies: [] }, - { name: "phase3", status: "done" as const, dependencies: [], unmetDependencies: [] }, + { name: 'phase0', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { + name: 'phase1', + status: 'blocked' as const, + dependencies: ['phase0'], + unmetDependencies: ['phase0'], + }, + { name: 'phase2', status: 'in-progress' as const, dependencies: [], unmetDependencies: [] }, + { name: 'phase3', status: 'done' as const, dependencies: [], unmetDependencies: [] }, ]; const result = renderPrdPane(prds); - expect(result).toContain("phase0"); - expect(result).toContain("phase1"); - expect(result).toContain("phase2"); - expect(result).toContain("phase3"); - expect(result).toContain("{green-fg}"); - expect(result).toContain("{yellow-fg}"); - expect(result).toContain("{cyan-fg}"); - expect(result).toContain("{#888888-fg}"); - expect(result).toContain("(deps: phase0)"); + expect(result).toContain('phase0'); + expect(result).toContain('phase1'); + expect(result).toContain('phase2'); + expect(result).toContain('phase3'); + expect(result).toContain('{green-fg}'); + expect(result).toContain('{yellow-fg}'); + expect(result).toContain('{cyan-fg}'); + expect(result).toContain('{#888888-fg}'); + expect(result).toContain('(deps: phase0)'); }); - it("should sort PRDs by priority when provided", () => { + it('should sort PRDs by priority when provided', () => { const prds = [ - { name: "alpha", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "beta", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "gamma", status: "ready" as const, dependencies: [], unmetDependencies: [] }, + { name: 'alpha', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { name: 'beta', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { name: 'gamma', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, ]; - const result = renderPrdPane(prds, ["gamma", "alpha", "beta"]); - const lines = result.split("\n"); + const result = renderPrdPane(prds, ['gamma', 'alpha', 'beta']); + const lines = result.split('\n'); - expect(lines[0]).toContain("gamma"); - expect(lines[1]).toContain("alpha"); - expect(lines[2]).toContain("beta"); + expect(lines[0]).toContain('gamma'); + expect(lines[1]).toContain('alpha'); + expect(lines[2]).toContain('beta'); }); }); - describe("sortPrdsByPriority", () => { + describe('sortPrdsByPriority', () => { const prds = [ - { name: "alpha", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "beta", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "gamma", status: "ready" as const, dependencies: [], unmetDependencies: [] }, - { name: "delta", status: "ready" as const, dependencies: [], unmetDependencies: [] }, + { name: 'alpha', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { name: 'beta', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { name: 'gamma', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, + { name: 'delta', status: 'ready' as const, dependencies: [], unmetDependencies: [] }, ]; - it("should return original order when priority is empty", () => { + it('should return original order when priority is empty', () => { const result = sortPrdsByPriority(prds, []); - expect(result.map((p) => p.name)).toEqual(["alpha", "beta", "gamma", "delta"]); + expect(result.map((p) => p.name)).toEqual(['alpha', 'beta', 'gamma', 'delta']); }); - it("should sort by priority order", () => { - const result = sortPrdsByPriority(prds, ["gamma", "alpha"]); - expect(result.map((p) => p.name)).toEqual(["gamma", "alpha", "beta", "delta"]); + it('should sort by priority order', () => { + const result = sortPrdsByPriority(prds, ['gamma', 'alpha']); + expect(result.map((p) => p.name)).toEqual(['gamma', 'alpha', 'beta', 'delta']); }); - it("should put unmentioned PRDs at the end alphabetically", () => { - const result = sortPrdsByPriority(prds, ["delta"]); - expect(result.map((p) => p.name)).toEqual(["delta", "alpha", "beta", "gamma"]); + it('should put unmentioned PRDs at the end alphabetically', () => { + const result = sortPrdsByPriority(prds, ['delta']); + expect(result.map((p) => p.name)).toEqual(['delta', 'alpha', 'beta', 'gamma']); }); - it("should not mutate original array", () => { + it('should not mutate original array', () => { const original = [...prds]; - sortPrdsByPriority(prds, ["gamma"]); + sortPrdsByPriority(prds, ['gamma']); expect(prds.map((p) => p.name)).toEqual(original.map((p) => p.name)); }); }); - describe("renderProcessPane", () => { - it("should render running and stopped processes", () => { + describe('renderProcessPane', () => { + it('should render running and stopped processes', () => { const processes = [ - { name: "executor", running: true, pid: 12345 }, - { name: "reviewer", running: false, pid: null }, + { name: 'executor', running: true, pid: 12345 }, + { name: 'reviewer', running: false, pid: null }, ]; const result = renderProcessPane(processes); - expect(result).toContain("executor: Running (PID: 12345)"); - expect(result).toContain("reviewer: Not running"); + expect(result).toContain('executor: Running (PID: 12345)'); + expect(result).toContain('reviewer: Not running'); }); - it("should handle empty process list", () => { - expect(renderProcessPane([])).toBe(""); + it('should handle empty process list', () => { + expect(renderProcessPane([])).toBe(''); }); }); - describe("renderPrPane", () => { - it("should return empty message when no PRs", () => { - expect(renderPrPane([])).toBe("No matching pull requests"); + describe('renderPrPane', () => { + it('should return empty message when no PRs', () => { + expect(renderPrPane([])).toBe('No matching pull requests'); }); - it("should render PRs with CI status and review scores", () => { + it('should render PRs with CI status and review scores', () => { const prs = [ - { number: 1, title: "Feature", branch: "feat/a", ciStatus: "pass" as const, reviewScore: 100 }, - { number: 2, title: "Fix", branch: "feat/b", ciStatus: "fail" as const, reviewScore: null }, + { + number: 1, + title: 'Feature', + branch: 'feat/a', + ciStatus: 'pass' as const, + reviewScore: 100, + labels: [], + }, + { + number: 2, + title: 'Fix', + branch: 'feat/b', + ciStatus: 'fail' as const, + reviewScore: null, + labels: [], + }, ]; const result = renderPrPane(prs); - expect(result).toContain("#1 Feature"); - expect(result).toContain("[Review: 100%]"); - expect(result).toContain("#2 Fix"); - expect(result).toContain("feat/a"); - expect(result).toContain("feat/b"); - expect(result).toContain("{green-fg}"); - expect(result).toContain("{red-fg}"); + expect(result).toContain('#1 Feature'); + expect(result).toContain('[Review: 100%]'); + expect(result).toContain('#2 Fix'); + expect(result).toContain('feat/a'); + expect(result).toContain('feat/b'); + expect(result).toContain('{green-fg}'); + expect(result).toContain('{red-fg}'); }); }); - describe("renderLogPane", () => { + describe('renderLogPane', () => { let tempDir: string; - it("should return empty message when no logs exist", () => { + it('should return empty message when no logs exist', () => { const logs = [ - { name: "executor", path: "/tmp/nonexistent.log", exists: false, size: 0, lastLines: [] }, + { name: 'executor', path: '/tmp/nonexistent.log', exists: false, size: 0, lastLines: [] }, ]; - expect(renderLogPane("/tmp", logs)).toBe("No log files found"); + expect(renderLogPane('/tmp', logs)).toBe('No log files found'); }); - it("should render log content from existing file", () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nw-log-test-")); - const logPath = path.join(tempDir, "executor.log"); - fs.writeFileSync(logPath, "Line 1\nLine 2\nLine 3"); + it('should render log content from existing file', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nw-log-test-')); + const logPath = path.join(tempDir, 'executor.log'); + fs.writeFileSync(logPath, 'Line 1\nLine 2\nLine 3'); - const logs = [ - { name: "executor", path: logPath, exists: true, size: 100, lastLines: [] }, - ]; + const logs = [{ name: 'executor', path: logPath, exists: true, size: 100, lastLines: [] }]; const result = renderLogPane(tempDir, logs); - expect(result).toContain("--- executor.log ---"); - expect(result).toContain("Line 1"); - expect(result).toContain("Line 3"); + expect(result).toContain('--- executor.log ---'); + expect(result).toContain('Line 1'); + expect(result).toContain('Line 3'); fs.rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/packages/cli/src/__tests__/commands/init.test.ts b/packages/cli/src/__tests__/commands/init.test.ts index 48a420b3..745590bd 100644 --- a/packages/cli/src/__tests__/commands/init.test.ts +++ b/packages/cli/src/__tests__/commands/init.test.ts @@ -575,4 +575,42 @@ describe('init command', () => { expect(content).toContain('Custom Night Watch Template'); }); }); + + describe('label sync preconditions', () => { + it('NIGHT_WATCH_LABELS includes e2e-validated for init sync', async () => { + const { NIGHT_WATCH_LABELS } = await import('@night-watch/core'); + const names = NIGHT_WATCH_LABELS.map((l: { name: string }) => l.name); + expect(names).toContain('e2e-validated'); + }); + + it('init skips label sync when no GitHub remote', () => { + // getGitHubRemoteStatus returns hasGitHubRemote: false for non-git dirs + const status = getGitHubRemoteStatus(tempDir); + expect(status.hasGitHubRemote).toBe(false); + }); + + it('label sync condition: skips when hasGitHubRemote is false', () => { + // Simulate the condition check: no sync when no GitHub remote + const remoteStatus = { hasGitHubRemote: false, remoteUrl: null }; + const ghAuthenticated = true; + const shouldSync = remoteStatus.hasGitHubRemote && ghAuthenticated; + expect(shouldSync).toBe(false); + }); + + it('label sync condition: skips when gh not authenticated', () => { + // Simulate the condition check: no sync when gh not authenticated + const remoteStatus = { hasGitHubRemote: true, remoteUrl: 'https://github.com/test/repo' }; + const ghAuthenticated = false; + const shouldSync = remoteStatus.hasGitHubRemote && ghAuthenticated; + expect(shouldSync).toBe(false); + }); + + it('label sync condition: proceeds when GitHub remote and gh authenticated', () => { + // Simulate the condition check: sync proceeds when both conditions met + const remoteStatus = { hasGitHubRemote: true, remoteUrl: 'https://github.com/test/repo' }; + const ghAuthenticated = true; + const shouldSync = remoteStatus.hasGitHubRemote && ghAuthenticated; + expect(shouldSync).toBe(true); + }); + }); }); diff --git a/packages/cli/src/__tests__/commands/install.test.ts b/packages/cli/src/__tests__/commands/install.test.ts index c9a8c627..0e9afcda 100644 --- a/packages/cli/src/__tests__/commands/install.test.ts +++ b/packages/cli/src/__tests__/commands/install.test.ts @@ -94,6 +94,17 @@ function createTestConfig(overrides: Partial = {}): INightWat targetColumn: 'Draft' as const, analysisPrompt: '', }, + prResolver: { + enabled: false, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, jobProviders: { executor: undefined, reviewer: undefined, @@ -346,4 +357,77 @@ describe('install command', () => { const hasQaEntry = result.entries.some((entry) => entry.includes("' qa ")); expect(hasQaEntry).toBe(false); }); + + it('should add pr-resolver crontab entry when prResolver.enabled is true', () => { + const config = createTestConfig({ + prResolver: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, + }); + const result = performInstall(tempDir, config); + + expect(result.success).toBe(true); + // executor + reviewer + pr-resolver = 3 + expect(result.entries).toHaveLength(3); + + const prResolverEntry = result.entries[2]; + expect(prResolverEntry).toContain("' resolve "); + expect(prResolverEntry).toContain('pr-resolver.log'); + expect(prResolverEntry).toContain('15 6,14,22 * * *'); + expect(prResolverEntry).toContain('# night-watch-cli:'); + }); + + it('should not include pr-resolver entry when prResolver.enabled is false', () => { + const config = createTestConfig({ + prResolver: { + enabled: false, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, + }); + const result = performInstall(tempDir, config); + + expect(result.success).toBe(true); + expect(result.entries).toHaveLength(2); // executor and reviewer only + + const hasPrResolverEntry = result.entries.some((entry) => entry.includes("' resolve ")); + expect(hasPrResolverEntry).toBe(false); + }); + + it('should skip pr-resolver entry when noPrResolver option is set', () => { + const config = createTestConfig({ + prResolver: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, + }); + const result = performInstall(tempDir, config, { noPrResolver: true }); + + expect(result.success).toBe(true); + expect(result.entries).toHaveLength(2); // executor and reviewer only + + const hasPrResolverEntry = result.entries.some((entry) => entry.includes("' resolve ")); + expect(hasPrResolverEntry).toBe(false); + }); }); diff --git a/packages/cli/src/__tests__/commands/qa.test.ts b/packages/cli/src/__tests__/commands/qa.test.ts index 2960f0f5..90ab5198 100644 --- a/packages/cli/src/__tests__/commands/qa.test.ts +++ b/packages/cli/src/__tests__/commands/qa.test.ts @@ -58,6 +58,7 @@ function createTestConfig(overrides: Partial = {}): INightWat artifacts: 'both', skipLabel: 'skip-qa', autoInstallPlaywright: true, + validatedLabel: 'e2e-validated', }, ...overrides, } as INightWatchConfig; @@ -170,6 +171,35 @@ describe('qa command', () => { expect(env.NW_QA_SKIP_LABEL).toBe('skip-qa'); }); + it('should set NW_QA_VALIDATED_LABEL from config', () => { + const config = createTestConfig(); + const options: IQaOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_QA_VALIDATED_LABEL).toBe('e2e-validated'); + }); + + it('should use custom validatedLabel from config', () => { + const config = createTestConfig({ + qa: { + enabled: true, + schedule: '30 1,7,13,19 * * *', + maxRuntime: 3600, + branchPatterns: [], + artifacts: 'both', + skipLabel: 'skip-qa', + autoInstallPlaywright: true, + validatedLabel: 'custom-e2e-label', + }, + }); + const options: IQaOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_QA_VALIDATED_LABEL).toBe('custom-e2e-label'); + }); + it('should set NW_QA_AUTO_INSTALL_PLAYWRIGHT to 1 when true', () => { const config = createTestConfig(); const options: IQaOptions = { dryRun: false }; diff --git a/packages/cli/src/__tests__/commands/queue.test.ts b/packages/cli/src/__tests__/commands/queue.test.ts index f351d843..815d9805 100644 --- a/packages/cli/src/__tests__/commands/queue.test.ts +++ b/packages/cli/src/__tests__/commands/queue.test.ts @@ -220,7 +220,7 @@ describe('queue command', () => { }); }); - it('dispatch preserves persisted NW queue markers', async () => { + it('dispatch preserves persisted reviewer runtime markers from the queue entry', async () => { vi.mocked(buildQueuedJobEnv).mockReturnValue({ NW_PROVIDER_CMD: 'claude', }); @@ -235,6 +235,11 @@ describe('queue command', () => { envJson: { NW_DRY_RUN: '1', NW_CRON_TRIGGER: '1', + NW_TARGET_PR: '92', + NW_REVIEWER_WORKER_MODE: '1', + NW_REVIEWER_PARALLEL: '0', + NW_REVIEWER_MAX_RUNTIME: '1800', + NW_BRANCH_PATTERNS: 'night-watch/', ANTHROPIC_BASE_URL: 'https://wrong-proxy.com', }, enqueuedAt: 300, @@ -256,6 +261,11 @@ describe('queue command', () => { // Legitimate queue markers from envJson are preserved expect(spawnEnv.NW_DRY_RUN).toBe('1'); expect(spawnEnv.NW_CRON_TRIGGER).toBe('1'); + expect(spawnEnv.NW_TARGET_PR).toBe('92'); + expect(spawnEnv.NW_REVIEWER_WORKER_MODE).toBe('1'); + expect(spawnEnv.NW_REVIEWER_PARALLEL).toBe('0'); + expect(spawnEnv.NW_REVIEWER_MAX_RUNTIME).toBe('1800'); + expect(spawnEnv.NW_BRANCH_PATTERNS).toBe('night-watch/'); // Non-queue-marker keys from envJson are dropped (provider identity must come from config) expect(spawnEnv.ANTHROPIC_BASE_URL).not.toBe('https://wrong-proxy.com'); diff --git a/packages/cli/src/__tests__/commands/resolve.test.ts b/packages/cli/src/__tests__/commands/resolve.test.ts new file mode 100644 index 00000000..8376e9bd --- /dev/null +++ b/packages/cli/src/__tests__/commands/resolve.test.ts @@ -0,0 +1,266 @@ +/** + * Tests for the resolve command + * + * These tests focus on testing the exported helper functions directly, + * which is more reliable than mocking the entire module system. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Mock console methods before importing +vi.spyOn(console, 'log').mockImplementation(() => {}); +vi.spyOn(console, 'error').mockImplementation(() => {}); + +// Mock process.exit +vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); +}); + +// Mock process.cwd +const mockCwd = vi.spyOn(process, 'cwd'); + +// Import after setting up mocks +import { buildEnvVars, applyCliOverrides, IResolveOptions } from '@/cli/commands/resolve.js'; +import { INightWatchConfig } from '@night-watch/core/types.js'; +import { sendNotifications } from '@night-watch/core/utils/notify.js'; + +// Helper to create a valid config with prResolver fields +function createTestConfig(overrides: Partial = {}): INightWatchConfig { + return { + prdDir: 'docs/PRDs/night-watch', + maxRuntime: 7200, + reviewerMaxRuntime: 3600, + branchPrefix: 'night-watch', + branchPatterns: ['feat/', 'night-watch/'], + minReviewScore: 80, + maxLogSize: 524288, + cronSchedule: '0 0-21 * * *', + reviewerSchedule: '0 0,3,6,9,12,15,18,21 * * *', + reviewerMaxPrsPerRun: 0, + provider: 'claude', + reviewerEnabled: true, + autoMerge: false, + autoMergeMethod: 'squash', + jobProviders: {}, + prResolver: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, + ...overrides, + }; +} + +describe('resolve command', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-resolve-test-')); + mockCwd.mockReturnValue(tempDir); + + // Save original environment + originalEnv = { ...process.env }; + + // Clear NW_* environment variables + for (const key of Object.keys(process.env)) { + if (key.startsWith('NW_')) { + delete process.env[key]; + } + } + + vi.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + + // Restore original environment + for (const key of Object.keys(process.env)) { + if (key.startsWith('NW_')) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(originalEnv)) { + if (key.startsWith('NW_')) { + process.env[key] = value; + } + } + + vi.clearAllMocks(); + }); + + describe('buildEnvVars', () => { + it('buildEnvVars includes pr-resolver-specific vars', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_PR_RESOLVER_MAX_RUNTIME).toBe('3600'); + expect(env.NW_PR_RESOLVER_MAX_PRS_PER_RUN).toBe('0'); + expect(env.NW_PR_RESOLVER_PER_PR_TIMEOUT).toBe('600'); + expect(env.NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION).toBe('1'); + expect(env.NW_PR_RESOLVER_AI_REVIEW_RESOLUTION).toBe('0'); + expect(env.NW_PR_RESOLVER_READY_LABEL).toBe('ready-to-merge'); + expect(env.NW_PR_RESOLVER_BRANCH_PATTERNS).toBe(''); + }); + + it('should set NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION to 0 when disabled', () => { + const config = createTestConfig({ + prResolver: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 5, + perPrTimeout: 300, + aiConflictResolution: false, + aiReviewResolution: true, + readyLabel: 'ready', + }, + }); + const options: IResolveOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION).toBe('0'); + expect(env.NW_PR_RESOLVER_AI_REVIEW_RESOLUTION).toBe('1'); + expect(env.NW_PR_RESOLVER_MAX_PRS_PER_RUN).toBe('5'); + expect(env.NW_PR_RESOLVER_PER_PR_TIMEOUT).toBe('300'); + expect(env.NW_PR_RESOLVER_READY_LABEL).toBe('ready'); + }); + + it('should set NW_PR_RESOLVER_BRANCH_PATTERNS as comma-joined string', () => { + const config = createTestConfig({ + prResolver: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: ['feat/', 'fix/', 'night-watch/'], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + }, + }); + const options: IResolveOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_PR_RESOLVER_BRANCH_PATTERNS).toBe('feat/,fix/,night-watch/'); + }); + + it('should set NW_DRY_RUN when dryRun is true', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: true }; + + const env = buildEnvVars(config, options); + + expect(env.NW_DRY_RUN).toBe('1'); + }); + + it('should not set NW_DRY_RUN when dryRun is false', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_DRY_RUN).toBeUndefined(); + }); + + it('should include base env vars (NW_PROVIDER_CMD)', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false }; + + const env = buildEnvVars(config, options); + + expect(env.NW_PROVIDER_CMD).toBe('claude'); + }); + }); + + describe('applyCliOverrides', () => { + it('applyCliOverrides applies timeout override', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false, timeout: '1800' }; + + const result = applyCliOverrides(config, options); + + expect(result.prResolver.maxRuntime).toBe(1800); + }); + + it('should not mutate original config when applying timeout override', () => { + const config = createTestConfig(); + const originalMaxRuntime = config.prResolver.maxRuntime; + const options: IResolveOptions = { dryRun: false, timeout: '900' }; + + const result = applyCliOverrides(config, options); + + // Original config should not be mutated + expect(config.prResolver.maxRuntime).toBe(originalMaxRuntime); + // Returned config should have the override applied + expect(result.prResolver.maxRuntime).toBe(900); + }); + + it('should apply provider override', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false, provider: 'codex' }; + + const result = applyCliOverrides(config, options); + + expect(result._cliProviderOverride).toBe('codex'); + }); + + it('should not override when timeout is not provided', () => { + const config = createTestConfig(); + const options: IResolveOptions = { dryRun: false }; + + const result = applyCliOverrides(config, options); + + expect(result.prResolver.maxRuntime).toBe(config.prResolver.maxRuntime); + }); + + it('should not override when timeout is not a valid number', () => { + const config = createTestConfig(); + const originalMaxRuntime = config.prResolver.maxRuntime; + const options: IResolveOptions = { dryRun: false, timeout: 'abc' }; + + const result = applyCliOverrides(config, options); + + expect(result.prResolver.maxRuntime).toBe(originalMaxRuntime); + }); + }); + + describe('notification integration', () => { + it('sendNotifications should be importable', () => { + expect(typeof sendNotifications).toBe('function'); + }); + + it('sends pr_resolver_completed notification on success', () => { + // Verify that the event name used on success is pr_resolver_completed + // This mirrors how the resolve command action handler dispatches notifications + const exitCode = 0; + const event = + exitCode === 0 ? ('pr_resolver_completed' as const) : ('pr_resolver_failed' as const); + expect(event).toBe('pr_resolver_completed'); + }); + + it('sends pr_resolver_failed notification on failure', () => { + // Verify that the event name used on failure is pr_resolver_failed + const exitCode = 1; + const event = + exitCode === 0 ? ('pr_resolver_completed' as const) : ('pr_resolver_failed' as const); + expect(event).toBe('pr_resolver_failed'); + }); + }); +}); diff --git a/packages/cli/src/__tests__/commands/review.test.ts b/packages/cli/src/__tests__/commands/review.test.ts index d725e28b..ffd5e6c5 100644 --- a/packages/cli/src/__tests__/commands/review.test.ts +++ b/packages/cli/src/__tests__/commands/review.test.ts @@ -34,6 +34,7 @@ import { buildReviewNotificationTargets, isFailingCheck, shouldSendReviewNotification, + shouldSendReviewCompletionNotification, } from '@/cli/commands/review.js'; import { INightWatchConfig } from '@night-watch/core/types.js'; import { sendNotifications } from '@night-watch/core/utils/notify.js'; @@ -322,6 +323,28 @@ describe('review command', () => { }); }); + describe('shouldSendReviewCompletionNotification', () => { + it('should send completion notifications for successful review completions', () => { + expect(shouldSendReviewCompletionNotification(0, 'success_reviewed')).toBe(true); + expect(shouldSendReviewCompletionNotification(0, undefined)).toBe(true); + }); + + it('should suppress completion notifications for no-op outcomes', () => { + expect(shouldSendReviewCompletionNotification(0, 'skip_no_open_prs')).toBe(false); + expect(shouldSendReviewCompletionNotification(0, 'queued')).toBe(false); + }); + + it('should suppress completion notifications when reviewer fails or times out', () => { + expect(shouldSendReviewCompletionNotification(1, 'failure')).toBe(false); + expect(shouldSendReviewCompletionNotification(124, 'timeout')).toBe(false); + }); + + it('should defensively suppress failure markers even if the exit code is zero', () => { + expect(shouldSendReviewCompletionNotification(0, 'failure')).toBe(false); + expect(shouldSendReviewCompletionNotification(0, 'timeout')).toBe(false); + }); + }); + describe('parseAutoMergedPrNumbers', () => { it('parses comma-separated #PR tokens', () => { expect(parseAutoMergedPrNumbers('#12,#34,#56')).toEqual([12, 34, 56]); diff --git a/packages/cli/src/__tests__/commands/slice.test.ts b/packages/cli/src/__tests__/commands/slice.test.ts index 7f4cd41e..3b2e6556 100644 --- a/packages/cli/src/__tests__/commands/slice.test.ts +++ b/packages/cli/src/__tests__/commands/slice.test.ts @@ -26,6 +26,7 @@ const mockCwd = vi.spyOn(process, 'cwd'); // Import after setting up mocks import { buildEnvVars, + buildPlannerIssueBody, applyCliOverrides, createPlannerIssue, ISliceOptions, @@ -294,6 +295,80 @@ describe('slice command', () => { }); }); + describe('buildPlannerIssueBody', () => { + const prdSubDir = 'prds'; + + function writePrd(filename: string, content: string): void { + const dir = path.join(tempDir, prdSubDir); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, filename), content); + } + + it('should use PRD content directly as the issue body', () => { + writePrd('01-test-feature.md', '# PRD: Test Feature\n\n## Problem\n\nSomething is broken.\n'); + + const config = createTestConfig({ prdDir: prdSubDir }); + const result = { + sliced: true, + file: '01-test-feature.md', + item: { hash: 'abc12345', title: 'Test Feature', description: 'A description', checked: false, section: 'Phase 1' }, + }; + + const body = buildPlannerIssueBody(tempDir, config, result); + + expect(body).toContain('# PRD: Test Feature'); + expect(body).not.toContain('## Planner Generated PRD'); + }); + + it('should include source metadata in a collapsible details section', () => { + writePrd('01-test-feature.md', '# PRD: Test Feature\n'); + + const config = createTestConfig({ prdDir: prdSubDir }); + const result = { + sliced: true, + file: '01-test-feature.md', + item: { hash: 'abc12345', title: 'Test Feature', description: 'A description', checked: false, section: 'Phase 1' }, + }; + + const body = buildPlannerIssueBody(tempDir, config, result); + + expect(body).toContain('
    '); + expect(body).toContain('Source metadata'); + expect(body).toContain('Source section: Phase 1'); + expect(body).toContain('Source summary: A description'); + }); + + it('should not include source summary when description is empty', () => { + writePrd('01-test-feature.md', '# PRD: Test Feature\n'); + + const config = createTestConfig({ prdDir: prdSubDir }); + const result = { + sliced: true, + file: '01-test-feature.md', + item: { hash: 'abc12345', title: 'Test Feature', description: '', checked: false, section: 'Phase 1' }, + }; + + const body = buildPlannerIssueBody(tempDir, config, result); + + expect(body).not.toContain('Source summary:'); + }); + + it('should truncate very large PRD content', () => { + writePrd('01-large.md', 'x'.repeat(65000)); + + const config = createTestConfig({ prdDir: prdSubDir }); + const result = { + sliced: true, + file: '01-large.md', + item: { hash: 'abc12345', title: 'Large', description: '', checked: false, section: 'X' }, + }; + + const body = buildPlannerIssueBody(tempDir, config, result); + + expect(body).toContain('...[truncated]'); + }); + }); + describe('createPlannerIssue', () => { it('should skip issue creation when board provider is disabled', async () => { const config = createTestConfig({ diff --git a/packages/cli/src/__tests__/commands/summary.test.ts b/packages/cli/src/__tests__/commands/summary.test.ts index c936af86..8f34bcae 100644 --- a/packages/cli/src/__tests__/commands/summary.test.ts +++ b/packages/cli/src/__tests__/commands/summary.test.ts @@ -359,6 +359,7 @@ describe('summary command', () => { url: 'https://github.com/test/repo/pull/42', ciStatus: 'fail', reviewScore: null, + labels: [], }, ]); diff --git a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts index 3f9cd4b0..866a2a2b 100644 --- a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts +++ b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts @@ -12,6 +12,7 @@ const executorScript = path.join(repoRoot, 'scripts', 'night-watch-cron.sh'); const reviewerScript = path.join(repoRoot, 'scripts', 'night-watch-pr-reviewer-cron.sh'); const qaScript = path.join(repoRoot, 'scripts', 'night-watch-qa-cron.sh'); const auditScript = path.join(repoRoot, 'scripts', 'night-watch-audit-cron.sh'); +const prResolverScript = path.join(repoRoot, 'scripts', 'night-watch-pr-resolver-cron.sh'); const tempDirs: string[] = []; @@ -1036,9 +1037,7 @@ describe('core flow smoke tests (bash scripts)', () => { NW_TARGET_PR: '', // No target PR (use global lock) }); - // Note: Reviewer script currently exits 0 on timeout (missing explicit exit code) - // The timeout is still emitted in stdout - expect(result.status).toBe(0); + expect(result.status).toBe(124); expect(result.stdout).toContain('NIGHT_WATCH_RESULT:timeout'); }); @@ -1446,6 +1445,101 @@ describe('core flow smoke tests (bash scripts)', () => { expect(result.stdout).toContain('NIGHT_WATCH_RESULT:skip_all_passing'); }); + it('reviewer should label targeted PR needs-human-review when score stays missing after max retries', () => { + const projectDir = mkTempDir('nw-smoke-reviewer-missing-score-exhausted-'); + initGitRepo(projectDir); + fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true }); + + const fakeBin = mkTempDir('nw-smoke-reviewer-missing-score-exhausted-bin-'); + const labelFile = path.join(projectDir, '.needs-human-review-labels'); + + fs.writeFileSync( + path.join(fakeBin, 'gh'), + '#!/usr/bin/env bash\n' + + 'args="$*"\n' + + 'if [[ "$1" == "repo" && "$2" == "view" ]]; then\n' + + " echo 'owner/repo'\n" + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' + + ' if [[ "$args" == *"number,headRefName,labels"* ]]; then\n' + + " printf '1\\tnight-watch/missing-score\\t\\n'\n" + + ' elif [[ "$args" == *"number,headRefName"* ]]; then\n' + + " printf '1\\tnight-watch/missing-score\\n'\n" + + ' else\n' + + " echo 'night-watch/missing-score'\n" + + ' fi\n' + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "pr" && "$2" == "view" ]]; then\n' + + ' if [[ "$args" == *"mergeStateStatus"* ]]; then\n' + + " echo 'CLEAN'\n" + + ' elif [[ "$args" == *"headRefOid"* ]]; then\n' + + " echo 'abc123'\n" + + ' elif [[ "$args" == *"title,headRefName,body,url"* ]]; then\n' + + ` echo '{"title":"Missing score","headRefName":"night-watch/missing-score","body":"","url":"https://example.test/pr/1"}'\n` + + ' elif [[ "$args" == *"comments"* ]]; then\n' + + ' exit 0\n' + + ' else\n' + + ' echo \'{"number":1}\'\n' + + ' fi\n' + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "pr" && "$2" == "checks" ]]; then\n' + + ' if [[ "$args" == *"--json bucket,state,conclusion"* ]]; then\n' + + " echo '1'\n" + + ' exit 0\n' + + ' fi\n' + + ' if [[ "$args" == *"--json name,bucket,state,conclusion"* ]]; then\n' + + " echo 'review [state=completed, conclusion=startup_failure]'\n" + + ' exit 0\n' + + ' fi\n' + + " echo 'review startup_failure'\n" + + ' exit 1\n' + + 'fi\n' + + 'if [[ "$1" == "pr" && "$2" == "edit" && "$args" == *"--add-label needs-human-review"* ]]; then\n' + + ' printf \'%s\\n\' "$args" >> "$NW_SMOKE_LABEL_FILE"\n' + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "api" ]]; then\n' + + ' exit 0\n' + + 'fi\n' + + 'if [[ "$1" == "issue" && "$2" == "view" ]]; then\n' + + ' echo \'{"title":"","body":"","url":""}\'\n' + + ' exit 0\n' + + 'fi\n' + + 'exit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + + fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { + encoding: 'utf-8', + mode: 0o755, + }); + + const result = runScript(reviewerScript, projectDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + NW_PROVIDER_CMD: 'claude', + NW_DEFAULT_BRANCH: 'main', + NW_BRANCH_PATTERNS: 'night-watch/', + NW_MIN_REVIEW_SCORE: '80', + NW_AUTO_MERGE: '0', + NW_REVIEWER_PARALLEL: '0', + NW_REVIEWER_MAX_RETRIES: '1', + NW_REVIEWER_RETRY_DELAY: '0', + NW_QUEUE_ENABLED: '0', + NW_TARGET_PR: '1', + NW_SMOKE_LABEL_FILE: labelFile, + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain('NIGHT_WATCH_RESULT:failure'); + expect(result.stdout).toContain('attempts=2'); + expect(fs.readFileSync(labelFile, 'utf-8')).toContain( + 'pr edit 1 --add-label needs-human-review', + ); + }); + it('reviewer should cap processed PRs per run in dry-run mode', () => { const projectDir = mkTempDir('nw-smoke-reviewer-max-prs-per-run-'); initGitRepo(projectDir); @@ -1646,9 +1740,7 @@ describe('core flow smoke tests (bash scripts)', () => { NW_TARGET_PR: '', // No target PR (use global lock) }); - // Note: Reviewer script currently exits 0 on failure (missing explicit exit code propagation) - // The failure is still emitted in stdout via emit_final_status - expect(result.status).toBe(0); + expect(result.status).toBe(1); expect(result.stdout).toContain('NIGHT_WATCH_RESULT:failure'); }); @@ -1720,7 +1812,7 @@ describe('core flow smoke tests (bash scripts)', () => { NW_TARGET_PR: '', // No target PR (use global lock) }); - expect(result.status).toBe(0); + expect(result.status).toBe(1); expect(result.stdout).toContain('NIGHT_WATCH_RESULT:failure'); const argv = fs.readFileSync(argsFile, 'utf-8').split('\0').filter(Boolean); @@ -1779,14 +1871,18 @@ describe('core flow smoke tests (bash scripts)', () => { 'fi\n' + 'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' + ' if [[ "$args" == *"number,headRefName"* ]]; then\n' + - " printf '31\\tnight-watch/parallel-timeout\\n32\\tnight-watch/parallel-success\\n'\n" + + " printf '31\\tnight-watch/parallel-timeout\\t\\n32\\tnight-watch/parallel-success\\t\\n'\n" + ' else\n' + " printf 'night-watch/parallel-timeout\\nnight-watch/parallel-success\\n'\n" + ' fi\n' + ' exit 0\n' + 'fi\n' + 'if [[ "$1" == "pr" && "$2" == "checks" ]]; then\n' + - " echo 'fail 1/1 checks'\n" + + ' if [[ "$args" == *"--json"* ]]; then\n' + + " echo '[{\"bucket\":\"fail\",\"state\":\"failure\",\"conclusion\":\"failure\",\"name\":\"test-check\"}]'\n" + + ' else\n' + + " echo 'fail 1/1 checks'\n" + + ' fi\n' + ' exit 1\n' + 'fi\n' + 'exit 0\n', @@ -1809,10 +1905,7 @@ describe('core flow smoke tests (bash scripts)', () => { NW_TARGET_PR: '', // No target PR (use global lock) }); - // Note: Parallel mode calls `exit 0` at line 378 regardless of worker results - // The aggregation logic sets EXIT_CODE but emit_final_status doesn't propagate it - // The timeout is still emitted in stdout - expect(result.status).toBe(0); + expect(result.status).toBe(124); expect(result.stdout).toContain('NIGHT_WATCH_RESULT:timeout'); }); @@ -2466,4 +2559,55 @@ describe('core flow smoke tests (bash scripts)', () => { expect(result.status).toBe(42); expect(result.stdout).toContain('NIGHT_WATCH_RESULT:failure|provider_exit=42'); }); + + it('pr-resolver should emit skip_dry_run when NW_DRY_RUN=1', () => { + const projectDir = mkTempDir('nw-smoke-pr-resolver-dry-run-'); + fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true }); + + const fakeBin = mkTempDir('nw-smoke-bin-pr-resolver-dry-run-'); + fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { + encoding: 'utf-8', + mode: 0o755, + }); + + const result = runScript(prResolverScript, projectDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + NW_PROVIDER_CMD: 'claude', + NW_DRY_RUN: '1', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('NIGHT_WATCH_RESULT:skip_dry_run'); + }); + + it('pr-resolver should emit skip_no_open_prs when gh returns empty array', () => { + const projectDir = mkTempDir('nw-smoke-pr-resolver-no-prs-'); + fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true }); + + const fakeBin = mkTempDir('nw-smoke-bin-pr-resolver-no-prs-'); + fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', { + encoding: 'utf-8', + mode: 0o755, + }); + + fs.writeFileSync( + path.join(fakeBin, 'gh'), + '#!/usr/bin/env bash\n' + + 'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' + + " echo '[]'\n" + + ' exit 0\n' + + 'fi\n' + + 'exit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + + const result = runScript(prResolverScript, projectDir, { + PATH: `${fakeBin}:${process.env.PATH}`, + NW_PROVIDER_CMD: 'claude', + NW_DEFAULT_BRANCH: 'main', + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('NIGHT_WATCH_RESULT:skip_no_open_prs'); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 97777853..6a977bf6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,6 +32,7 @@ import { boardCommand } from './commands/board.js'; import { queueCommand } from './commands/queue.js'; import { notifyCommand } from './commands/notify.js'; import { summaryCommand } from './commands/summary.js'; +import { resolveCommand } from './commands/resolve.js'; // Find the package root (works from both src/ in dev and dist/src/ in production) const __filename = fileURLToPath(import.meta.url); @@ -129,4 +130,7 @@ notifyCommand(program); // Register summary command (morning briefing) summaryCommand(program); +// Register resolve command (PR conflict resolver) +resolveCommand(program); + program.parse(); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a7467974..77f0efdc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -376,6 +376,7 @@ export function buildInitConfig(params: { }, audit: { ...defaults.audit }, analytics: { ...defaults.analytics }, + prResolver: { ...defaults.prResolver }, jobProviders: { ...defaults.jobProviders }, queue: { ...defaults.queue, @@ -573,7 +574,7 @@ export function initCommand(program: Command): void { const cwd = process.cwd(); const force = options.force || false; const prdDir = options.prdDir || DEFAULT_PRD_DIR; - const totalSteps = 13; + const totalSteps = 14; const interactive = isInteractiveInitSession(); console.log(); @@ -891,8 +892,41 @@ export function initCommand(program: Command): void { } } - // Step 11: Register in global registry - step(11, totalSteps, 'Registering project in global registry...'); + // Step 11: Sync Night Watch labels to GitHub + step(11, totalSteps, 'Syncing Night Watch labels to GitHub...'); + let labelSyncStatus = 'Skipped'; + if (!remoteStatus.hasGitHubRemote || !ghAuthenticated) { + labelSyncStatus = !remoteStatus.hasGitHubRemote + ? 'Skipped (no GitHub remote)' + : 'Skipped (gh auth required)'; + info('Skipping label sync (no GitHub remote or gh not authenticated).'); + } else { + try { + const { NIGHT_WATCH_LABELS } = await import('@night-watch/core'); + let created = 0; + for (const label of NIGHT_WATCH_LABELS) { + try { + execSync( + `gh label create "${label.name}" --description "${label.description}" --color "${label.color}" --force`, + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + created++; + } catch { + // Label creation is best-effort + } + } + labelSyncStatus = `Synced ${created}/${NIGHT_WATCH_LABELS.length} labels`; + success(`Synced ${created}/${NIGHT_WATCH_LABELS.length} labels to GitHub`); + } catch (labelErr) { + labelSyncStatus = 'Failed'; + warn( + `Could not sync labels: ${labelErr instanceof Error ? labelErr.message : String(labelErr)}`, + ); + } + } + + // Step 12: Register in global registry + step(12, totalSteps, 'Registering project in global registry...'); try { const { registerProject } = await import('@night-watch/core'); const entry = registerProject(cwd); @@ -903,8 +937,8 @@ export function initCommand(program: Command): void { ); } - // Step 12: Install AI skills - step(12, totalSteps, 'Installing Night Watch skills...'); + // Step 13: Install AI skills + step(13, totalSteps, 'Installing Night Watch skills...'); const skillsResult = installSkills(cwd, selectedProvider, force, TEMPLATES_DIR); if (skillsResult.installed > 0) { success(`Installed ${skillsResult.installed} skills to ${skillsResult.location}`); @@ -918,7 +952,7 @@ export function initCommand(program: Command): void { } // Print summary - step(13, totalSteps, 'Initialization complete!'); + step(14, totalSteps, 'Initialization complete!'); // Summary with table header('Initialization Complete'); @@ -933,6 +967,7 @@ export function initCommand(program: Command): void { filesTable.push(['', `instructions/prd-creator.md (${templateSources[5].source})`]); filesTable.push(['Config File', CONFIG_FILE_NAME]); filesTable.push(['Board Setup', boardSetupStatus]); + filesTable.push(['Label Sync', labelSyncStatus]); filesTable.push(['Global Registry', '~/.night-watch/projects.json']); let skillsSummary: string; if (skillsResult.installed > 0) { diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 184c8ae3..71642626 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -36,6 +36,8 @@ export interface IInstallOptions { audit?: boolean; noAnalytics?: boolean; analytics?: boolean; + noPrResolver?: boolean; + prResolver?: boolean; force?: boolean; } @@ -147,6 +149,8 @@ export function performInstall( audit?: boolean; noAnalytics?: boolean; analytics?: boolean; + noPrResolver?: boolean; + prResolver?: boolean; force?: boolean; }, ): IInstallResult { @@ -245,6 +249,16 @@ export function performInstall( entries.push(analyticsEntry); } + // PR Resolver entry (if enabled and noPrResolver not set) + const disablePrResolver = options?.noPrResolver === true || options?.prResolver === false; + const installPrResolver = disablePrResolver ? false : config.prResolver.enabled; + if (installPrResolver) { + const prResolverSchedule = config.prResolver.schedule; + const prResolverLog = path.join(logDir, 'pr-resolver.log'); + const prResolverEntry = `${prResolverSchedule} ${pathPrefix}${providerEnvPrefix}${cliBinPrefix}${cronTriggerPrefix}cd ${shellQuote(projectDir)} && ${shellQuote(nightWatchBin)} resolve >> ${shellQuote(prResolverLog)} 2>&1 ${marker}`; + entries.push(prResolverEntry); + } + const existingEntries = new Set( Array.from(new Set([...getEntries(marker), ...getProjectEntries(projectDir)])), ); @@ -280,6 +294,7 @@ export function installCommand(program: Command): void { .option('--no-qa', 'Skip installing QA cron') .option('--no-audit', 'Skip installing audit cron') .option('--no-analytics', 'Skip installing analytics cron') + .option('--no-pr-resolver', 'Skip installing PR resolver cron') .option('-f, --force', 'Replace existing cron entries for this project') .action(async (options: IInstallOptions) => { try { @@ -409,6 +424,21 @@ export function installCommand(program: Command): void { entries.push(analyticsEntry); } + // Determine if PR resolver should be installed + const disablePrResolver = + options.noPrResolver === true || + (options as Record).prResolver === false; + const installPrResolver = disablePrResolver ? false : config.prResolver.enabled; + + // PR Resolver entry (if enabled) + let prResolverLog: string | undefined; + if (installPrResolver) { + prResolverLog = path.join(logDir, 'pr-resolver.log'); + const prResolverSchedule = config.prResolver.schedule; + const prResolverEntry = `${prResolverSchedule} ${pathPrefix}${providerEnvPrefix}${cliBinPrefix}${cronTriggerPrefix}cd ${shellQuote(projectDir)} && ${shellQuote(nightWatchBin)} resolve >> ${shellQuote(prResolverLog)} 2>&1 ${marker}`; + entries.push(prResolverEntry); + } + // Add all entries const existingEntrySet = new Set(existingEntries); const currentCrontab = readCrontab(); @@ -443,6 +473,9 @@ export function installCommand(program: Command): void { if (installAnalytics && analyticsLog) { dim(` Analytics: ${analyticsLog}`); } + if (installPrResolver && prResolverLog) { + dim(` PR Resolver: ${prResolverLog}`); + } console.log(); dim('To uninstall, run: night-watch uninstall'); dim('To check status, run: night-watch status'); diff --git a/packages/cli/src/commands/qa.ts b/packages/cli/src/commands/qa.ts index aefdd241..baf14ee1 100644 --- a/packages/cli/src/commands/qa.ts +++ b/packages/cli/src/commands/qa.ts @@ -100,6 +100,7 @@ export function buildEnvVars( // QA-specific settings env.NW_QA_SKIP_LABEL = config.qa.skipLabel; + env.NW_QA_VALIDATED_LABEL = config.qa.validatedLabel; env.NW_QA_ARTIFACTS = config.qa.artifacts; env.NW_QA_AUTO_INSTALL_PLAYWRIGHT = config.qa.autoInstallPlaywright ? '1' : '0'; env.NW_CLAUDE_MODEL_ID = @@ -190,6 +191,7 @@ export function qaCommand(program: Command): void { config.qa.branchPatterns.length > 0 ? config.qa.branchPatterns : config.branchPatterns; configTable.push(['Branch Patterns', branchPatterns.join(', ')]); configTable.push(['Skip Label', config.qa.skipLabel]); + configTable.push(['Validated Label', config.qa.validatedLabel]); configTable.push(['Artifacts', config.qa.artifacts]); configTable.push([ 'Auto-install Playwright', diff --git a/packages/cli/src/commands/queue.ts b/packages/cli/src/commands/queue.ts index f01873c0..e40a2df0 100644 --- a/packages/cli/src/commands/queue.ts +++ b/packages/cli/src/commands/queue.ts @@ -234,8 +234,10 @@ export function createQueueCommand(): Command { .command('dispatch') .description('Dispatch the next pending job (used by cron scripts)') .option('--log ', 'Log file to write dispatch output') - .action((_opts: { log?: string }) => { - const entry = dispatchNextJob(loadConfig(process.cwd()).queue); + .option('--project-dir ', 'Project directory to load queue config from (defaults to cwd)') + .action((_opts: { log?: string; projectDir?: string }) => { + const configDir = _opts.projectDir ?? process.cwd(); + const entry = dispatchNextJob(loadConfig(configDir).queue); if (!entry) { logger.info('No pending jobs to dispatch'); @@ -350,8 +352,10 @@ export function createQueueCommand(): Command { queue .command('can-start') .description('Return a zero exit status when the global queue has an available slot') - .action(() => { - const queueConfig = loadConfig(process.cwd()).queue; + .option('--project-dir ', 'Project directory to load queue config from (defaults to cwd)') + .action((opts: { projectDir?: string }) => { + const configDir = opts.projectDir ?? process.cwd(); + const queueConfig = loadConfig(configDir).queue; process.exit(canStartJob(queueConfig) ? 0 : 1); }); @@ -388,7 +392,26 @@ export function createQueueCommand(): Command { * Provider identity (ANTHROPIC_BASE_URL, API keys, model ids) is always recomputed * from the queued job's own project config via buildQueuedJobEnv. */ -const QUEUE_MARKER_KEYS = new Set(['NW_DRY_RUN', 'NW_CRON_TRIGGER', 'NW_DEFAULT_BRANCH']); +const QUEUE_MARKER_KEYS = new Set([ + 'NW_DRY_RUN', + 'NW_CRON_TRIGGER', + 'NW_DEFAULT_BRANCH', + 'NW_TARGET_PR', + 'NW_REVIEWER_WORKER_MODE', + 'NW_REVIEWER_PARALLEL', + 'NW_REVIEWER_WORKER_STAGGER', + 'NW_REVIEWER_MAX_RUNTIME', + 'NW_REVIEWER_MAX_RETRIES', + 'NW_REVIEWER_RETRY_DELAY', + 'NW_REVIEWER_MAX_PRS_PER_RUN', + 'NW_MIN_REVIEW_SCORE', + 'NW_BRANCH_PATTERNS', + 'NW_PRD_DIR', + 'NW_AUTO_MERGE', + 'NW_AUTO_MERGE_METHOD', + 'NW_MAX_RUNTIME', + 'NW_QA_MAX_RUNTIME', +]); /** * Filter envJson to only pass through legitimate queue/runtime markers. diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts new file mode 100644 index 00000000..ad7c0bf2 --- /dev/null +++ b/packages/cli/src/commands/resolve.ts @@ -0,0 +1,251 @@ +/** + * Resolve command - executes the PR resolver cron script + */ + +import { Command } from 'commander'; +import { + INightWatchConfig, + createSpinner, + createTable, + dim, + executeScriptWithOutput, + getScriptPath, + header, + info, + loadConfig, + parseScriptResult, + resolveJobProvider, + sendNotifications, + error as uiError, +} from '@night-watch/core'; +import { + buildBaseEnvVars, + formatProviderDisplay, + maybeApplyCronSchedulingDelay, +} from './shared/env-builder.js'; +import { execFileSync } from 'child_process'; +import * as path from 'path'; + +/** + * Options for the resolve command + */ +export interface IResolveOptions { + dryRun: boolean; + timeout?: string; + provider?: string; +} + +/** + * Build environment variables map from config and CLI options for PR resolver + */ +export function buildEnvVars( + config: INightWatchConfig, + options: IResolveOptions, +): Record { + // Start with base env vars shared by all job types + const env = buildBaseEnvVars(config, 'pr-resolver', options.dryRun); + + // Runtime for PR resolver (uses NW_PR_RESOLVER_* variables) + env.NW_PR_RESOLVER_MAX_RUNTIME = String(config.prResolver.maxRuntime); + env.NW_PR_RESOLVER_MAX_PRS_PER_RUN = String(config.prResolver.maxPrsPerRun); + env.NW_PR_RESOLVER_PER_PR_TIMEOUT = String(config.prResolver.perPrTimeout); + env.NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION = config.prResolver.aiConflictResolution ? '1' : '0'; + env.NW_PR_RESOLVER_AI_REVIEW_RESOLUTION = config.prResolver.aiReviewResolution ? '1' : '0'; + env.NW_PR_RESOLVER_READY_LABEL = config.prResolver.readyLabel; + env.NW_PR_RESOLVER_BRANCH_PATTERNS = config.prResolver.branchPatterns.join(','); + + return env; +} + +/** + * Apply CLI flag overrides to the config for PR resolver + */ +export function applyCliOverrides( + config: INightWatchConfig, + options: IResolveOptions, +): INightWatchConfig { + const overridden = { ...config, prResolver: { ...config.prResolver } }; + + if (options.timeout) { + const timeout = parseInt(options.timeout, 10); + if (!isNaN(timeout)) { + overridden.prResolver.maxRuntime = timeout; + } + } + + if (options.provider) { + // Use _cliProviderOverride to ensure CLI flag takes precedence over jobProviders + overridden._cliProviderOverride = options.provider as INightWatchConfig['provider']; + } + + return overridden; +} + +/** + * Get open PRs with conflict status (no branch pattern filtering) + */ +function getOpenPrs(): { number: number; title: string; branch: string; mergeable: string }[] { + try { + const args = ['pr', 'list', '--state', 'open', '--json', 'number,title,headRefName,mergeable']; + + const result = execFileSync('gh', args, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const prs = JSON.parse(result.trim() || '[]'); + return prs.map( + (pr: { number: number; title: string; headRefName: string; mergeable: string }) => ({ + number: pr.number, + title: pr.title, + branch: pr.headRefName, + mergeable: pr.mergeable, + }), + ); + } catch { + // gh CLI not available or not authenticated + return []; + } +} + +/** + * Register the resolve command with the program + */ +export function resolveCommand(program: Command): void { + program + .command('resolve') + .description('Run PR conflict resolver now') + .option('--dry-run', 'Show what would be executed without running') + .option('--timeout ', 'Override max runtime') + .option('--provider ', 'AI provider to use') + .action(async (options: IResolveOptions) => { + // Get the project directory (current working directory) + const projectDir = process.cwd(); + + // Load config from file and environment + let config = loadConfig(projectDir); + + // Apply CLI flag overrides + config = applyCliOverrides(config, options); + + if (!config.prResolver.enabled && !options.dryRun) { + info('PR resolver is disabled in config; skipping.'); + process.exit(0); + } + + // Build environment variables + const envVars = buildEnvVars(config, options); + + // Get the script path + const scriptPath = getScriptPath('night-watch-pr-resolver-cron.sh'); + + if (options.dryRun) { + header('Dry Run: PR Resolver'); + + // Resolve resolver-specific provider + const resolverProvider = resolveJobProvider(config, 'pr-resolver'); + + // Configuration section with table + header('Configuration'); + const configTable = createTable({ head: ['Setting', 'Value'] }); + configTable.push(['Provider', resolverProvider]); + configTable.push([ + 'Max Runtime', + `${config.prResolver.maxRuntime}s (${Math.floor(config.prResolver.maxRuntime / 60)}min)`, + ]); + configTable.push([ + 'Max PRs Per Run', + config.prResolver.maxPrsPerRun === 0 + ? 'Unlimited' + : String(config.prResolver.maxPrsPerRun), + ]); + configTable.push(['Per-PR Timeout', `${config.prResolver.perPrTimeout}s`]); + configTable.push([ + 'AI Conflict Resolution', + config.prResolver.aiConflictResolution ? 'Enabled' : 'Disabled', + ]); + configTable.push([ + 'AI Review Resolution', + config.prResolver.aiReviewResolution ? 'Enabled' : 'Disabled', + ]); + configTable.push(['Ready Label', config.prResolver.readyLabel]); + configTable.push([ + 'Branch Patterns', + config.prResolver.branchPatterns.length > 0 + ? config.prResolver.branchPatterns.join(', ') + : '(all)', + ]); + console.log(configTable.toString()); + + // Check for open PRs + header('Open PRs'); + const openPrs = getOpenPrs(); + + if (openPrs.length === 0) { + dim(' (no open PRs found)'); + } else { + for (const pr of openPrs) { + const conflictStatus = pr.mergeable === 'CONFLICTING' ? ' [CONFLICT]' : ''; + info(`#${pr.number}: ${pr.title}${conflictStatus}`); + dim(` Branch: ${pr.branch}`); + } + } + + // Environment variables + header('Environment Variables'); + for (const [key, value] of Object.entries(envVars)) { + dim(` ${key}=${value}`); + } + + // Full command that would be executed + header('Command'); + dim(` bash ${scriptPath} ${projectDir}`); + console.log(); + + process.exit(0); + } + + // Execute the script with spinner + const spinner = createSpinner('Running PR resolver...'); + spinner.start(); + + try { + await maybeApplyCronSchedulingDelay(config, 'pr-resolver', projectDir); + const { exitCode, stdout, stderr } = await executeScriptWithOutput( + scriptPath, + [projectDir], + envVars, + ); + const scriptResult = parseScriptResult(`${stdout}\n${stderr}`); + + if (exitCode === 0) { + if (scriptResult?.status === 'queued') { + spinner.succeed('PR resolver queued — another job is currently running'); + } else if (scriptResult?.status?.startsWith('skip_')) { + spinner.succeed('PR resolver completed (no PRs needed resolution)'); + } else { + spinner.succeed('PR resolver completed successfully'); + } + } else { + spinner.fail(`PR resolver exited with code ${exitCode}`); + } + + // Send notifications (fire-and-forget, failures do not affect exit code) + const notificationEvent = + exitCode === 0 ? ('pr_resolver_completed' as const) : ('pr_resolver_failed' as const); + + await sendNotifications(config, { + event: notificationEvent, + projectName: path.basename(projectDir), + exitCode, + provider: formatProviderDisplay(envVars.NW_PROVIDER_CMD, envVars.NW_PROVIDER_LABEL), + }); + + process.exit(exitCode); + } catch (err) { + spinner.fail('Failed to execute resolve command'); + uiError(`${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 657d9b92..bd22e536 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -54,6 +54,25 @@ export function shouldSendReviewNotification(scriptStatus?: string): boolean { return !scriptStatus.startsWith('skip_'); } +/** + * Review completion notifications are only valid for successful reviewer runs. + * Guard against both non-zero exits and mismatched legacy status markers. + */ +export function shouldSendReviewCompletionNotification( + exitCode: number, + scriptStatus?: string, +): boolean { + if (exitCode !== 0) { + return false; + } + + if (scriptStatus === 'failure' || scriptStatus === 'timeout') { + return false; + } + + return shouldSendReviewNotification(scriptStatus); +} + /** * Parse comma-separated PR numbers like "#12,#34" into numeric IDs. */ @@ -148,8 +167,7 @@ export function postReadyForHumanReviewComment( finalScore: number | undefined, cwd: string, ): void { - const scoreNote = - finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; + const scoreNote = finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; const body = `## ✅ Ready for Human Review\n\n` + `Night Watch has reviewed this PR${scoreNote} and found no issues requiring automated fixes.\n\n` + @@ -486,15 +504,18 @@ export function reviewCommand(program: Command): void { // Send notifications (fire-and-forget, failures do not affect exit code) if (!options.dryRun) { - const skipNotification = !shouldSendReviewNotification(scriptResult?.status); + const shouldNotifyCompletion = shouldSendReviewCompletionNotification( + exitCode, + scriptResult?.status, + ); - if (skipNotification) { - info('Skipping review notification (no actionable review result)'); + if (!shouldNotifyCompletion) { + info('Skipping review completion notification (review did not complete successfully)'); } // Enrich with PR details (graceful — null if gh fails) let fallbackPrDetails: IPrDetails | null = null; - if (!skipNotification && exitCode === 0) { + if (shouldNotifyCompletion) { const reviewedPrNumbers = parseReviewedPrNumbers(scriptResult?.data.prs); const firstReviewedPrNumber = reviewedPrNumbers[0]; if (firstReviewedPrNumber !== undefined) { @@ -506,7 +527,7 @@ export function reviewCommand(program: Command): void { } } - if (!skipNotification) { + if (shouldNotifyCompletion) { // Extract retry attempts from script result const attempts = parseRetryAttempts(scriptResult?.data.attempts); const finalScore = parseFinalReviewScore(scriptResult?.data.final_score); @@ -514,12 +535,16 @@ export function reviewCommand(program: Command): void { const reviewedPrNumbers = parseReviewedPrNumbers(scriptResult?.data.prs); const noChangesPrNumbers = parseReviewedPrNumbers(scriptResult?.data.no_changes_prs); const fallbackPrNumber = fallbackPrDetails?.number; + let prsToNotify: number[]; + if (reviewedPrNumbers.length > 0) { + prsToNotify = reviewedPrNumbers; + } else if (fallbackPrNumber !== undefined) { + prsToNotify = [fallbackPrNumber]; + } else { + prsToNotify = []; + } const notificationTargets = buildReviewNotificationTargets( - reviewedPrNumbers.length > 0 - ? reviewedPrNumbers - : fallbackPrNumber !== undefined - ? [fallbackPrNumber] - : [], + prsToNotify, noChangesPrNumbers, legacyNoChangesNeeded, ); @@ -561,7 +586,10 @@ export function reviewCommand(program: Command): void { event: reviewEvent, projectName: path.basename(projectDir), exitCode, - provider: formatProviderDisplay(envVars.NW_PROVIDER_CMD, envVars.NW_PROVIDER_LABEL), + provider: formatProviderDisplay( + envVars.NW_PROVIDER_CMD, + envVars.NW_PROVIDER_LABEL, + ), prUrl: prDetails?.url, prTitle: prDetails?.title, prBody: prDetails?.body, diff --git a/packages/cli/src/commands/slice.ts b/packages/cli/src/commands/slice.ts index 57e34aea..bdc33550 100644 --- a/packages/cli/src/commands/slice.ts +++ b/packages/cli/src/commands/slice.ts @@ -89,10 +89,10 @@ function releasePlannerLock(lockFile: string): void { } function resolvePlannerIssueColumn(config: INightWatchConfig): BoardColumnName { - return config.roadmapScanner.issueColumn === 'Ready' ? 'Ready' : 'Draft'; + return config.roadmapScanner.issueColumn === 'Draft' ? 'Draft' : 'Ready'; } -function buildPlannerIssueBody( +export function buildPlannerIssueBody( projectDir: string, config: INightWatchConfig, result: ISliceResult, @@ -110,27 +110,25 @@ function buildPlannerIssueBody( const maxBodyChars = 60000; const truncated = prdContent.length > maxBodyChars; - const prdPreview = truncated - ? `${prdContent.slice(0, maxBodyChars)}\n\n...[truncated]` - : prdContent; - - const sourceLines = sourceItem - ? [ - `- Source section: ${sourceItem.section}`, - `- Source item: ${sourceItem.title}`, - sourceItem.description ? `- Source summary: ${sourceItem.description}` : '', - ].filter((line) => line.length > 0) - : []; + const prdBody = truncated ? `${prdContent.slice(0, maxBodyChars)}\n\n...[truncated]` : prdContent; + + const metaLines: string[] = [`- PRD file: \`${relativePrdPath}\``]; + if (sourceItem) { + metaLines.push(`- Source section: ${sourceItem.section}`); + if (sourceItem.description) { + metaLines.push(`- Source summary: ${sourceItem.description}`); + } + } return [ - '## Planner Generated PRD', + prdBody, '', - `- PRD file: \`${relativePrdPath}\``, - ...sourceLines, + '
    ', + 'Source metadata', '', - '---', + ...metaLines, '', - prdPreview, + '
    ', ].join('\n'); } @@ -153,9 +151,12 @@ export async function createPlannerIssue( return { created: false, skippedReason: 'board-not-configured' }; } + const issueTitle = `PRD: ${result.item.title}`; + const normalizeTitle = (t: string) => t.replace(/^PRD:\s*/i, '').trim().toLowerCase(); + const existingIssues = await provider.getAllIssues(); const existing = existingIssues.find( - (issue) => issue.title.trim().toLowerCase() === result.item!.title.trim().toLowerCase(), + (issue) => normalizeTitle(issue.title) === normalizeTitle(result.item!.title), ); if (existing) { return { @@ -167,7 +168,7 @@ export async function createPlannerIssue( } const issue = await provider.createIssue({ - title: result.item.title, + title: issueTitle, body: buildPlannerIssueBody(projectDir, config, result), column: resolvePlannerIssueColumn(config), }); diff --git a/packages/cli/src/commands/uninstall.ts b/packages/cli/src/commands/uninstall.ts index 6e4f6acc..08e8ce09 100644 --- a/packages/cli/src/commands/uninstall.ts +++ b/packages/cli/src/commands/uninstall.ts @@ -55,7 +55,13 @@ export function performUninstall( if (!options?.keepLogs) { const logDir = path.join(projectDir, 'logs'); if (fs.existsSync(logDir)) { - const logFiles = ['executor.log', 'reviewer.log', 'slicer.log', 'audit.log']; + const logFiles = [ + 'executor.log', + 'reviewer.log', + 'slicer.log', + 'audit.log', + 'pr-resolver.log', + ]; logFiles.forEach((logFile) => { const logPath = path.join(logDir, logFile); if (fs.existsSync(logPath)) { @@ -122,7 +128,13 @@ export function uninstallCommand(program: Command): void { if (!options.keepLogs) { const logDir = path.join(projectDir, 'logs'); if (fs.existsSync(logDir)) { - const logFiles = ['executor.log', 'reviewer.log', 'slicer.log', 'audit.log']; + const logFiles = [ + 'executor.log', + 'reviewer.log', + 'slicer.log', + 'audit.log', + 'pr-resolver.log', + ]; let logsRemoved = 0; logFiles.forEach((logFile) => { diff --git a/packages/core/src/__tests__/board/labels.test.ts b/packages/core/src/__tests__/board/labels.test.ts new file mode 100644 index 00000000..e4429b19 --- /dev/null +++ b/packages/core/src/__tests__/board/labels.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for board label taxonomy + */ + +import { describe, it, expect } from 'vitest'; +import { + NIGHT_WATCH_LABELS, + PRIORITY_LABELS, + CATEGORY_LABELS, + HORIZON_LABELS, + isValidPriority, + isValidCategory, + isValidHorizon, +} from '../../board/labels.js'; + +describe('NIGHT_WATCH_LABELS', () => { + it('includes e2e-validated label', () => { + const names = NIGHT_WATCH_LABELS.map((l) => l.name); + expect(names).toContain('e2e-validated'); + }); + + it('e2e-validated has correct description and green color', () => { + const label = NIGHT_WATCH_LABELS.find((l) => l.name === 'e2e-validated'); + expect(label).toBeDefined(); + expect(label!.color).toBe('0e8a16'); + expect(label!.description).toBe( + 'PR acceptance requirements validated by e2e/integration tests', + ); + }); + + it('includes all priority labels', () => { + const names = NIGHT_WATCH_LABELS.map((l) => l.name); + for (const p of PRIORITY_LABELS) { + expect(names).toContain(p); + } + }); + + it('includes all category labels', () => { + const names = NIGHT_WATCH_LABELS.map((l) => l.name); + for (const c of CATEGORY_LABELS) { + expect(names).toContain(c); + } + }); + + it('includes all horizon labels', () => { + const names = NIGHT_WATCH_LABELS.map((l) => l.name); + for (const h of HORIZON_LABELS) { + expect(names).toContain(h); + } + }); + + it('each label has required fields', () => { + for (const label of NIGHT_WATCH_LABELS) { + expect(typeof label.name).toBe('string'); + expect(label.name.length).toBeGreaterThan(0); + expect(typeof label.description).toBe('string'); + expect(typeof label.color).toBe('string'); + expect(label.color).toMatch(/^[0-9a-f]{6}$/i); + } + }); +}); + +describe('isValidPriority', () => { + it('returns true for valid priority labels', () => { + expect(isValidPriority('P0')).toBe(true); + expect(isValidPriority('P1')).toBe(true); + expect(isValidPriority('P2')).toBe(true); + }); + + it('returns false for invalid labels', () => { + expect(isValidPriority('P3')).toBe(false); + expect(isValidPriority('e2e-validated')).toBe(false); + expect(isValidPriority('')).toBe(false); + }); +}); + +describe('isValidCategory', () => { + it('returns true for valid category labels', () => { + expect(isValidCategory('reliability')).toBe(true); + expect(isValidCategory('quality')).toBe(true); + expect(isValidCategory('product')).toBe(true); + }); + + it('returns false for invalid labels', () => { + expect(isValidCategory('e2e-validated')).toBe(false); + expect(isValidCategory('P0')).toBe(false); + }); +}); + +describe('isValidHorizon', () => { + it('returns true for valid horizon labels', () => { + expect(isValidHorizon('short-term')).toBe(true); + expect(isValidHorizon('medium-term')).toBe(true); + expect(isValidHorizon('long-term')).toBe(true); + }); + + it('returns false for invalid labels', () => { + expect(isValidHorizon('e2e-validated')).toBe(false); + expect(isValidHorizon('immediate')).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 2dfc92c1..7ce16807 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -877,10 +877,10 @@ describe('config', () => { expect(config.roadmapScanner.slicerMaxRuntime).toBe(600); }); - it('should default planner issueColumn to Draft', () => { + it('should default planner issueColumn to Ready', () => { const config = loadConfig(tempDir); - expect(config.roadmapScanner.issueColumn).toBe('Draft'); + expect(config.roadmapScanner.issueColumn).toBe('Ready'); }); it('should default planner priorityMode to roadmap-first', () => { @@ -1928,7 +1928,7 @@ describe('config', () => { expect(config.roadmapScanner.autoScanInterval).toBe(300); expect(config.roadmapScanner.roadmapPath).toBe('ROADMAP.md'); expect(config.roadmapScanner.priorityMode).toBe('roadmap-first'); - expect(config.roadmapScanner.issueColumn).toBe('Draft'); + expect(config.roadmapScanner.issueColumn).toBe('Ready'); }); it('jobProviders from file replaces default (replace semantics)', () => { diff --git a/packages/core/src/__tests__/jobs/job-registry.test.ts b/packages/core/src/__tests__/jobs/job-registry.test.ts index 857a4ae0..7b78a7b0 100644 --- a/packages/core/src/__tests__/jobs/job-registry.test.ts +++ b/packages/core/src/__tests__/jobs/job-registry.test.ts @@ -20,11 +20,11 @@ import { import { VALID_JOB_TYPES, DEFAULT_QUEUE_PRIORITY, LOG_FILE_NAMES } from '../../constants.js'; describe('JOB_REGISTRY', () => { - it('should define all 6 job types', () => { - expect(JOB_REGISTRY).toHaveLength(6); + it('should define all 7 job types', () => { + expect(JOB_REGISTRY).toHaveLength(7); }); - it('should include executor, reviewer, qa, audit, slicer, analytics', () => { + it('should include executor, reviewer, qa, audit, slicer, analytics, pr-resolver', () => { const ids = JOB_REGISTRY.map((j) => j.id); expect(ids).toContain('executor'); expect(ids).toContain('reviewer'); @@ -32,6 +32,11 @@ describe('JOB_REGISTRY', () => { expect(ids).toContain('audit'); expect(ids).toContain('slicer'); expect(ids).toContain('analytics'); + expect(ids).toContain('pr-resolver'); + }); + + it('should include pr-resolver in job registry', () => { + expect(getJobDef('pr-resolver')).toBeDefined(); }); it('each job definition has required fields', () => { @@ -66,6 +71,23 @@ describe('getJobDef', () => { expect(def!.name).toBe('QA'); }); + it('qa job has validatedLabel extra field', () => { + const def = getJobDef('qa'); + expect(def).toBeDefined(); + expect(def!.extraFields).toContainEqual( + expect.objectContaining({ name: 'validatedLabel' }), + ); + }); + + it('qa job validatedLabel extra field has correct defaults', () => { + const def = getJobDef('qa'); + expect(def).toBeDefined(); + const field = def!.extraFields?.find((f) => f.name === 'validatedLabel'); + expect(field).toBeDefined(); + expect(field!.type).toBe('string'); + expect(field!.defaultValue).toBe('e2e-validated'); + }); + it('returns correct definition for slicer', () => { const def = getJobDef('slicer'); expect(def).toBeDefined(); @@ -107,15 +129,16 @@ describe('getJobDefByLogName', () => { }); describe('getValidJobTypes', () => { - it('returns all 6 job types', () => { + it('returns all 7 job types', () => { const types = getValidJobTypes(); - expect(types).toHaveLength(6); + expect(types).toHaveLength(7); expect(types).toContain('executor'); expect(types).toContain('reviewer'); expect(types).toContain('qa'); expect(types).toContain('audit'); expect(types).toContain('slicer'); expect(types).toContain('analytics'); + expect(types).toContain('pr-resolver'); }); }); @@ -128,6 +151,7 @@ describe('getDefaultQueuePriority', () => { expect(typeof priority.audit).toBe('number'); expect(typeof priority.slicer).toBe('number'); expect(typeof priority.analytics).toBe('number'); + expect(typeof priority['pr-resolver']).toBe('number'); }); it('executor has highest priority', () => { @@ -135,6 +159,12 @@ describe('getDefaultQueuePriority', () => { expect(priority.executor).toBeGreaterThan(priority.reviewer); expect(priority.reviewer).toBeGreaterThan(priority.qa); }); + + it('pr-resolver has priority between reviewer and slicer', () => { + const priority = getDefaultQueuePriority(); + expect(priority['pr-resolver']).toBeGreaterThan(priority.slicer); + expect(priority['pr-resolver']).toBeLessThan(priority.reviewer); + }); }); describe('getLogFileNames', () => { @@ -288,6 +318,50 @@ describe('normalizeJobConfig', () => { const result = normalizeJobConfig({ targetColumn: 'NotAColumn' }, analyticsDef); expect(result.targetColumn).toBe('Draft'); }); + + it('pr-resolver has correct defaults', () => { + const def = getJobDef('pr-resolver')!; + expect(def.defaultConfig.schedule).toBe('15 6,14,22 * * *'); + expect(def.defaultConfig.maxRuntime).toBe(3600); + expect(def.queuePriority).toBe(35); + }); + + it('normalizeJobConfig handles pr-resolver extra fields with defaults', () => { + const def = getJobDef('pr-resolver')!; + const result = normalizeJobConfig({}, def); + expect(result.enabled).toBe(true); + expect(result.schedule).toBe('15 6,14,22 * * *'); + expect(result.maxRuntime).toBe(3600); + expect(result.branchPatterns).toEqual([]); + expect(result.maxPrsPerRun).toBe(0); + expect(result.perPrTimeout).toBe(600); + expect(result.aiConflictResolution).toBe(true); + expect(result.aiReviewResolution).toBe(false); + expect(result.readyLabel).toBe('ready-to-merge'); + }); + + it('normalizeJobConfig handles pr-resolver extra fields with custom values', () => { + const def = getJobDef('pr-resolver')!; + const result = normalizeJobConfig( + { + enabled: false, + branchPatterns: ['feat/', 'fix/'], + maxPrsPerRun: 5, + perPrTimeout: 300, + aiConflictResolution: false, + aiReviewResolution: true, + readyLabel: 'merge-ready', + }, + def, + ); + expect(result.enabled).toBe(false); + expect(result.branchPatterns).toEqual(['feat/', 'fix/']); + expect(result.maxPrsPerRun).toBe(5); + expect(result.perPrTimeout).toBe(300); + expect(result.aiConflictResolution).toBe(false); + expect(result.aiReviewResolution).toBe(true); + expect(result.readyLabel).toBe('merge-ready'); + }); }); describe('buildJobEnvOverrides', () => { diff --git a/packages/core/src/__tests__/scheduling.test.ts b/packages/core/src/__tests__/scheduling.test.ts index 949b4409..82252b9a 100644 --- a/packages/core/src/__tests__/scheduling.test.ts +++ b/packages/core/src/__tests__/scheduling.test.ts @@ -2,10 +2,28 @@ * Tests for the scheduling utilities */ -import { describe, it, expect } from 'vitest'; -import { isJobTypeEnabled } from '../utils/scheduling.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isJobTypeEnabled, getSchedulingPlan } from '../utils/scheduling.js'; +import { loadRegistry } from '../utils/registry.js'; +import { loadConfig } from '../config.js'; import { INightWatchConfig } from '../types.js'; +vi.mock('../utils/registry.js', () => ({ + loadRegistry: vi.fn(() => []), +})); + +vi.mock('../config.js', () => ({ + loadConfig: vi.fn(), +})); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + describe('scheduling', () => { describe('isJobTypeEnabled', () => { const baseConfig: INightWatchConfig = { @@ -147,4 +165,120 @@ describe('scheduling', () => { expect(isJobTypeEnabled(baseConfig, 'unknown' as never)).toBe(true); }); }); + + describe('getSchedulingPlan — schedule equivalence filtering', () => { + const makeConfig = (overrides: Partial): INightWatchConfig => ({ + defaultBranch: 'main', + prdDir: 'docs/prds', + maxRuntime: 7200, + reviewerMaxRuntime: 1800, + branchPrefix: 'night-watch/', + branchPatterns: ['night-watch/'], + minReviewScore: 70, + maxLogSize: 10485760, + cronSchedule: '5 * * * *', + reviewerSchedule: '50 3 * * 1', + scheduleBundleId: null, + cronScheduleOffset: 0, + schedulingPriority: 3, + maxRetries: 3, + reviewerMaxRetries: 2, + reviewerRetryDelay: 30, + provider: 'claude', + executorEnabled: true, + reviewerEnabled: true, + providerEnv: {}, + notifications: { webhooks: [] }, + prdPriority: [], + roadmapScanner: { + enabled: false, + roadmapPath: 'ROADMAP.md', + autoScanInterval: 300, + slicerSchedule: '0 4 * * *', + slicerMaxRuntime: 900, + priorityMode: 'roadmap-first', + issueColumn: 'Draft', + }, + templatesDir: 'templates', + boardProvider: { enabled: false, provider: 'github' }, + autoMerge: false, + autoMergeMethod: 'squash', + fallbackOnRateLimit: true, + claudeModel: 'sonnet', + qa: { + enabled: false, + schedule: '0 3 * * *', + maxRuntime: 1800, + branchPatterns: ['night-watch/'], + artifacts: 'both', + skipLabel: 'qa-skip', + autoInstallPlaywright: true, + }, + audit: { + enabled: false, + schedule: '50 3 * * 1', + maxRuntime: 1800, + }, + analytics: { + enabled: false, + schedule: '0 6 * * 1', + maxRuntime: 900, + lookbackDays: 7, + targetColumn: 'Draft', + analysisPrompt: 'Default prompt', + }, + jobProviders: {}, + queue: { + enabled: false, + mode: 'conservative', + maxConcurrency: 1, + maxWaitTime: 3600, + priority: { + executor: 100, + reviewer: 90, + qa: 80, + audit: 10, + slicer: 70, + analytics: 10, + }, + providerBuckets: {}, + }, + ...overrides, + }); + + const CURRENT_DIR = '/projects/alpha'; + const PEER_DIR = '/projects/beta'; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('excludes peers with a different cronSchedule from balancing', () => { + const currentConfig = makeConfig({ cronSchedule: '5 * * * *' }); + const peerConfig = makeConfig({ cronSchedule: '0 */6 * * *' }); + + vi.mocked(loadRegistry).mockReturnValue([{ name: 'beta', path: PEER_DIR }]); + vi.mocked(loadConfig).mockReturnValue(peerConfig); + + const plan = getSchedulingPlan(CURRENT_DIR, currentConfig, 'executor'); + + // The peer has a different schedule so only the current project counts + expect(plan.peerCount).toBe(1); + expect(plan.balancedDelayMinutes).toBe(0); + }); + + it('includes peers with the same cronSchedule in balancing', () => { + const sharedSchedule = '5 * * * *'; + const currentConfig = makeConfig({ cronSchedule: sharedSchedule }); + const peerConfig = makeConfig({ cronSchedule: sharedSchedule }); + + vi.mocked(loadRegistry).mockReturnValue([{ name: 'beta', path: PEER_DIR }]); + vi.mocked(loadConfig).mockReturnValue(peerConfig); + + const plan = getSchedulingPlan(CURRENT_DIR, currentConfig, 'executor'); + + // Both projects share the same schedule, so peerCount should be 2 + expect(plan.peerCount).toBe(2); + }); + }); }); diff --git a/packages/core/src/__tests__/templates/slicer-prompt.test.ts b/packages/core/src/__tests__/templates/slicer-prompt.test.ts index 0bf8d335..551e1125 100644 --- a/packages/core/src/__tests__/templates/slicer-prompt.test.ts +++ b/packages/core/src/__tests__/templates/slicer-prompt.test.ts @@ -74,7 +74,7 @@ describe("slicer-prompt", () => { const result = renderSlicerPrompt(vars); - expect(result).toContain("COMPLEXITY SCORE"); + expect(result).toContain("Complexity Scoring"); expect(result).toContain("Touches 1-5 files"); expect(result).toContain("LOW"); expect(result).toContain("MEDIUM"); @@ -128,7 +128,7 @@ describe("slicer-prompt", () => { it("should load template from file", () => { const template = loadSlicerTemplate(); - expect(template).toContain("PRD Creator Agent"); + expect(template).toContain("Principal Software Architect"); expect(template).toContain("{{TITLE}}"); expect(template).toContain("{{SECTION}}"); expect(template).toContain("{{DESCRIPTION}}"); diff --git a/packages/core/src/__tests__/utils/job-queue.test.ts b/packages/core/src/__tests__/utils/job-queue.test.ts index fa80acf7..e9f81a2a 100644 --- a/packages/core/src/__tests__/utils/job-queue.test.ts +++ b/packages/core/src/__tests__/utils/job-queue.test.ts @@ -99,6 +99,23 @@ describe('enqueueJob', () => { const entry = getQueueEntry(id); expect(entry?.envJson).toEqual({ FOO: 'bar', BAZ: '1' }); }); + + it('deduplicates — returns existing id when a pending entry already exists for same project+jobType', () => { + const id1 = enqueueJob('/projects/foo', 'foo', 'executor', {}); + const id2 = enqueueJob('/projects/foo', 'foo', 'executor', {}); + expect(id2).toBe(id1); // returns existing, no new row + + const status = getQueueStatus(); + expect(status.pending.total).toBe(1); // only one row + }); + + it('allows separate entries for different job types on the same project', () => { + const id1 = enqueueJob('/projects/foo', 'foo', 'executor', {}); + const id2 = enqueueJob('/projects/foo', 'foo', 'reviewer', {}); + expect(id2).not.toBe(id1); + const status = getQueueStatus(); + expect(status.pending.total).toBe(2); + }); }); describe('getNextPendingJob', () => { diff --git a/packages/core/src/__tests__/utils/status-data.test.ts b/packages/core/src/__tests__/utils/status-data.test.ts index f4bd1a58..ddfa7873 100644 --- a/packages/core/src/__tests__/utils/status-data.test.ts +++ b/packages/core/src/__tests__/utils/status-data.test.ts @@ -657,6 +657,7 @@ describe('status-data utilities', () => { url: 'https://github.com/test/repo/pull/1', ciStatus: 'unknown', reviewScore: null, + labels: [], }); expect(result[1]).toEqual({ number: 2, @@ -665,6 +666,7 @@ describe('status-data utilities', () => { url: 'https://github.com/test/repo/pull/2', ciStatus: 'unknown', reviewScore: null, + labels: [], }); }); diff --git a/packages/core/src/__tests__/utils/summary.test.ts b/packages/core/src/__tests__/utils/summary.test.ts index 318ee137..692370f2 100644 --- a/packages/core/src/__tests__/utils/summary.test.ts +++ b/packages/core/src/__tests__/utils/summary.test.ts @@ -230,11 +230,66 @@ describe('getSummaryData', () => { it('should count mixed job statuses correctly', async () => { vi.mocked(getJobRunsAnalytics).mockReturnValue({ recentRuns: [ - { id: 1, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'success', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, - { id: 2, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, - { id: 3, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'timeout', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, - { id: 4, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'rate_limited', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, - { id: 5, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'skipped', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + { + id: 2, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + { + id: 3, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'timeout', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + { + id: 4, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'rate_limited', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + { + id: 5, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'skipped', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, ], byProviderBucket: {}, averageWaitSeconds: null, @@ -385,6 +440,7 @@ describe('getSummaryData', () => { url: 'https://github.com/test/repo/pull/42', ciStatus: 'fail', reviewScore: null, + labels: [], }, ]); @@ -394,6 +450,25 @@ describe('getSummaryData', () => { expect(ciActionItem).toContain('failing CI'); }); + it('should include action item for PRs with ready-to-merge label', async () => { + vi.mocked(collectPrInfo).mockResolvedValue([ + { + number: 7, + title: 'Ready PR', + branch: 'feat/ready', + url: 'https://github.com/test/repo/pull/7', + ciStatus: 'pass', + reviewScore: 100, + labels: ['ready-to-merge'], + }, + ]); + + const data = await getSummaryData(tempDir); + const readyActionItem = data.actionItems.find((item) => item.includes('ready-to-merge')); + expect(readyActionItem).toBeDefined(); + expect(readyActionItem).toContain('1 PR'); + }); + it('should include action item for pending queue items', async () => { vi.mocked(getJobRunsAnalytics).mockReturnValue({ recentRuns: [ @@ -469,8 +544,30 @@ describe('getSummaryData', () => { it('should use plural "jobs" for multiple items', async () => { vi.mocked(getJobRunsAnalytics).mockReturnValue({ recentRuns: [ - { id: 1, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, - { id: 2, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + { + id: 2, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, ], byProviderBucket: {}, averageWaitSeconds: null, @@ -497,6 +594,7 @@ describe('getSummaryData', () => { url: 'https://github.com/test/repo/pull/1', ciStatus: 'pass', reviewScore: 85, + labels: [], }, ]); diff --git a/packages/core/src/board/labels.ts b/packages/core/src/board/labels.ts index d96742a7..b768cd28 100644 --- a/packages/core/src/board/labels.ts +++ b/packages/core/src/board/labels.ts @@ -137,6 +137,11 @@ export const NIGHT_WATCH_LABELS: ILabelDefinition[] = [ description: 'Created by the analytics job for Amplitude findings', color: '1d76db', }, + { + name: 'e2e-validated', + description: 'PR acceptance requirements validated by e2e/integration tests', + color: '0e8a16', + }, ]; // --------------------------------------------------------------------------- diff --git a/packages/core/src/config-env.ts b/packages/core/src/config-env.ts index 6bfc2f39..3931a7d9 100644 --- a/packages/core/src/config-env.ts +++ b/packages/core/src/config-env.ts @@ -209,6 +209,23 @@ export function buildEnvOverrideConfig( } } + // pr-resolver uses camelCase key 'prResolver' in config; handled separately + const prResolverDef = getJobDef('pr-resolver'); + if (prResolverDef) { + const currentBase = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (env as any).prResolver ?? (fileConfig as any)?.prResolver ?? prResolverDef.defaultConfig; + const overrides = buildJobEnvOverrides( + prResolverDef.envPrefix, + currentBase as Record, + prResolverDef.extraFields, + ); + if (overrides) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (env as any).prResolver = overrides; + } + } + // Per-job provider overrides (NW_JOB_PROVIDER_) const jobProvidersEnv: IJobProviders = {}; for (const jobType of VALID_JOB_TYPES) { diff --git a/packages/core/src/config-normalize.ts b/packages/core/src/config-normalize.ts index ce7bd7db..1a031c31 100644 --- a/packages/core/src/config-normalize.ts +++ b/packages/core/src/config-normalize.ts @@ -257,7 +257,7 @@ export function normalizeConfig(rawConfig: Record): Partial): Partial)[_key] = { ...(base[_key] as object), diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index c1e9aba6..ca0525ca 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -9,6 +9,7 @@ import { IAuditConfig, IJobProviders, INotificationConfig, + IPrResolverConfig, IProviderPreset, IProviderScheduleOverride, IQaConfig, @@ -97,7 +98,7 @@ export const DEFAULT_ROADMAP_SCANNER: IRoadmapScannerConfig = { slicerSchedule: DEFAULT_SLICER_SCHEDULE, slicerMaxRuntime: DEFAULT_SLICER_MAX_RUNTIME, priorityMode: 'roadmap-first', - issueColumn: 'Draft', + issueColumn: 'Ready', }; // Templates Configuration @@ -123,6 +124,7 @@ export const DEFAULT_QA_MAX_RUNTIME = 3600; // 1 hour export const DEFAULT_QA_ARTIFACTS: QaArtifacts = 'both'; export const DEFAULT_QA_SKIP_LABEL = 'skip-qa'; export const DEFAULT_QA_AUTO_INSTALL_PLAYWRIGHT = true; +export const DEFAULT_QA_VALIDATED_LABEL = 'e2e-validated'; export const DEFAULT_QA: IQaConfig = { enabled: DEFAULT_QA_ENABLED, @@ -132,6 +134,7 @@ export const DEFAULT_QA: IQaConfig = { artifacts: DEFAULT_QA_ARTIFACTS, skipLabel: DEFAULT_QA_SKIP_LABEL, autoInstallPlaywright: DEFAULT_QA_AUTO_INSTALL_PLAYWRIGHT, + validatedLabel: DEFAULT_QA_VALIDATED_LABEL, }; export const QA_LOG_NAME = 'night-watch-qa'; @@ -168,9 +171,32 @@ export const DEFAULT_ANALYTICS: IAnalyticsConfig = { analysisPrompt: DEFAULT_ANALYTICS_PROMPT, }; +// PR Resolver Configuration +export const DEFAULT_PR_RESOLVER_ENABLED = true; +export const DEFAULT_PR_RESOLVER_SCHEDULE = '15 6,14,22 * * *'; +export const DEFAULT_PR_RESOLVER_MAX_RUNTIME = 3600; +export const DEFAULT_PR_RESOLVER_MAX_PRS_PER_RUN = 0; +export const DEFAULT_PR_RESOLVER_PER_PR_TIMEOUT = 600; +export const DEFAULT_PR_RESOLVER_AI_CONFLICT_RESOLUTION = true; +export const DEFAULT_PR_RESOLVER_AI_REVIEW_RESOLUTION = false; +export const DEFAULT_PR_RESOLVER_READY_LABEL = 'ready-to-merge'; + +export const DEFAULT_PR_RESOLVER: IPrResolverConfig = { + enabled: DEFAULT_PR_RESOLVER_ENABLED, + schedule: DEFAULT_PR_RESOLVER_SCHEDULE, + maxRuntime: DEFAULT_PR_RESOLVER_MAX_RUNTIME, + branchPatterns: [], + maxPrsPerRun: DEFAULT_PR_RESOLVER_MAX_PRS_PER_RUN, + perPrTimeout: DEFAULT_PR_RESOLVER_PER_PR_TIMEOUT, + aiConflictResolution: DEFAULT_PR_RESOLVER_AI_CONFLICT_RESOLUTION, + aiReviewResolution: DEFAULT_PR_RESOLVER_AI_REVIEW_RESOLUTION, + readyLabel: DEFAULT_PR_RESOLVER_READY_LABEL, +}; + export const AUDIT_LOG_NAME = 'audit'; export const PLANNER_LOG_NAME = 'slicer'; export const ANALYTICS_LOG_NAME = 'analytics'; +export const PR_RESOLVER_LOG_NAME = 'pr-resolver'; // Valid providers (backward compat - derived from built-in presets) export const VALID_PROVIDERS: Provider[] = ['claude', 'codex']; diff --git a/packages/core/src/jobs/job-registry.ts b/packages/core/src/jobs/job-registry.ts index 73b97236..8bcfb0ac 100644 --- a/packages/core/src/jobs/job-registry.ts +++ b/packages/core/src/jobs/job-registry.ts @@ -93,6 +93,43 @@ export const JOB_REGISTRY: IJobDefinition[] = [ maxRuntime: 3600, }, }, + { + id: 'pr-resolver', + name: 'PR Conflict Solver', + description: + 'Resolves merge conflicts via AI rebase; optionally addresses review comments and labels PRs ready-to-merge', + cliCommand: 'resolve', + logName: 'pr-resolver', + lockSuffix: '-pr-resolver.lock', + queuePriority: 35, + envPrefix: 'NW_PR_RESOLVER', + extraFields: [ + { name: 'branchPatterns', type: 'string[]', defaultValue: [] }, + { name: 'maxPrsPerRun', type: 'number', defaultValue: 0 }, + { name: 'perPrTimeout', type: 'number', defaultValue: 600 }, + { name: 'aiConflictResolution', type: 'boolean', defaultValue: true }, + { name: 'aiReviewResolution', type: 'boolean', defaultValue: false }, + { name: 'readyLabel', type: 'string', defaultValue: 'ready-to-merge' }, + ], + defaultConfig: { + enabled: true, + schedule: '15 6,14,22 * * *', + maxRuntime: 3600, + branchPatterns: [], + maxPrsPerRun: 0, + perPrTimeout: 600, + aiConflictResolution: true, + aiReviewResolution: false, + readyLabel: 'ready-to-merge', + } as IBaseJobConfig & { + branchPatterns: string[]; + maxPrsPerRun: number; + perPrTimeout: number; + aiConflictResolution: boolean; + aiReviewResolution: boolean; + readyLabel: string; + }, + }, { id: 'slicer', name: 'Slicer', @@ -127,6 +164,7 @@ export const JOB_REGISTRY: IJobDefinition[] = [ }, { name: 'skipLabel', type: 'string', defaultValue: 'skip-qa' }, { name: 'autoInstallPlaywright', type: 'boolean', defaultValue: true }, + { name: 'validatedLabel', type: 'string', defaultValue: 'e2e-validated' }, ], defaultConfig: { enabled: true, @@ -136,11 +174,13 @@ export const JOB_REGISTRY: IJobDefinition[] = [ artifacts: 'both', skipLabel: 'skip-qa', autoInstallPlaywright: true, + validatedLabel: 'e2e-validated', } as IBaseJobConfig & { branchPatterns: string[]; artifacts: string; skipLabel: string; autoInstallPlaywright: boolean; + validatedLabel: string; }, }, { diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts index 63a1abbe..c2d371a5 100644 --- a/packages/core/src/shared/types.ts +++ b/packages/core/src/shared/types.ts @@ -227,7 +227,7 @@ export interface INightWatchConfig { providerScheduleOverrides?: IProviderScheduleOverride[]; } -export type QueueMode = 'conservative' | 'provider-aware'; +export type QueueMode = 'conservative' | 'provider-aware' | 'auto'; export interface IProviderBucketConfig { maxConcurrency: number; @@ -281,6 +281,7 @@ export interface IPrInfo { url: string; ciStatus: 'pass' | 'fail' | 'pending' | 'unknown'; reviewScore: number | null; + labels: string[]; } // ==================== Log Info ==================== diff --git a/packages/core/src/templates/slicer-prompt.ts b/packages/core/src/templates/slicer-prompt.ts index 6fcfa740..77f3d852 100644 --- a/packages/core/src/templates/slicer-prompt.ts +++ b/packages/core/src/templates/slicer-prompt.ts @@ -32,10 +32,11 @@ export interface ISlicerPromptVars { /** * The default slicer prompt template. * This is used if the template file cannot be read. + * Keep in sync with templates/slicer.md. */ -const DEFAULT_SLICER_TEMPLATE = `You are a **PRD Creator Agent**. Your job: analyze the codebase and write a complete Product Requirements Document (PRD) for a feature. +const DEFAULT_SLICER_TEMPLATE = `You are a **Principal Software Architect**. Your job: analyze the codebase and write a complete Product Requirements Document (PRD) for a feature. The PRD will be used directly as a GitHub issue body, so it must be self-contained and immediately actionable by an engineer. -When this activates: \`PRD Creator: Initializing\` +When this activates: \`Planning Mode: Principal Architect\` --- @@ -56,22 +57,16 @@ The PRD directory is: \`{{PRD_DIR}}\` ## Your Task -0. **Load Planner Skill** - Read and apply \`.claude/skills/prd-creator/SKILL.md\` before writing the PRD. If unavailable, continue with this template. - -1. **Explore the Codebase** - Read relevant existing files to understand the project structure, patterns, and conventions. - -2. **Assess Complexity** - Score the complexity using the rubric and determine whether this is LOW, MEDIUM, or HIGH complexity. - -3. **Write a Complete PRD** - Create a full PRD following the prd-creator template structure with Context, Solution, Phases, Tests, and Acceptance Criteria. - -4. **Write the PRD File** - Use the Write tool to create the PRD file at the exact path specified in \`{{OUTPUT_FILE_PATH}}\`. +1. **Explore the Codebase** — Read relevant existing files to understand structure, patterns, and conventions. +2. **Assess Complexity** — Score using the rubric below and determine LOW / MEDIUM / HIGH. +3. **Write a Complete PRD** — Follow the exact template structure below. Every section must be filled with concrete information. +4. **Write the PRD File** — Use the Write tool to create the PRD file at \`{{OUTPUT_FILE_PATH}}\`. --- ## Complexity Scoring \`\`\` -COMPLEXITY SCORE (sum all that apply): +1 Touches 1-5 files +2 Touches 6-10 files +3 Touches 10+ files @@ -83,7 +78,7 @@ COMPLEXITY SCORE (sum all that apply): | Score | Level | Template Mode | | ----- | ------ | ----------------------------------------------- | -| 1-3 | LOW | Minimal (skip sections marked with MEDIUM/HIGH) | +| 1-3 | LOW | Minimal (skip sections marked MEDIUM/HIGH) | | 4-6 | MEDIUM | Standard (all sections) | | 7+ | HIGH | Full + mandatory checkpoints every phase | \`\`\` @@ -92,25 +87,77 @@ COMPLEXITY SCORE (sum all that apply): ## PRD Template Structure -Your PRD MUST follow this exact structure with these sections: -1. **Context** - Problem, files analyzed, current behavior, integration points -2. **Solution** - Approach, architecture diagram, key decisions, data changes -3. **Sequence Flow** (MEDIUM/HIGH) - Mermaid sequence diagram -4. **Execution Phases** - Concrete phases with files, implementation steps, and tests -5. **Acceptance Criteria** - Checklist of completion requirements +Your PRD MUST use this structure. Replace every [bracketed placeholder] with real content. + +# PRD: [Title] + +**Complexity: [SCORE] → [LEVEL] mode** + +## 1. Context + +**Problem:** [1-2 sentences] + +**Files Analyzed:** +- \`path/to/file.ts\` — [what you found] + +**Current Behavior:** +- [3-5 bullets] + +### Integration Points +- Entry point: [cron / CLI / event / route] +- Caller file: [file invoking new code] +- User flow: User does X → triggers Y → result Z + +## 2. Solution + +**Approach:** +- [3-5 bullets] + +**Key Decisions:** [library choices, error handling, reused utilities] + +**Data Changes:** [schema changes, or "None"] + +## 3. Sequence Flow (MEDIUM/HIGH only) + +[mermaid sequenceDiagram] + +## 4. Execution Phases + +### Phase N: [Name] — [User-visible outcome] + +**Files (max 5):** +- \`src/path/file.ts\` — [what changes] + +**Implementation:** +- [ ] Step 1 + +**Tests Required:** +| Test File | Test Name | Assertion | +|-----------|-----------|-----------| +| \`src/__tests__/feature.test.ts\` | \`should X when Y\` | \`expect(r).toBe(Z)\` | + +**Checkpoint:** Run \`yarn verify\` and related tests after this phase. + +## 5. Acceptance Criteria + +- [ ] All phases complete +- [ ] All tests pass +- [ ] \`yarn verify\` passes +- [ ] Feature is reachable (not orphaned code) --- ## Critical Instructions -1. **Read all relevant existing files BEFORE writing any code** -2. **Follow existing patterns in the codebase** -3. **Write the PRD with concrete file paths and implementation details** -4. **Include specific test names and assertions** -5. **Use the Write tool to create the PRD file at \`{{OUTPUT_FILE_PATH}}\`** -6. **The PRD must be complete and actionable - no TODO placeholders** +1. Read all relevant files BEFORE writing the PRD +2. Follow existing patterns — use \`@/*\` path aliases, match naming conventions +3. Include concrete file paths and implementation steps +4. Include specific test names and assertions +5. Use the Write tool to create the file at \`{{OUTPUT_FILE_PATH}}\` +6. No placeholder text in the final PRD +7. The PRD is the GitHub issue body — make it self-contained -DO NOT leave placeholder text like "[Name]" or "[description]" in the final PRD. +DO NOT leave [bracketed placeholder] text in the output. DO NOT skip any sections. DO NOT forget to write the file. `; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3ba9f1d..b11e3db4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -44,7 +44,15 @@ export type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; /** * Job types that can have per-job provider configuration */ -export type JobType = 'executor' | 'reviewer' | 'qa' | 'audit' | 'slicer' | 'analytics' | 'planner'; +export type JobType = + | 'executor' + | 'reviewer' + | 'qa' + | 'audit' + | 'slicer' + | 'analytics' + | 'planner' + | 'pr-resolver'; /** * Time-based provider schedule override. @@ -96,6 +104,7 @@ export interface IJobProviders { slicer?: Provider; analytics?: Provider; planner?: Provider; + 'pr-resolver'?: Provider; } /** @@ -279,6 +288,9 @@ export interface INightWatchConfig { /** Analytics job configuration (Amplitude integration) */ analytics: IAnalyticsConfig; + /** PR conflict resolver configuration */ + prResolver: IPrResolverConfig; + /** Per-job provider configuration */ jobProviders: IJobProviders; @@ -318,6 +330,8 @@ export interface IQaConfig { skipLabel: string; /** Auto-install Playwright if missing during QA run */ autoInstallPlaywright: boolean; + /** GitHub label to apply when e2e tests pass (proves acceptance requirements met) */ + validatedLabel: string; } export interface IAuditConfig { @@ -344,6 +358,27 @@ export interface IAnalyticsConfig { analysisPrompt: string; } +export interface IPrResolverConfig { + /** Whether the PR resolver is enabled */ + enabled: boolean; + /** Cron schedule for PR resolver execution */ + schedule: string; + /** Maximum runtime in seconds for the PR resolver */ + maxRuntime: number; + /** Branch patterns to filter which PRs to process (empty = all) */ + branchPatterns: string[]; + /** Maximum number of PRs to process per run (0 = unlimited) */ + maxPrsPerRun: number; + /** Per-PR timeout in seconds */ + perPrTimeout: number; + /** Whether to use AI to resolve merge conflicts */ + aiConflictResolution: boolean; + /** Whether to use AI to address review comments */ + aiReviewResolution: boolean; + /** GitHub label to apply to conflict-free PRs */ + readyLabel: string; +} + export type WebhookType = 'slack' | 'discord' | 'telegram'; export type NotificationEvent = | 'run_started' @@ -355,7 +390,10 @@ export type NotificationEvent = | 'review_ready_for_human' | 'pr_auto_merged' | 'rate_limit_fallback' - | 'qa_completed'; + | 'qa_completed' + | 'pr_resolver_completed' + | 'pr_resolver_conflict_resolved' + | 'pr_resolver_failed'; /** * Git merge methods for auto-merge diff --git a/packages/core/src/utils/job-queue.ts b/packages/core/src/utils/job-queue.ts index c98b5593..8c0d176c 100644 --- a/packages/core/src/utils/job-queue.ts +++ b/packages/core/src/utils/job-queue.ts @@ -31,6 +31,7 @@ import { checkLockFile, executorLockPath, plannerLockPath, + prResolverLockPath, qaLockPath, reviewerLockPath, } from './status-data.js'; @@ -97,6 +98,8 @@ function getLockPathForJob(projectPath: string, jobType: JobType): string { return plannerLockPath(projectPath); case 'analytics': return analyticsLockPath(projectPath); + case 'pr-resolver': + return prResolverLockPath(projectPath); } } @@ -253,6 +256,22 @@ export function enqueueJob( ): number { const db = openDb(); try { + // Deduplicate: skip if an active entry already exists for this project+job + const existing = db + .prepare( + `SELECT id FROM job_queue WHERE project_path = ? AND job_type = ? AND status IN ('pending', 'dispatched', 'running')`, + ) + .get(projectPath, jobType) as { id: number } | undefined; + + if (existing) { + logger.info('Skipping duplicate enqueue — active entry already exists', { + id: existing.id, + jobType, + project: projectName, + }); + return existing.id; + } + const priority = getJobPriority(jobType, config); const now = Math.floor(Date.now() / 1000); const envJson = JSON.stringify(envVars); diff --git a/packages/core/src/utils/notify.ts b/packages/core/src/utils/notify.ts index 9961e0e2..bb11e808 100644 --- a/packages/core/src/utils/notify.ts +++ b/packages/core/src/utils/notify.ts @@ -64,6 +64,12 @@ export function getEventEmoji(event: NotificationEvent): string { return '\uD83D\uDD00'; case 'qa_completed': return '\uD83E\uDDEA'; + case 'pr_resolver_completed': + return '\uD83D\uDD27'; + case 'pr_resolver_conflict_resolved': + return '\u2705'; + case 'pr_resolver_failed': + return '\u274C'; } } @@ -92,6 +98,12 @@ export function getEventTitle(event: NotificationEvent): string { return 'PR Auto-Merged'; case 'qa_completed': return 'QA Completed'; + case 'pr_resolver_completed': + return 'PR Resolver Completed'; + case 'pr_resolver_conflict_resolved': + return 'PR Conflict Resolved'; + case 'pr_resolver_failed': + return 'PR Resolver Failed'; } } @@ -120,6 +132,12 @@ export function getEventColor(event: NotificationEvent): number { return 0x9b59b6; case 'qa_completed': return 0x2ecc71; + case 'pr_resolver_completed': + return 0x00c853; + case 'pr_resolver_conflict_resolved': + return 0x00ff00; + case 'pr_resolver_failed': + return 0xff0000; } } diff --git a/packages/core/src/utils/scheduling.ts b/packages/core/src/utils/scheduling.ts index f59e2450..8a2b4873 100644 --- a/packages/core/src/utils/scheduling.ts +++ b/packages/core/src/utils/scheduling.ts @@ -52,6 +52,16 @@ export function isJobTypeEnabled(config: INightWatchConfig, jobType: JobType): b } } +function getJobSchedule(config: INightWatchConfig, jobType: JobType): string { + switch (jobType) { + case 'reviewer': + return config.reviewerSchedule ?? ''; + case 'executor': + default: + return config.cronSchedule ?? ''; + } +} + function loadPeerConfig(projectPath: string): INightWatchConfig | null { if (!fs.existsSync(projectPath) || !fs.existsSync(path.join(projectPath, CONFIG_FILE_NAME))) { return null; @@ -71,12 +81,17 @@ function collectSchedulingPeers( ): ISchedulingPeer[] { const peers = new Map(); const currentPath = path.resolve(currentProjectDir); + const currentSchedule = getJobSchedule(currentConfig, jobType); const addPeer = (projectPath: string, config: INightWatchConfig): void => { const resolvedPath = path.resolve(projectPath); if (!isJobTypeEnabled(config, jobType)) { return; } + // Only balance with peers that share the same cron schedule + if (getJobSchedule(config, jobType) !== currentSchedule) { + return; + } peers.set(resolvedPath, { path: resolvedPath, diff --git a/packages/core/src/utils/status-data.ts b/packages/core/src/utils/status-data.ts index a92ef9fd..4b33927a 100644 --- a/packages/core/src/utils/status-data.ts +++ b/packages/core/src/utils/status-data.ts @@ -52,6 +52,7 @@ export interface IPrInfo { url: string; ciStatus: 'pass' | 'fail' | 'pending' | 'unknown'; reviewScore: number | null; + labels: string[]; } /** @@ -153,6 +154,13 @@ export function analyticsLockPath(projectDir: string): string { return `${LOCK_FILE_PREFIX}analytics-${projectRuntimeKey(projectDir)}.lock`; } +/** + * Compute the lock file path for the PR resolver of a given project directory. + */ +export function prResolverLockPath(projectDir: string): string { + return `${LOCK_FILE_PREFIX}pr-resolver-${projectRuntimeKey(projectDir)}.lock`; +} + /** * Check if a process with the given PID is running */ @@ -625,7 +633,7 @@ export async function collectPrInfo( } const { stdout: output } = await execAsync( - 'gh pr list --state open --json headRefName,number,title,url,statusCheckRollup,reviewDecision', + 'gh pr list --state open --json headRefName,number,title,url,statusCheckRollup,reviewDecision,labels', { cwd: projectDir, encoding: 'utf-8', @@ -656,6 +664,7 @@ export async function collectPrInfo( contexts?: unknown[]; }> | null; reviewDecision?: string | null; + labels?: Array<{ name: string }>; } const prs: IGhPr[] = JSON.parse(trimmed); @@ -679,6 +688,7 @@ export async function collectPrInfo( url: pr.url, ciStatus: deriveCiStatus(pr.statusCheckRollup), reviewScore: deriveReviewScore(pr.reviewDecision), + labels: (pr.labels ?? []).map((l) => l.name), }; }); } catch { diff --git a/packages/core/src/utils/summary.ts b/packages/core/src/utils/summary.ts index b14ee3fb..ec12681a 100644 --- a/packages/core/src/utils/summary.ts +++ b/packages/core/src/utils/summary.ts @@ -112,6 +112,14 @@ function buildActionItems( items.push(`PR #${pr.number} has failing CI — check ${pr.url}`); } + // PRs marked ready-to-merge + const readyToMergePrs = prs.filter((pr) => pr.labels.includes('ready-to-merge')); + if (readyToMergePrs.length > 0) { + items.push( + `${readyToMergePrs.length} PR${readyToMergePrs.length > 1 ? 's' : ''} marked ready-to-merge — review and merge`, + ); + } + // Pending queue items (informational) if (pendingItems.length > 0) { const jobTypes = [...new Set(pendingItems.map((item) => item.jobType))]; diff --git a/scripts/night-watch-helpers.sh b/scripts/night-watch-helpers.sh index 692987bc..aa59d43f 100644 --- a/scripts/night-watch-helpers.sh +++ b/scripts/night-watch-helpers.sh @@ -1158,7 +1158,7 @@ dispatch_next_queued_job() { log "QUEUE: Checking for pending jobs to dispatch" # Call CLI to dispatch next job (this handles priority, expiration, and spawning) - "${cli_bin}" queue dispatch --log "${LOG_FILE:-/dev/null}" 2>/dev/null || true + "${cli_bin}" queue dispatch --project-dir "${PROJECT_DIR:-$(pwd)}" --log "${LOG_FILE:-/dev/null}" 2>/dev/null || true } complete_queued_job() { diff --git a/scripts/night-watch-pr-resolver-cron.sh b/scripts/night-watch-pr-resolver-cron.sh new file mode 100755 index 00000000..6e145f75 --- /dev/null +++ b/scripts/night-watch-pr-resolver-cron.sh @@ -0,0 +1,402 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Night Watch PR Resolver Cron Runner (project-agnostic) +# Usage: night-watch-pr-resolver-cron.sh /path/to/project +# +# NOTE: This script expects environment variables to be set by the caller. +# The Node.js CLI will inject config values via environment variables. +# Required env vars (with defaults shown): +# NW_PR_RESOLVER_MAX_RUNTIME=3600 - Maximum runtime in seconds (1 hour) +# NW_PROVIDER_CMD=claude - AI provider CLI to use (claude, codex, etc.) +# NW_DRY_RUN=0 - Set to 1 for dry-run mode (prints diagnostics only) +# NW_PR_RESOLVER_MAX_PRS_PER_RUN=0 - Max PRs to process per run (0 = unlimited) +# NW_PR_RESOLVER_PER_PR_TIMEOUT=600 - Per-PR AI timeout in seconds +# NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION=1 - Set to 1 to use AI for conflict resolution +# NW_PR_RESOLVER_AI_REVIEW_RESOLUTION=0 - Set to 1 to also address review comments +# NW_PR_RESOLVER_READY_LABEL=ready-to-merge - Label to add when PR is conflict-free +# NW_PR_RESOLVER_BRANCH_PATTERNS= - Comma-separated branch prefixes to filter (empty = all) + +PROJECT_DIR="${1:?Usage: $0 /path/to/project}" +PROJECT_NAME=$(basename "${PROJECT_DIR}") +LOG_DIR="${PROJECT_DIR}/logs" +LOG_FILE="${LOG_DIR}/pr-resolver.log" +MAX_RUNTIME="${NW_PR_RESOLVER_MAX_RUNTIME:-3600}" # 1 hour +MAX_LOG_SIZE="524288" # 512 KB +PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}" +PROVIDER_LABEL="${NW_PROVIDER_LABEL:-}" +MAX_PRS_PER_RUN="${NW_PR_RESOLVER_MAX_PRS_PER_RUN:-0}" +PER_PR_TIMEOUT="${NW_PR_RESOLVER_PER_PR_TIMEOUT:-600}" +AI_CONFLICT_RESOLUTION="${NW_PR_RESOLVER_AI_CONFLICT_RESOLUTION:-1}" +AI_REVIEW_RESOLUTION="${NW_PR_RESOLVER_AI_REVIEW_RESOLUTION:-0}" +READY_LABEL="${NW_PR_RESOLVER_READY_LABEL:-ready-to-merge}" +BRANCH_PATTERNS_RAW="${NW_PR_RESOLVER_BRANCH_PATTERNS:-}" +SCRIPT_START_TIME=$(date +%s) + +# Normalize numeric settings to safe ranges +if ! [[ "${MAX_PRS_PER_RUN}" =~ ^[0-9]+$ ]]; then + MAX_PRS_PER_RUN="0" +fi +if ! [[ "${PER_PR_TIMEOUT}" =~ ^[0-9]+$ ]]; then + PER_PR_TIMEOUT="600" +fi +if [ "${MAX_PRS_PER_RUN}" -gt 100 ]; then + MAX_PRS_PER_RUN="100" +fi +if [ "${PER_PR_TIMEOUT}" -gt 3600 ]; then + PER_PR_TIMEOUT="3600" +fi + +mkdir -p "${LOG_DIR}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=night-watch-helpers.sh +source "${SCRIPT_DIR}/night-watch-helpers.sh" + +# Ensure provider CLI is on PATH (nvm, fnm, volta, common bin dirs) +if ! ensure_provider_on_path "${PROVIDER_CMD}"; then + echo "ERROR: Provider '${PROVIDER_CMD}' not found in PATH or common installation locations" >&2 + exit 127 +fi +PROJECT_RUNTIME_KEY=$(project_runtime_key "${PROJECT_DIR}") +PROVIDER_MODEL_DISPLAY=$(resolve_provider_model_display "${PROVIDER_CMD}" "${PROVIDER_LABEL}") +# NOTE: Lock file path must match resolverLockPath() in src/utils/status-data.ts +LOCK_FILE="/tmp/night-watch-pr-resolver-${PROJECT_RUNTIME_KEY}.lock" +SCRIPT_TYPE="pr-resolver" + +emit_result() { + local status="${1:?status required}" + local details="${2:-}" + if [ -n "${details}" ]; then + echo "NIGHT_WATCH_RESULT:${status}|${details}" + else + echo "NIGHT_WATCH_RESULT:${status}" + fi +} + +# ── Global Job Queue Gate ──────────────────────────────────────────────────── +# Atomically claim a DB slot or enqueue for later dispatch — no flock needed. +if [ "${NW_QUEUE_ENABLED:-0}" = "1" ]; then + if [ "${NW_QUEUE_DISPATCHED:-0}" = "1" ]; then + arm_global_queue_cleanup + else + claim_or_enqueue "${SCRIPT_TYPE}" "${PROJECT_DIR}" + fi +fi +# ────────────────────────────────────────────────────────────────────────────── + +# PR discovery: returns JSON array of open PRs with required fields +discover_open_prs() { + gh pr list --state open \ + --json number,title,headRefName,mergeable,isDraft,labels \ + 2>/dev/null || echo "[]" +} + +# Check if a branch matches any configured branch prefix patterns. +# Returns 0 (match/pass) or 1 (no match, skip PR). +matches_branch_patterns() { + local branch="${1}" + if [ -z "${BRANCH_PATTERNS_RAW}" ]; then + return 0 # No filter configured = match all + fi + IFS=',' read -ra patterns <<< "${BRANCH_PATTERNS_RAW}" + for pattern in "${patterns[@]}"; do + pattern="${pattern# }" # trim leading space + if [[ "${branch}" == ${pattern}* ]]; then + return 0 + fi + done + return 1 +} + +# Process a single PR: resolve conflicts and/or review comments, then label. +# Echoes "ready" if the PR ends up conflict-free, "conflicted" otherwise. +# Returns 0 on success, 1 on unrecoverable failure. +process_pr() { + local pr_number="${1:?pr_number required}" + local pr_branch="${2:?pr_branch required}" + local pr_title="${3:-}" + local worktree_dir="/tmp/nw-resolver-pr${pr_number}-$$" + + log "INFO: Processing PR #${pr_number}: ${pr_title}" "branch=${pr_branch}" + + # Inner cleanup for worktree created during this PR's processing + cleanup_pr_worktree() { + if git -C "${PROJECT_DIR}" worktree list --porcelain 2>/dev/null \ + | grep -qF "worktree ${worktree_dir}"; then + git -C "${PROJECT_DIR}" worktree remove --force "${worktree_dir}" 2>/dev/null || true + fi + rm -rf "${worktree_dir}" 2>/dev/null || true + } + + # ── Determine default branch ───────────────────────────────────────────── + local default_branch + default_branch="${NW_DEFAULT_BRANCH:-}" + if [ -z "${default_branch}" ]; then + default_branch=$(detect_default_branch "${PROJECT_DIR}") + fi + + # ── Check current mergeable status ────────────────────────────────────── + local mergeable + mergeable=$(gh pr view "${pr_number}" --json mergeable --jq '.mergeable' 2>/dev/null || echo "UNKNOWN") + + if [ "${mergeable}" = "CONFLICTING" ]; then + log "INFO: PR #${pr_number} has conflicts, attempting resolution" "branch=${pr_branch}" + + # Fetch the PR branch so we have an up-to-date ref + git -C "${PROJECT_DIR}" fetch --quiet origin "${pr_branch}" 2>/dev/null || true + + # Create an isolated worktree on the PR branch + if ! prepare_branch_worktree "${PROJECT_DIR}" "${worktree_dir}" "${pr_branch}" "${default_branch}" "${LOG_FILE}"; then + log "WARN: Failed to create worktree for PR #${pr_number}" "branch=${pr_branch}" + cleanup_pr_worktree + return 1 + fi + + local rebase_success=0 + + # Attempt a clean rebase first (no AI needed if it auto-resolves) + if git -C "${worktree_dir}" rebase "origin/${default_branch}" --quiet 2>/dev/null; then + rebase_success=1 + log "INFO: PR #${pr_number} rebased cleanly (no conflicts)" "branch=${pr_branch}" + else + # Clean up the failed rebase state + git -C "${worktree_dir}" rebase --abort 2>/dev/null || true + + if [ "${AI_CONFLICT_RESOLUTION}" = "1" ]; then + log "INFO: Invoking AI to resolve conflicts for PR #${pr_number}" "branch=${pr_branch}" + + local ai_prompt + ai_prompt="You are working in a git repository at ${worktree_dir}. \ +Branch '${pr_branch}' has merge conflicts with '${default_branch}'. \ +Please resolve the merge conflicts by: \ +1) Running: git rebase origin/${default_branch} \ +2) Resolving any conflict markers in the affected files \ +3) Staging resolved files with: git add \ +4) Continuing the rebase with: git rebase --continue \ +5) Finally pushing with: git push --force-with-lease origin ${pr_branch} \ +Work exclusively in the directory: ${worktree_dir}" + + local -a cmd_parts + mapfile -d '' -t cmd_parts < <(build_provider_cmd "${worktree_dir}" "${ai_prompt}") + + if timeout "${PER_PR_TIMEOUT}" "${cmd_parts[@]}" >> "${LOG_FILE}" 2>&1; then + rebase_success=1 + log "INFO: AI resolved conflicts for PR #${pr_number}" "branch=${pr_branch}" + else + log "WARN: AI failed to resolve conflicts for PR #${pr_number}" "branch=${pr_branch}" + cleanup_pr_worktree + return 1 + fi + else + log "WARN: Skipping PR #${pr_number} — conflicts exist and AI resolution is disabled" "branch=${pr_branch}" + cleanup_pr_worktree + return 1 + fi + fi + + if [ "${rebase_success}" = "1" ]; then + # Safety: never force-push to the default branch + if [ "${pr_branch}" = "${default_branch}" ]; then + log "WARN: Refusing to force-push to default branch ${default_branch} for PR #${pr_number}" + cleanup_pr_worktree + return 1 + fi + # Push the rebased branch (AI may have already pushed; --force-with-lease is idempotent) + git -C "${worktree_dir}" push --force-with-lease origin "${pr_branch}" >> "${LOG_FILE}" 2>&1 || { + log "WARN: Push after rebase failed for PR #${pr_number}" "branch=${pr_branch}" + } + fi + fi + + # ── Secondary: AI review comment resolution (opt-in) ──────────────────── + if [ "${AI_REVIEW_RESOLUTION}" = "1" ]; then + local unresolved_count + unresolved_count=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}/reviews" \ + --jq '[.[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0") + + if [ "${unresolved_count}" -gt "0" ]; then + log "INFO: PR #${pr_number} has ${unresolved_count} change request(s), invoking AI" "branch=${pr_branch}" + + local review_workdir="${worktree_dir}" + if [ ! -d "${review_workdir}" ]; then + review_workdir="${PROJECT_DIR}" + fi + + local review_prompt + review_prompt="You are working in the git repository at ${review_workdir}. \ +PR #${pr_number} on branch '${pr_branch}' has unresolved review comments requesting changes. \ +Please: \ +1) Run 'gh pr view ${pr_number} --comments' to read the review comments \ +2) Implement the requested changes \ +3) Commit the changes with a descriptive message \ +4) Push with: git push origin ${pr_branch} \ +Work in the directory: ${review_workdir}" + + local -a review_cmd_parts + mapfile -d '' -t review_cmd_parts < <(build_provider_cmd "${review_workdir}" "${review_prompt}") + + if timeout "${PER_PR_TIMEOUT}" "${review_cmd_parts[@]}" >> "${LOG_FILE}" 2>&1; then + log "INFO: AI addressed review comments for PR #${pr_number}" "branch=${pr_branch}" + else + log "WARN: AI failed to address review comments for PR #${pr_number}" "branch=${pr_branch}" + fi + fi + fi + + # ── Re-check mergeable status after processing ────────────────────────── + # Brief wait for GitHub to propagate the push and recompute mergeability + sleep 3 + local final_mergeable + final_mergeable=$(gh pr view "${pr_number}" --json mergeable --jq '.mergeable' 2>/dev/null || echo "UNKNOWN") + + # ── Labeling ───────────────────────────────────────────────────────────── + local result + if [ "${final_mergeable}" != "CONFLICTING" ]; then + # Ensure the ready label exists in the repo (idempotent) + gh label create "${READY_LABEL}" \ + --color "0075ca" \ + --description "PR is conflict-free and ready to merge" \ + 2>/dev/null || true + gh pr edit "${pr_number}" --add-label "${READY_LABEL}" 2>/dev/null || true + log "INFO: PR #${pr_number} marked as '${READY_LABEL}'" "branch=${pr_branch}" + result="ready" + else + gh pr edit "${pr_number}" --remove-label "${READY_LABEL}" 2>/dev/null || true + log "WARN: PR #${pr_number} still has conflicts after processing" "branch=${pr_branch}" + result="conflicted" + fi + + cleanup_pr_worktree + echo "${result}" +} + +# ── Validate provider ──────────────────────────────────────────────────────── +if ! validate_provider "${PROVIDER_CMD}"; then + echo "ERROR: Unknown provider: ${PROVIDER_CMD}" >&2 + exit 1 +fi + +rotate_log +log_separator +log "RUN-START: pr-resolver invoked project=${PROJECT_DIR} provider=${PROVIDER_CMD} dry_run=${NW_DRY_RUN:-0}" +log "CONFIG: max_runtime=${MAX_RUNTIME}s max_prs=${MAX_PRS_PER_RUN} per_pr_timeout=${PER_PR_TIMEOUT}s ai_conflict=${AI_CONFLICT_RESOLUTION} ai_review=${AI_REVIEW_RESOLUTION} ready_label=${READY_LABEL} branch_patterns=${BRANCH_PATTERNS_RAW:-}" + +if ! acquire_lock "${LOCK_FILE}"; then + emit_result "skip_locked" + exit 0 +fi + +cd "${PROJECT_DIR}" + +# ── Dry-run mode ──────────────────────────────────────────────────────────── +if [ "${NW_DRY_RUN:-0}" = "1" ]; then + echo "=== Dry Run: PR Resolver ===" + echo "Provider (model): ${PROVIDER_MODEL_DISPLAY}" + echo "Branch Patterns: ${BRANCH_PATTERNS_RAW:-}" + echo "Max PRs Per Run: ${MAX_PRS_PER_RUN}" + echo "Per-PR Timeout: ${PER_PR_TIMEOUT}s" + echo "AI Conflict Resolution: ${AI_CONFLICT_RESOLUTION}" + echo "AI Review Resolution: ${AI_REVIEW_RESOLUTION}" + echo "Ready Label: ${READY_LABEL}" + echo "Max Runtime: ${MAX_RUNTIME}s" + log "INFO: Dry run mode — exiting without processing" + emit_result "skip_dry_run" + exit 0 +fi + +send_telegram_status_message "Night Watch PR Resolver: started" "Project: ${PROJECT_NAME} +Provider (model): ${PROVIDER_MODEL_DISPLAY} +Branch patterns: ${BRANCH_PATTERNS_RAW:-all} +Action: scanning open PRs for merge conflicts." + +# ── Discover open PRs ──────────────────────────────────────────────────────── +pr_json=$(discover_open_prs) + +if [ -z "${pr_json}" ] || [ "${pr_json}" = "[]" ]; then + log "SKIP: No open PRs found" + send_telegram_status_message "Night Watch PR Resolver: nothing to do" "Project: ${PROJECT_NAME} +Provider (model): ${PROVIDER_MODEL_DISPLAY} +Result: no open PRs found." + emit_result "skip_no_open_prs" + exit 0 +fi + +pr_count=$(printf '%s' "${pr_json}" | jq 'length' 2>/dev/null || echo "0") +log "INFO: Found ${pr_count} open PR(s) to evaluate" + +# ── Main processing loop ───────────────────────────────────────────────────── +processed=0 +conflicts_resolved=0 +reviews_addressed=0 +prs_ready=0 +prs_failed=0 + +while IFS= read -r pr_line; do + [ -z "${pr_line}" ] && continue + + pr_number=$(printf '%s' "${pr_line}" | jq -r '.number') + pr_branch=$(printf '%s' "${pr_line}" | jq -r '.headRefName') + pr_title=$(printf '%s' "${pr_line}" | jq -r '.title') + is_draft=$(printf '%s' "${pr_line}" | jq -r '.isDraft') + labels=$(printf '%s' "${pr_line}" | jq -r '[.labels[].name] | join(",")') + + [ -z "${pr_number}" ] || [ -z "${pr_branch}" ] && continue + + # Skip draft PRs + if [ "${is_draft}" = "true" ]; then + log "INFO: Skipping draft PR #${pr_number}" "branch=${pr_branch}" + continue + fi + + # Skip PRs labelled skip-resolver + if [[ "${labels}" == *"skip-resolver"* ]]; then + log "INFO: Skipping PR #${pr_number} (skip-resolver label)" "branch=${pr_branch}" + continue + fi + + # Apply branch pattern filter + if ! matches_branch_patterns "${pr_branch}"; then + log "DEBUG: Skipping PR #${pr_number} — branch '${pr_branch}' does not match patterns" "patterns=${BRANCH_PATTERNS_RAW}" + continue + fi + + # Enforce max PRs per run + if [ "${MAX_PRS_PER_RUN}" -gt "0" ] && [ "${processed}" -ge "${MAX_PRS_PER_RUN}" ]; then + log "INFO: Reached max PRs per run (${MAX_PRS_PER_RUN}), stopping" + break + fi + + # Enforce global timeout + elapsed=$(( $(date +%s) - SCRIPT_START_TIME )) + if [ "${elapsed}" -ge "${MAX_RUNTIME}" ]; then + log "WARN: Global timeout reached (${MAX_RUNTIME}s), stopping early" + break + fi + + processed=$(( processed + 1 )) + + result="" + if result=$(process_pr "${pr_number}" "${pr_branch}" "${pr_title}" 2>&1); then + # process_pr echoes "ready" or "conflicted" on the last line; extract it + last_line=$(printf '%s' "${result}" | tail -1) + if [ "${last_line}" = "ready" ]; then + prs_ready=$(( prs_ready + 1 )) + conflicts_resolved=$(( conflicts_resolved + 1 )) + fi + else + prs_failed=$(( prs_failed + 1 )) + fi + +done < <(printf '%s' "${pr_json}" | jq -c '.[]') + +log "RUN-END: pr-resolver complete processed=${processed} conflicts_resolved=${conflicts_resolved} prs_ready=${prs_ready} prs_failed=${prs_failed}" + +send_telegram_status_message "Night Watch PR Resolver: completed" "Project: ${PROJECT_NAME} +Provider (model): ${PROVIDER_MODEL_DISPLAY} +PRs processed: ${processed} +Conflicts resolved: ${conflicts_resolved} +PRs marked '${READY_LABEL}': ${prs_ready} +PRs failed: ${prs_failed}" + +emit_result "success" "prs_processed=${processed}|conflicts_resolved=${conflicts_resolved}|reviews_addressed=${reviews_addressed}|prs_ready=${prs_ready}|prs_failed=${prs_failed}" diff --git a/scripts/night-watch-pr-reviewer-cron.sh b/scripts/night-watch-pr-reviewer-cron.sh index 855f351b..1da3ea73 100755 --- a/scripts/night-watch-pr-reviewer-cron.sh +++ b/scripts/night-watch-pr-reviewer-cron.sh @@ -105,7 +105,9 @@ extract_review_score_from_text() { # ── Global Job Queue Gate ──────────────────────────────────────────────────── # Atomically claim a DB slot or enqueue for later dispatch — no flock needed. if [ "${NW_QUEUE_ENABLED:-0}" = "1" ]; then - if [ "${NW_QUEUE_DISPATCHED:-0}" = "1" ]; then + if [ "${NW_QUEUE_INHERITED_SLOT:-0}" = "1" ]; then + : + elif [ "${NW_QUEUE_DISPATCHED:-0}" = "1" ]; then arm_global_queue_cleanup else claim_or_enqueue "${SCRIPT_TYPE}" "${PROJECT_DIR}" @@ -664,7 +666,11 @@ while IFS=$'\t' read -r pr_number pr_branch pr_labels; do } | awk '!seen[$0]++' ) LATEST_SCORE=$(extract_review_score_from_text "${ALL_COMMENTS}") - if [ -n "${LATEST_SCORE}" ] && [ "${LATEST_SCORE}" -lt "${MIN_REVIEW_SCORE}" ]; then + if [ -z "${LATEST_SCORE}" ]; then + log "INFO: PR #${pr_number} (${pr_branch}) has no review score yet — needs initial review" + NEEDS_WORK=1 + PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}" + elif [ "${LATEST_SCORE}" -lt "${MIN_REVIEW_SCORE}" ]; then log "INFO: PR #${pr_number} (${pr_branch}) has review score ${LATEST_SCORE}/100 (threshold: ${MIN_REVIEW_SCORE})" NEEDS_WORK=1 PRS_NEEDING_WORK="${PRS_NEEDING_WORK} #${pr_number}" @@ -675,7 +681,7 @@ done < <( ) if [ "${NEEDS_WORK}" -eq 0 ]; then - log "SKIP: All ${OPEN_PRS} open PR(s) have passing CI and review score >= ${MIN_REVIEW_SCORE} (or no score yet)" + log "SKIP: All ${OPEN_PRS} open PR(s) have passing CI and review score >= ${MIN_REVIEW_SCORE}" # ── Auto-merge eligible PRs ─────────────────────────────── if [ "${NW_AUTO_MERGE:-0}" = "1" ]; then @@ -814,6 +820,7 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED NW_TARGET_PR="${pr_number}" \ NW_REVIEWER_WORKER_MODE="1" \ NW_REVIEWER_PARALLEL="0" \ + NW_QUEUE_INHERITED_SLOT="1" \ bash "${SCRIPT_DIR}/night-watch-pr-reviewer-cron.sh" "${PROJECT_DIR}" > "${worker_output}" 2>&1 ) & @@ -922,7 +929,7 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED cleanup_reviewer_worktrees emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}" "${MAX_WORKER_ATTEMPTS}" "${MAX_WORKER_FINAL_SCORE}" "0" "${NO_CHANGES_PRS}" - exit 0 + exit "${EXIT_CODE}" fi REVIEW_RUN_TOKEN="${PROJECT_RUNTIME_KEY}-$$" @@ -1015,8 +1022,16 @@ if [ -n "${TARGET_PR}" ]; then fi if [ -n "${TARGET_SCORE}" ]; then TARGET_SCOPE_PROMPT+=$'- latest review score: '"${TARGET_SCORE}"$'/100\n' + TARGET_SCOPE_PROMPT+=$'- action: fix\n' + # Inject the latest review comment body for the fix prompt + REVIEW_BODY=$(gh api "repos/$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)/issues/${TARGET_PR}/comments" --jq '[.[] | select(.body | test("Overall Score|Score:.*[0-9]+/100"))] | last | .body // ""' 2>/dev/null || echo "") + if [ -n "${REVIEW_BODY}" ]; then + TRUNCATED_REVIEW=$(printf '%s' "${REVIEW_BODY}" | head -c 6000) + TARGET_SCOPE_PROMPT+=$'\n## Latest Review Feedback\n'"${TRUNCATED_REVIEW}"$'\n' + fi else TARGET_SCOPE_PROMPT+=$'- latest review score: not found\n' + TARGET_SCOPE_PROMPT+=$'- action: review\n' fi fi @@ -1201,7 +1216,8 @@ for ATTEMPT in $(seq 1 "${TOTAL_ATTEMPTS}"); do fi continue fi - log "RETRY: No review score found for PR #${TARGET_PR} after ${TOTAL_ATTEMPTS} attempts; failing run." + log "RETRY: No review score found for PR #${TARGET_PR} after ${TOTAL_ATTEMPTS} attempts; labeling needs-human-review and failing run." + gh pr edit "${TARGET_PR}" --add-label "needs-human-review" 2>/dev/null || true EXIT_CODE=1 break fi @@ -1316,3 +1332,4 @@ fi REVIEWER_TOTAL_ELAPSED=$(( $(date +%s) - SCRIPT_START_TIME )) log "OUTCOME: exit_code=${EXIT_CODE} total_elapsed=${REVIEWER_TOTAL_ELAPSED}s prs=${PRS_NEEDING_WORK_CSV:-none} attempts=${ATTEMPTS_MADE}" emit_final_status "${EXIT_CODE}" "${PRS_NEEDING_WORK_CSV}" "${AUTO_MERGED_PRS}" "${AUTO_MERGE_FAILED_PRS}" "${ATTEMPTS_MADE}" "${FINAL_SCORE}" "${NO_CHANGES_NEEDED}" "${NO_CHANGES_PRS}" +exit "${EXIT_CODE}" diff --git a/scripts/night-watch-qa-cron.sh b/scripts/night-watch-qa-cron.sh index 163dd463..deb6a2b3 100755 --- a/scripts/night-watch-qa-cron.sh +++ b/scripts/night-watch-qa-cron.sh @@ -25,6 +25,7 @@ PROVIDER_CMD="${NW_PROVIDER_CMD:-claude}" PROVIDER_LABEL="${NW_PROVIDER_LABEL:-}" BRANCH_PATTERNS_RAW="${NW_BRANCH_PATTERNS:-feat/,night-watch/}" SKIP_LABEL="${NW_QA_SKIP_LABEL:-skip-qa}" +VALIDATED_LABEL="${NW_QA_VALIDATED_LABEL:-e2e-validated}" QA_ARTIFACTS="${NW_QA_ARTIFACTS:-both}" QA_AUTO_INSTALL_PLAYWRIGHT="${NW_QA_AUTO_INSTALL_PLAYWRIGHT:-1}" SCRIPT_START_TIME=$(date +%s) @@ -55,6 +56,16 @@ emit_result() { fi } +LABEL_ENSURED=0 +ensure_validated_label() { + if [ "${LABEL_ENSURED}" -eq 1 ]; then return 0; fi + gh label create "${VALIDATED_LABEL}" \ + --description "PR acceptance requirements validated by e2e/integration tests" \ + --color "0e8a16" \ + --force 2>/dev/null || true + LABEL_ENSURED=1 +} + # ── Global Job Queue Gate ──────────────────────────────────────────────────── # Atomically claim a DB slot or enqueue for later dispatch — no flock needed. if [ "${NW_QUEUE_ENABLED:-0}" = "1" ]; then @@ -487,6 +498,7 @@ fi EXIT_CODE=0 PROCESSED_PRS_CSV="" PASSING_PRS_CSV="" +VALIDATED_PRS_CSV="" ISSUES_FOUND_PRS_CSV="" NO_TESTS_PRS_CSV="" UNCLASSIFIED_PRS_CSV="" @@ -617,12 +629,23 @@ for pr_ref in ${PRS_NEEDING_QA}; do case "${QA_OUTCOME}" in passing) PASSING_PRS_CSV=$(append_csv "${PASSING_PRS_CSV}" "#${pr_num}") + # Apply e2e-validated label + ensure_validated_label + gh pr edit "${pr_num}" --add-label "${VALIDATED_LABEL}" 2>/dev/null || true + VALIDATED_PRS_CSV=$(append_csv "${VALIDATED_PRS_CSV}" "#${pr_num}") + log "QA: PR #${pr_num} — added '${VALIDATED_LABEL}' label (tests passing)" ;; issues_found) ISSUES_FOUND_PRS_CSV=$(append_csv "${ISSUES_FOUND_PRS_CSV}" "#${pr_num}") + # Remove e2e-validated label if present + gh pr edit "${pr_num}" --remove-label "${VALIDATED_LABEL}" 2>/dev/null || true + log "QA: PR #${pr_num} — removed '${VALIDATED_LABEL}' label (issues found)" ;; no_tests_needed) NO_TESTS_PRS_CSV=$(append_csv "${NO_TESTS_PRS_CSV}" "#${pr_num}") + # Remove e2e-validated label — no tests doesn't prove acceptance + gh pr edit "${pr_num}" --remove-label "${VALIDATED_LABEL}" 2>/dev/null || true + log "QA: PR #${pr_num} — removed '${VALIDATED_LABEL}' label (no tests needed)" ;; *) UNCLASSIFIED_PRS_CSV=$(append_csv "${UNCLASSIFIED_PRS_CSV}" "#${pr_num}") @@ -646,6 +669,7 @@ cleanup_worktrees "${PROJECT_DIR}" FINAL_PROCESSED_PRS_CSV="${PROCESSED_PRS_CSV:-${PRS_NEEDING_QA_CSV}}" PASSING_PRS_SUMMARY=$(csv_or_none "${PASSING_PRS_CSV}") +VALIDATED_PRS_SUMMARY=$(csv_or_none "${VALIDATED_PRS_CSV}") ISSUES_FOUND_PRS_SUMMARY=$(csv_or_none "${ISSUES_FOUND_PRS_CSV}") NO_TESTS_PRS_SUMMARY=$(csv_or_none "${NO_TESTS_PRS_CSV}") UNCLASSIFIED_PRS_SUMMARY=$(csv_or_none "${UNCLASSIFIED_PRS_CSV}") @@ -664,6 +688,7 @@ Provider (model): ${PROVIDER_MODEL_DISPLAY} Artifacts: ${QA_ARTIFACTS_DESC} (mode=${QA_ARTIFACTS}) Processed PRs: ${FINAL_PROCESSED_PRS_CSV} Passing tests: ${PASSING_PRS_SUMMARY} +E2E validated: ${VALIDATED_PRS_SUMMARY} Issues found by tests: ${ISSUES_FOUND_PRS_SUMMARY} No tests needed: ${NO_TESTS_PRS_SUMMARY} Reported (unclassified): ${UNCLASSIFIED_PRS_SUMMARY} @@ -680,9 +705,9 @@ ${QA_SCREENSHOT_SUMMARY}" fi send_telegram_status_message "🧪 Night Watch QA: warning" "${TELEGRAM_WARNING_BODY}" if [ -n "${REPO}" ]; then - emit_result "warning_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|warnings=${WARNING_PRS_SUMMARY}|repo=${REPO}" + emit_result "warning_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|validated=${VALIDATED_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|warnings=${WARNING_PRS_SUMMARY}|repo=${REPO}" else - emit_result "warning_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|warnings=${WARNING_PRS_SUMMARY}" + emit_result "warning_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|validated=${VALIDATED_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|warnings=${WARNING_PRS_SUMMARY}" fi else log "DONE: QA runner completed successfully" @@ -691,6 +716,7 @@ Provider (model): ${PROVIDER_MODEL_DISPLAY} Artifacts: ${QA_ARTIFACTS_DESC} (mode=${QA_ARTIFACTS}) Processed PRs: ${FINAL_PROCESSED_PRS_CSV} Passing tests: ${PASSING_PRS_SUMMARY} +E2E validated: ${VALIDATED_PRS_SUMMARY} Issues found by tests: ${ISSUES_FOUND_PRS_SUMMARY} No tests needed: ${NO_TESTS_PRS_SUMMARY} Reported (unclassified): ${UNCLASSIFIED_PRS_SUMMARY}" @@ -701,9 +727,9 @@ ${QA_SCREENSHOT_SUMMARY}" fi send_telegram_status_message "🧪 Night Watch QA: completed" "${TELEGRAM_SUCCESS_BODY}" if [ -n "${REPO}" ]; then - emit_result "success_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|repo=${REPO}" + emit_result "success_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|validated=${VALIDATED_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}|repo=${REPO}" else - emit_result "success_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}" + emit_result "success_qa" "prs=${FINAL_PROCESSED_PRS_CSV}|passing=${PASSING_PRS_SUMMARY}|validated=${VALIDATED_PRS_SUMMARY}|issues=${ISSUES_FOUND_PRS_SUMMARY}|no_tests=${NO_TESTS_PRS_SUMMARY}|unclassified=${UNCLASSIFIED_PRS_SUMMARY}" fi fi elif [ ${EXIT_CODE} -eq 124 ]; then diff --git a/scripts/test-helpers.bats b/scripts/test-helpers.bats index b3418c25..c933f4bd 100644 --- a/scripts/test-helpers.bats +++ b/scripts/test-helpers.bats @@ -75,3 +75,48 @@ teardown() { [ "${result}" = "02-test-prd.md" ] } + +# ── pr-resolver lock acquisition ───────────────────────────────────────────── + +@test "pr-resolver lock acquisition: acquire_lock succeeds when no lock exists" { + local test_lock="/tmp/nw-test-resolver-$$.lock" + + # Ensure clean state + rm -f "${test_lock}" + + run acquire_lock "${test_lock}" + [ "$status" -eq 0 ] + [ -f "${test_lock}" ] + + # PID written to lock file must be the current test process + local lock_pid + lock_pid=$(cat "${test_lock}") + [ -n "${lock_pid}" ] + + rm -f "${test_lock}" +} + +@test "pr-resolver lock acquisition: acquire_lock fails when active lock exists" { + local test_lock="/tmp/nw-test-resolver-active-$$.lock" + + # Write current PID as an active lock holder + echo $$ > "${test_lock}" + + run acquire_lock "${test_lock}" + [ "$status" -eq 1 ] + + rm -f "${test_lock}" +} + +@test "pr-resolver lock acquisition: acquire_lock removes stale lock and succeeds" { + local test_lock="/tmp/nw-test-resolver-stale-$$.lock" + + # Write a PID that does not exist (use a very high number unlikely to be running) + echo "999999999" > "${test_lock}" + + run acquire_lock "${test_lock}" + [ "$status" -eq 0 ] + [ -f "${test_lock}" ] + + rm -f "${test_lock}" +} diff --git a/templates/night-watch-pr-reviewer.md b/templates/night-watch-pr-reviewer.md index e49ae907..773cfa77 100644 --- a/templates/night-watch-pr-reviewer.md +++ b/templates/night-watch-pr-reviewer.md @@ -21,7 +21,8 @@ If current PR code or review feedback conflicts with the PRD context, call out t ## Important: Early Exit - If there are **no open PRs** on `night-watch/` or `feat/` branches, **stop immediately** and report "No PRs to review." -- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80** (or no review score yet), **stop immediately** and report "All PRs are in good shape." +- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80**, **stop immediately** and report "All PRs are in good shape." +- If a PR has no review score yet, it needs a first review — do NOT skip it. - Do **NOT** loop or retry. Process each PR **once** per run. After processing all PRs, stop. - Do **NOT** re-check PRs after pushing fixes -- the CI will re-run automatically on the next push. diff --git a/templates/pr-reviewer.md b/templates/pr-reviewer.md index d54ad532..22249acf 100644 --- a/templates/pr-reviewer.md +++ b/templates/pr-reviewer.md @@ -21,7 +21,8 @@ If current PR code or review feedback conflicts with the PRD context, call out t ## Important: Early Exit - If there are **no open PRs** on `night-watch/` or `feat/` branches, **stop immediately** and report "No PRs to review." -- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80** (or no review score yet), **stop immediately** and report "All PRs are in good shape." +- If all open PRs have **no merge conflicts**, **passing CI**, and **review score >= 80**, **stop immediately** and report "All PRs are in good shape." +- If a PR has no review score yet, it needs a first review — do NOT skip it. - Do **NOT** loop or retry. Process each PR **once** per run. After processing all PRs, stop. - Do **NOT** re-check PRs after pushing fixes -- the CI will re-run automatically on the next push. diff --git a/templates/slicer.md b/templates/slicer.md index 3da563e4..82f1e6f4 100644 --- a/templates/slicer.md +++ b/templates/slicer.md @@ -1,6 +1,6 @@ -You are a **PRD Creator Agent**. Your job: analyze the codebase and write a complete Product Requirements Document (PRD) for a feature. +You are a **Principal Software Architect**. Your job: analyze the codebase and write a complete Product Requirements Document (PRD) for a feature. The PRD will be used directly as a GitHub issue body, so it must be self-contained and immediately actionable by an engineer. -When this activates: `PRD Creator: Initializing` +When this activates: `Planning Mode: Principal Architect` --- @@ -21,19 +21,18 @@ The PRD directory is: `{{PRD_DIR}}` ## Your Task -0. **Load Planner Skill** - Read and apply `instructions/prd-creator.md` before writing the PRD. If unavailable, continue with the instructions in this template. - -1. **Explore the Codebase** - Read relevant existing files to understand the project structure, patterns, and conventions. Look for: - - CLAUDE.md or similar AI assistant documentation files - - Existing code patterns in the area you'll be modifying - - Related features or modules that this feature interacts with +1. **Explore the Codebase** — Read relevant existing files to understand structure, patterns, and conventions. Look for: + - `CLAUDE.md` or similar AI assistant documentation + - Existing code in the area you'll be modifying + - Related features or modules this feature interacts with - Test patterns and verification commands + - `.env` files or env-loading utilities (use those patterns, never hardcode values) -2. **Assess Complexity** - Score the complexity using the rubric below and determine whether this is LOW, MEDIUM, or HIGH complexity. +2. **Assess Complexity** — Score using the rubric below and determine LOW / MEDIUM / HIGH. -3. **Write a Complete PRD** - Create a full PRD following the template structure below. The PRD must be actionable, with concrete file paths, implementation steps, and test specifications. +3. **Write a Complete PRD** — Follow the exact template structure below. Every section must be filled with concrete information. No placeholder text. -4. **Write the PRD File** - Use the Write tool to create the PRD file at the exact path specified in `{{OUTPUT_FILE_PATH}}`. +4. **Write the PRD File** — Use the Write tool to create the PRD file at `{{OUTPUT_FILE_PATH}}`. --- @@ -52,7 +51,7 @@ COMPLEXITY SCORE (sum all that apply): | Score | Level | Template Mode | | ----- | ------ | ----------------------------------------------- | -| 1-3 | LOW | Minimal (skip sections marked with MEDIUM/HIGH) | +| 1-3 | LOW | Minimal (skip sections marked MEDIUM/HIGH) | | 4-6 | MEDIUM | Standard (all sections) | | 7+ | HIGH | Full + mandatory checkpoints every phase | ``` @@ -61,13 +60,11 @@ COMPLEXITY SCORE (sum all that apply): ## PRD Template Structure -Your PRD MUST follow this exact structure: +Your PRD MUST follow this exact structure. Replace every `[bracketed placeholder]` with real content. ````markdown # PRD: [Title from roadmap item] -**Depends on:** [List any prerequisite PRDs/files, or omit if none] - **Complexity: [SCORE] → [LEVEL] mode** - [Complexity breakdown as bullet list] @@ -80,32 +77,32 @@ Your PRD MUST follow this exact structure: **Files Analyzed:** -- `path/to/file.ts` — [what the file does] -- [List all files you inspected before planning] +- `path/to/file.ts` — [what the file does / what you looked for] +- [List every file you read before writing this PRD] **Current Behavior:** -- [3-5 bullets describing current state] +- [3-5 bullets describing what happens today] -### Integration Points Checklist +### Integration Points **How will this feature be reached?** -- [ ] Entry point identified: [e.g., route, event, cron, CLI command] -- [ ] Caller file identified: [file that will invoke this new code] -- [ ] Registration/wiring needed: [e.g., add route to router, register handler, add menu item] +- [ ] Entry point: [cron job / CLI command / event / API route] +- [ ] Caller file: [file that will invoke this new code] +- [ ] Registration/wiring: [anything that must be connected] **Is this user-facing?** -- [ ] YES → UI components required (list them) -- [ ] NO → Internal/background feature (explain how it's triggered) +- [ ] YES → UI components required: [list them] +- [ ] NO → Internal/background (explain how it is triggered) **Full user flow:** 1. User does: [action] -2. Triggers: [what code path] +2. Triggers: [code path] 3. Reaches new feature via: [specific connection point] -4. Result displayed in: [where user sees outcome] +4. Result: [what the user sees] --- @@ -115,13 +112,12 @@ Your PRD MUST follow this exact structure: - [3-5 bullets explaining the chosen solution] -**Architecture Diagram** : +**Architecture Diagram** (MEDIUM/HIGH): ```mermaid flowchart LR - A[Component A] --> B[Component B] --> C[Component C] + A[Component A] --> B[Component B] --> C[Result] ``` -```` **Key Decisions:** @@ -131,14 +127,14 @@ flowchart LR --- -## 3. Sequence Flow +## 3. Sequence Flow (MEDIUM/HIGH) ```mermaid sequenceDiagram - participant A as Component A - participant B as Component B + participant A as ComponentA + participant B as ComponentB A->>B: methodName(args) - alt Error case + alt Error B-->>A: ErrorType else Success B-->>A: Response @@ -149,12 +145,11 @@ sequenceDiagram ## 4. Execution Phases -**CRITICAL RULES:** - +Critical rules: 1. Each phase = ONE user-testable vertical slice 2. Max 5 files per phase (split if larger) 3. Each phase MUST include concrete tests -4. Checkpoint after each phase (automated ALWAYS required) +4. Checkpoint after each phase ### Phase 1: [Name] — [User-visible outcome in 1 sentence] @@ -174,17 +169,16 @@ sequenceDiagram **Verification Plan:** -1. **Unit Tests:** File and test names -2. **Integration Test:** (if applicable) -3. **User Verification:** +1. **Unit Tests:** [file and test names] +2. **User Verification:** - Action: [what to do] - Expected: [what should happen] -**Checkpoint:** Run automated review after this phase completes. +**Checkpoint:** Run `yarn verify` and related tests after this phase. --- -[Repeat for additional phases as needed] +[Repeat Phase block for each additional phase] --- @@ -192,26 +186,25 @@ sequenceDiagram - [ ] All phases complete - [ ] All specified tests pass -- [ ] Verification commands pass -- [ ] All automated checkpoint reviews passed +- [ ] `yarn verify` passes - [ ] Feature is reachable (entry point connected, not orphaned code) -- [ ] [additional criterion specific to this feature] -- [ ] [additional criterion specific to this feature] - +- [ ] [Feature-specific criterion] +- [ ] [Feature-specific criterion] ```` --- ## Critical Instructions -1. **Read all relevant existing files BEFORE writing any code** -2. **Follow existing patterns in the codebase** -3. **Write the PRD with concrete file paths and implementation details** -4. **Include specific test names and assertions** -5. **Use the Write tool to create the PRD file at `{{OUTPUT_FILE_PATH}}`** -6. **The PRD must be complete and actionable - no TODO placeholders** +1. **Read all relevant files BEFORE writing the PRD** — never guess at file paths or API shapes +2. **Follow existing patterns** — reuse utilities, match naming conventions, use path aliases (`@/*`) +3. **Concrete file paths and implementation details** — no vague steps +4. **Specific test names and assertions** — not "write tests" +5. **Use the Write tool** to create the file at `{{OUTPUT_FILE_PATH}}` +6. **No placeholder text** in the final PRD — every section must have real content +7. **Self-contained** — the PRD will be read as a GitHub issue; it must make sense without context -DO NOT leave placeholder text like "[Name]" or "[description]" in the final PRD. +DO NOT leave `[bracketed placeholder]` text in the output. DO NOT skip any sections. DO NOT forget to write the file. @@ -221,14 +214,11 @@ DO NOT forget to write the file. After writing the PRD file, report: -```markdown -## PRD Creation Complete - -**File:** {{OUTPUT_FILE_PATH}} -**Title:** [PRD title] -**Complexity:** [score] → [level] -**Phases:** [count] - -### Summary -[1-2 sentences summarizing the PRD content] -```` +``` +PRD Creation Complete +File: {{OUTPUT_FILE_PATH}} +Title: [PRD title] +Complexity: [score] → [level] +Phases: [count] +Summary: [1-2 sentences] +``` diff --git a/web/App.tsx b/web/App.tsx index a5896fb8..4006d4af 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,22 +1,26 @@ import React from 'react'; import { Navigate, Route, HashRouter as Router, Routes } from 'react-router-dom'; -import Sidebar from './components/Sidebar'; -import TopBar from './components/TopBar'; -import { ToastContainer } from './components/ui/Toast'; -import { useGlobalMode } from './hooks/useGlobalMode'; -import { useStatusSync } from './hooks/useStatusSync'; +import Sidebar from './components/Sidebar.js'; +import TopBar from './components/TopBar.js'; +import CommandPalette from './components/CommandPalette.js'; +import ActivityCenter from './components/ActivityCenter.js'; +import { ToastContainer } from './components/ui/Toast.js'; +import { useGlobalMode } from './hooks/useGlobalMode.js'; +import { useStatusSync } from './hooks/useStatusSync.js'; +import { useCommandPalette } from './hooks/useCommandPalette.js'; // import Agents from './pages/Agents'; -import Board from './pages/Board'; -import Dashboard from './pages/Dashboard'; -import Logs from './pages/Logs'; -import PRs from './pages/PRs'; -import Roadmap from './pages/Roadmap'; -import Scheduling from './pages/Scheduling'; -import Settings from './pages/Settings'; +import Board from './pages/Board.js'; +import Dashboard from './pages/Dashboard.js'; +import Logs from './pages/Logs.js'; +import PRs from './pages/PRs.js'; +import Roadmap from './pages/Roadmap.js'; +import Scheduling from './pages/Scheduling.js'; +import Settings from './pages/Settings.js'; const App: React.FC = () => { useGlobalMode(); useStatusSync(); + useCommandPalette(); return ( @@ -47,6 +51,8 @@ const App: React.FC = () => { + + ); diff --git a/web/components/ActivityCenter.tsx b/web/components/ActivityCenter.tsx new file mode 100644 index 00000000..9581fb00 --- /dev/null +++ b/web/components/ActivityCenter.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useRef } from 'react'; +import { X, CheckCircle, AlertCircle, Pause, Play, GitPullRequest, Clock } from 'lucide-react'; +import { useStore } from '../store/useStore.js'; +import { useActivityFeed } from '../hooks/useActivityFeed.js'; +import type { IActivityEvent } from '../hooks/useActivityFeed.js'; + +function getEventIcon(type: IActivityEvent['type']): React.ReactNode { + switch (type) { + case 'agent_completed': + return ; + case 'agent_failed': + return ; + case 'automation_paused': + return ; + case 'automation_resumed': + return ; + case 'pr_opened': + return ; + case 'schedule_fired': + return ; + default: + return ; + } +} + +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function getEventDescription(event: IActivityEvent): string { + switch (event.type) { + case 'agent_completed': + return `${event.agent} completed${event.prd ? ` PRD-${event.prd}` : ''}${event.duration ? ` (${event.duration})` : ''}`; + case 'agent_failed': + return `${event.agent} failed${event.error ? `: ${event.error.substring(0, 50)}` : ''}`; + case 'automation_paused': + return 'Automation paused'; + case 'automation_resumed': + return 'Automation resumed'; + case 'pr_opened': + return `PR #${event.prNumber} opened${event.prTitle ? `: ${event.prTitle.substring(0, 40)}${event.prTitle.length > 40 ? '...' : ''}` : ''}`; + case 'schedule_fired': + return `${event.agent} scheduled run triggered`; + default: + return 'Unknown event'; + } +} + +const ActivityCenter: React.FC = () => { + const { activityCenterOpen, setActivityCenterOpen } = useStore(); + const { groupedEvents, markAsRead } = useActivityFeed(); + const panelRef = useRef(null); + + useEffect(() => { + if (activityCenterOpen) { + markAsRead(); + } + }, [activityCenterOpen, markAsRead]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + panelRef.current && + !panelRef.current.contains(event.target as Node) && + activityCenterOpen + ) { + setActivityCenterOpen(false); + } + }; + + if (activityCenterOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [activityCenterOpen, setActivityCenterOpen]); + + const handleClose = () => { + setActivityCenterOpen(false); + }; + + return ( + <> + {/* Backdrop overlay */} +
    + + {/* Slide-out panel */} +
    + {/* Header */} +
    +

    Activity

    + +
    + + {/* Content */} +
    + {groupedEvents.length === 0 ? ( +
    + +

    No recent activity

    +

    Events will appear here as they occur

    +
    + ) : ( +
    + {groupedEvents.map((group) => ( +
    + {/* Day header */} +
    + {group.label} +
    + + {/* Events */} +
    + {group.events.map((event) => ( +
    + {/* Icon */} +
    + {getEventIcon(event.type)} +
    + + {/* Content */} +
    +

    + {getEventDescription(event)} +

    +
    + + {/* Time */} +
    + + {formatRelativeTime(event.ts)} + +
    +
    + ))} +
    +
    + ))} +
    + )} +
    + + {/* Footer */} +
    +

    Showing {groupedEvents.reduce((acc, g) => acc + g.events.length, 0)} events

    +
    +
    + + ); +}; + +export default ActivityCenter; diff --git a/web/components/CommandPalette.tsx b/web/components/CommandPalette.tsx new file mode 100644 index 00000000..083c4e9c --- /dev/null +++ b/web/components/CommandPalette.tsx @@ -0,0 +1,319 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + Play, + Pause, + Search, + ChevronRight, +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { useStore } from '../store/useStore.js'; +import { + triggerJob, + triggerInstallCron, + triggerUninstallCron, + useApi, + fetchScheduleInfo, +} from '../api.js'; +import { WEB_JOB_REGISTRY } from '../utils/jobs.js'; + +type AgentStatus = 'idle' | 'running' | 'unknown'; + +interface ICommand { + id: string; + label: string; + category: 'navigate' | 'agents' | 'scheduling'; + shortcut?: string; + icon?: React.ReactNode; + disabled?: boolean; + action: () => void; +} + +const CommandPalette: React.FC = () => { + const { commandPaletteOpen, setCommandPaletteOpen, addToast, status } = useStore(); + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + + // Fetch schedule info for scheduling commands + const { data: scheduleInfo } = useApi(fetchScheduleInfo, [], { enabled: commandPaletteOpen }); + + // Create a stable key that only changes when running states actually change + // This prevents unnecessary command regeneration when status object is replaced + const runningStatesKey = useMemo(() => { + if (!status?.processes) return ''; + return status.processes + .map((p) => `${p.name}:${p.running ? 1 : 0}`) + .sort() + .join('|'); + }, [status?.processes]); + + // Get agent running status from status + const agentStatus: Record = useMemo(() => { + const result: Record = {}; + + if (status?.processes) { + status.processes.forEach((p) => { + result[p.name] = p.running ? 'running' : 'idle'; + }); + } + + // Mark any agent not in processes as idle + WEB_JOB_REGISTRY.forEach((job) => { + const processName = job.processName; + if (!result[processName]) { + result[processName] = 'idle'; + } + }); + + return result; + }, [runningStatesKey, status?.processes]); + + // Build commands + const commands = useMemo((): ICommand[] => { + const result: ICommand[] = []; + const agentStatusMap = agentStatus; + + // Navigation commands + result.push( + { id: 'dashboard', label: 'Dashboard', category: 'navigate', shortcut: 'Cmd+1', icon: , action: () => navigate('/') }, + { id: 'logs', label: 'Logs', category: 'navigate', shortcut: 'Cmd+2', icon: , action: () => navigate('/logs') }, + { id: 'board', label: 'Board', category: 'navigate', shortcut: 'Cmd+3', icon: , action: () => navigate('/board') }, + { id: 'scheduling', label: 'Scheduling', category: 'navigate', shortcut: 'Cmd+4', icon: , action: () => navigate('/scheduling') }, + { id: 'settings', label: 'Settings', category: 'navigate', shortcut: 'Cmd+,', icon: , action: () => navigate('/settings') } + ); + + // Agent commands + WEB_JOB_REGISTRY.forEach((job) => { + const status = agentStatusMap[job.processName] ?? 'unknown'; + const canRun = status === 'idle'; + + if (canRun) { + result.push({ + id: `run-${job.id}`, + label: `Run ${job.label}`, + category: 'agents', + icon: , + action: async () => { + try { + await triggerJob(job.id); + addToast({ title: 'Job Triggered', message: `${job.label} has been queued.`, type: 'success' }); + } catch { + addToast({ title: 'Trigger Failed', message: `Failed to trigger ${job.label}`, type: 'error' }); + } + }, + }); + } + }); + + // Scheduling commands + const isPaused = scheduleInfo?.paused ?? false; + result.push({ + id: 'pause-automation', + label: 'Pause Automation', + category: 'scheduling', + icon: , + disabled: isPaused, + action: async () => { + try { + await triggerUninstallCron(); + addToast({ title: 'Automation Paused', message: 'Cron schedules have been deactivated.', type: 'info' }); + } catch { + addToast({ title: 'Action Failed', message: 'Failed to pause automation', type: 'error' }); + } + }, + }); + + result.push({ + id: 'resume-automation', + label: 'Resume Automation', + category: 'scheduling', + icon: , + disabled: !isPaused, + action: async () => { + try { + await triggerInstallCron(); + addToast({ title: 'Automation Resumed', message: 'Cron schedules have been reactivated.', type: 'success' }); + } catch { + addToast({ title: 'Action Failed', message: 'Failed to resume automation', type: 'error' }); + } + }, + }); + + return result; + }, [scheduleInfo?.paused, addToast, navigate, agentStatus]); + + // Filter commands by search term + const filteredCommands = useMemo(() => { + if (!searchTerm.trim()) { + return commands; + } + + const lowerSearch = searchTerm.toLowerCase(); + return commands.filter((cmd) => { + const matchesLabel = cmd.label.toLowerCase().includes(lowerSearch); + const matchesCategory = cmd.category.toLowerCase().includes(lowerSearch); + return matchesLabel || matchesCategory; + }); + }, [commands, searchTerm]); + + // Group commands by category + const groupedCommands = useMemo(() => { + const groups: Record = { + navigate: [], + agents: [], + scheduling: [], + }; + + filteredCommands.forEach((cmd) => { + if (groups[cmd.category]) { + groups[cmd.category].push(cmd); + } + }); + + return groups; + }, [filteredCommands]); + + // Handle keyboard navigation + useEffect(() => { + if (!commandPaletteOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : filteredCommands.length - 1 + ); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredCommands.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) { + e.preventDefault(); + filteredCommands[selectedIndex].action(); + setCommandPaletteOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [commandPaletteOpen, filteredCommands, selectedIndex, setCommandPaletteOpen]); + + // Focus input when palette opens + useEffect(() => { + if (commandPaletteOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [commandPaletteOpen]); + + // Reset selected index when search changes + useEffect(() => { + setSelectedIndex(0); + }, [filteredCommands.length]); + + if (!commandPaletteOpen) return null; + + return ( +
    setCommandPaletteOpen(false)} + > + {/* Backdrop */} +
    + + {/* Modal */} +
    e.stopPropagation()} + > + {/* Search Input */} +
    +
    + + setSearchTerm(e.target.value)} + className="flex-1 bg-transparent text-slate-200 placeholder-slate-500 text-sm outline-none" + /> +
    +
    + + {/* Commands List */} +
    + {Object.entries(groupedCommands).map(([category, cmds]) => ( +
    + {/* Category Header */} +
    + {category} +
    + + {/* Commands */} + {cmds.map((cmd, index) => { + const globalIndex = filteredCommands.indexOf(cmd); + const isSelected = globalIndex === selectedIndex; + + return ( + + ); + })} +
    + ))} + + {/* Empty State */} + {filteredCommands.length === 0 && ( +
    + No commands found for "{searchTerm}" +
    + )} +
    + + {/* Footer */} +
    + ESC to close + | + ↑↓ to navigate + | + Enter to select +
    +
    +
    + ); +}; + +export default CommandPalette; diff --git a/web/components/LogFilterBar.tsx b/web/components/LogFilterBar.tsx new file mode 100644 index 00000000..4fdfb2de --- /dev/null +++ b/web/components/LogFilterBar.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Search, AlertTriangle } from 'lucide-react'; +import { JOB_DEFINITIONS } from '../utils/jobs.js'; +import { useStore } from '../store/useStore.js'; + +interface ILogFilterBarProps { + selectedAgent: string | null; + onSelectAgent: (agent: string | null) => void; + searchTerm: string; + onSearchChange: (term: string) => void; + errorsOnly: boolean; + onErrorsOnlyChange: (enabled: boolean) => void; +} + +const LogFilterBar: React.FC = (props) => { + const { + selectedAgent, + onSelectAgent, + searchTerm, + onSearchChange, + errorsOnly, + onErrorsOnlyChange, + } = props; + + const status = useStore((s) => s.status); + + // Get running status for each agent + const getProcessStatus = (processName: string): boolean => { + if (!status?.processes) return false; + const process = status.processes.find((p) => p.name === processName); + return process?.running ?? false; + }; + + return ( +
    + {/* Agent pills row */} +
    + {/* All option */} + + + {/* Agent pills */} + {JOB_DEFINITIONS.map((job) => { + const isSelected = selectedAgent === job.processName; + const isRunning = getProcessStatus(job.processName); + + return ( + + ); + })} +
    + + {/* Search and errors toggle row */} +
    + {/* Search input */} +
    + onSearchChange(e.target.value)} + className="w-full pl-9 pr-4 py-1.5 rounded-md border border-slate-700 bg-slate-950 text-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent placeholder:text-slate-600" + /> + +
    + + {/* Errors only toggle */} + +
    +
    + ); +}; + +export default LogFilterBar; diff --git a/web/components/TopBar.tsx b/web/components/TopBar.tsx index 9c93833f..d6bf6e24 100644 --- a/web/components/TopBar.tsx +++ b/web/components/TopBar.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { Search, Bell, Wifi, WifiOff } from 'lucide-react'; -import { useStore } from '../store/useStore'; +import { useStore } from '../store/useStore.js'; +import { useActivityFeed } from '../hooks/useActivityFeed.js'; const TopBar: React.FC = () => { - const { projectName } = useStore(); + const { projectName, setActivityCenterOpen } = useStore(); + const { hasUnread } = useActivityFeed(); const isLive = true; // Mock connection status return ( @@ -13,9 +15,9 @@ const TopBar: React.FC = () => {
    Active Project

    {projectName}

    - +
    - +
    {isLive ? : } {isLive ? 'Online' : 'Offline'} @@ -35,12 +37,17 @@ const TopBar: React.FC = () => { {/* Actions */}
    -
    - +
    AD
    diff --git a/web/components/providers/ScheduleOverrideEditor.tsx b/web/components/providers/ScheduleOverrideEditor.tsx index 9d29841f..b6ce4c1c 100644 --- a/web/components/providers/ScheduleOverrideEditor.tsx +++ b/web/components/providers/ScheduleOverrideEditor.tsx @@ -118,7 +118,6 @@ const OverrideForm: React.FC = ({ value={override.presetId} onChange={(val) => onChange({ ...override, presetId: val })} options={presetOptions} - placeholder="Select a provider preset" />
    diff --git a/web/hooks/useActivityFeed.ts b/web/hooks/useActivityFeed.ts new file mode 100644 index 00000000..ba2402ee --- /dev/null +++ b/web/hooks/useActivityFeed.ts @@ -0,0 +1,250 @@ +import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useStore } from '../store/useStore.js'; +import { fetchLogs } from '../api.js'; +import { WEB_JOB_REGISTRY } from '../utils/jobs.js'; +import type { IStatusSnapshot } from '@shared/types'; + +export interface IActivityEvent { + id: string; + type: 'agent_completed' | 'agent_failed' | 'schedule_fired' | 'automation_paused' | 'automation_resumed' | 'pr_opened'; + agent?: string; + duration?: string; + prd?: string; + error?: string; + prNumber?: number; + prTitle?: string; + ts: Date; +} + +interface IDayGroup { + label: string; + events: IActivityEvent[]; +} + +const MAX_EVENTS = 50; +const LOG_LINES_TO_FETCH = 200; + +function generateEventId(): string { + return Math.random().toString(36).substring(2, 10) + Date.now().toString(36); +} + +function formatDuration(startTime: number): string { + const seconds = Math.floor((Date.now() - startTime) / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function getDayLabel(date: Date): string { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const isToday = date.toDateString() === today.toDateString(); + const isYesterday = date.toDateString() === yesterday.toDateString(); + + if (isToday) return 'Today'; + if (isYesterday) return 'Yesterday'; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function parseLogEntryForEvent(logLine: string, agentName: string): IActivityEvent | null { + const tsMatch = logLine.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/); + const timestamp = tsMatch ? new Date(tsMatch[1]) : new Date(); + + if (logLine.includes('[ERROR]') || logLine.includes('[error]') || /\bError:/.test(logLine) || /\bFailed\b/.test(logLine)) { + const errorMatch = logLine.match(/(?:\[ERROR\]|Error:|Failed)\s*(.+)/i); + return { + id: generateEventId(), + type: 'agent_failed', + agent: agentName, + error: errorMatch?.[1]?.substring(0, 100) || 'Unknown error', + ts: timestamp, + }; + } + + if (logLine.includes('completed') || logLine.includes('Completed') || logLine.includes('finished') || logLine.includes('Finished')) { + const prdMatch = logLine.match(/PRD[-\s]*(\w+)/i); + const durationMatch = logLine.match(/(?:duration|took)[:\s]*(\d+[hms]+)/i); + return { + id: generateEventId(), + type: 'agent_completed', + agent: agentName, + duration: durationMatch?.[1], + prd: prdMatch?.[1], + ts: timestamp, + }; + } + + return null; +} + +export function useActivityFeed(): { + events: IActivityEvent[]; + groupedEvents: IDayGroup[]; + hasUnread: boolean; + markAsRead: () => void; +} { + const status = useStore((s) => s.status); + const activityCenterOpen = useStore((s) => s.activityCenterOpen); + const [events, setEvents] = useState([]); + const [lastReadTimestamp, setLastReadTimestamp] = useState(() => { + const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('nw-activity-last-read') : null; + return saved ? new Date(saved) : new Date(0); + }); + const previousStatusRef = useRef(null); + const runningStartTimesRef = useRef>(new Map()); + + const markAsRead = useCallback(() => { + const now = new Date(); + setLastReadTimestamp(now); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('nw-activity-last-read', now.toISOString()); + } + }, []); + + const hasUnread = !activityCenterOpen && events.some((e) => e.ts > lastReadTimestamp); + + useEffect(() => { + if (previousStatusRef.current && status) { + const prevStatus = previousStatusRef.current; + const newEvents: IActivityEvent[] = []; + + status.processes.forEach((currentProcess) => { + const prevProcess = prevStatus.processes.find((p) => p.name === currentProcess.name); + const jobDef = WEB_JOB_REGISTRY.find((j) => j.processName === currentProcess.name); + const agentLabel = jobDef?.label || currentProcess.name; + + if (prevProcess?.running && !currentProcess.running) { + const startTime = runningStartTimesRef.current.get(currentProcess.name); + const duration = startTime ? formatDuration(startTime) : undefined; + + newEvents.push({ + id: generateEventId(), + type: 'agent_completed', + agent: agentLabel, + duration, + ts: new Date(), + }); + runningStartTimesRef.current.delete(currentProcess.name); + } + + if (!prevProcess?.running && currentProcess.running) { + runningStartTimesRef.current.set(currentProcess.name, Date.now()); + } + }); + + const wasInstalled = prevStatus.crontab?.installed; + const isInstalled = status.crontab?.installed; + if (wasInstalled && !isInstalled) { + newEvents.push({ + id: generateEventId(), + type: 'automation_paused', + ts: new Date(), + }); + } else if (!wasInstalled && isInstalled) { + newEvents.push({ + id: generateEventId(), + type: 'automation_resumed', + ts: new Date(), + }); + } + + const prevPrNumbers = new Set(prevStatus.prs.map((pr) => pr.number)); + status.prs.forEach((pr) => { + if (!prevPrNumbers.has(pr.number) && pr.ciStatus !== 'unknown') { + newEvents.push({ + id: generateEventId(), + type: 'pr_opened', + prNumber: pr.number, + prTitle: pr.title, + ts: new Date(), + }); + } + }); + + if (newEvents.length > 0) { + setEvents((prev) => [...newEvents, ...prev].slice(0, MAX_EVENTS)); + } + } + + previousStatusRef.current = status; + }, [status]); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchInitialEvents = async () => { + const initialEvents: IActivityEvent[] = []; + + try { + const logPromises = WEB_JOB_REGISTRY.slice(0, 5).map(async (job) => { + try { + const response = await fetchLogs(job.processName, LOG_LINES_TO_FETCH); + if (abortController.signal.aborted) return; + const lines = response?.lines || []; + const recentLines = lines.slice(-20); + recentLines.forEach((line) => { + const event = parseLogEntryForEvent(line, job.label); + if (event) { + initialEvents.push(event); + } + }); + } catch { + // Silently ignore log fetch errors during initial load + } + }); + + await Promise.all(logPromises); + + if (abortController.signal.aborted) return; + + const uniqueEvents = initialEvents + .filter((event, index, self) => + index === self.findIndex((e) => + e.ts.getTime() === event.ts.getTime() && e.type === event.type + ) + ) + .sort((a, b) => b.ts.getTime() - a.ts.getTime()) + .slice(0, MAX_EVENTS); + + setEvents((prev) => { + const existingIds = new Set(prev.map((e) => e.id)); + const newEvents = uniqueEvents.filter((e) => !existingIds.has(e.id)); + return [...newEvents, ...prev].slice(0, MAX_EVENTS); + }); + } catch { + // Silently ignore initial fetch errors + } + }; + + fetchInitialEvents(); + + return () => { + abortController.abort(); + }; + }, []); + + const groupedEvents = useMemo((): IDayGroup[] => { + const result: IDayGroup[] = []; + const grouped = new Map(); + + events.forEach((event) => { + const label = getDayLabel(event.ts); + if (!grouped.has(label)) { + grouped.set(label, []); + } + grouped.get(label)!.push(event); + }); + + grouped.forEach((groupEvts, label) => { + result.push({ label, events: groupEvts }); + }); + + return result; + }, [events]); + + return { events, groupedEvents, hasUnread, markAsRead }; +} diff --git a/web/hooks/useCommandPalette.ts b/web/hooks/useCommandPalette.ts new file mode 100644 index 00000000..2b7510a1 --- /dev/null +++ b/web/hooks/useCommandPalette.ts @@ -0,0 +1,54 @@ +import { useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useStore } from '../store/useStore.js'; + +export function useCommandPalette(): void { + const { setCommandPaletteOpen } = useStore(); + const navigate = useNavigate(); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + const isShortcut = e.metaKey || e.ctrlKey; + + // Cmd+K / Ctrl+K to Toggle command palette + if (isShortcut && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + setCommandPaletteOpen((prev: boolean) => !prev); + return; + } + + // Cmd+1-4 for quick navigation + if (isShortcut && e.key >= '1' && e.key <= '4') { + e.preventDefault(); + const routeMap: Record = { + 1: '/', + 2: '/logs', + 3: '/board', + 4: '/scheduling', + }; + navigate(routeMap[Number(e.key)]); + setCommandPaletteOpen(false); + return; + } + + // Cmd+, for Settings shortcut + if (isShortcut && e.key === ',') { + e.preventDefault(); + navigate('/settings'); + setCommandPaletteOpen(false); + return; + } + + // Escape to close command palette + if (e.key === 'Escape') { + setCommandPaletteOpen(false); + } + }, [navigate, setCommandPaletteOpen]); + + // Register keyboard listener + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); +} diff --git a/web/pages/Logs.tsx b/web/pages/Logs.tsx index 9aefb50a..d748967b 100644 --- a/web/pages/Logs.tsx +++ b/web/pages/Logs.tsx @@ -1,20 +1,25 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Pause, Play, Search, ArrowDownCircle, AlertCircle } from 'lucide-react'; -import Button from '../components/ui/Button'; -import { useApi, fetchLogs } from '../api'; -import { useStore } from '../store/useStore'; +import { Pause, Play, ArrowDownCircle, AlertCircle } from 'lucide-react'; +import Button from '../components/ui/Button.js'; +import LogFilterBar from '../components/LogFilterBar.js'; +import { useApi, fetchLogs } from '../api.js'; +import { useStore } from '../store/useStore.js'; import { JOB_DEFINITIONS } from '../utils/jobs.js'; type LogName = string; const Logs: React.FC = () => { const [autoScroll, setAutoScroll] = useState(true); - const [filter, setFilter] = useState(''); const [activeLog, setActiveLog] = useState('executor'); const scrollRef = useRef(null); const { selectedProjectId, globalModeLoading } = useStore(); const status = useStore((s) => s.status); + // New filter state + const [selectedAgent, setSelectedAgent] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [errorsOnly, setErrorsOnly] = useState(false); + const { data: logData, loading: logLoading, error: logError, refetch: refetchLogs } = useApi( () => fetchLogs(activeLog, 500), [activeLog, selectedProjectId], @@ -41,13 +46,64 @@ const Logs: React.FC = () => { return () => window.clearInterval(intervalId); }, [autoScroll, activeLog, refetchLogs]); - const filteredLogs = logs.filter(log => log.toLowerCase().includes(filter.toLowerCase())); + // Handle agent selection - also switch the log file + const handleSelectAgent = (agent: string | null) => { + setSelectedAgent(agent); + if (agent) { + setActiveLog(agent); + } + setSearchTerm(''); + }; - const handleLogChange = (logName: LogName) => { - setActiveLog(logName); - setFilter(''); + // Parse log line to extract agent name from [agent-name] prefix + const parseLogAgent = (log: string): string | null => { + const match = log.match(/\[(\w+)\]/); + if (match) { + const agentName = match[1].toLowerCase(); + // Check if it matches one of our known agents + const knownAgent = JOB_DEFINITIONS.find( + (j) => j.processName.toLowerCase() === agentName || j.label.toLowerCase() === agentName + ); + return knownAgent?.processName ?? null; + } + return null; }; + // Filter logs based on selected agent, search term, and errors only + const filteredLogs = logs.filter((log) => { + // Agent filter - check if log contains the agent prefix + if (selectedAgent) { + const logAgent = parseLogAgent(log); + // Also check if the log line contains the selected agent name anywhere + const containsAgent = log.toLowerCase().includes(selectedAgent.toLowerCase()); + if (logAgent !== selectedAgent && !containsAgent) { + return false; + } + } + + // Search term filter + if (searchTerm && !log.toLowerCase().includes(searchTerm.toLowerCase())) { + return false; + } + + // Errors only filter + if (errorsOnly) { + const hasError = log.includes('[ERROR]') || + log.includes('[error]') || + log.includes('error:') || + log.includes('Error:') || + log.includes('failed') || + log.includes('Failed') || + log.includes('exception') || + log.includes('Exception'); + if (!hasError) { + return false; + } + } + + return true; + }); + const getProcessStatus = (logName: LogName) => { if (!status?.processes) return false; const process = status.processes.find(p => p.name === logName); @@ -67,39 +123,20 @@ const Logs: React.FC = () => { return (
    + {/* Filter Bar */} +
    + +
    + {/* Controls */} -
    -
    -
    - setFilter(e.target.value)} - /> - -
    -
    -
    - {JOB_DEFINITIONS.map(({ processName, label }) => ( - - ))} -
    -
    +
    - +
    + +
    + + + {/* Section B: Agent Schedule Cards */} + +

    Agents

    +
    + {agents.map((agent) => ( +
    +
    +
    + {agent.icon} +

    {agent.name}

    +
    + handleJobToggle(agent.id, checked)} + /> +
    - -

    Agents

    -
    - {agents.map((agent) => ( -
    -
    -
    - {agent.icon} -

    {agent.name}

    + {agent.enabled ? ( +
    +
    +
    Schedule
    +
    + {formatScheduleLabel( + (agent.id === 'planner' ? 'slicer' : agent.id) as 'executor' | 'reviewer' | 'qa' | 'audit' | 'slicer' | 'analytics', + agent.id === 'qa' + ? config?.qa?.schedule || '' + : agent.id === 'audit' + ? config?.audit?.schedule || '' + : agent.id === 'planner' + ? config?.roadmapScanner?.slicerSchedule || '35 */12 * * *' + : agent.id === 'analytics' + ? config?.analytics?.schedule || '0 6 * * 1' + : agent.schedule, + agent.id === 'qa' + ? config?.qa?.schedule || '' + : agent.id === 'audit' + ? config?.audit?.schedule || '' + : agent.id === 'planner' + ? config?.roadmapScanner?.slicerSchedule || '35 */12 * * *' + : agent.id === 'analytics' + ? config?.analytics?.schedule || '0 6 * * 1' + : agent.schedule, + )}
    - handleJobToggle(agent.id, checked)} - />
    - - {agent.enabled ? ( -
    -
    -
    Schedule
    -
    - {formatScheduleLabel( - (agent.id === 'planner' ? 'slicer' : agent.id) as 'executor' | 'reviewer' | 'qa' | 'audit' | 'slicer' | 'analytics', - agent.id === 'qa' - ? config?.qa?.schedule || '' - : agent.id === 'audit' - ? config?.audit?.schedule || '' - : agent.id === 'planner' - ? config?.roadmapScanner?.slicerSchedule || '35 */12 * * *' - : agent.id === 'analytics' - ? config?.analytics?.schedule || '0 6 * * 1' - : agent.schedule, - agent.id === 'qa' - ? config?.qa?.schedule || '' - : agent.id === 'audit' - ? config?.audit?.schedule || '' - : agent.id === 'planner' - ? config?.roadmapScanner?.slicerSchedule || '35 */12 * * *' - : agent.id === 'analytics' - ? config?.analytics?.schedule || '0 6 * * 1' - : agent.schedule, - )} -
    -
    - {renderDelayNote(agent.delayInfo)} -
    -
    Next Run
    - {renderNextRun(agent.nextRun)} -
    -
    -
    - - {agent.enabled ? 'Active' : 'Disabled'} -
    - -
    + {renderDelayNote(agent.delayInfo)} +
    +
    Next Run
    + {renderNextRun(agent.nextRun)} +
    +
    +
    + + {agent.enabled ? 'Active' : 'Disabled'}
    - ) : ( -
    {agent.name} is disabled.
    - )} + +
    - ))} + ) : ( +
    {agent.name} is disabled.
    + )}
    - + ))}
    - ), - }, - { - id: 'schedules', - label: 'Schedules', - content: ( -
    - { /* timeline click in schedules tab */ }} - queueStatus={queueStatus} - queueAnalytics={queueAnalytics} - /> - + -
    - - -
    + {/* Section C: Configure Schedules */} +
    + { /* timeline click handler */ }} + queueStatus={queueStatus} + queueAnalytics={queueAnalytics} + /> + + +
    + +
    - ), - }, - { - id: 'crontab', - label: 'Crontab', - content: ( - -
    +
    + + {/* Section D: Cron Entries (Collapsible) */} + + + {expandedCrontab && ( +
    + {scheduleInfo.entries.length === 0 ? ( +
    No crontab entries found.
    + ) : ( +
    + {scheduleInfo.entries.map((entry, idx) => ( +
    + {entry} +
    + ))}
    -
    + )} +
    + )} + + {/* Section E: Parallelism & Queue (Collapsible) */} + + + {expandedParallelism && ( +
    updateField('roadmapScanner', { ...form.roadmapScanner, - issueColumn: val === 'Ready' ? 'Ready' : 'Draft', + issueColumn: val === 'Draft' ? 'Draft' : 'Ready', }) } options={[ - { label: 'Draft (default)', value: 'Draft' }, - { label: 'Ready', value: 'Ready' }, + { label: 'Ready (default)', value: 'Ready' }, + { label: 'Draft', value: 'Draft' }, ]} helperText="Column where planner-created issues are added after PRD generation." /> diff --git a/web/playwright.config.run.ts b/web/playwright.config.run.ts new file mode 100644 index 00000000..466e71df --- /dev/null +++ b/web/playwright.config.run.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e/qa', + fullyParallel: false, + forbidOnly: false, + retries: 0, + workers: 1, + reporter: [['html'], ['list']], + + use: { + baseURL: 'http://localhost:7576', + trace: 'retain-on-failure', + screenshot: 'on', + video: { + mode: 'on', + size: { width: 1280, height: 720 }, + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Don't start a server - use existing + webServer: undefined, +}); diff --git a/web/store/useStore.ts b/web/store/useStore.ts index e10d4e40..d6586d3f 100644 --- a/web/store/useStore.ts +++ b/web/store/useStore.ts @@ -30,6 +30,14 @@ interface AppState { addToast: (toast: Omit) => void; removeToast: (id: string) => void; + // Command palette state + commandPaletteOpen: boolean; + setCommandPaletteOpen: (v: boolean | ((prev: boolean) => boolean)) => void; + + // Activity center state + activityCenterOpen: boolean; + setActivityCenterOpen: (v: boolean) => void; + // Status state (single source of truth, synced via SSE + polling) status: IStatusSnapshot | null; setStatus: (s: IStatusSnapshot) => void; @@ -69,6 +77,15 @@ export const useStore = create((set, get) => ({ }, removeToast: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })), + commandPaletteOpen: false, + setCommandPaletteOpen: (v) => set((state) => ({ + commandPaletteOpen: typeof v === 'function' ? v(state.commandPaletteOpen) : v, + })), + + // Activity center state + activityCenterOpen: false, + setActivityCenterOpen: (v) => set({ activityCenterOpen: v }), + // Status state (single source of truth, updated by useStatusSync) status: null, setStatus: (snapshot) => set((state) => { diff --git a/web/tests/e2e/qa/qa-ux-revamp-command-palette-activity-center.spec.ts b/web/tests/e2e/qa/qa-ux-revamp-command-palette-activity-center.spec.ts new file mode 100644 index 00000000..181f2ba3 --- /dev/null +++ b/web/tests/e2e/qa/qa-ux-revamp-command-palette-activity-center.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for UX Revamp: Command Palette, Activity Center, Log Filters + * PR #92: feat(web): UX Revamp - Command Palette, Activity Center, Log Filters + */ +test.describe('UX Revamp - Command Palette', () => { + test.beforeEach(async ({ page }) => { + await page.goto('#/'); + await page.waitForLoadState('networkidle'); + // Wait for app to be ready + await page.waitForTimeout(2000); + }); + + test('should open command palette with Ctrl+K keyboard shortcut', async ({ page }) => { + // Press Ctrl+K to open command palette + await page.keyboard.press('Control+k'); + + // Command palette should be visible + const commandPalette = page.locator('[data-command-palette]'); + await expect(commandPalette).toBeVisible({ timeout: 10000 }); + + // Take screenshot + await page.screenshot({ path: 'test-results/command-palette-open.png', fullPage: false }); + }); + + test('should display navigation commands in command palette', async ({ page }) => { + await page.keyboard.press('Control+k'); + await page.waitForTimeout(500); + + // Check for navigation category - using more flexible selector + const navigateCategory = page.locator('text=/navigate/i'); + await expect(navigateCategory.first()).toBeVisible({ timeout: 5000 }); + + // Check for common navigation commands + await expect(page.locator('text=Dashboard')).toBeVisible(); + await expect(page.locator('text=Logs')).toBeVisible(); + + await page.screenshot({ path: 'test-results/command-palette-navigation.png', fullPage: false }); + }); + + test('should filter commands by search term', async ({ page }) => { + await page.keyboard.press('Control+k'); + await page.waitForTimeout(500); + + // Type search term + const input = page.locator('[data-command-palette] input'); + await input.fill('log'); + + // Should show Logs command + await expect(page.locator('button:has-text("Logs")')).toBeVisible(); + + await page.screenshot({ path: 'test-results/command-palette-filtered.png', fullPage: false }); + }); + + test('should close command palette with Escape', async ({ page }) => { + await page.keyboard.press('Control+k'); + await page.waitForTimeout(500); + + // Command palette should be visible + await expect(page.locator('[data-command-palette]')).toBeVisible(); + + // Press Escape + await page.keyboard.press('Escape'); + + // Command palette should be hidden + await expect(page.locator('[data-command-palette]')).not.toBeVisible({ timeout: 5000 }); + }); + + test('should navigate to Logs page when selecting Logs command', async ({ page }) => { + await page.keyboard.press('Control+k'); + await page.waitForTimeout(500); + + // Click on Logs command + await page.click('button:has-text("Logs")'); + + // Should navigate to logs page + await expect(page).toHaveURL(/.*#\/logs/); + + await page.screenshot({ path: 'test-results/navigated-to-logs.png', fullPage: false }); + }); +}); + +test.describe('UX Revamp - Activity Center', () => { + test.beforeEach(async ({ page }) => { + await page.goto('#/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + }); + + test('should have activity center button in top bar', async ({ page }) => { + // Check for bell icon button using svg selector + const bellButton = page.locator('button').filter({ has: page.locator('svg') }).nth(0); + await expect(bellButton.first()).toBeVisible(); + + await page.screenshot({ path: 'test-results/top-bar-activity-button.png', fullPage: false }); + }); + + test('should open activity center when clicking bell icon', async ({ page }) => { + // Find and click the bell icon button + const bellButton = page.locator('button').filter({ has: page.locator('svg.lucide-bell, svg[class*="bell"]') }); + await bellButton.first().click(); + + // Activity center panel should be visible + await expect(page.locator('text=/activity/i')).toBeVisible({ timeout: 5000 }); + + await page.screenshot({ path: 'test-results/activity-center-open.png', fullPage: false }); + }); +}); + +test.describe('UX Revamp - Log Filters', () => { + test.beforeEach(async ({ page }) => { + await page.goto('#/logs'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + }); + + test('should display log filter bar on logs page', async ({ page }) => { + // Check for filter elements - look for buttons with agent names or "All" + const allButton = page.locator('button:has-text("All")'); + await expect(allButton.first()).toBeVisible({ timeout: 10000 }); + + await page.screenshot({ path: 'test-results/log-filter-bar.png', fullPage: false }); + }); + + test('should display search input for log filtering', async ({ page }) => { + // Check for search input with filter placeholder + const searchInput = page.locator('input[placeholder*="filter" i], input[placeholder*="search" i]'); + await expect(searchInput.first()).toBeVisible({ timeout: 5000 }); + }); + + test('should display agent filter pills', async ({ page }) => { + // Check for agent pills (Executor, Reviewer, etc.) + const executorPill = page.locator('button:has-text("Executor")'); + await expect(executorPill.first()).toBeVisible({ timeout: 5000 }); + + await page.screenshot({ path: 'test-results/log-agent-pills.png', fullPage: false }); + }); + + test('should display errors only checkbox', async ({ page }) => { + // Check for errors only toggle + const errorToggle = page.locator('text=/error.*only/i, label:has(input[type="checkbox"])'); + await expect(errorToggle.first()).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('UX Revamp - Integration', () => { + test.beforeEach(async ({ page }) => { + await page.goto('#/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + }); + + test('should navigate from command palette to logs and see filters', async ({ page }) => { + // Open command palette + await page.keyboard.press('Control+k'); + await expect(page.locator('[data-command-palette]')).toBeVisible({ timeout: 5000 }); + + // Navigate to logs + await page.click('button:has-text("Logs")'); + await expect(page).toHaveURL(/.*#\/logs/); + + // Log filters should be visible + const allButton = page.locator('button:has-text("All")'); + await expect(allButton.first()).toBeVisible({ timeout: 5000 }); + + await page.screenshot({ path: 'test-results/integration-logs-filters.png', fullPage: false }); + }); + + test('should display top bar with status indicator', async ({ page }) => { + // Check for online status indicator + const statusIndicator = page.locator('text=/online|offline/i'); + await expect(statusIndicator.first()).toBeVisible({ timeout: 5000 }); + + await page.screenshot({ path: 'test-results/top-bar-status.png', fullPage: false }); + }); +});