diff --git a/docs/integrations/integrations.md b/docs/integrations/integrations.md index e4e1dbb5..72067ccf 100644 --- a/docs/integrations/integrations.md +++ b/docs/integrations/integrations.md @@ -4,6 +4,83 @@ Night Watch CLI integrates with several third-party services to enable notifications, avatar generation, analytics, and project management. +## Inbound Webhook Triggers + +Configure inbound triggers from Settings -> Integrations -> Inbound Webhook +Triggers. The same values are stored in the `webhookTriggers` config block for +users who manage configuration as code. + +The endpoint format is `/api/jobs/:id/run` in single-project mode and +`/api/projects/:projectId/jobs/:id/run` in global mode. For GitHub webhooks, +the matched rule's `jobId` is dispatched. If the signature is valid but no rule +matches, Night Watch returns `202` with `accepted: false` and does not start a +job. + +### Signed curl + +Use the Night Watch signature header for generic webhook clients: + +```bash +payload='{"source":"manual"}' +signature=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "$NIGHT_WATCH_WEBHOOK_SECRET" | awk '{print $2}') + +curl -X POST 'https://YOUR_NIGHT_WATCH_HOST/api/jobs/reviewer/run' \ + -H 'Content-Type: application/json' \ + -H "X-Night-Watch-Signature: sha256=$signature" \ + --data "$payload" +``` + +### GitHub Webhooks + +GitHub repository webhooks can start a Night Watch job when a configured event rule matches. +Night Watch verifies GitHub's `X-Hub-Signature-256` HMAC signature before it evaluates the event. + +**Setup:** + +1. Create a strong shared secret and expose it to the Night Watch server: + +```bash +export NIGHT_WATCH_WEBHOOK_SECRET="your-random-webhook-secret" +``` + +2. In Settings -> Integrations, enable Inbound Webhook Triggers: + - Secret Environment Variable: `NIGHT_WATCH_WEBHOOK_SECRET` + - Allowed Jobs: choose each job that external callers may dispatch + - GitHub Events: enable GitHub and select the events used by your rules + - GitHub Rules: map event/action/branch/failure filters to a Night Watch job + +3. In GitHub, go to Repository Settings -> Webhooks -> Add webhook: + - Payload URL: `https://YOUR_NIGHT_WATCH_HOST/api/jobs/reviewer/run` + - Content type: `application/json` + - Secret: the same value as `NIGHT_WATCH_WEBHOOK_SECRET` + - SSL verification: enabled + - Selected events: choose the events used by your rules, such as `workflow_run`, `check_suite`, `pull_request`, or `repository_dispatch` + +4. Optional config-file equivalent: + +```json +{ + "webhookTriggers": { + "enabled": true, + "secretEnv": "NIGHT_WATCH_WEBHOOK_SECRET", + "allowedJobIds": ["reviewer", "qa"], + "github": { + "enabled": true, + "events": ["workflow_run"], + "rules": [ + { + "event": "workflow_run", + "action": "completed", + "jobId": "qa", + "branchPatterns": ["main", "release/*"], + "onlyOnFailure": true + } + ] + } + } +} +``` + ## Notification Integrations ### Slack diff --git a/docs/prds/webhook-trigger.md b/docs/prds/webhook-trigger.md new file mode 100644 index 00000000..5f96282e --- /dev/null +++ b/docs/prds/webhook-trigger.md @@ -0,0 +1,304 @@ +# PRD: Webhook Trigger + +**Complexity: 7 → HIGH mode** + +--- + +## 1. Context + +**Problem:** Night Watch jobs can be started by cron or manual UI/CLI actions, but external systems cannot dispatch a specific job through a stable authenticated HTTP endpoint. GitHub Actions, repository webhooks, release automation, and third-party schedulers all need a secure way to say "run this job now" without shelling into the host. + +**Files Analyzed:** + +- `packages/server/src/routes/action.routes.ts` — existing manual action endpoints and job spawning helper +- `packages/server/src/routes/index.ts` — API route registration +- `packages/server/src/index.ts` — Express app bootstrap and middleware setup +- `packages/core/src/jobs/job-registry.ts` — registered job IDs, CLI commands, lock suffixes, queue priorities +- `packages/core/src/types.ts` — `JobType`, `INightWatchConfig`, webhook and notification types +- `packages/cli/src/commands/serve.ts` — server command and runtime options +- `packages/cli/src/commands/shared/env-builder.ts` — existing `NW_*` env construction patterns +- `packages/server/src/__tests__/server/actions.test.ts` — coverage pattern for action route behavior +- `web/api.ts` — frontend API client and project-scoped API path conventions +- `docs/integrations/integrations.md` — existing webhook and GitHub Actions integration documentation + +**Current Behavior:** + +- UI actions POST to `/api/actions/run`, `/api/actions/review`, and registry-driven action routes +- Global mode scopes actions under `/api/projects/:id/actions/*` +- `spawnAction()` shells out to `night-watch ` in detached mode and returns `{ started, pid }` +- Manual UI triggers bypass the global queue by setting `NW_QUEUE_ENABLED=0` +- No `/api/jobs/:id/run` route exists +- No inbound webhook HMAC verification exists +- No GitHub webhook event parser maps `workflow_run`, `check_suite`, `pull_request`, or `repository_dispatch` payloads to Night Watch jobs +- External callers must use ad hoc shell access, cron edits, or the UI server without a request-signing contract + +**Integration Points Checklist:** + +```markdown +**How will this feature be reached?** + +- [x] Entry point: new `POST /api/jobs/:id/run` route in the existing server +- [x] Caller: external webhook clients, GitHub webhooks, GitHub Actions, and internal UI/API clients +- [x] Registration: route registered alongside existing action routes and project-scoped global routes +- [x] Config: new `webhookTriggers` section in `INightWatchConfig` + +**Is this user-facing?** + +- [x] YES → Settings page gains inbound webhook trigger configuration +- [x] YES → Integrations docs include GitHub webhook setup and HMAC examples +- [x] YES → API responses return dispatch IDs, job IDs, and rejection reasons + +**Full user flow:** + +1. User enables webhook triggers and stores a signing secret in Settings +2. User configures a GitHub webhook for `workflow_run` failures +3. GitHub POSTs to `/api/jobs/reviewer/run` with `X-Hub-Signature-256` +4. Server verifies HMAC, validates event rules, and maps payload to `night-watch review` +5. Job is spawned or queued, and response returns `{ accepted: true, jobId: "reviewer", pid }` +6. Invalid signatures return `401` without starting a job +``` + +--- + +## 2. Solution + +**Approach:** + +- Add a registry-backed job dispatch endpoint: `POST /api/jobs/:id/run`, where `:id` is a `JobType` from `JOB_REGISTRY`. +- Verify inbound requests with HMAC before parsing job intent. Support Night Watch native signatures (`X-Night-Watch-Signature: sha256=`) and GitHub signatures (`X-Hub-Signature-256: sha256=`). +- Introduce `IWebhookTriggerConfig` with `enabled`, `secretEnv`, `allowedJobIds`, `github.enabled`, `github.events`, and optional event-to-job rules. +- Reuse the existing `spawnAction()` flow after verification so locks, project scoping, CLI invocation, and SSE behavior remain consistent. +- Add a GitHub webhook adapter that validates event type, extracts repository/PR/check metadata, and optionally passes context to the child process via `NW_WEBHOOK_*` env vars. +- Preserve manual UI routes. The new endpoint is for signed external dispatch and should default to queue-aware execution unless the rule explicitly bypasses the queue. + +**Architecture Diagram:** + +```mermaid +flowchart TD + A[External Caller / GitHub Webhook] --> B[POST /api/jobs/:id/run] + B --> C{Webhook triggers enabled?} + C -- No --> R1[403 disabled] + C -- Yes --> D[Read raw request body] + D --> E{HMAC valid?} + E -- No --> R2[401 invalid signature] + E -- Yes --> F{Job ID allowed?} + F -- No --> R3[403 job not allowed] + F -- Yes --> G{GitHub event?} + G -- Yes --> H[Apply event-to-job rules] + G -- No --> I[Use :id dispatch target] + H --> J[Build NW_WEBHOOK_* env] + I --> J + J --> K[spawnAction / queue-aware CLI command] + K --> L[202 accepted with dispatch metadata] +``` + +**Key Decisions:** + +- Route uses `jobs/:id/run` instead of overloading `/api/actions/*` so external dispatch has a clean contract and separate auth behavior. +- HMAC verification uses the raw body with `crypto.timingSafeEqual`; JSON parsing happens only after verification. +- GitHub compatibility supports `X-Hub-Signature-256` first-class instead of requiring a custom proxy. +- Job IDs come from `JOB_REGISTRY`; no parallel hard-coded list. +- Default `allowedJobIds` excludes destructive or administrative commands. Runtime jobs only: executor, reviewer, qa, audit, planner/slicer, pr-resolver, merger, analytics. +- External dispatch should not set `NW_QUEUE_ENABLED=0` by default. Webhook-triggered jobs should participate in global queue coordination. +- Response status is `202` for accepted asynchronous dispatch, `400` for malformed payloads, `401` for signature failures, `403` for disabled/disallowed dispatch, and `409` for active per-project locks. + +**Data Changes:** + +New config shape in `INightWatchConfig`: + +```typescript +interface IWebhookTriggerConfig { + enabled: boolean; + secretEnv: string; // default: "NIGHT_WATCH_WEBHOOK_SECRET" + allowedJobIds: JobType[]; + requireTimestamp: boolean; + maxSkewSeconds: number; + github: { + enabled: boolean; + events: string[]; + rules: Array<{ + event: string; + action?: string; + jobId: JobType; + branchPatterns?: string[]; + onlyOnFailure?: boolean; + }>; + }; +} +``` + +No required SQLite migration in Phase 1. Optional dispatch audit history can reuse `job_runs.metadata_json` or be added later if traceability needs grow. + +--- + +## 3. Sequence Flow + +```mermaid +sequenceDiagram + participant GH as GitHub + participant Server as Night Watch Server + participant Config as Config + participant CLI as night-watch CLI + participant Job as Job Script + + GH->>Server: POST /api/jobs/reviewer/run + X-Hub-Signature-256 + Server->>Server: Capture raw body + Server->>Config: Load webhookTriggers config + Server->>Server: Verify HMAC with configured secret + alt Invalid signature + Server-->>GH: 401 invalid signature + else Valid signature + Server->>Server: Validate event/action/rule + Server->>CLI: spawn night-watch review with NW_WEBHOOK_* env + CLI->>Job: Execute reviewer script + Server-->>GH: 202 accepted { jobId, pid, dispatchId } + end +``` + +--- + +## 4. Execution Phases + +### Phase 1: Config, Types & Secret Loading + +**User-visible outcome:** `night-watch serve` can reject inbound job webhook calls when webhook triggers are disabled or missing a secret. + +**Files (4):** + +- `packages/core/src/types.ts` — add `IWebhookTriggerConfig` and `webhookTriggers` to `INightWatchConfig` +- `packages/core/src/constants.ts` — add default webhook trigger config +- `packages/core/src/config.ts` — merge and validate webhook trigger config +- `templates/night-watch.config.json` — document disabled-by-default config + +**Implementation:** + +- [ ] Add `IWebhookTriggerConfig` with disabled default +- [ ] Add defaults: `enabled: false`, `secretEnv: "NIGHT_WATCH_WEBHOOK_SECRET"`, `requireTimestamp: false`, `maxSkewSeconds: 300` +- [ ] Validate `allowedJobIds` against `JOB_REGISTRY` +- [ ] Validate GitHub rule `jobId` values against `JOB_REGISTRY` +- [ ] Reject enabled config when `secretEnv` is empty + +**Tests Required:** + +| Test File | Test Name | Assertion | +| -------------------------------------------- | --------------------------------------------- | --------------------------------------------- | +| `packages/core/src/__tests__/config.test.ts` | `should default webhook triggers to disabled` | `config.webhookTriggers.enabled === false` | +| `packages/core/src/__tests__/config.test.ts` | `should reject invalid webhook job ids` | invalid job id is removed or validation fails | + +**Verification Plan:** + +1. Unit tests pass +2. `yarn verify` passes + +--- + +### Phase 2: Signed Job Dispatch Route + +**User-visible outcome:** A signed `POST /api/jobs/:id/run` starts an allowed job and returns `202`. + +**Files (4):** + +- `packages/server/src/routes/job.routes.ts` — new signed dispatch route +- `packages/server/src/routes/index.ts` — register job routes in single-project and global modes +- `packages/server/src/routes/action.routes.ts` — extract reusable spawn helper if needed +- `packages/server/src/__tests__/server/jobs.test.ts` — route tests + +**Implementation:** + +- [ ] Add raw-body middleware for `/api/jobs/*` before JSON parsing affects signature verification +- [ ] Implement `verifyHmacSignature(rawBody, header, secret)` with `crypto.createHmac("sha256", secret)` +- [ ] Accept `X-Night-Watch-Signature` and `X-Hub-Signature-256` formats +- [ ] Add `POST /api/jobs/:id/run` route +- [ ] Resolve `:id` via `JOB_REGISTRY` +- [ ] Reject disabled config, unknown jobs, disallowed jobs, invalid signatures, and locked jobs +- [ ] Spawn the matching CLI command and return `{ accepted: true, jobId, pid, dispatchId }` + +**Tests Required:** + +| Test File | Test Name | Assertion | +| --------------------------------------------------- | ------------------------------------- | ---------------------------------------- | +| `packages/server/src/__tests__/server/jobs.test.ts` | `should reject unsigned job dispatch` | response status is `401` | +| `packages/server/src/__tests__/server/jobs.test.ts` | `should dispatch signed allowed job` | `spawn` called with matching CLI command | +| `packages/server/src/__tests__/server/jobs.test.ts` | `should reject disallowed job id` | response status is `403` | + +**Verification Plan:** + +1. Server tests pass +2. Manual signed curl starts a dry-run-safe job in a fixture project + +--- + +### Phase 3: GitHub Webhook Adapter + +**User-visible outcome:** GitHub webhooks can trigger a configured Night Watch job when an event rule matches. + +**Files (3):** + +- `packages/server/src/routes/job.routes.ts` — GitHub header parsing and rule matching +- `packages/server/src/__tests__/server/jobs-github.test.ts` — GitHub signature and event tests +- `docs/integrations/integrations.md` — GitHub webhook setup guide + +**Implementation:** + +- [ ] Read `X-GitHub-Event`, `X-GitHub-Delivery`, and `X-Hub-Signature-256` +- [ ] Match configured GitHub rules by event, action, branch pattern, and failure status +- [ ] Support initial events: `workflow_run`, `check_suite`, `pull_request`, `repository_dispatch` +- [ ] Populate `NW_WEBHOOK_SOURCE=github`, `NW_WEBHOOK_EVENT`, `NW_WEBHOOK_DELIVERY`, `NW_WEBHOOK_PR_NUMBER`, and `NW_WEBHOOK_BRANCH` +- [ ] Return `202 ignored` for valid signatures with no matching rule + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ---------------------------------------------------------- | --------------------------------------- | ----------------------------------- | +| `packages/server/src/__tests__/server/jobs-github.test.ts` | `should accept GitHub sha256 signature` | response status is `202` | +| `packages/server/src/__tests__/server/jobs-github.test.ts` | `should ignore unmatched GitHub event` | response body has `accepted: false` | + +**Verification Plan:** + +1. GitHub webhook fixture tests pass +2. Test delivery from GitHub UI succeeds and returns accepted/ignored result + +--- + +### Phase 4: Settings UI & Docs + +**User-visible outcome:** Users can configure inbound webhook triggers without hand-editing JSON. + +**Files (4):** + +- `web/pages/settings/IntegrationsTab.tsx` — add inbound webhook trigger section +- `web/api.ts` — mirror webhook trigger config types if needed +- `web/components/settings/WebhookEditor.tsx` — reuse or extend for inbound trigger rules +- `docs/integrations/integrations.md` — add signed curl and GitHub webhook examples + +**Implementation:** + +- [ ] Add enable switch, secret env name input, allowed job selector, and GitHub event rule editor +- [ ] Display the endpoint URL for single-project and global-project modes +- [ ] Add copyable curl example using `openssl dgst -sha256 -hmac` +- [ ] Add GitHub setup steps for repository webhook secret and selected events + +**Tests Required:** + +| Test File | Test Name | Assertion | +| ------------------------------------------------------- | ------------------------------------------------ | --------------------------------------- | +| `web/pages/settings/__tests__/IntegrationsTab.test.tsx` | `should render inbound webhook trigger settings` | form contains `webhookTriggers.enabled` | + +**Verification Plan:** + +1. `yarn verify` passes +2. Settings save/load preserves webhook trigger rules + +--- + +## 5. Acceptance Criteria + +- [ ] `POST /api/jobs/:id/run` exists for single-project and global-project API paths +- [ ] Requests without valid HMAC signatures cannot start jobs +- [ ] GitHub `X-Hub-Signature-256` verification works against the raw body +- [ ] Unknown or disallowed job IDs return clear 4xx responses +- [ ] Accepted requests spawn the matching Night Watch CLI job +- [ ] GitHub webhook events can be mapped to configured jobs +- [ ] Settings UI exposes inbound webhook trigger configuration +- [ ] Integration docs include signed curl and GitHub webhook examples +- [ ] `yarn verify` passes diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4ff45852..7bedbf3d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -385,6 +385,18 @@ export function buildInitConfig(params: { ...defaults.queue, priority: { ...defaults.queue.priority }, }, + webhookTriggers: { + ...defaults.webhookTriggers, + allowedJobIds: [...defaults.webhookTriggers.allowedJobIds], + github: { + ...defaults.webhookTriggers.github, + events: [...defaults.webhookTriggers.github.events], + rules: defaults.webhookTriggers.github.rules.map((rule) => ({ + ...rule, + branchPatterns: rule.branchPatterns ? [...rule.branchPatterns] : undefined, + })), + }, + }, }; } diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 927ce4bb..7d76b248 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -102,6 +102,57 @@ describe('config', () => { expect(config.reviewerEnabled).toBe(true); }); + it('should default webhook triggers to disabled', () => { + const config = loadConfig(tempDir); + + expect(config.webhookTriggers.enabled).toBe(false); + expect(config.webhookTriggers.secretEnv).toBe('NIGHT_WATCH_WEBHOOK_SECRET'); + expect(config.webhookTriggers.requireTimestamp).toBe(false); + expect(config.webhookTriggers.maxSkewSeconds).toBe(300); + }); + + it('should reject invalid webhook job ids', () => { + const configPath = path.join(tempDir, 'night-watch.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + webhookTriggers: { + enabled: false, + allowedJobIds: ['executor', 'not-a-job', 'reviewer'], + github: { + enabled: true, + rules: [ + { event: 'workflow_run', jobId: 'qa' }, + { event: 'workflow_run', jobId: 'not-a-job' }, + ], + }, + }, + }), + ); + + const config = loadConfig(tempDir); + + expect(config.webhookTriggers.allowedJobIds).toEqual(['executor', 'reviewer']); + expect(config.webhookTriggers.github.rules).toEqual([{ event: 'workflow_run', jobId: 'qa' }]); + }); + + it('should reject enabled webhook triggers with an empty secret env name', () => { + const configPath = path.join(tempDir, 'night-watch.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + webhookTriggers: { + enabled: true, + secretEnv: ' ', + }, + }), + ); + + expect(() => loadConfig(tempDir)).toThrow( + 'webhookTriggers.secretEnv must be non-empty when webhook triggers are enabled', + ); + }); + it('should merge config file with defaults', () => { // Write a config file with some overrides const configPath = path.join(tempDir, 'night-watch.config.json'); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a95283d2..216bc13f 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -13,6 +13,8 @@ import { IProviderPreset, IProviderScheduleOverride, IQueueConfig, + IWebhookTriggerConfig, + IWebhookTriggerGithubRule, JobType, } from './types.js'; import { @@ -57,9 +59,11 @@ import { DEFAULT_SCHEDULING_PRIORITY, DEFAULT_SECONDARY_FALLBACK_MODEL, DEFAULT_TEMPLATES_DIR, + DEFAULT_WEBHOOK_TRIGGERS, } from './constants.js'; import { normalizeConfig } from './config-normalize.js'; import { buildEnvOverrideConfig } from './config-env.js'; +import { getJobDef } from './jobs/job-registry.js'; export { validateProvider } from './config-normalize.js'; @@ -109,9 +113,128 @@ export function getDefaultConfig(): INightWatchConfig { jobProviders: { ...DEFAULT_JOB_PROVIDERS }, providerScheduleOverrides: [...DEFAULT_PROVIDER_SCHEDULE_OVERRIDES], queue: { ...DEFAULT_QUEUE }, + webhookTriggers: cloneWebhookTriggers(DEFAULT_WEBHOOK_TRIGGERS), }; } +function cloneWebhookTriggers(config: IWebhookTriggerConfig): IWebhookTriggerConfig { + return { + ...config, + allowedJobIds: [...config.allowedJobIds], + github: { + ...config.github, + events: [...config.github.events], + rules: config.github.rules.map((rule) => { + const cloned = { ...rule }; + if (rule.branchPatterns) { + cloned.branchPatterns = [...rule.branchPatterns]; + } + return cloned; + }), + }, + }; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isValidJobType(value: unknown): value is JobType { + return typeof value === 'string' && getJobDef(value as JobType) !== undefined; +} + +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const strings = value.filter((item): item is string => typeof item === 'string'); + return strings.length > 0 ? strings : []; +} + +function normalizeWebhookGithubRules(value: unknown): IWebhookTriggerGithubRule[] | undefined { + if (!Array.isArray(value)) return undefined; + + const rules: IWebhookTriggerGithubRule[] = []; + for (const item of value) { + if (!isPlainObject(item)) continue; + + const event = typeof item.event === 'string' ? item.event.trim() : ''; + if (!event || !isValidJobType(item.jobId)) continue; + + const rule: IWebhookTriggerGithubRule = { + event, + jobId: item.jobId, + }; + + if (typeof item.action === 'string' && item.action.trim()) { + rule.action = item.action.trim(); + } + const branchPatterns = readStringArray(item.branchPatterns); + if (branchPatterns !== undefined) { + rule.branchPatterns = branchPatterns; + } + if (typeof item.onlyOnFailure === 'boolean') { + rule.onlyOnFailure = item.onlyOnFailure; + } + + rules.push(rule); + } + + return rules; +} + +function normalizeWebhookTriggersConfig(value: unknown): IWebhookTriggerConfig | undefined { + if (!isPlainObject(value)) return undefined; + + const config = cloneWebhookTriggers(DEFAULT_WEBHOOK_TRIGGERS); + + if (typeof value.enabled === 'boolean') { + config.enabled = value.enabled; + } + if (typeof value.secretEnv === 'string') { + config.secretEnv = value.secretEnv.trim(); + } + const allowedJobIds = readStringArray(value.allowedJobIds); + if (allowedJobIds !== undefined) { + config.allowedJobIds = allowedJobIds.filter(isValidJobType); + } + if (typeof value.requireTimestamp === 'boolean') { + config.requireTimestamp = value.requireTimestamp; + } + if (typeof value.maxSkewSeconds === 'number' && Number.isFinite(value.maxSkewSeconds)) { + const n = Math.floor(value.maxSkewSeconds); + config.maxSkewSeconds = n > 0 ? n : DEFAULT_WEBHOOK_TRIGGERS.maxSkewSeconds; + } + + if (isPlainObject(value.github)) { + if (typeof value.github.enabled === 'boolean') { + config.github.enabled = value.github.enabled; + } + const events = readStringArray(value.github.events); + if (events !== undefined) { + config.github.events = events.map((event) => event.trim()).filter(Boolean); + } + const rules = normalizeWebhookGithubRules(value.github.rules); + if (rules !== undefined) { + config.github.rules = rules; + } + } + + return config; +} + +function validateWebhookTriggers(config: IWebhookTriggerConfig): IWebhookTriggerConfig { + const validated = cloneWebhookTriggers(config); + validated.allowedJobIds = validated.allowedJobIds.filter(isValidJobType); + validated.github.rules = validated.github.rules.filter((rule) => isValidJobType(rule.jobId)); + + if (validated.enabled && validated.secretEnv.trim().length === 0) { + throw new Error( + 'webhookTriggers.secretEnv must be non-empty when webhook triggers are enabled', + ); + } + + return validated; +} + /** * Load configuration from a JSON file */ @@ -122,7 +245,12 @@ function loadConfigFile(configPath: string): Partial | null { } const content = fs.readFileSync(configPath, 'utf-8'); const rawConfig = JSON.parse(content) as Record; - return normalizeConfig(rawConfig); + const normalized = normalizeConfig(rawConfig); + const webhookTriggers = normalizeWebhookTriggersConfig(rawConfig.webhookTriggers); + if (webhookTriggers) { + normalized.webhookTriggers = webhookTriggers; + } + return normalized; } catch (error) { console.warn( `Warning: Could not parse config file at ${configPath}: ${ @@ -179,6 +307,26 @@ function mergeConfigLayer(base: INightWatchConfig, layer: Partial)[_key] = { + ...baseWebhook, + ...layerWebhook, + allowedJobIds: [...layerWebhook.allowedJobIds], + github: { + ...baseWebhook.github, + ...layerWebhook.github, + events: [...layerWebhook.github.events], + rules: layerWebhook.github.rules.map((rule) => { + const cloned = { ...rule }; + if (rule.branchPatterns) { + cloned.branchPatterns = [...rule.branchPatterns]; + } + return cloned; + }), + }, + }; } else if ( _key === 'providerEnv' || _key === 'boardProvider' || @@ -254,6 +402,8 @@ function mergeConfigs( : merged.primaryFallbackModel; } + merged.webhookTriggers = validateWebhookTriggers(merged.webhookTriggers); + return merged; } diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 1c46db98..32011fb5 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -16,6 +16,7 @@ import { IQaConfig, IQueueConfig, IRoadmapScannerConfig, + IWebhookTriggerConfig, JobType, MergeMethod, Provider, @@ -237,6 +238,22 @@ export const DEFAULT_JOB_PROVIDERS: IJobProviders = {}; // Default provider schedule overrides (empty = no time-based overrides) export const DEFAULT_PROVIDER_SCHEDULE_OVERRIDES: IProviderScheduleOverride[] = []; +// Webhook Trigger Configuration +export const DEFAULT_WEBHOOK_TRIGGER_SECRET_ENV = 'NIGHT_WATCH_WEBHOOK_SECRET'; +export const DEFAULT_WEBHOOK_TRIGGER_MAX_SKEW_SECONDS = 300; +export const DEFAULT_WEBHOOK_TRIGGERS: IWebhookTriggerConfig = { + enabled: false, + secretEnv: DEFAULT_WEBHOOK_TRIGGER_SECRET_ENV, + allowedJobIds: getValidJobTypes(), + requireTimestamp: false, + maxSkewSeconds: DEFAULT_WEBHOOK_TRIGGER_MAX_SKEW_SECONDS, + github: { + enabled: false, + events: [], + rules: [], + }, +}; + /** * Built-in provider presets. These are the default configurations for known providers. * Users can override these or add custom presets via config.providerPresets. diff --git a/packages/core/src/shared/types.ts b/packages/core/src/shared/types.ts index cdb6e754..5d49d315 100644 --- a/packages/core/src/shared/types.ts +++ b/packages/core/src/shared/types.ts @@ -128,6 +128,31 @@ export interface INotificationConfig { webhooks: IWebhookConfig[]; } +// ==================== Inbound Webhook Triggers ==================== + +export interface IWebhookTriggerGithubRule { + event: string; + action?: string; + jobId: JobType; + branchPatterns?: string[]; + onlyOnFailure?: boolean; +} + +export interface IWebhookTriggerGithubConfig { + enabled: boolean; + events: string[]; + rules: IWebhookTriggerGithubRule[]; +} + +export interface IWebhookTriggerConfig { + enabled: boolean; + secretEnv: string; + allowedJobIds: JobType[]; + requireTimestamp: boolean; + maxSkewSeconds: number; + github: IWebhookTriggerGithubConfig; +} + // ==================== Roadmap Scanner Config ==================== export interface IRoadmapScannerConfig { @@ -281,6 +306,8 @@ export interface INightWatchConfig { prResolver?: IPrResolverConfig; merger?: IMergerConfig; queue: IQueueConfig; + /** Authenticated inbound job webhook trigger configuration */ + webhookTriggers?: IWebhookTriggerConfig; /** Time-based provider schedule overrides */ providerScheduleOverrides?: IProviderScheduleOverride[]; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 526a831a..fe351521 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -109,6 +109,29 @@ export interface IJobProviders { merger?: Provider; } +export interface IWebhookTriggerGithubRule { + event: string; + action?: string; + jobId: JobType; + branchPatterns?: string[]; + onlyOnFailure?: boolean; +} + +export interface IWebhookTriggerGithubConfig { + enabled: boolean; + events: string[]; + rules: IWebhookTriggerGithubRule[]; +} + +export interface IWebhookTriggerConfig { + enabled: boolean; + secretEnv: string; + allowedJobIds: JobType[]; + requireTimestamp: boolean; + maxSkewSeconds: number; + github: IWebhookTriggerGithubConfig; +} + /** * Claude model to use for native (non-proxy) execution */ @@ -316,6 +339,9 @@ export interface INightWatchConfig { /** Global job queue configuration */ queue: IQueueConfig; + /** Authenticated inbound job webhook trigger configuration */ + webhookTriggers: IWebhookTriggerConfig; + /** * Internal: CLI override for provider (--provider flag). * Takes precedence over all other provider settings. diff --git a/packages/server/src/__tests__/server/jobs-github.test.ts b/packages/server/src/__tests__/server/jobs-github.test.ts new file mode 100644 index 00000000..a27ebd39 --- /dev/null +++ b/packages/server/src/__tests__/server/jobs-github.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for GitHub webhook job dispatch endpoints. + */ + +import { createHmac } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp } from '../../index.js'; + +vi.mock('child_process', () => ({ + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const callback = typeof _opts === 'function' ? (_opts as typeof cb) : cb; + callback?.(null, { stdout: '', stderr: '' }); + }, + ), + execFile: vi.fn(), + execSync: vi.fn(() => ''), + spawn: vi.fn(), +})); + +vi.mock('@night-watch/core/utils/crontab.js', () => ({ + getEntries: vi.fn(() => []), + getProjectEntries: vi.fn(() => []), + generateMarker: vi.fn((name: string) => `# night-watch-cli: ${name}`), +})); + +import { spawn } from 'child_process'; + +const SECRET_ENV = 'NIGHT_WATCH_TEST_GITHUB_WEBHOOK_SECRET'; +const WEBHOOK_SECRET = 'test-github-secret'; +const originalSecret = process.env[SECRET_ENV]; + +interface IGithubTestConfigOptions { + onlyOnFailure?: boolean; +} + +function signBody(rawBody: string): string { + return `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex')}`; +} + +function writeConfig(projectDir: string, options: IGithubTestConfigOptions = {}): void { + const configData = { + projectName: 'test-project', + defaultBranch: 'main', + provider: 'claude', + reviewerEnabled: true, + prdDirectory: 'docs/PRDs/night-watch', + webhookTriggers: { + enabled: true, + secretEnv: SECRET_ENV, + allowedJobIds: ['reviewer', 'qa'], + github: { + enabled: true, + events: ['workflow_run'], + rules: [ + { + event: 'workflow_run', + action: 'completed', + jobId: 'qa', + branchPatterns: ['feat/*'], + onlyOnFailure: options.onlyOnFailure ?? true, + }, + ], + }, + }, + }; + + fs.writeFileSync( + path.join(projectDir, 'night-watch.config.json'), + JSON.stringify(configData, null, 2), + ); +} + +function createWorkflowRunPayload(conclusion: string): string { + return JSON.stringify({ + action: 'completed', + workflow_run: { + conclusion, + head_branch: 'feat/webhook-adapter', + pull_requests: [{ number: 42 }], + }, + }); +} + +describe('server GitHub jobs API', () => { + let tempDir: string; + let app: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-jobs-github-test-')); + + process.env[SECRET_ENV] = WEBHOOK_SECRET; + + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' })); + fs.mkdirSync(path.join(tempDir, 'docs', 'PRDs', 'night-watch'), { recursive: true }); + writeConfig(tempDir); + + vi.mocked(spawn).mockReturnValue({ + pid: 12345, + unref: vi.fn(), + on: vi.fn(), + } as any); + + app = createApp(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalSecret === undefined) { + delete process.env[SECRET_ENV]; + } else { + process.env[SECRET_ENV] = originalSecret; + } + }); + + it('should accept GitHub sha256 signature', async () => { + const rawBody = createWorkflowRunPayload('failure'); + + const response = await request(app) + .post('/api/jobs/reviewer/run') + .set('Content-Type', 'application/json') + .set('X-GitHub-Event', 'workflow_run') + .set('X-GitHub-Delivery', 'delivery-123') + .set('X-Hub-Signature-256', signBody(rawBody)) + .send(rawBody); + + expect(response.status).toBe(202); + expect(response.body).toMatchObject({ + accepted: true, + jobId: 'qa', + pid: 12345, + }); + + expect(vi.mocked(spawn)).toHaveBeenCalledWith( + 'night-watch', + ['qa'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + cwd: tempDir, + }), + ); + + const spawnCall = vi.mocked(spawn).mock.calls[0]; + const env = spawnCall?.[2]?.env as NodeJS.ProcessEnv | undefined; + expect(env?.NW_WEBHOOK_SOURCE).toBe('github'); + expect(env?.NW_WEBHOOK_EVENT).toBe('workflow_run'); + expect(env?.NW_WEBHOOK_DELIVERY).toBe('delivery-123'); + expect(env?.NW_WEBHOOK_PR_NUMBER).toBe('42'); + expect(env?.NW_WEBHOOK_BRANCH).toBe('feat/webhook-adapter'); + expect(env?.NW_WEBHOOK_JOB_ID).toBe('qa'); + expect(env?.NW_WEBHOOK_DISPATCH_ID).toBe(response.body.dispatchId); + }); + + it('should ignore unmatched GitHub event', async () => { + const rawBody = createWorkflowRunPayload('success'); + + const response = await request(app) + .post('/api/jobs/reviewer/run') + .set('Content-Type', 'application/json') + .set('X-GitHub-Event', 'workflow_run') + .set('X-GitHub-Delivery', 'delivery-456') + .set('X-Hub-Signature-256', signBody(rawBody)) + .send(rawBody); + + expect(response.status).toBe(202); + expect(response.body).toMatchObject({ + accepted: false, + ignored: true, + }); + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/__tests__/server/jobs.test.ts b/packages/server/src/__tests__/server/jobs.test.ts new file mode 100644 index 00000000..548d6a04 --- /dev/null +++ b/packages/server/src/__tests__/server/jobs.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for signed server job dispatch endpoints. + */ + +import { createHmac } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp } from '../../index.js'; + +vi.mock('child_process', () => ({ + exec: vi.fn( + ( + _cmd: string, + _opts: unknown, + cb?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const callback = typeof _opts === 'function' ? (_opts as typeof cb) : cb; + callback?.(null, { stdout: '', stderr: '' }); + }, + ), + execFile: vi.fn(), + execSync: vi.fn(() => ''), + spawn: vi.fn(), +})); + +vi.mock('@night-watch/core/utils/crontab.js', () => ({ + getEntries: vi.fn(() => []), + getProjectEntries: vi.fn(() => []), + generateMarker: vi.fn((name: string) => `# night-watch-cli: ${name}`), +})); + +import { spawn } from 'child_process'; + +const SECRET_ENV = 'NIGHT_WATCH_TEST_WEBHOOK_SECRET'; +const WEBHOOK_SECRET = 'test-secret'; +const originalSecret = process.env[SECRET_ENV]; +const originalQueueEnabled = process.env.NW_QUEUE_ENABLED; + +function signBody(rawBody: string): string { + return `sha256=${createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex')}`; +} + +function writeConfig(projectDir: string, allowedJobIds: string[]): void { + const configData = { + projectName: 'test-project', + defaultBranch: 'main', + provider: 'claude', + reviewerEnabled: true, + prdDirectory: 'docs/PRDs/night-watch', + webhookTriggers: { + enabled: true, + secretEnv: SECRET_ENV, + allowedJobIds, + }, + }; + + fs.writeFileSync( + path.join(projectDir, 'night-watch.config.json'), + JSON.stringify(configData, null, 2), + ); +} + +describe('server jobs API', () => { + let tempDir: string; + let app: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-jobs-test-')); + + process.env[SECRET_ENV] = WEBHOOK_SECRET; + delete process.env.NW_QUEUE_ENABLED; + + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' })); + fs.mkdirSync(path.join(tempDir, 'docs', 'PRDs', 'night-watch'), { recursive: true }); + writeConfig(tempDir, ['reviewer']); + + vi.mocked(spawn).mockReturnValue({ + pid: 12345, + unref: vi.fn(), + on: vi.fn(), + } as any); + + app = createApp(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalSecret === undefined) { + delete process.env[SECRET_ENV]; + } else { + process.env[SECRET_ENV] = originalSecret; + } + if (originalQueueEnabled === undefined) { + delete process.env.NW_QUEUE_ENABLED; + } else { + process.env.NW_QUEUE_ENABLED = originalQueueEnabled; + } + }); + + it('should reject unsigned job dispatch', async () => { + const response = await request(app).post('/api/jobs/reviewer/run').send({}); + + expect(response.status).toBe(401); + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); + + it('should dispatch signed allowed job', async () => { + const rawBody = JSON.stringify({ source: 'test' }); + + const response = await request(app) + .post('/api/jobs/reviewer/run') + .set('Content-Type', 'application/json') + .set('X-Night-Watch-Signature', signBody(rawBody)) + .send(rawBody); + + expect(response.status).toBe(202); + expect(response.body).toMatchObject({ + accepted: true, + jobId: 'reviewer', + pid: 12345, + }); + expect(response.body.dispatchId).toEqual(expect.any(String)); + + expect(vi.mocked(spawn)).toHaveBeenCalledWith( + 'night-watch', + ['review'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + cwd: tempDir, + }), + ); + + const spawnCall = vi.mocked(spawn).mock.calls[0]; + const env = spawnCall?.[2]?.env as NodeJS.ProcessEnv | undefined; + expect(env?.NW_QUEUE_ENABLED).toBeUndefined(); + expect(env?.NW_WEBHOOK_JOB_ID).toBe('reviewer'); + expect(env?.NW_WEBHOOK_DISPATCH_ID).toBe(response.body.dispatchId); + }); + + it('should reject disallowed job id', async () => { + const rawBody = JSON.stringify({}); + + const response = await request(app) + .post('/api/jobs/qa/run') + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signBody(rawBody)) + .send(rawBody); + + expect(response.status).toBe(403); + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e5e4d4f2..de5f4e6f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,6 +36,7 @@ import { createProjectConfigRoutes, } from './routes/config.routes.js'; import { createDoctorRoutes, createProjectDoctorRoutes } from './routes/doctor.routes.js'; +import { createJobRoutes, createProjectJobRoutes } from './routes/job.routes.js'; import { createLogRoutes, createProjectLogRoutes } from './routes/log.routes.js'; import { createPrdRoutes, createProjectPrdRoutes } from './routes/prd.routes.js'; import { createProjectRoadmapRoutes, createRoadmapRoutes } from './routes/roadmap.routes.js'; @@ -48,6 +49,12 @@ import { createGlobalQueueRoutes, createQueueRoutes } from './routes/queue.route const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const JOB_RAW_BODY_LIMIT = '1mb'; + +function setupJobRawBodyParsing(app: Express): void { + app.use('/api/jobs', express.raw({ type: '*/*', limit: JOB_RAW_BODY_LIMIT })); + app.use('/api/projects/:projectId/jobs', express.raw({ type: '*/*', limit: JOB_RAW_BODY_LIMIT })); +} function resolveWebDistPath(): string { // 1. Bundled/published mode: web assets copied into dist/web/ by build.mjs @@ -103,6 +110,7 @@ function setupStaticFiles(app: Express): void { export function createApp(projectDir: string): Express { const app = express(); app.use(cors()); + setupJobRawBodyParsing(app); app.use(express.json()); let config = loadConfig(projectDir); @@ -123,6 +131,7 @@ export function createApp(projectDir: string): Express { app.use('/api/config', createConfigRoutes({ projectDir, getConfig: () => config, reloadConfig })); app.use('/api/board', createBoardRoutes({ projectDir, getConfig: () => config })); app.use('/api/actions', createActionRoutes({ projectDir, getConfig: () => config, sseClients })); + app.use('/api/jobs', createJobRoutes({ projectDir, getConfig: () => config })); app.use( '/api/roadmap', createRoadmapRoutes({ projectDir, getConfig: () => config, reloadConfig }), @@ -180,6 +189,7 @@ function createProjectRouter() { router.use(createProjectLogRoutes()); router.use(createProjectBoardRoutes()); router.use(createProjectActionRoutes({ projectSseClients })); + router.use(createProjectJobRoutes()); router.use(createProjectRoadmapRoutes()); router.get('/prs', async (req: Request, res: Response): Promise => { @@ -196,6 +206,7 @@ function createProjectRouter() { export function createGlobalApp(): Express { const app = express(); app.use(cors()); + setupJobRawBodyParsing(app); app.use(express.json()); app.get('/api/mode', (_req: Request, res: Response): void => { diff --git a/packages/server/src/routes/job.routes.ts b/packages/server/src/routes/job.routes.ts new file mode 100644 index 00000000..eaaf8c3a --- /dev/null +++ b/packages/server/src/routes/job.routes.ts @@ -0,0 +1,449 @@ +/** + * Signed job dispatch routes: /api/jobs/:id/run. + */ + +import { spawn } from 'child_process'; +import { createHmac, randomUUID, timingSafeEqual } from 'crypto'; + +import { Request, Response, Router } from 'express'; + +import { + INightWatchConfig, + IWebhookTriggerGithubRule, + JOB_REGISTRY, + JobType, + analyticsLockPath, + auditLockPath, + checkLockFile, + executorLockPath, + mergerLockPath, + plannerLockPath, + prResolverLockPath, + qaLockPath, + reviewerLockPath, +} from '@night-watch/core'; + +interface IJobRoutesDeps { + projectDir: string; + getConfig: () => INightWatchConfig; +} + +interface IProjectJobRoutesDeps { + getConfig: (req: Request) => INightWatchConfig; + getProjectDir: (req: Request) => string; + pathPrefix: string; +} + +interface IJobDispatchResponse { + accepted: true; + jobId: JobType; + pid: number; + dispatchId: string; +} + +interface IIgnoredJobDispatchResponse { + accepted: false; + ignored: true; + reason: string; +} + +interface IGithubWebhookHeaders { + event: string | undefined; + delivery: string | undefined; + signature: string | undefined; +} + +interface IGithubWebhookContext { + event: string; + delivery?: string; + action?: string; + branch?: string; + prNumber?: string; + failed: boolean; +} + +interface IGithubWebhookMatch { + rule: IWebhookTriggerGithubRule; + context: IGithubWebhookContext; +} + +interface IObjectRecord { + [key: string]: unknown; +} + +const SUPPORTED_GITHUB_EVENTS = [ + 'workflow_run', + 'check_suite', + 'pull_request', + 'repository_dispatch', +] as const; + +function isSupportedGithubEvent(value: string): boolean { + return SUPPORTED_GITHUB_EVENTS.some((event) => event === value); +} + +function getRawBody(req: Request): Buffer { + if (Buffer.isBuffer(req.body)) { + return req.body; + } + if (typeof req.body === 'string') { + return Buffer.from(req.body, 'utf-8'); + } + return Buffer.alloc(0); +} + +export function verifyHmacSignature( + rawBody: Buffer, + header: string | undefined, + secret: string, +): boolean { + const match = header?.match(/^sha256=([a-f0-9]{64})$/i); + if (!match) { + return false; + } + + const expected = createHmac('sha256', secret).update(rawBody).digest(); + const actual = Buffer.from(match[1], 'hex'); + + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function getSignatureHeaders(req: Request): Array { + return [req.get('X-Night-Watch-Signature'), req.get('X-Hub-Signature-256')]; +} + +function getGithubWebhookHeaders(req: Request): IGithubWebhookHeaders { + return { + event: req.get('X-GitHub-Event'), + delivery: req.get('X-GitHub-Delivery'), + signature: req.get('X-Hub-Signature-256'), + }; +} + +function hasGithubHeaders(headers: IGithubWebhookHeaders): boolean { + return Boolean(headers.event && headers.signature); +} + +function isObjectRecord(value: unknown): value is IObjectRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readObject(value: unknown): IObjectRecord | undefined { + return isObjectRecord(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readNumberString(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return readString(value); +} + +function readFirstPullRequestNumber(value: unknown): string | undefined { + if (!Array.isArray(value)) return undefined; + for (const item of value) { + const pr = readObject(item); + const number = readNumberString(pr?.number); + if (number) return number; + } + return undefined; +} + +function isFailureConclusion(value: unknown): boolean { + const conclusion = readString(value)?.toLowerCase(); + if (!conclusion) return false; + return !['success', 'neutral', 'skipped'].includes(conclusion); +} + +function parseJsonBody(rawBody: Buffer): unknown { + if (rawBody.length === 0) { + return {}; + } + return JSON.parse(rawBody.toString('utf-8')); +} + +function buildGithubContext( + headers: IGithubWebhookHeaders, + payload: IObjectRecord, +): IGithubWebhookContext | undefined { + if (!headers.event || !isSupportedGithubEvent(headers.event)) { + return undefined; + } + + const context: IGithubWebhookContext = { + event: headers.event, + delivery: headers.delivery, + action: readString(payload.action), + failed: false, + }; + + switch (headers.event) { + case 'workflow_run': { + const workflowRun = readObject(payload.workflow_run); + context.branch = readString(workflowRun?.head_branch); + context.prNumber = readFirstPullRequestNumber(workflowRun?.pull_requests); + context.failed = isFailureConclusion(workflowRun?.conclusion); + break; + } + case 'check_suite': { + const checkSuite = readObject(payload.check_suite); + context.branch = readString(checkSuite?.head_branch); + context.prNumber = readFirstPullRequestNumber(checkSuite?.pull_requests); + context.failed = isFailureConclusion(checkSuite?.conclusion); + break; + } + case 'pull_request': { + const pullRequest = readObject(payload.pull_request); + const head = readObject(pullRequest?.head); + context.branch = readString(head?.ref); + context.prNumber = readNumberString(payload.number) ?? readNumberString(pullRequest?.number); + break; + } + case 'repository_dispatch': { + const clientPayload = readObject(payload.client_payload); + context.action = readString(payload.action) ?? readString(payload.event_type); + context.branch = + readString(clientPayload?.branch) ?? + readString(clientPayload?.ref) ?? + readString(payload.ref) ?? + readString(payload.branch); + context.prNumber = + readNumberString(clientPayload?.pr_number) ?? + readNumberString(clientPayload?.pull_request_number) ?? + readNumberString(payload.pr_number); + context.failed = + readString(clientPayload?.status)?.toLowerCase() === 'failure' || + readString(clientPayload?.conclusion)?.toLowerCase() === 'failure'; + break; + } + } + + return context; +} + +function globToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const wildcarded = escaped.replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${wildcarded}$`); +} + +function matchesBranchPattern( + branch: string | undefined, + branchPatterns: string[] | undefined, +): boolean { + if (!branchPatterns || branchPatterns.length === 0) return true; + if (!branch) return false; + return branchPatterns.some((pattern) => globToRegExp(pattern).test(branch)); +} + +function findGithubWebhookMatch( + config: INightWatchConfig, + headers: IGithubWebhookHeaders, + payload: IObjectRecord, +): IGithubWebhookMatch | undefined { + const context = buildGithubContext(headers, payload); + if (!context) return undefined; + if (config.webhookTriggers.github.events.length > 0) { + const eventAllowed = config.webhookTriggers.github.events.includes(context.event); + if (!eventAllowed) return undefined; + } + + const rule = config.webhookTriggers.github.rules.find((candidate) => { + if (candidate.event !== context.event) return false; + if (candidate.action && candidate.action !== context.action) return false; + if (candidate.onlyOnFailure && !context.failed) return false; + return matchesBranchPattern(context.branch, candidate.branchPatterns); + }); + + if (!rule) return undefined; + return { rule, context }; +} + +function buildGithubWebhookEnv(context: IGithubWebhookContext): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + NW_WEBHOOK_SOURCE: 'github', + NW_WEBHOOK_EVENT: context.event, + }; + if (context.delivery) { + env.NW_WEBHOOK_DELIVERY = context.delivery; + } + if (context.prNumber) { + env.NW_WEBHOOK_PR_NUMBER = context.prNumber; + } + if (context.branch) { + env.NW_WEBHOOK_BRANCH = context.branch; + } + return env; +} + +function getLockPathForJob(projectDir: string, jobId: JobType): string { + switch (jobId) { + case 'executor': + return executorLockPath(projectDir); + case 'reviewer': + return reviewerLockPath(projectDir); + case 'qa': + return qaLockPath(projectDir); + case 'audit': + return auditLockPath(projectDir); + case 'slicer': + case 'planner': + return plannerLockPath(projectDir); + case 'analytics': + return analyticsLockPath(projectDir); + case 'pr-resolver': + return prResolverLockPath(projectDir); + case 'merger': + return mergerLockPath(projectDir); + } +} + +function createJobRouteHandlers(ctx: IProjectJobRoutesDeps): Router { + const router = Router({ mergeParams: true }); + const p = ctx.pathPrefix; + + router.post(`/${p}:id/run`, (req: Request, res: Response): void => { + try { + const config = ctx.getConfig(req); + const webhookConfig = config.webhookTriggers; + if (!webhookConfig.enabled) { + res.status(403).json({ error: 'Webhook triggers are disabled' }); + return; + } + + const secret = process.env[webhookConfig.secretEnv]; + if (!secret) { + res.status(403).json({ error: 'Webhook signing secret is not configured' }); + return; + } + + const rawBody = getRawBody(req); + const hasValidSignature = getSignatureHeaders(req).some((header) => + verifyHmacSignature(rawBody, header, secret), + ); + if (!hasValidSignature) { + res.status(401).json({ error: 'Invalid signature' }); + return; + } + + const requestedJobId = req.params.id; + const requestedJobDef = JOB_REGISTRY.find((job) => job.id === requestedJobId); + if (!requestedJobDef) { + res.status(404).json({ error: 'Unknown job id' }); + return; + } + + if (!webhookConfig.allowedJobIds.includes(requestedJobDef.id)) { + res.status(403).json({ error: 'Job is not allowed for webhook dispatch' }); + return; + } + + const githubHeaders = getGithubWebhookHeaders(req); + let githubEnv: NodeJS.ProcessEnv = {}; + let matchedGithubJobId: JobType | undefined; + if (hasGithubHeaders(githubHeaders) && webhookConfig.github.enabled) { + let payload: unknown; + try { + payload = parseJsonBody(rawBody); + } catch { + res.status(400).json({ error: 'Malformed JSON payload' }); + return; + } + + const match = findGithubWebhookMatch(config, githubHeaders, readObject(payload) ?? {}); + if (!match) { + const response: IIgnoredJobDispatchResponse = { + accepted: false, + ignored: true, + reason: 'No matching GitHub webhook rule', + }; + res.status(202).json(response); + return; + } + + matchedGithubJobId = match.rule.jobId; + githubEnv = buildGithubWebhookEnv(match.context); + } + + const dispatchJobId = matchedGithubJobId ?? requestedJobDef.id; + const jobDef = JOB_REGISTRY.find((job) => job.id === dispatchJobId); + if (!jobDef) { + res.status(404).json({ error: 'Unknown job id' }); + return; + } + + if (!webhookConfig.allowedJobIds.includes(jobDef.id)) { + res.status(403).json({ error: 'Job is not allowed for webhook dispatch' }); + return; + } + + const projectDir = ctx.getProjectDir(req); + const lock = checkLockFile(getLockPathForJob(projectDir, jobDef.id)); + if (lock.running) { + res.status(409).json({ + error: `${jobDef.name} is already running (PID ${lock.pid})`, + pid: lock.pid, + }); + return; + } + + const dispatchId = randomUUID(); + const child = spawn('night-watch', [jobDef.cliCommand], { + detached: true, + stdio: 'ignore', + cwd: projectDir, + env: { + ...process.env, + NW_WEBHOOK_DISPATCH_ID: dispatchId, + NW_WEBHOOK_JOB_ID: jobDef.id, + ...githubEnv, + }, + }); + + child.unref(); + + if (child.pid === undefined) { + res.status(500).json({ error: 'Failed to spawn process: no PID assigned' }); + return; + } + + const response: IJobDispatchResponse = { + accepted: true, + jobId: jobDef.id, + pid: child.pid, + dispatchId, + }; + res.status(202).json(response); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + return router; +} + +/** + * Single-project job routes (mounted at /api/jobs). + */ +export function createJobRoutes(deps: IJobRoutesDeps): Router { + return createJobRouteHandlers({ + getConfig: () => deps.getConfig(), + getProjectDir: () => deps.projectDir, + pathPrefix: '', + }); +} + +/** + * Project-scoped job routes for global mode (mounted at /api/projects/:id). + */ +export function createProjectJobRoutes(): Router { + return createJobRouteHandlers({ + getConfig: (req) => req.projectConfig!, + getProjectDir: (req) => req.projectDir!, + pathPrefix: 'jobs/', + }); +} diff --git a/scripts/night-watch-pr-reviewer-cron.sh b/scripts/night-watch-pr-reviewer-cron.sh index 0b0f9259..3332fc1d 100755 --- a/scripts/night-watch-pr-reviewer-cron.sh +++ b/scripts/night-watch-pr-reviewer-cron.sh @@ -63,11 +63,6 @@ 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}") GLOBAL_LOCK_FILE="/tmp/night-watch-pr-reviewer-${PROJECT_RUNTIME_KEY}.lock" @@ -93,6 +88,13 @@ emit_result() { fi } +require_provider_on_path() { + 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 +} + extract_review_score_from_text() { local review_text="${1:-}" printf '%s' "${review_text}" \ @@ -853,6 +855,8 @@ if [ -z "${TARGET_PR}" ] && [ "${WORKER_MODE}" != "1" ] && [ "${PARALLEL_ENABLED exit 0 fi + require_provider_on_path + log "PARALLEL: Launching ${#PR_NUMBER_ARRAY[@]} reviewer worker(s)" declare -a WORKER_PIDS=() @@ -1022,6 +1026,8 @@ if [ "${NW_DRY_RUN:-0}" = "1" ]; then exit 0 fi +require_provider_on_path + if ! prepare_detached_worktree "${PROJECT_DIR}" "${REVIEW_WORKTREE_DIR}" "${DEFAULT_BRANCH}" "${LOG_FILE}"; then log "FAIL: Unable to create isolated reviewer worktree ${REVIEW_WORKTREE_DIR}" exit 1 diff --git a/templates/night-watch.config.json b/templates/night-watch.config.json index 19d6db1d..1914e740 100644 --- a/templates/night-watch.config.json +++ b/templates/night-watch.config.json @@ -55,6 +55,27 @@ "audit": 10 } }, + "webhookTriggers": { + "enabled": false, + "secretEnv": "NIGHT_WATCH_WEBHOOK_SECRET", + "allowedJobIds": [ + "executor", + "reviewer", + "pr-resolver", + "slicer", + "qa", + "audit", + "analytics", + "merger" + ], + "requireTimestamp": false, + "maxSkewSeconds": 300, + "github": { + "enabled": false, + "events": [], + "rules": [] + } + }, "jobProviders": {}, "autoMerge": false, "autoMergeMethod": "squash", diff --git a/web/api.ts b/web/api.ts index 0376dd9e..6767437e 100644 --- a/web/api.ts +++ b/web/api.ts @@ -29,6 +29,9 @@ import type { IRoadmapStatus, IStatusSnapshot, IWebhookConfig, + IWebhookTriggerConfig, + IWebhookTriggerGithubConfig, + IWebhookTriggerGithubRule, JobType, MergeMethod, QaArtifacts, @@ -41,7 +44,8 @@ import { getWebJobDef } from './utils/jobs'; export type { ClaudeModel, DayOfWeek, IAnalyticsConfig, IAuditConfig, IBoardProviderConfig, IJobProviders, ILogInfo, IMergerConfig, INightWatchConfig, INotificationConfig, IPrdInfo, IProviderBucketConfig, IProviderPreset, IProviderScheduleOverride, IPrInfo, IProcessInfo, IQaConfig, - IPrResolverConfig, IQueueConfig, IRoadmapItem, IRoadmapScannerConfig, IRoadmapStatus, IStatusSnapshot, IWebhookConfig, + IPrResolverConfig, IQueueConfig, IRoadmapItem, IRoadmapScannerConfig, IRoadmapStatus, IStatusSnapshot, IWebhookConfig, IWebhookTriggerConfig, + IWebhookTriggerGithubConfig, IWebhookTriggerGithubRule, JobType, MergeMethod, QaArtifacts, QueueMode }; diff --git a/web/pages/Settings.tsx b/web/pages/Settings.tsx index 7bb0d7bf..63102d9b 100644 --- a/web/pages/Settings.tsx +++ b/web/pages/Settings.tsx @@ -19,16 +19,16 @@ import { IQaConfig, IRoadmapScannerConfig, IWebhookConfig, + IWebhookTriggerConfig, + JobType, removeProject, triggerInstallCron, updateConfig, updateGlobalNotifications, useApi, } from '../api'; -import WebhookEditor from '../components/settings/WebhookEditor.js'; import PresetFormModal from '../components/providers/PresetFormModal.js'; import Button from '../components/ui/Button'; -import Card from '../components/ui/Card'; import Badge from '../components/ui/Badge.js'; import Tabs from '../components/ui/Tabs'; import { useStore } from '../store/useStore'; @@ -59,6 +59,30 @@ const JOB_PROVIDER_KEYS: Array = [ 'merger', ]; +const WEBHOOK_TRIGGER_JOB_IDS: JobType[] = [ + 'executor', + 'reviewer', + 'qa', + 'audit', + 'slicer', + 'analytics', + 'pr-resolver', + 'merger', +]; + +const DEFAULT_WEBHOOK_TRIGGERS: IWebhookTriggerConfig = { + enabled: false, + secretEnv: 'NIGHT_WATCH_WEBHOOK_SECRET', + allowedJobIds: WEBHOOK_TRIGGER_JOB_IDS, + requireTimestamp: false, + maxSkewSeconds: 300, + github: { + enabled: false, + events: [], + rules: [], + }, +}; + type ConfigForm = { provider: INightWatchConfig['provider']; providerLabel: string; @@ -103,6 +127,7 @@ type ConfigForm = { prResolver: IPrResolverConfig; merger: IMergerConfig; queue: INightWatchConfig['queue']; + webhookTriggers: IWebhookTriggerConfig; }; const toFormState = (config: INightWatchConfig): ConfigForm => { @@ -174,6 +199,7 @@ const toFormState = (config: INightWatchConfig): ConfigForm => { }, providerBuckets: {}, }, + webhookTriggers: config.webhookTriggers ?? DEFAULT_WEBHOOK_TRIGGERS, }; }; @@ -377,6 +403,7 @@ const Settings: React.FC = () => { prResolver: form.prResolver, merger: form.merger, queue: form.queue, + webhookTriggers: form.webhookTriggers, }); // Update form directly from server response to ensure it reflects persisted values @@ -555,8 +582,10 @@ const Settings: React.FC = () => { form={form} updateField={updateField as (key: K, value: (typeof form)[K]) => void} globalWebhook={globalWebhook} + isGlobalMode={isGlobalMode} onSetGlobal={handleSetGlobal} onUnsetGlobal={handleUnsetGlobal} + selectedProjectId={selectedProjectId} /> ), }, diff --git a/web/pages/settings/IntegrationsTab.tsx b/web/pages/settings/IntegrationsTab.tsx index bed18a11..35c88e8c 100644 --- a/web/pages/settings/IntegrationsTab.tsx +++ b/web/pages/settings/IntegrationsTab.tsx @@ -1,6 +1,15 @@ +import { Clipboard, Plus, Trash2 } from 'lucide-react'; import React from 'react'; -import { IBoardProviderConfig, INotificationConfig, IWebhookConfig } from '../../api.js'; +import { + IBoardProviderConfig, + INotificationConfig, + IWebhookConfig, + IWebhookTriggerConfig, + IWebhookTriggerGithubRule, + JobType, +} from '../../api.js'; import WebhookEditor from '../../components/settings/WebhookEditor.js'; +import Button from '../../components/ui/Button'; import Card from '../../components/ui/Card'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; @@ -9,23 +18,144 @@ import Switch from '../../components/ui/Switch'; interface IIntegrationsFormFields { boardProvider: IBoardProviderConfig; notifications: INotificationConfig; + webhookTriggers: IWebhookTriggerConfig; } interface IIntegrationsTabProps { form: IIntegrationsFormFields; updateField: (key: K, value: IIntegrationsFormFields[K]) => void; globalWebhook?: IWebhookConfig | null; + isGlobalMode?: boolean; onSetGlobal?: (webhook: IWebhookConfig) => Promise; onUnsetGlobal?: () => Promise; + selectedProjectId?: string | null; +} + +const webhookJobOptions: Array<{ label: string; value: JobType }> = [ + { label: 'Executor', value: 'executor' }, + { label: 'Reviewer', value: 'reviewer' }, + { label: 'QA', value: 'qa' }, + { label: 'Audit', value: 'audit' }, + { label: 'Slicer / Planner', value: 'slicer' }, + { label: 'Analytics', value: 'analytics' }, + { label: 'PR Resolver', value: 'pr-resolver' }, + { label: 'Merger', value: 'merger' }, +]; + +const githubEventOptions = [ + { label: 'workflow_run', value: 'workflow_run' }, + { label: 'check_suite', value: 'check_suite' }, + { label: 'pull_request', value: 'pull_request' }, + { label: 'repository_dispatch', value: 'repository_dispatch' }, +]; + +function encodeProjectId(id: string): string { + return encodeURIComponent(id.replace(/\//g, '~')); +} + +function getOrigin(): string { + if (typeof window === 'undefined' || window.location.origin === 'null') return ''; + return window.location.origin; +} + +function formatBranches(branchPatterns?: string[]): string { + return branchPatterns?.join(', ') ?? ''; +} + +function parseBranches(value: string): string[] | undefined { + const branches = value.split(',').map((part) => part.trim()).filter(Boolean); + return branches.length > 0 ? branches : undefined; +} + +function createGithubRule(jobId: JobType): IWebhookTriggerGithubRule { + return { + event: 'workflow_run', + action: 'completed', + jobId, + }; +} + +function buildEndpointUrl(isGlobalMode: boolean, selectedProjectId: string | null | undefined, jobId: JobType): string { + const path = + isGlobalMode && selectedProjectId + ? `/api/projects/${encodeProjectId(selectedProjectId)}/jobs/${jobId}/run` + : `/api/jobs/${jobId}/run`; + return `${getOrigin()}${path}`; +} + +function buildCurlExample(endpointUrl: string): string { + return [ + `payload='{"source":"manual"}'`, + 'signature=$(printf \'%s\' "$payload" | openssl dgst -sha256 -hmac "$NIGHT_WATCH_WEBHOOK_SECRET" | awk \'{print $2}\')', + `curl -X POST '${endpointUrl}' \\`, + " -H 'Content-Type: application/json' \\", + ' -H "X-Night-Watch-Signature: sha256=$signature" \\', + ' --data "$payload"', + ].join('\n'); } const IntegrationsTab: React.FC = ({ form, updateField, globalWebhook, + isGlobalMode = false, onSetGlobal, onUnsetGlobal, + selectedProjectId, }) => { + const [copied, setCopied] = React.useState<'curl' | 'endpoint' | null>(null); + const webhookTriggers = form.webhookTriggers; + const endpointJobId = webhookTriggers.allowedJobIds[0] ?? 'reviewer'; + const endpointUrl = buildEndpointUrl(isGlobalMode, selectedProjectId, endpointJobId); + const curlExample = buildCurlExample(endpointUrl); + + const updateWebhookTriggers = (updates: Partial) => { + updateField('webhookTriggers', { + ...webhookTriggers, + ...updates, + }); + }; + + const updateGithub = (updates: Partial) => { + updateWebhookTriggers({ + github: { + ...webhookTriggers.github, + ...updates, + }, + }); + }; + + const toggleAllowedJob = (jobId: JobType) => { + const allowedJobIds = webhookTriggers.allowedJobIds.includes(jobId) + ? webhookTriggers.allowedJobIds.filter((id) => id !== jobId) + : [...webhookTriggers.allowedJobIds, jobId]; + updateWebhookTriggers({ allowedJobIds }); + }; + + const toggleGithubEvent = (event: string) => { + const events = webhookTriggers.github.events.includes(event) + ? webhookTriggers.github.events.filter((id) => id !== event) + : [...webhookTriggers.github.events, event]; + updateGithub({ events }); + }; + + const updateRule = (index: number, updates: Partial) => { + const rules = webhookTriggers.github.rules.map((rule, ruleIndex) => + ruleIndex === index ? { ...rule, ...updates } : rule, + ); + updateGithub({ rules }); + }; + + const removeRule = (index: number) => { + updateGithub({ rules: webhookTriggers.github.rules.filter((_, ruleIndex) => ruleIndex !== index) }); + }; + + const copyText = async (value: string, target: 'curl' | 'endpoint') => { + await navigator.clipboard?.writeText(value); + setCopied(target); + window.setTimeout(() => setCopied(null), 1500); + }; + return (
@@ -95,6 +225,197 @@ const IntegrationsTab: React.FC = ({ )} + +
+
+

Inbound Webhook Triggers

+

Run signed Night Watch jobs from external systems

+
+ updateWebhookTriggers({ enabled: checked })} + /> +
+ +
+ updateWebhookTriggers({ secretEnv: e.target.value })} + helperText="Server environment variable containing the shared HMAC secret" + /> + + updateWebhookTriggers({ + maxSkewSeconds: e.target.value ? Math.max(0, Number(e.target.value)) : 0, + }) + } + helperText="Seconds, used when timestamp validation is required" + /> +
+ updateWebhookTriggers({ requireTimestamp: checked })} + /> +
+
+ +
+ +
+ {webhookJobOptions.map((job) => ( + + ))} +
+
+ +
+
+ + +
+
+
+ + +
+
+              {curlExample}
+            
+
+
+ +
+
+
+

GitHub Events

+

Rules dispatch the configured job when GitHub payloads match

+
+ updateGithub({ enabled: checked })} + /> +
+ +
+ +
+ {githubEventOptions.map((event) => ( + + ))} +
+
+ +
+ {webhookTriggers.github.rules.map((rule, index) => ( +
+
+ updateRule(index, { action: e.target.value || undefined })} + placeholder="completed" + /> + updateRule(index, { branchPatterns: parseBranches(e.target.value) })} + placeholder="main, release/*" + /> + updateRule(index, { onlyOnFailure: checked })} + /> + +
+
+ ))} + +
+
+
+

Notification Webhooks

diff --git a/web/pages/settings/__tests__/IntegrationsTab.test.tsx b/web/pages/settings/__tests__/IntegrationsTab.test.tsx new file mode 100644 index 00000000..712cc583 --- /dev/null +++ b/web/pages/settings/__tests__/IntegrationsTab.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { IBoardProviderConfig, INotificationConfig, IWebhookTriggerConfig } from '../../../api'; +import IntegrationsTab from '../IntegrationsTab'; + +describe('IntegrationsTab', () => { + it('should render inbound webhook trigger settings', () => { + const boardProvider: IBoardProviderConfig = { + enabled: true, + provider: 'github', + }; + const notifications: INotificationConfig = { + webhooks: [], + }; + const webhookTriggers: IWebhookTriggerConfig = { + enabled: false, + secretEnv: 'NIGHT_WATCH_WEBHOOK_SECRET', + allowedJobIds: ['reviewer', 'qa'], + requireTimestamp: false, + maxSkewSeconds: 300, + github: { + enabled: true, + events: ['workflow_run'], + rules: [{ event: 'workflow_run', action: 'completed', jobId: 'qa' }], + }, + }; + + const { container } = render( + , + ); + + expect(screen.getByText('Inbound Webhook Triggers')).toBeInTheDocument(); + expect(container.querySelector('input[name="webhookTriggers.enabled"]')).toBeInTheDocument(); + }); +});