Skip to content

Commit cd73ead

Browse files
chore: improve exception approvals to handle approvals better (#28)
1 parent 25248cc commit cd73ead

File tree

11 files changed

+699
-99
lines changed

11 files changed

+699
-99
lines changed

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Two separate Node.js processes:
5858
- **`pull_request` trigger (default):** on opened/synchronize/reopened, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200
5959
- **`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
6060
- **`workflow_job` trigger:** same two-stage pattern as `workflow_run` but gates on a single named job completing rather than the whole workflow
61-
- **`issue_comment` trigger:** parses `/layne exception-approve` commands from PR comments; validates the commenter is an authorized exception approver; stores exceptions in Redis keyed to the current head SHA; re-enqueues the scan if the current check run is in `failure` state
61+
- **`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
6262
- Job ID is deduplicated by `{repo}#{pr}@{sha}` - duplicate webhook deliveries are no-ops (Redis lock + queue check)
6363
- Exported `app` and `processWebhookRequest` for use in tests
6464

@@ -81,9 +81,9 @@ Two separate Node.js processes:
8181
9. Run scanners in parallel via `src/dispatcher.js``dispatch()`
8282
10. Validate finding locations against the actual file content (`src/location-validator.js``validateFindingLocations`)
8383
11. Suppress findings that have a `// SECURITY:` comment at the merge base (`src/suppressor.js``suppressFindings`)
84-
12. Filter to actionable findings; stamp each with a deterministic `_findingId` (`LAYNE-xxxxxxxx`) via `src/exception-approvals.js``generateFindingId`
84+
12. Filter to actionable findings; stamp each with a deterministic `_findingId` (`LAYNE-xxxxxxxxxxxxxxxx`) via `src/exception-approvals.js``generateFindingId`
8585
13. Convert findings to annotations via `src/reporter.js``buildAnnotations()`
86-
14. If `exceptionApprovers` is configured: load stored exceptions from Redis and call `buildExceptionSummary` to potentially override conclusion to `success`
86+
14. If `exceptionApprovers` is configured: load stored exceptions from Redis (`loadExceptions`), remove stale ones whose flagged line changed (`filterStaleExceptions`), resolve approvals that survived a line-number shift via rebase (`resolveDriftedExceptions`), then call `buildExceptionSummary` to potentially override conclusion to `success`
8787
15. Complete Check Run
8888
16. Post PR comment if `comment.enabled` via `src/commenter.js``postComment`
8989
17. Apply/remove PR labels via `src/github.js``ensureLabelsExist` + `setLabels`

src/__tests__/exception-approvals.test.js

Lines changed: 370 additions & 37 deletions
Large diffs are not rendered by default.

src/__tests__/fetcher.test.js

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ vi.mock('child_process', () => ({
1717
execFile: mockExecFile,
1818
}));
1919

20-
const { createWorkspace, setupRepo, getChangedFiles, getChangedLineRanges, checkoutFiles, cleanupWorkspace } = await import('../fetcher.js');
20+
const { createWorkspace, setupRepo, getChangedFiles, getChangedLineRanges, checkoutFiles, cleanupWorkspace, fetchCommit } = await import('../fetcher.js');
2121

2222
// ---------------------------------------------------------------------------
2323
// Helpers
@@ -179,6 +179,44 @@ describe('getChangedFiles()', () => {
179179

180180
// ---------------------------------------------------------------------------
181181

182+
describe('fetchCommit()', () => {
183+
beforeEach(() => vi.clearAllMocks());
184+
185+
it('fetches the given SHA with --depth 1 and --filter=blob:none', async () => {
186+
await fetchCommit({ workspacePath: '/tmp/ws', sha: 'deadbeef' });
187+
188+
const [cmd, args] = mockExecFile.mock.calls[0];
189+
expect(cmd).toBe('git');
190+
expect(args).toContain('fetch');
191+
expect(args).toContain('--depth');
192+
expect(args).toContain('1');
193+
expect(args).toContain('--filter=blob:none');
194+
expect(args).toContain('origin');
195+
expect(args).toContain('deadbeef');
196+
});
197+
198+
it('uses Git protocol v2', async () => {
199+
await fetchCommit({ workspacePath: '/tmp/ws', sha: 'deadbeef' });
200+
expect(mockExecFile.mock.calls[0][1]).toContain('protocol.version=2');
201+
});
202+
203+
it('runs inside the given workspace path', async () => {
204+
await fetchCommit({ workspacePath: '/tmp/my-workspace', sha: 'abc' });
205+
const args = mockExecFile.mock.calls[0][1];
206+
expect(args).toContain('-C');
207+
expect(args).toContain('/tmp/my-workspace');
208+
});
209+
210+
it('throws when git exits with a non-zero code', async () => {
211+
mockExecFile.mockImplementationOnce((cmd, args, cb) =>
212+
cb(new Error('unknown revision'), '', '')
213+
);
214+
await expect(fetchCommit({ workspacePath: '/tmp/ws', sha: 'bad' })).rejects.toThrow('unknown revision');
215+
});
216+
});
217+
218+
// ---------------------------------------------------------------------------
219+
182220
describe('getChangedLineRanges()', () => {
183221
beforeEach(() => vi.clearAllMocks());
184222

@@ -206,6 +244,24 @@ describe('getChangedLineRanges()', () => {
206244
});
207245
});
208246

247+
it('scopes the diff to specific files when files array is provided', async () => {
248+
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, '', ''));
249+
await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h', files: ['src/a.js', 'src/b.js'] });
250+
251+
const args = mockExecFile.mock.calls[0][1];
252+
expect(args).toContain('--');
253+
expect(args).toContain('src/a.js');
254+
expect(args).toContain('src/b.js');
255+
});
256+
257+
it('does not append -- when files array is empty', async () => {
258+
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, '', ''));
259+
await getChangedLineRanges({ workspacePath: '/tmp/ws', baseSha: 'b', headSha: 'h' });
260+
261+
const args = mockExecFile.mock.calls[0][1];
262+
expect(args).not.toContain('--');
263+
});
264+
209265
it('ignores deleted hunks that have no lines in the head revision', async () => {
210266
mockExecFile.mockImplementationOnce((cmd, args, cb) => cb(null, [
211267
'diff --git a/src/app.js b/src/app.js',

src/__tests__/server.test.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -976,13 +976,13 @@ describe('issue_comment handler', () => {
976976
));
977977

978978
expect(storeExceptions).toHaveBeenCalledWith(expect.objectContaining({
979-
owner: 'org',
980-
repo: 'my-repo',
981-
prNumber: 42,
982-
headSha: 'abc123',
983-
findingIds: ['LAYNE-a3f29c81'],
984-
approver: 'alice',
985-
reason: 'test cred',
979+
owner: 'org',
980+
repo: 'my-repo',
981+
prNumber: 42,
982+
approvedHeadSha: 'abc123',
983+
findingIds: ['LAYNE-a3f29c81'],
984+
approver: 'alice',
985+
reason: 'test cred',
986986
}));
987987
});
988988

src/__tests__/worker.test.js

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ vi.mock('../location-validator.js', () => ({
112112
}));
113113

114114
vi.mock('../exception-approvals.js', () => ({
115-
generateFindingId: vi.fn().mockReturnValue('LAYNE-a3f29c81'),
116-
loadExceptions: vi.fn().mockResolvedValue(new Map()),
117-
buildExceptionSummary: vi.fn(({ baseSummary }) => ({ conclusion: 'failure', summary: baseSummary })),
115+
generateFindingId: vi.fn().mockReturnValue('LAYNE-a3f29c81'),
116+
loadExceptions: vi.fn().mockResolvedValue(new Map()),
117+
filterStaleExceptions: vi.fn(async ({ exceptions }) => exceptions),
118+
resolveDriftedExceptions: vi.fn(async () => new Map()),
119+
buildExceptionSummary: vi.fn(({ baseSummary }) => ({ conclusion: 'failure', summary: baseSummary })),
118120
}));
119121

120122
const { Worker: MockWorker } = await import('bullmq');
@@ -130,7 +132,7 @@ const { loadScanConfig } = await import('../config.js');
130132
const { notify } = await import('../notifiers/index.js');
131133
const { postComment } = await import('../commenter.js');
132134
const { redis } = await import('../queue.js');
133-
const { generateFindingId, loadExceptions, buildExceptionSummary } = await import('../exception-approvals.js');
135+
const { generateFindingId, loadExceptions, filterStaleExceptions, resolveDriftedExceptions, buildExceptionSummary } = await import('../exception-approvals.js');
134136
const { createScanContext, filterFindingsToChangedLines } = await import('../scan-context.js');
135137
const { processJob, shutdown } = await import('../worker.js');
136138

@@ -933,11 +935,50 @@ describe('processJob()', () => {
933935
owner: 'org',
934936
repo: 'repo',
935937
prNumber: 7,
936-
headSha: 'abc123',
937938
findingIds: ['LAYNE-a3f29c81'],
938939
}));
939940
});
940941

942+
it('calls filterStaleExceptions with loaded exceptions after loadExceptions', async () => {
943+
const loaded = new Map([['LAYNE-a3f29c81', { approver: 'alice', reason: 'ok', timestamp: '', approvedHeadSha: 'abc123' }]]);
944+
loadExceptions.mockResolvedValueOnce(loaded);
945+
buildExceptionSummary.mockReturnValueOnce({ conclusion: 'success', summary: 'Excepted.' });
946+
947+
await processJob(baseJob);
948+
949+
expect(filterStaleExceptions).toHaveBeenCalledWith(expect.objectContaining({
950+
exceptions: loaded,
951+
currentHeadSha: 'abc123',
952+
}));
953+
});
954+
955+
it('calls resolveDriftedExceptions with unmatched blocking findings after filterStaleExceptions', async () => {
956+
// filterStaleExceptions returns empty — no direct exception match for the finding.
957+
filterStaleExceptions.mockResolvedValueOnce(new Map());
958+
959+
await processJob(baseJob);
960+
961+
expect(resolveDriftedExceptions).toHaveBeenCalledWith(expect.objectContaining({
962+
unmatchedFindings: expect.arrayContaining([
963+
expect.objectContaining({ _findingId: 'LAYNE-a3f29c81' }),
964+
]),
965+
currentHeadSha: 'abc123',
966+
}));
967+
});
968+
969+
it('merges drifted exceptions into the exceptions map passed to buildExceptionSummary', async () => {
970+
filterStaleExceptions.mockResolvedValueOnce(new Map());
971+
const drifted = new Map([['LAYNE-a3f29c81', { approver: 'bob', reason: 'drift', timestamp: '' }]]);
972+
resolveDriftedExceptions.mockResolvedValueOnce(drifted);
973+
buildExceptionSummary.mockReturnValueOnce({ conclusion: 'success', summary: 'Drift pass.' });
974+
975+
await processJob(baseJob);
976+
977+
expect(buildExceptionSummary).toHaveBeenCalledWith(expect.objectContaining({
978+
exceptions: expect.objectContaining({ size: 1 }),
979+
}));
980+
});
981+
941982
it('does not call loadExceptions when there are no blocking findings', async () => {
942983
const lowFinding = { ...finding, severity: 'low' };
943984
dispatch.mockResolvedValueOnce([lowFinding]);

src/exception-approvals.js

Lines changed: 143 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import crypto from 'crypto';
22
import { getTeamMembers } from './github.js';
33
import { redis } from './queue.js';
4+
import { fetchCommit, getChangedLineRanges, buildLineMapForFile } from './fetcher.js';
45

56
const EXCEPTION_TTL = 30 * 24 * 60 * 60; // 30 days in seconds
67

78
export function generateFindingId(finding) {
89
const input = `${finding.tool}:${finding.file}:${finding.line ?? finding.startLine}`;
9-
return 'LAYNE-' + crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
10+
return 'LAYNE-' + crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
1011
}
1112

1213
// Returns { ids, reason } | { ids, reason: null, error } | null
@@ -21,10 +22,10 @@ export function parseExceptionCommand(body) {
2122
const afterCommand = commandLine.slice(commandIndex + '/layne exception-approve'.length).trim();
2223

2324
const tokens = afterCommand.split(/\s+/).filter(Boolean);
24-
const ids = tokens.filter(t => /^LAYNE-[0-9a-f]{8}$/.test(t));
25+
const ids = tokens.filter(t => /^LAYNE-[0-9a-f]{16}$/.test(t));
2526

2627
if (ids.length === 0) {
27-
return { ids: [], reason: null, error: 'No valid finding IDs found. IDs must match LAYNE-xxxxxxxx format.' };
28+
return { ids: [], reason: null, error: 'No valid finding IDs found. IDs must match LAYNE-xxxxxxxxxxxxxxxx format.' };
2829
}
2930

3031
const reasonIndex = tokens.findIndex(t => t === 'reason:');
@@ -40,19 +41,23 @@ export function parseExceptionCommand(body) {
4041
return { ids, reason };
4142
}
4243

43-
export async function storeExceptions({ owner, repo, prNumber, headSha, findingIds, approver, reason }) {
44-
const value = JSON.stringify({ approver, reason, timestamp: new Date().toISOString() });
44+
export async function storeExceptions({ owner, repo, prNumber, approvedHeadSha, findingIds, approver, reason }) {
45+
const value = JSON.stringify({ approver, reason, timestamp: new Date().toISOString(), approvedHeadSha });
46+
const setKey = `layne:exception-ids:${owner}/${repo}#${prNumber}`;
4547

46-
await Promise.all(findingIds.map(findingId => {
47-
const key = `layne:exception:${owner}/${repo}#${prNumber}@${headSha}:${findingId}`;
48-
return redis.set(key, value, 'EX', EXCEPTION_TTL);
49-
}));
48+
await Promise.all([
49+
...findingIds.map(findingId => {
50+
const key = `layne:exception:${owner}/${repo}#${prNumber}:${findingId}`;
51+
return redis.set(key, value, 'EX', EXCEPTION_TTL);
52+
}),
53+
redis.sadd(setKey, ...findingIds).then(() => redis.expire(setKey, EXCEPTION_TTL)),
54+
]);
5055
}
5156

52-
// Returns Map<findingId, { approver, reason, timestamp }>
53-
export async function loadExceptions({ owner, repo, prNumber, headSha, findingIds }) {
57+
// Returns Map<findingId, { approver, reason, timestamp, approvedHeadSha }>
58+
export async function loadExceptions({ owner, repo, prNumber, findingIds }) {
5459
const results = await Promise.all(findingIds.map(async findingId => {
55-
const key = `layne:exception:${owner}/${repo}#${prNumber}@${headSha}:${findingId}`;
60+
const key = `layne:exception:${owner}/${repo}#${prNumber}:${findingId}`;
5661
const val = await redis.get(key);
5762
return [findingId, val ? JSON.parse(val) : null];
5863
}));
@@ -64,6 +69,132 @@ export async function loadExceptions({ owner, repo, prNumber, headSha, findingId
6469
return map;
6570
}
6671

72+
// Removes exceptions whose flagged line was changed between the approval commit and the
73+
// current head. Groups by approvedHeadSha to minimise git fetches - one fetch per unique
74+
// approval SHA regardless of how many findings were approved in that commit.
75+
// On fetch or diff failure the entire group is invalidated (conservative fallback).
76+
export async function filterStaleExceptions({ exceptions, findings, workspacePath, currentHeadSha }) {
77+
if (exceptions.size === 0) return exceptions;
78+
79+
const findingById = new Map(findings.map(f => [f._findingId, f]));
80+
81+
// Group exception findingIds by the SHA at which they were approved.
82+
const bySha = new Map();
83+
for (const [findingId, data] of exceptions) {
84+
const { approvedHeadSha } = data;
85+
if (approvedHeadSha === currentHeadSha) continue;
86+
if (!bySha.has(approvedHeadSha)) bySha.set(approvedHeadSha, []);
87+
bySha.get(approvedHeadSha).push(findingId);
88+
}
89+
90+
if (bySha.size === 0) return exceptions;
91+
92+
const filtered = new Map(exceptions);
93+
94+
for (const [approvedHeadSha, findingIds] of bySha) {
95+
const files = [...new Set(findingIds.map(id => findingById.get(id)?.file).filter(Boolean))];
96+
97+
let changedRanges;
98+
try {
99+
await fetchCommit({ workspacePath, sha: approvedHeadSha });
100+
changedRanges = await getChangedLineRanges({
101+
workspacePath, baseSha: approvedHeadSha, headSha: currentHeadSha, files,
102+
});
103+
} catch (err) {
104+
console.warn(`[exception-approvals] Could not check staleness for ${approvedHeadSha}: ${err.message} — invalidating as a precaution`);
105+
for (const id of findingIds) filtered.delete(id);
106+
continue;
107+
}
108+
109+
for (const findingId of findingIds) {
110+
const finding = findingById.get(findingId);
111+
if (!finding) continue;
112+
113+
const line = finding.line ?? finding.startLine;
114+
const ranges = changedRanges[finding.file] ?? [];
115+
if (ranges.some(r => line >= r.start && line <= r.end)) {
116+
console.log(`[exception-approvals] Invalidating exception ${findingId}: ${finding.file}:${line} changed since approval`);
117+
filtered.delete(findingId);
118+
}
119+
}
120+
}
121+
122+
return filtered;
123+
}
124+
125+
// For blocking findings that have no matching stored exception, checks whether the finding
126+
// is a line-shifted version of a previously approved one (e.g. a rebase added lines above
127+
// the flagged code). Uses the PR-scoped exception ID set to load all stored exceptions,
128+
// then builds a per-file line map (headLine → baseLine) between the approval SHA and the
129+
// current head. If the current finding's line maps back to a line that was approved, the
130+
// exception is carried forward (only possible for unchanged context lines — modified lines
131+
// map to null in the line map and are therefore never matched).
132+
export async function resolveDriftedExceptions({
133+
unmatchedFindings,
134+
owner, repo, prNumber,
135+
workspacePath,
136+
currentHeadSha,
137+
}) {
138+
if (unmatchedFindings.length === 0) return new Map();
139+
140+
const setKey = `layne:exception-ids:${owner}/${repo}#${prNumber}`;
141+
const allIds = await redis.smembers(setKey);
142+
if (allIds.length === 0) return new Map();
143+
144+
const allExceptions = await loadExceptions({ owner, repo, prNumber, findingIds: allIds });
145+
if (allExceptions.size === 0) return new Map();
146+
147+
// Group stored exceptions by approvedHeadSha, skipping the current SHA (no drift possible).
148+
const bySha = new Map();
149+
for (const [findingId, data] of allExceptions) {
150+
if (data.approvedHeadSha === currentHeadSha) continue;
151+
if (!bySha.has(data.approvedHeadSha)) bySha.set(data.approvedHeadSha, new Map());
152+
bySha.get(data.approvedHeadSha).set(findingId, data);
153+
}
154+
155+
if (bySha.size === 0) return new Map();
156+
157+
const resolved = new Map();
158+
const affectedFiles = [...new Set(unmatchedFindings.map(f => f.file))];
159+
160+
for (const [approvedHeadSha, exceptionsAtSha] of bySha) {
161+
try {
162+
await fetchCommit({ workspacePath, sha: approvedHeadSha });
163+
} catch (err) {
164+
console.warn(`[exception-approvals] Could not fetch ${approvedHeadSha} for drift check: ${err.message} — skipping`);
165+
continue;
166+
}
167+
168+
for (const file of affectedFiles) {
169+
const findingsInFile = unmatchedFindings.filter(f => f.file === file && !resolved.has(f._findingId));
170+
if (findingsInFile.length === 0) continue;
171+
172+
let lineMap;
173+
try {
174+
lineMap = await buildLineMapForFile({ workspacePath, baseSha: approvedHeadSha, headSha: currentHeadSha, filePath: file });
175+
} catch (err) {
176+
console.warn(`[exception-approvals] Could not build line map for ${file}@${approvedHeadSha}: ${err.message} — skipping`);
177+
continue;
178+
}
179+
180+
for (const finding of findingsInFile) {
181+
const currentLine = finding.line ?? finding.startLine;
182+
const originalLine = lineMap.get(currentLine);
183+
if (originalLine === null || originalLine === undefined) continue;
184+
185+
const oldFindingId = generateFindingId({ tool: finding.tool, file: finding.file, line: originalLine });
186+
const exception = exceptionsAtSha.get(oldFindingId);
187+
if (exception) {
188+
console.log(`[exception-approvals] Drift resolved: ${finding._findingId} (line ${currentLine}) ← ${oldFindingId} (line ${originalLine}) approved by @${exception.approver} at ${approvedHeadSha}`);
189+
resolved.set(finding._findingId, exception);
190+
}
191+
}
192+
}
193+
}
194+
195+
return resolved;
196+
}
197+
67198
export function buildExceptionSummary({ findings, exceptions, baseSummary }) {
68199
const blockingFindings = findings.filter(f =>
69200
f._findingId && (f.severity === 'critical' || f.severity === 'high')

0 commit comments

Comments
 (0)