Skip to content

Commit 3d78f29

Browse files
fix: prevent layne from running on merged PRs (#54)
* fix: prevent layne from running on merged prs * Add changeset
1 parent 2aad819 commit 3d78f29

5 files changed

Lines changed: 141 additions & 28 deletions

File tree

.changeset/yummy-radios-flow.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+
Checks if the PR has already been merged and doesn't run Layne

src/__tests__/github.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ describe('findPullRequestBySha()', () => {
210210
}));
211211
});
212212

213-
it('returns the first PR when results are found', async () => {
214-
const pr = { number: 7, head: { ref: 'feat/x', sha: 'sha123' }, base: { ref: 'main', sha: 'base1' }, labels: [] };
215-
mockListPRsForCommit.mockResolvedValueOnce({ data: [pr, { number: 8 }] });
213+
it('returns the first open PR when results are found', async () => {
214+
const pr = { number: 7, state: 'open', head: { ref: 'feat/x', sha: 'sha123' }, base: { ref: 'main', sha: 'base1' }, labels: [] };
215+
mockListPRsForCommit.mockResolvedValueOnce({ data: [pr, { number: 8, state: 'closed' }] });
216216

217217
const result = await findPullRequestBySha({ ...BASE, headSha: 'sha123' });
218218

@@ -226,6 +226,23 @@ describe('findPullRequestBySha()', () => {
226226

227227
expect(result).toBeNull();
228228
});
229+
230+
it('returns null when all associated PRs are closed or merged', async () => {
231+
mockListPRsForCommit.mockResolvedValueOnce({ data: [{ number: 5, state: 'closed' }, { number: 6, state: 'merged' }] });
232+
233+
const result = await findPullRequestBySha({ ...BASE, headSha: 'sha123' });
234+
235+
expect(result).toBeNull();
236+
});
237+
238+
it('returns the open PR when the list has mixed states', async () => {
239+
const openPr = { number: 9, state: 'open', head: { ref: 'feat/y', sha: 'sha456' }, base: { ref: 'main', sha: 'base2' }, labels: [] };
240+
mockListPRsForCommit.mockResolvedValueOnce({ data: [{ number: 8, state: 'closed' }, openPr] });
241+
242+
const result = await findPullRequestBySha({ ...BASE, headSha: 'sha456' });
243+
244+
expect(result).toBe(openPr);
245+
});
229246
});
230247

231248
describe('ensureLabelsExist()', () => {

src/__tests__/server.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ beforeEach(() => {
148148
(findPullRequestBySha as ReturnType<typeof vi.fn>).mockResolvedValue(null);
149149
(getLatestCheckRun as ReturnType<typeof vi.fn>).mockResolvedValue({ conclusion: 'failure' });
150150
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
151+
state: 'open',
151152
head: { sha: 'abc123', ref: 'feature/login' },
152153
base: { sha: 'def456', ref: 'main' },
153154
labels: [],
@@ -610,6 +611,41 @@ describe('workflow_run trigger — workflow_run event', () => {
610611
expect(res).toEqual({ status: 200, body: 'Accepted' });
611612
expect(scanQueue.add).toHaveBeenCalledOnce();
612613
});
614+
615+
it('does not enqueue a scan when cache hits but PR is already merged', async () => {
616+
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'closed' });
617+
618+
const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));
619+
620+
expect(res).toEqual({ status: 200, body: 'PR not found' });
621+
expect(findPullRequestBySha).not.toHaveBeenCalled();
622+
expect(scanQueue.add).not.toHaveBeenCalled();
623+
});
624+
625+
it('does not enqueue when PR is merged by the time getPullRequest state check runs', async () => {
626+
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
627+
(findPullRequestBySha as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
628+
number: 42,
629+
head: { ref: 'feature/login', sha: 'abc123' },
630+
base: { ref: 'main', sha: 'def456' },
631+
labels: [],
632+
});
633+
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'closed' });
634+
635+
const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));
636+
637+
expect(res).toEqual({ status: 200, body: 'PR not found' });
638+
expect(scanQueue.add).not.toHaveBeenCalled();
639+
});
640+
641+
it('returns PR not found when getPullRequest throws during state validation', async () => {
642+
(getPullRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('GitHub API error'));
643+
644+
const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' }));
645+
646+
expect(res).toEqual({ status: 200, body: 'PR not found' });
647+
expect(scanQueue.add).not.toHaveBeenCalled();
648+
});
613649
});
614650

615651
// ---------------------------------------------------------------------------
@@ -837,6 +873,16 @@ describe('workflow_job trigger — workflow_job event', () => {
837873
expect(res).toEqual({ status: 200, body: 'Accepted' });
838874
expect(scanQueue.add).toHaveBeenCalledOnce();
839875
});
876+
877+
it('does not enqueue a scan when cache hits but PR is already merged', async () => {
878+
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: 'closed' });
879+
880+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
881+
882+
expect(res).toEqual({ status: 200, body: 'PR not found' });
883+
expect(findPullRequestBySha).not.toHaveBeenCalled();
884+
expect(scanQueue.add).not.toHaveBeenCalled();
885+
});
840886
});
841887

842888
// ---------------------------------------------------------------------------
@@ -1095,4 +1141,21 @@ describe('issue_comment handler', () => {
10951141
expect(createPrComment).toHaveBeenCalled();
10961142
expect(scanQueue.add).not.toHaveBeenCalled();
10971143
});
1144+
1145+
it('does not store exceptions or enqueue scan when PR is already merged', async () => {
1146+
(getPullRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
1147+
state: 'closed',
1148+
head: { sha: 'abc123', ref: 'feature/login' },
1149+
base: { sha: 'def456', ref: 'main' },
1150+
labels: [],
1151+
});
1152+
1153+
const res = await processWebhookRequest(webhookRequest(
1154+
commentPayload(), { event: 'issue_comment' }
1155+
));
1156+
1157+
expect(res).toEqual({ status: 200, body: 'PR not open' });
1158+
expect(storeExceptions).not.toHaveBeenCalled();
1159+
expect(scanQueue.add).not.toHaveBeenCalled();
1160+
});
10981161
});

src/github.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export async function findPullRequestBySha({ installationId, owner, repo, headSh
175175
commit_sha: headSha,
176176
});
177177

178-
return data[0] ?? null;
178+
return data.find(pr => pr.state === 'open') ?? null;
179179
}
180180

181181
/**

src/server.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,12 @@ async function handleIssueComment(payload: Record<string, unknown>): Promise<{ s
233233
return { status: 200, body: 'PR not found' };
234234
}
235235

236-
const prData = pr as { head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
236+
const prData = pr as { state?: string; head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
237+
238+
if (prData.state !== 'open') {
239+
debug('server', `issue_comment on non-open PR #${issueData.number} (state=${prData.state ?? 'unknown'}), ignoring`);
240+
return { status: 200, body: 'PR not open' };
241+
}
237242
const headSha = prData.head.sha;
238243

239244
await storeExceptions({
@@ -464,40 +469,63 @@ async function resolvePrData({ installation, repository, headSha }: {
464469
const cacheKey = prCacheKey(repository.full_name, headSha);
465470
const cached = await redis.get(cacheKey);
466471

472+
let prData: PRCacheData | null = null;
473+
467474
if (cached) {
468-
// JSON.parse result typed as PRCacheData shape
469-
return JSON.parse(cached) as PRCacheData;
475+
prData = JSON.parse(cached) as PRCacheData;
476+
} else {
477+
debug('server', `PR cache miss for ${repository.full_name}@${headSha} — querying GitHub API`);
478+
479+
try {
480+
const pr = await findPullRequestBySha({
481+
installationId: (installation as { id: number }).id,
482+
owner: repository.owner.login,
483+
repo: repository.name,
484+
headSha,
485+
});
486+
487+
if (!pr) return null;
488+
489+
const apiPr = pr as { number: number; head: { ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
490+
491+
prData = {
492+
prNumber: apiPr.number,
493+
headSha,
494+
headRef: apiPr.head.ref,
495+
baseSha: apiPr.base.sha,
496+
baseRef: apiPr.base.ref,
497+
labels: apiPr.labels?.map(l => l.name) ?? [],
498+
installationId: (installation as { id: number }).id,
499+
cloneUrl: repository.clone_url ?? '',
500+
repoFullName: repository.full_name,
501+
};
502+
} catch (err) {
503+
console.error(`[server] Failed to recover PR from GitHub API: ${(err as Error).message}`);
504+
return null;
505+
}
470506
}
471507

472-
debug('server', `PR cache miss for ${repository.full_name}@${headSha} — querying GitHub API`);
508+
if (!prData) return null;
473509

510+
// Confirm the PR is still open — the cache may be stale (written when the PR
511+
// was open) and the workflow may have fired after merge.
474512
try {
475-
const pr = await findPullRequestBySha({
476-
installationId: (installation as { id: number }).id,
513+
const livePr = await getPullRequest({
514+
installationId: prData.installationId,
477515
owner: repository.owner.login,
478516
repo: repository.name,
479-
headSha,
517+
prNumber: prData.prNumber,
480518
});
481-
482-
if (!pr) return null;
483-
484-
const prData = pr as { number: number; head: { ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> };
485-
486-
return {
487-
prNumber: prData.number,
488-
headSha,
489-
headRef: prData.head.ref,
490-
baseSha: prData.base.sha,
491-
baseRef: prData.base.ref,
492-
labels: prData.labels?.map(l => l.name) ?? [],
493-
installationId: (installation as { id: number }).id,
494-
cloneUrl: repository.clone_url ?? '',
495-
repoFullName: repository.full_name,
496-
};
519+
if ((livePr as { state?: string }).state !== 'open') {
520+
debug('server', `PR #${prData.prNumber} is no longer open, skipping scan`);
521+
return null;
522+
}
497523
} catch (err) {
498-
console.error(`[server] Failed to recover PR from GitHub API: ${(err as Error).message}`);
524+
console.error(`[server] Failed to verify PR state: ${(err as Error).message}`);
499525
return null;
500526
}
527+
528+
return prData;
501529
}
502530

503531
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)