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
8 changes: 8 additions & 0 deletions docs/schema/yaml/1.0.0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ environment:
branch: ''
# @param environment.agentSession.skills.path (required)
path: ''
# @param environment.ignoreFiles
ignoreFiles:
# @param environment.ignoreFiles[]
- ''
# @section services
services:
# @param services[]
Expand Down Expand Up @@ -778,3 +782,7 @@ services:
branch: ''
# @param services.dev.agentSession.skills.path (required)
path: ''
# @param services.ignoreFiles
ignoreFiles:
# @param services.ignoreFiles[]
- ''
145 changes: 145 additions & 0 deletions src/server/lib/__tests__/pushIgnoreFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
getEffectiveIgnoreFiles,
getServicePushIgnorePolicy,
hasLifecycleConfigChange,
normalizeIgnoreFiles,
shouldSkipPushDeploy,
} from '../pushIgnoreFiles';

describe('pushIgnoreFiles', () => {
test('merges environment and service ignoreFiles uniquely', () => {
expect(getEffectiveIgnoreFiles(['docs/**', '**/*.spec.ts'], ['docs/**', '**/*.stories.tsx'])).toEqual([
'docs/**',
'**/*.spec.ts',
'**/*.stories.tsx',
]);
});

test('rejects invalid ignore patterns', () => {
expect(() => normalizeIgnoreFiles([''])).toThrow('cannot be empty');
expect(() => normalizeIgnoreFiles(['/tmp/**'])).toThrow('repo-relative');
expect(() => normalizeIgnoreFiles(['../secrets/**'])).toThrow('traverse');
expect(() => normalizeIgnoreFiles(['docs/..'])).toThrow('traverse');
expect(() => normalizeIgnoreFiles([123])).toThrow('must be strings');
expect(() => normalizeIgnoreFiles(Array.from({ length: 51 }, (_value, index) => `docs/${index}.md`))).toThrow(
'too many patterns'
);
expect(() => normalizeIgnoreFiles(['a'.repeat(201)])).toThrow('exceeds maximum length');
});

test('builds service policy from config inheritance', () => {
const policy = getServicePushIgnorePolicy(
{
version: '1.0.0',
environment: { ignoreFiles: ['docs/**'] },
services: [
{
name: 'api',
ignoreFiles: ['**/*.spec.ts'],
},
],
} as any,
'api'
);

expect(policy).toEqual({
serviceName: 'api',
ignoreFiles: ['docs/**', '**/*.spec.ts'],
});
});

test('builds service-only policy and returns null when no policy exists', () => {
const config = {
version: '1.0.0',
environment: {},
services: [
{
name: 'api',
ignoreFiles: ['docs/**'],
},
{
name: 'worker',
},
],
} as any;

expect(getServicePushIgnorePolicy(config, 'api')).toEqual({
serviceName: 'api',
ignoreFiles: ['docs/**'],
});
expect(getServicePushIgnorePolicy(config, 'worker')).toBeNull();
expect(getServicePushIgnorePolicy(config, 'missing')).toBeNull();
});

test('matches paths case-sensitively with broad glob support', () => {
expect(
shouldSkipPushDeploy({
changedFiles: ['src/api.spec.ts', '.github/workflows/test.yml'],
servicePolicies: [{ serviceName: 'api', ignoreFiles: ['**/*'] }],
})
).toEqual({ shouldSkip: true, reason: 'all_changed_files_ignored' });

expect(
shouldSkipPushDeploy({
changedFiles: ['src/API.spec.ts'],
servicePolicies: [{ serviceName: 'api', ignoreFiles: ['src/api.spec.ts'] }],
})
).toEqual({
shouldSkip: false,
reason: 'file_not_ignored',
serviceName: 'api',
filePath: 'src/API.spec.ts',
});
});

test('requires every changed file to match every affected service policy', () => {
expect(
shouldSkipPushDeploy({
changedFiles: ['docs/readme.md', 'src/api.ts'],
servicePolicies: [{ serviceName: 'api', ignoreFiles: ['docs/**'] }],
})
).toEqual({
shouldSkip: false,
reason: 'file_not_ignored',
serviceName: 'api',
filePath: 'src/api.ts',
});
});

test('fails open when changed files or service policies are missing', () => {
expect(shouldSkipPushDeploy({ changedFiles: [], servicePolicies: [] })).toEqual({
shouldSkip: false,
reason: 'no_changed_files',
});
expect(
shouldSkipPushDeploy({
changedFiles: ['docs/readme.md'],
servicePolicies: [],
})
).toEqual({
shouldSkip: false,
reason: 'no_service_policies',
});
});

test('detects lifecycle config changes by new path', () => {
expect(hasLifecycleConfigChange(['docs/readme.md', 'lifecycle.yaml'])).toBe(true);
expect(hasLifecycleConfigChange(['docs/lifecycle.yml'])).toBe(false);
});
});
143 changes: 119 additions & 24 deletions src/server/lib/github/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ import {
verifyWebhookSignature,
getSHAForBranch,
checkIfCommentExists,
getChangedFilesFromPushPayload,
getChangedFilesForPush,
} from 'server/lib/github';
import * as client from 'server/lib/github/client';
import { cacheRequest } from 'server/lib/github/cacheRequest';

jest.mock('server/services/globalConfig', () => {
const RedisMock = {
Expand All @@ -43,6 +46,7 @@ jest.mock('server/services/globalConfig', () => {
});

jest.mock('server/lib/github/client');
jest.mock('server/lib/github/cacheRequest');
jest.mock('server/lib/github/utils');
jest.mock('server/lib/logger', () => ({
getLogger: jest.fn().mockReturnValue({
Expand All @@ -52,7 +56,11 @@ jest.mock('server/lib/logger', () => ({
warn: jest.fn(),
}),
}));
import { getLogger, rootLogger as logger } from 'server/lib/logger';
import { getLogger } from 'server/lib/logger';

beforeEach(() => {
jest.clearAllMocks();
});

test('createOrUpdatePullRequestComment success', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
Expand All @@ -71,45 +79,42 @@ test('createOrUpdatePullRequestComment success', async () => {
});

test('getPullRequest success', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockResolvedValue({ data: 'foo' }),
});
const result = await getPullRequest('foo', 'bar', 1, 123, logger);
(cacheRequest as jest.Mock).mockResolvedValue({ data: 'foo' });
const result = await getPullRequest('foo', 'bar', 1, 123);
expect(result.data).toEqual('foo');
});

test('getPullRequestByRepositoryFullName success', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockResolvedValue({ data: 'foo' }),
});
const result = await getPullRequestByRepositoryFullName('example-org/example-repo', 123, 1);
(cacheRequest as jest.Mock).mockResolvedValue({ data: 'foo' });
const result = await getPullRequestByRepositoryFullName('example-org/example-repo', 123);
expect(result.data).toEqual('foo');
});

test('getPullRequestByRepositoryFullName failure', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockRejectedValue(new Error('error')),
});
await expect(getPullRequestByRepositoryFullName('example-org/example-repo', 123, 1)).rejects.toThrow();
(cacheRequest as jest.Mock).mockRejectedValue(new Error('error'));
await expect(getPullRequestByRepositoryFullName('example-org/example-repo', 123)).rejects.toThrow();
});

test('getPullRequestByRepositoryFullName invalid repository name', async () => {
await expect(getPullRequestByRepositoryFullName('foo', 123, 1)).rejects.toThrow();
(cacheRequest as jest.Mock).mockRejectedValue(new Error('error'));
await expect(getPullRequestByRepositoryFullName('foo', 123)).rejects.toThrow();
});

test('createDeploy success', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockResolvedValue({ data: 'foo' }),
});
const result = await createDeploy('foo', 'bar', 'main', 1);
const result = await createDeploy({ repositoryId: 1, owner: 'foo', name: 'bar', branch: 'main', installationId: 1 });
expect(result.data).toEqual('foo');
});

test('createDeploy failure', async () => {
jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockRejectedValue(new Error('error')),
});
await expect(createDeploy('foo', 'bar', 'main', 1)).rejects.toThrow();
await expect(
createDeploy({ repositoryId: 1, owner: 'foo', name: 'bar', branch: 'main', installationId: 1 })
).rejects.toThrow();
});

test('verifyWebhookSignature false', async () => {
Expand All @@ -119,15 +124,15 @@ test('verifyWebhookSignature false', async () => {
},
rawBody: 'foo',
};
const result = await verifyWebhookSignature(req as unknown as client.WebhookRequest, '123');
const result = await verifyWebhookSignature(req as any);
expect(result).toEqual(false);
});

test('verifyWebhookSignature missing header', async () => {
const req = {
body: { foo: 'bar' },
};
const result = await verifyWebhookSignature(req as unknown as NextApiRequest);
const result = await verifyWebhookSignature(req as any);
expect(result).toEqual(false);
});

Expand All @@ -150,9 +155,7 @@ test('getSHAForBranch failure', async () => {
test('checkIfCommentExists to return true', async () => {
const mockComments = [{ body: 'This is a test comment' }, { body: `This comment contains the uniqueIdentifier` }];

jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockResolvedValue({ data: mockComments }),
});
(cacheRequest as jest.Mock).mockResolvedValue({ data: mockComments });
const result = await checkIfCommentExists({
fullName: 'example-org/example-repo',
pullRequestNumber: 123,
Expand All @@ -165,13 +168,105 @@ test('checkIfCommentExists to return true', async () => {
test('checkIfCommentExists to return false', async () => {
const mockComments = [{ body: 'This is a test comment' }, { body: `This comment contains the not` }];

jest.spyOn(client, 'createOctokitClient').mockResolvedValue({
request: jest.fn().mockResolvedValue({ data: mockComments }),
});
(cacheRequest as jest.Mock).mockResolvedValue({ data: mockComments });
const result = await checkIfCommentExists({
fullName: 'example-org/example-repo',
pullRequestNumber: 123,
commentIdentifier: 'uniqueIdentifier',
});
expect(result).toBe(false);
});

test('getChangedFilesForPush returns current filenames from compare responses', async () => {
(cacheRequest as jest.Mock).mockResolvedValue({
headers: {
'x-ratelimit-remaining': '4999',
'x-ratelimit-reset': '1770000000',
},
data: {
files: [
{
filename: 'src/new-name.ts',
previous_filename: 'src/old-name.ts',
status: 'renamed',
},
],
},
});

const result = await getChangedFilesForPush({
fullName: 'example-org/example-repo',
before: 'before-sha',
after: 'after-sha',
});

expect(cacheRequest).toHaveBeenCalledWith('GET /repos/example-org/example-repo/compare/before-sha...after-sha');
expect(result).toEqual({ canSkip: true, files: ['src/new-name.ts'] });
});

test('getChangedFilesFromPushPayload returns unique added and modified files', () => {
expect(
getChangedFilesFromPushPayload({
commits: [
{
added: ['src/new.ts'],
modified: ['docs/readme.md'],
},
{
modified: ['docs/readme.md', 'src/app.ts'],
},
],
commitCount: 2,
})
).toEqual({ canSkip: true, files: ['src/new.ts', 'docs/readme.md', 'src/app.ts'] });
});

test('getChangedFilesFromPushPayload falls back for incomplete or removed-file payloads', () => {
expect(
getChangedFilesFromPushPayload({
commits: [{ modified: ['src/app.ts'] }],
commitCount: 2,
})
).toEqual({ canSkip: false, files: [], reason: 'payload_commits_incomplete' });

expect(
getChangedFilesFromPushPayload({
commits: [{ removed: ['src/old.ts'] }],
commitCount: 1,
})
).toEqual({ canSkip: false, files: [], reason: 'payload_has_removed_files' });
});

test('getChangedFilesForPush fails open for large compare file lists', async () => {
(cacheRequest as jest.Mock).mockResolvedValue({
data: {
files: Array.from({ length: 300 }, (_value, index) => ({
filename: `file-${index}.ts`,
})),
},
});

await expect(
getChangedFilesForPush({
fullName: 'example-org/example-repo',
before: 'before-sha',
after: 'after-sha',
})
).resolves.toEqual({ canSkip: false, files: [], reason: 'large_or_incomplete_compare' });
});

test('getChangedFilesForPush fails open when compare cannot provide filenames', async () => {
(cacheRequest as jest.Mock).mockResolvedValue({
data: {
files: [{ filename: 'src/api.ts' }, { status: 'removed' }],
},
});

await expect(
getChangedFilesForPush({
fullName: 'example-org/example-repo',
before: 'before-sha',
after: 'after-sha',
})
).resolves.toEqual({ canSkip: false, files: [], reason: 'missing_file_names' });
});
Loading
Loading