Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/draft-scan-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"layne": major
---

Skip draft pull requests by default across direct and deferred triggers, and handle ready_for_review events. Set trigger.scanOnDraft to true globally or per repository to preserve scanning drafts. Deferred CI workflows should subscribe to ready_for_review.
2 changes: 1 addition & 1 deletion .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '22'
- run: rm -f package-lock.json && npm install
- run: npm ci
- run: npm run build
- run: npm run lint
- run: npm run validate-config
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Two separate Node.js processes:
- Express app with `POST /webhook`, `GET /health`, `GET /metrics` (when enabled), `GET /assets/layne-logo.png`
- Verifies GitHub HMAC signature before processing
- Handles four event types: `pull_request`, `workflow_run`, `workflow_job`, and `issue_comment`
- **`pull_request` trigger (default):** on opened/synchronize/reopened, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200
- **`pull_request` trigger (default):** ignores drafts by default; on opened/synchronize/reopened/ready_for_review for eligible PRs, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200
- **`workflow_run` trigger:** on `pull_request` events, caches PR metadata in Redis (TTL 7 days) and creates a `skipped` Check Run; on `workflow_run completed` events matching the configured workflow name and conclusion, looks up cached PR metadata (falls back to GitHub API if cache is cold) then enqueues the scan
- **`workflow_job` trigger:** same two-stage pattern as `workflow_run` but gates on a single named job completing rather than the whole workflow
- **`issue_comment` trigger:** parses `/layne exception-approve` commands from PR comments; validates the commenter is an authorized exception approver; stores exceptions in Redis scoped to the PR (not the commit SHA); re-enqueues the scan if the current check run is in `failure` state
Expand Down Expand Up @@ -158,6 +158,7 @@ Key points for code navigation:
- Supports `$global` key for defaults inherited by all repos
- Scanner blocks: per-repo spread over defaults (`{ ...DEFAULT_CONFIG.semgrep, ...repoOverrides.semgrep }`)
- `trigger`: controls when scanning fires - `pull_request` (default, immediate) or `workflow_run` (deferred until a named CI workflow completes); global default → per-repo override
- `trigger.scanOnDraft`: defaults to `false` and applies to pull request, workflow run, and workflow job triggers
- `notifications` and `labels`: per-repo notifier/key wins over global; per-repo absence = inherit global entirely
- `extraArgs` fully replaces the default (not extended)
- `config/layne.json` must be present in the Docker image (`COPY config/ ./config/`)
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/config-validator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { validateConfig } from '../config-validator.js';

describe('trigger draft config validation', () => {
it('accepts scanOnDraft for every trigger mode', () => {
expect(validateConfig({ '$global': { trigger: { scanOnDraft: false } } })).toEqual({ valid: true });
expect(validateConfig({ 'acme/run': { trigger: { on: 'workflow_run', workflow: 'CI', scanOnDraft: true } } })).toEqual({ valid: true });
expect(validateConfig({ 'acme/job': { trigger: { on: 'workflow_job', job: 'test', scanOnDraft: true } } })).toEqual({ valid: true });
});

it('rejects non-boolean scanOnDraft values', () => {
expect(validateConfig({ '$global': { trigger: { scanOnDraft: 'false' } } }).valid).toBe(false);
expect(validateConfig({ 'acme/repo': { trigger: { scanOnDraft: null } } }).valid).toBe(false);
});
});
20 changes: 17 additions & 3 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ describe('loadScanConfig()', () => {
it('returns the default pull_request trigger when no trigger is configured', async () => {
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({}));
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
expect(config.trigger).toEqual({ on: 'pull_request' });
expect(config.trigger).toEqual({ on: 'pull_request', scanOnDraft: false });
});

it('returns a workflow_run trigger configured at the repo level', async () => {
Expand All @@ -233,7 +233,7 @@ describe('loadScanConfig()', () => {
},
}));
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'Tests Done' });
expect(config.trigger).toEqual({ on: 'workflow_run', scanOnDraft: false, workflow: 'Tests Done' });
});

it('inherits $global trigger when the repo has no trigger block', async () => {
Expand All @@ -242,7 +242,7 @@ describe('loadScanConfig()', () => {
'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } },
}));
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'CI' });
expect(config.trigger).toEqual({ on: 'workflow_run', scanOnDraft: false, workflow: 'CI' });
});

it('repo-level trigger overrides $global trigger', async () => {
Expand All @@ -264,6 +264,20 @@ describe('loadScanConfig()', () => {
expect((config.trigger as { conclusions: string[] }).conclusions).toEqual(['success', 'failure']);
});

it('supports global scanOnDraft with a repository override', async () => {
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
'$global': { trigger: { scanOnDraft: true } },
'acme/frontend': { trigger: { scanOnDraft: false } },
'acme/backend': {},
}));

const frontend = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
const backend = await loadScanConfig({ owner: 'acme', repo: 'backend' });

expect(frontend.trigger).toEqual({ on: 'pull_request', scanOnDraft: false });
expect(backend.trigger).toEqual({ on: 'pull_request', scanOnDraft: true });
});

// --- comment ---

it('returns comment.enabled=false by default', async () => {
Expand Down
117 changes: 111 additions & 6 deletions src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ const { isReviewerAuthorized, parseExceptionCommand,
storeExceptions } = await import('../exception-approvals.js');
const { app, verifySignature, processWebhookRequest } = await import('../server.js');

const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request' } };
const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'] } };
const WORKFLOW_JOB_TRIGGER_CONFIG = { trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success'] } };
const EXCEPTION_CONFIG = { trigger: { on: 'pull_request' }, exceptionApprovers: { users: ['alice'], teams: [] } };
const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request', scanOnDraft: false } };
const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'], scanOnDraft: false } };
const WORKFLOW_JOB_TRIGGER_CONFIG = { trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success'], scanOnDraft: false } };
const EXCEPTION_CONFIG = { trigger: { on: 'pull_request', scanOnDraft: false }, exceptionApprovers: { users: ['alice'], teams: [] } };

function sign(body: Buffer | string): string {
return 'sha256=' + crypto
Expand All @@ -55,12 +55,13 @@ function sign(body: Buffer | string): string {
.digest('hex');
}

function prPayload(action = 'opened'): string {
function prPayload(action = 'opened', metadata: { draft?: boolean } = {}): string {
return JSON.stringify({
action,
number: 42,
pull_request: {
number: 42,
draft: metadata.draft ?? false,
head: { sha: 'abc123', ref: 'feature/login', repo: {} },
base: { sha: 'def456', ref: 'main' },
labels: [{ name: 'bug' }],
Expand Down Expand Up @@ -149,6 +150,7 @@ beforeEach(() => {
(getLatestCheckRun as ReturnType<typeof vi.fn>).mockResolvedValue({ conclusion: 'failure' });
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'open',
draft: false,
head: { sha: 'abc123', ref: 'feature/login' },
base: { sha: 'def456', ref: 'main' },
labels: [],
Expand Down Expand Up @@ -227,7 +229,7 @@ describe('processWebhookRequest()', () => {
expect(createCheckRun).not.toHaveBeenCalled();
});

it.each(['opened', 'synchronize', 'reopened'])(
it.each(['opened', 'synchronize', 'reopened', 'ready_for_review'])(
'accepts action "%s" only after the check run and queue job are created',
async (action) => {
const res = await processWebhookRequest(webhookRequest(prPayload(action)));
Expand All @@ -238,6 +240,37 @@ describe('processWebhookRequest()', () => {
}
);

it.each(['opened', 'synchronize', 'reopened'])(
'ignores draft action "%s" by default',
async (action) => {
const res = await processWebhookRequest(webhookRequest(prPayload(action, { draft: true })));

expect(res).toEqual({ status: 200, body: 'Draft ignored' });
expect(createCheckRun).not.toHaveBeenCalled();
expect(scanQueue.add).not.toHaveBeenCalled();
}
);

it('scans drafts and ready_for_review events when scanOnDraft is enabled', async () => {
(loadScanConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
trigger: { on: 'pull_request', scanOnDraft: true },
});

const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true })));
expect(draftRes).toEqual({ status: 200, body: 'Accepted' });
expect(scanQueue.add).toHaveBeenCalledOnce();

vi.clearAllMocks();
(loadScanConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
trigger: { on: 'pull_request', scanOnDraft: true },
});

const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review')));
expect(readyRes).toEqual({ status: 200, body: 'Accepted' });
expect(createCheckRun).toHaveBeenCalledOnce();
expect(scanQueue.add).toHaveBeenCalledOnce();
});

it('does not resolve before both persistence steps succeed', async () => {
const checkRun = deferred();
const enqueue = deferred();
Expand Down Expand Up @@ -403,6 +436,20 @@ describe('workflow_run trigger — pull_request event', () => {
expect(createCheckRun).not.toHaveBeenCalled();
});

it('ignores draft PRs and starts deferral when they become ready', async () => {
const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true })));

expect(draftRes).toEqual({ status: 200, body: 'Draft ignored' });
expect(redis.set).not.toHaveBeenCalled();
expect(skipCheckRun).not.toHaveBeenCalled();

const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review')));

expect(readyRes).toEqual({ status: 200, body: 'Deferred' });
expect(redis.set).toHaveBeenCalledOnce();
expect(skipCheckRun).toHaveBeenCalledOnce();
});

it('caches PR metadata in Redis with a 7-day TTL', async () => {
await processWebhookRequest(webhookRequest(prPayload('opened')));

Expand Down Expand Up @@ -476,6 +523,28 @@ describe('workflow_run trigger — workflow_run event', () => {
expect(scanQueue.add).toHaveBeenCalledOnce();
});

it('does not enqueue when the live PR is a draft by default', async () => {
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'open', draft: true });

const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));

expect(res).toEqual({ status: 200, body: 'PR not found' });
expect(createCheckRun).not.toHaveBeenCalled();
expect(scanQueue.add).not.toHaveBeenCalled();
});

it('enqueues a draft when scanOnDraft is enabled', async () => {
(loadScanConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
trigger: { ...WORKFLOW_TRIGGER_CONFIG.trigger, scanOnDraft: true },
});
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'open', draft: true });

const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));

expect(res).toEqual({ status: 200, body: 'Accepted' });
expect(scanQueue.add).toHaveBeenCalledOnce();
});

it('enqueues with the correct job payload from the cached PR data', async () => {
await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));

Expand Down Expand Up @@ -665,6 +734,20 @@ describe('workflow_job trigger — pull_request event', () => {
expect(createCheckRun).not.toHaveBeenCalled();
});

it('ignores draft PRs and starts deferral when they become ready', async () => {
const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true })));

expect(draftRes).toEqual({ status: 200, body: 'Draft ignored' });
expect(redis.set).not.toHaveBeenCalled();
expect(skipCheckRun).not.toHaveBeenCalled();

const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review')));

expect(readyRes).toEqual({ status: 200, body: 'Deferred' });
expect(redis.set).toHaveBeenCalledOnce();
expect(skipCheckRun).toHaveBeenCalledOnce();
});

it('caches PR metadata in Redis with a 7-day TTL', async () => {
await processWebhookRequest(webhookRequest(prPayload('opened')));

Expand Down Expand Up @@ -738,6 +821,28 @@ describe('workflow_job trigger — workflow_job event', () => {
expect(scanQueue.add).toHaveBeenCalledOnce();
});

it('does not enqueue when the live PR is a draft by default', async () => {
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'open', draft: true });

const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));

expect(res).toEqual({ status: 200, body: 'PR not found' });
expect(createCheckRun).not.toHaveBeenCalled();
expect(scanQueue.add).not.toHaveBeenCalled();
});

it('enqueues a draft when scanOnDraft is enabled', async () => {
(loadScanConfig as ReturnType<typeof vi.fn>).mockResolvedValue({
trigger: { ...WORKFLOW_JOB_TRIGGER_CONFIG.trigger, scanOnDraft: true },
});
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'open', draft: true });

const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));

expect(res).toEqual({ status: 200, body: 'Accepted' });
expect(scanQueue.add).toHaveBeenCalledOnce();
});

it('enqueues with the correct job payload from the cached PR data', async () => {
await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));

Expand Down
3 changes: 3 additions & 0 deletions src/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ function validateTrigger(block: unknown, ctx: string, errors: string[]): void {

const on = (b['on'] as string | undefined) ?? 'pull_request';

if (b['scanOnDraft'] !== undefined && typeof b['scanOnDraft'] !== 'boolean')
errors.push(`${ctx}.scanOnDraft: must be a boolean`);

if (on === 'workflow_run') {
if (b['workflow'] === undefined || b['workflow'] === null)
errors.push(`${ctx}.workflow: required when "on" is "workflow_run"`);
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const DEFAULT_CONFIG: Readonly<ScanConfig> = Object.freeze({
extraArgs: [],
} as DepDoctorConfig),
labels: Object.freeze({} as LabelConfig),
trigger: Object.freeze({ on: 'pull_request' } as TriggerConfig),
trigger: Object.freeze({ on: 'pull_request', scanOnDraft: false } as TriggerConfig),
comment: Object.freeze({ enabled: false, template: null } as CommentConfig),
exceptionApprovers: Object.freeze({ users: [], teams: [] } as ExceptionApproversConfig),
notifications: Object.freeze({} as Record<string, never>),
Expand Down
33 changes: 27 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const app = express();
const PORT = process.env.PORT ?? 3000;

const ACCEPTED_RESPONSE = { status: 200, body: 'Accepted' };
const HANDLED_PR_ACTIONS = new Set(['opened', 'synchronize', 'reopened']);
const HANDLED_PR_ACTIONS = new Set(['opened', 'synchronize', 'reopened', 'ready_for_review']);
const WEBHOOK_LOCK_TTL_SECONDS = 30;
const PR_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days

Expand Down Expand Up @@ -131,7 +131,7 @@ async function handlePullRequest(payload: Record<string, unknown>): Promise<{ st
repository: Record<string, unknown>;
installation: Record<string, unknown>;
};
const pr = pull_request as { number: number; head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
const pr = pull_request as { number: number; draft?: boolean; head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
const prNumber = pr.number;
const headSha = pr.head.sha;
const repo = repository as { full_name: string; owner: { login: string }; name: string };
Expand All @@ -146,6 +146,11 @@ async function handlePullRequest(payload: Record<string, unknown>): Promise<{ st

const config = await loadScanConfig({ owner: repo.owner.login, repo: repo.name });

if (pr.draft === true && !config.trigger.scanOnDraft) {
debug('server', `ignoring draft PR: ${repo.full_name} PR #${prNumber}`);
return { status: 200, body: 'Draft ignored' };
}

if (config.trigger.on === 'workflow_run' || config.trigger.on === 'workflow_job') {
return deferPullRequest({ pull_request: pr, repository: repo, installation, config });
}
Expand Down Expand Up @@ -375,7 +380,12 @@ async function handleWorkflowRun(payload: Record<string, unknown>): Promise<{ st
}

const headSha = run.head_sha;
const prData = await resolvePrData({ installation, repository: repo, headSha });
const prData = await resolvePrData({
installation,
repository: repo,
headSha,
scanOnDraft: config.trigger.scanOnDraft,
});

if (!prData) {
console.warn(`[server] workflow_run: could not find PR for ${repo.full_name}@${headSha} — scan skipped`);
Expand Down Expand Up @@ -438,7 +448,12 @@ async function handleWorkflowJob(payload: Record<string, unknown>): Promise<{ st
}

const headSha = job.head_sha;
const prData = await resolvePrData({ installation, repository: repo, headSha });
const prData = await resolvePrData({
installation,
repository: repo,
headSha,
scanOnDraft: config.trigger.scanOnDraft,
});

if (!prData) {
console.warn(`[server] workflow_job: could not find PR for ${repo.full_name}@${headSha} — scan skipped`);
Expand All @@ -461,10 +476,11 @@ async function handleWorkflowJob(payload: Record<string, unknown>): Promise<{ st
});
}

async function resolvePrData({ installation, repository, headSha }: {
async function resolvePrData({ installation, repository, headSha, scanOnDraft }: {
installation: Record<string, unknown>;
repository: { full_name: string; owner: { login: string }; name: string; clone_url?: string };
headSha: string;
scanOnDraft: boolean;
}): Promise<PRCacheData | null> {
const cacheKey = prCacheKey(repository.full_name, headSha);
const cached = await redis.get(cacheKey);
Expand Down Expand Up @@ -516,10 +532,15 @@ async function resolvePrData({ installation, repository, headSha }: {
repo: repository.name,
prNumber: prData.prNumber,
});
if ((livePr as { state?: string }).state !== 'open') {
const liveState = livePr as { state?: string; draft?: boolean };
if (liveState.state !== 'open') {
debug('server', `PR #${prData.prNumber} is no longer open, skipping scan`);
return null;
}
if (liveState.draft === true && !scanOnDraft) {
debug('server', `PR #${prData.prNumber} is a draft, skipping scan`);
return null;
}
} catch (err) {
console.error(`[server] Failed to verify PR state: ${(err as Error).message}`);
return null;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export interface LabelConfig {

export interface TriggerConfig {
on: TriggerOn;
scanOnDraft: boolean;
workflow?: string;
job?: string;
conclusions?: string[];
Expand Down
Loading
Loading