Skip to content

Commit cafaf75

Browse files
fix: Fixes an issue where Layne would rerun if there's a failed scan in the PR (#10)
1 parent 62af89e commit cafaf75

File tree

3 files changed

+78
-11
lines changed

3 files changed

+78
-11
lines changed

.changeset/spotty-breads-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": patch
3+
---
4+
5+
Fixes an issue that wouldn't reschedule a Layne scan if there's an existing failed scan

src/__tests__/server.test.js

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,8 @@ describe('processWebhookRequest()', () => {
273273
expect(opts.jobId).toBe('org/my-repo#42@abc123');
274274
});
275275

276-
it('does not create a second check run when the job already exists', async () => {
277-
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123' });
276+
it('does not create a second check run when the job is already active', async () => {
277+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('waiting') });
278278

279279
const res = await processWebhookRequest(webhookRequest(prPayload('opened')));
280280

@@ -283,6 +283,30 @@ describe('processWebhookRequest()', () => {
283283
expect(scanQueue.add).not.toHaveBeenCalled();
284284
});
285285

286+
it('removes the old job and re-enqueues when the existing job is already completed (re-run scenario)', async () => {
287+
const mockRemove = vi.fn().mockResolvedValue(undefined);
288+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('completed'), remove: mockRemove });
289+
290+
const res = await processWebhookRequest(webhookRequest(prPayload('opened')));
291+
292+
expect(res).toEqual({ status: 200, body: 'Accepted' });
293+
expect(mockRemove).toHaveBeenCalled();
294+
expect(createCheckRun).toHaveBeenCalled();
295+
expect(scanQueue.add).toHaveBeenCalled();
296+
});
297+
298+
it('removes the old job and re-enqueues when the existing job is in a failed state (re-run scenario)', async () => {
299+
const mockRemove = vi.fn().mockResolvedValue(undefined);
300+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('failed'), remove: mockRemove });
301+
302+
const res = await processWebhookRequest(webhookRequest(prPayload('opened')));
303+
304+
expect(res).toEqual({ status: 200, body: 'Accepted' });
305+
expect(mockRemove).toHaveBeenCalled();
306+
expect(createCheckRun).toHaveBeenCalled();
307+
expect(scanQueue.add).toHaveBeenCalled();
308+
});
309+
286310
it('does not create a second check run while another request is accepting the same webhook', async () => {
287311
redis.set.mockResolvedValueOnce(null);
288312

@@ -330,7 +354,7 @@ describe('processWebhookRequest()', () => {
330354

331355
it('increments webhooksTotal with deduplicated=true when a duplicate webhook is received', async () => {
332356
loadScanConfig.mockResolvedValue(PR_TRIGGER_CONFIG);
333-
scanQueue.getJob.mockResolvedValueOnce({ id: 'existing-job' });
357+
scanQueue.getJob.mockResolvedValueOnce({ id: 'existing-job', getState: vi.fn().mockResolvedValue('waiting') });
334358
await processWebhookRequest(webhookRequest(prPayload('opened')));
335359
expect(webhooksTotal.inc).toHaveBeenCalledWith({ action: 'opened', deduplicated: 'true' });
336360
});
@@ -488,8 +512,8 @@ describe('workflow_run trigger — workflow_run event', () => {
488512
expect(scanQueue.add).not.toHaveBeenCalled();
489513
});
490514

491-
it('deduplicates: does not enqueue when the job already exists', async () => {
492-
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123' });
515+
it('deduplicates: does not enqueue when the job is already active', async () => {
516+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('active') });
493517

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

@@ -498,6 +522,18 @@ describe('workflow_run trigger — workflow_run event', () => {
498522
expect(scanQueue.add).not.toHaveBeenCalled();
499523
});
500524

525+
it('removes the old job and re-enqueues when the existing job has already completed (re-run scenario)', async () => {
526+
const mockRemove = vi.fn().mockResolvedValue(undefined);
527+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('completed'), remove: mockRemove });
528+
529+
const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));
530+
531+
expect(res).toEqual({ status: 200, body: 'Accepted' });
532+
expect(mockRemove).toHaveBeenCalled();
533+
expect(createCheckRun).toHaveBeenCalled();
534+
expect(scanQueue.add).toHaveBeenCalled();
535+
});
536+
501537
it('returns 200 with "PR not found" when cache is cold and GitHub API returns nothing', async () => {
502538
redis.get.mockResolvedValue(null);
503539
findPullRequestBySha.mockResolvedValue(null);
@@ -703,8 +739,8 @@ describe('workflow_job trigger — workflow_job event', () => {
703739
expect(scanQueue.add).not.toHaveBeenCalled();
704740
});
705741

706-
it('deduplicates: does not enqueue when the job already exists', async () => {
707-
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123' });
742+
it('deduplicates: does not enqueue when the job is already active', async () => {
743+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('active') });
708744

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

@@ -713,6 +749,18 @@ describe('workflow_job trigger — workflow_job event', () => {
713749
expect(scanQueue.add).not.toHaveBeenCalled();
714750
});
715751

752+
it('removes the old job and re-enqueues when the existing job has already completed (re-run scenario)', async () => {
753+
const mockRemove = vi.fn().mockResolvedValue(undefined);
754+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123', getState: vi.fn().mockResolvedValue('completed'), remove: mockRemove });
755+
756+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
757+
758+
expect(res).toEqual({ status: 200, body: 'Accepted' });
759+
expect(mockRemove).toHaveBeenCalled();
760+
expect(createCheckRun).toHaveBeenCalled();
761+
expect(scanQueue.add).toHaveBeenCalled();
762+
});
763+
716764
it('returns 200 with "PR not found" when cache is cold and GitHub API returns nothing', async () => {
717765
redis.get.mockResolvedValue(null);
718766
findPullRequestBySha.mockResolvedValue(null);

src/server.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,23 @@ async function resolvePrData({ installation, repository, headSha }) {
327327
// Shared enqueue path (used by both pull_request and workflow_run triggers)
328328
// ---------------------------------------------------------------------------
329329

330+
// Returns true if the job is waiting/active/delayed (a real duplicate).
331+
// If the job is in a terminal state (completed/failed), removes it from BullMQ
332+
// so the next scanQueue.add() call with the same jobId isn't silently dropped
333+
// by BullMQ's duplicate-key Lua check.
334+
async function isJobActive(jobId) {
335+
const job = await scanQueue.getJob(jobId);
336+
if (!job) return false;
337+
const state = await job.getState();
338+
if (state === 'waiting' || state === 'active' || state === 'delayed') return true;
339+
await job.remove();
340+
debug('server', `removed ${state} job ${jobId} to allow re-scan`);
341+
return false;
342+
}
343+
330344
async function enqueueScan({ pull_request, repository, installation, jobId, action }) {
331-
if (await scanQueue.getJob(jobId)) {
332-
debug('server', `duplicate webhook ignored: job already exists for ${jobId}`);
345+
if (await isJobActive(jobId)) {
346+
debug('server', `duplicate webhook ignored: job already active for ${jobId}`);
333347
webhooksTotal.inc({ action, deduplicated: 'true' });
334348
return ACCEPTED_RESPONSE;
335349
}
@@ -343,8 +357,8 @@ async function enqueueScan({ pull_request, repository, installation, jobId, acti
343357
let checkRunId = null;
344358

345359
try {
346-
if (await scanQueue.getJob(jobId)) {
347-
debug('server', `duplicate webhook ignored after lock: job already exists for ${jobId}`);
360+
if (await isJobActive(jobId)) {
361+
debug('server', `duplicate webhook ignored after lock: job already active for ${jobId}`);
348362
webhooksTotal.inc({ action, deduplicated: 'true' });
349363
return ACCEPTED_RESPONSE;
350364
}

0 commit comments

Comments
 (0)