Skip to content

Commit 8e8c379

Browse files
committed
feat: repo validation
1 parent 9d304d6 commit 8e8c379

File tree

4 files changed

+323
-29
lines changed

4 files changed

+323
-29
lines changed

LICENSE

Lines changed: 0 additions & 21 deletions
This file was deleted.

__fixtures__/fs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ import { jest } from '@jest/globals'
33
export const readdirSync = jest.fn()
44
export const statSync = jest.fn()
55
export const unlinkSync = jest.fn()
6+
export const existsSync = jest.fn()
7+
export const readFileSync = jest.fn()

__tests__/main.test.ts

Lines changed: 185 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,31 @@ jest.unstable_mockModule('formdata-node/file-from-path', () => ({
2626

2727
// The module being tested should be imported dynamically. This ensures that the
2828
// mocks are used in place of any actual dependencies.
29-
const { run, formatPumpRoomResponse } = await import('../src/main.ts')
29+
// Import the PumpRoomApiResponse interface to avoid using 'any'
30+
interface PumpRoomApiResponse {
31+
repo_updated: boolean
32+
pushed_at: string
33+
tasks_current: number
34+
tasks_updated: number
35+
tasks_created: number
36+
tasks_deleted: number
37+
tasks_cached: number
38+
tasks_synchronized_with_cms: number
39+
}
40+
41+
let run: () => Promise<void>
42+
let formatPumpRoomResponse: (response: PumpRoomApiResponse) => string
43+
let validateUniqueFolderNames: (rootDir: string) => Promise<void>
44+
let validateInzhenerkaYml: (rootDir: string) => Promise<void>
45+
46+
// Import the module in beforeAll to ensure mocks are set up first
47+
beforeAll(async () => {
48+
const mainModule = await import('../src/main.ts')
49+
run = mainModule.run
50+
formatPumpRoomResponse = mainModule.formatPumpRoomResponse
51+
validateUniqueFolderNames = mainModule.validateUniqueFolderNames
52+
validateInzhenerkaYml = mainModule.validateInzhenerkaYml
53+
})
3054

3155
describe('main.ts', () => {
3256
const mockRootDir = '/mock/root/dir'
@@ -44,8 +68,11 @@ describe('main.ts', () => {
4468

4569
// Set up fs mocks
4670
fs.readdirSync.mockReturnValue(['file1.txt', 'file2.txt', 'dir1'])
47-
fs.statSync.mockImplementation((path) => ({
48-
isDirectory: () => path.includes('dir')
71+
fs.statSync.mockImplementation((filePath) => ({
72+
isDirectory: () => {
73+
if (!filePath || typeof filePath !== 'string') return false
74+
return filePath.includes('dir')
75+
}
4976
}))
5077
fs.unlinkSync.mockImplementation(() => {})
5178

@@ -95,13 +122,30 @@ describe('main.ts', () => {
95122
})
96123

97124
it('Creates a ZIP archive and uploads it successfully', async () => {
98-
await run()
125+
// Set up fs.readdirSync to return some files for the createZipArchive function
126+
fs.readdirSync.mockReturnValue(['file1.txt', 'dir1'])
127+
128+
// Set up fs.statSync to identify directories correctly
129+
fs.statSync.mockImplementation((filePath) => ({
130+
isDirectory: () => {
131+
if (!filePath || typeof filePath !== 'string') return false
132+
return filePath.includes('dir')
133+
}
134+
}))
99135

100-
// Verify that the ZIP constructor was called
101-
expect(admZip).toHaveBeenCalled()
136+
// Run the main function
137+
await run()
102138

103-
// Verify success message was logged
104-
expect(core.info).toHaveBeenCalled()
139+
// Verify that core.info was called with validation messages
140+
expect(core.info).toHaveBeenCalledWith(
141+
'🔍 Validating unique folder names...'
142+
)
143+
expect(core.info).toHaveBeenCalledWith(
144+
'✅ No folder duplicates found'
145+
)
146+
expect(core.info).toHaveBeenCalledWith(
147+
'🔍 Validating .inzhenerka.yml...'
148+
)
105149

106150
// Since we're mocking the API response and not actually calling the real API,
107151
// we can't directly test the formatted output in this test.
@@ -177,4 +221,137 @@ describe('main.ts', () => {
177221
// Verify that the action was marked as failed
178222
expect(core.setFailed).toHaveBeenCalledWith('File system error')
179223
})
224+
225+
// The validation functions are already imported in the beforeAll hook
226+
227+
describe('validateUniqueFolderNames', () => {
228+
beforeEach(() => {
229+
// Reset mocks
230+
jest.resetAllMocks()
231+
232+
// Default mock for fs.readdirSync and fs.statSync
233+
fs.readdirSync.mockReturnValue(['folder1', 'folder2', 'file.txt'])
234+
fs.statSync.mockImplementation((filePath) => ({
235+
isDirectory: () => {
236+
if (!filePath || typeof filePath !== 'string') return false
237+
return !filePath.includes('file')
238+
}
239+
}))
240+
})
241+
242+
it('Successfully validates when no duplicates exist', async () => {
243+
// Mock directories that will be recognized by isDirectory
244+
fs.readdirSync.mockReturnValue(['dir1', 'dir2'])
245+
fs.statSync.mockImplementation(() => ({
246+
isDirectory: () => true
247+
}))
248+
249+
await validateUniqueFolderNames(mockRootDir)
250+
251+
// Verify that success message was logged
252+
expect(core.info).toHaveBeenCalledWith('✅ No folder duplicates found')
253+
})
254+
255+
it('Detects case-insensitive duplicates', async () => {
256+
// Mock folders with case-insensitive duplicates
257+
fs.readdirSync.mockReturnValue(['Folder1', 'folder1', 'folder2'])
258+
fs.statSync.mockImplementation(() => ({
259+
isDirectory: () => true
260+
}))
261+
262+
// Expect the function to throw an error
263+
await expect(validateUniqueFolderNames(mockRootDir)).rejects.toThrow(
264+
'❌ Folder duplicates found:'
265+
)
266+
})
267+
268+
it('Handles empty directory', async () => {
269+
// Mock empty directory
270+
fs.readdirSync.mockReturnValue([])
271+
272+
await validateUniqueFolderNames(mockRootDir)
273+
274+
// Verify that info message was logged
275+
expect(core.info).toHaveBeenCalledWith('ℹ️ No folders found to validate')
276+
})
277+
278+
it('Handles directory with no subdirectories', async () => {
279+
// Mock directory with only files
280+
fs.readdirSync.mockReturnValue(['file1.txt', 'file2.txt'])
281+
fs.statSync.mockImplementation(() => ({
282+
isDirectory: () => false
283+
}))
284+
285+
await validateUniqueFolderNames(mockRootDir)
286+
287+
// Verify that info message was logged
288+
expect(core.info).toHaveBeenCalledWith('ℹ️ No folders found to validate')
289+
})
290+
})
291+
292+
describe('validateInzhenerkaYml', () => {
293+
beforeEach(() => {
294+
// Reset mocks
295+
jest.resetAllMocks()
296+
297+
// Default mock for fs.existsSync and fs.readFileSync
298+
fs.existsSync.mockReturnValue(true)
299+
fs.readFileSync.mockReturnValue('valid: yaml\ncontent: true')
300+
301+
// Default mock for axios.post
302+
axios.post.mockResolvedValue({ status: 200 })
303+
})
304+
305+
it('Successfully validates when configuration is valid', async () => {
306+
await validateInzhenerkaYml(mockRootDir)
307+
308+
// Verify that success message was logged
309+
expect(core.info).toHaveBeenCalledWith('✅ Configuration is valid')
310+
})
311+
312+
it('Throws error when configuration file is not found', async () => {
313+
// Mock file not found
314+
fs.existsSync.mockReturnValue(false)
315+
316+
// Expect the function to throw an error
317+
await expect(validateInzhenerkaYml(mockRootDir)).rejects.toThrow(
318+
'❌ .inzhenerka.yml file not found'
319+
)
320+
})
321+
322+
it('Throws error when API returns non-200 status', async () => {
323+
// Mock API error
324+
axios.post.mockResolvedValue({ status: 400 })
325+
326+
// Expect the function to throw an error
327+
await expect(validateInzhenerkaYml(mockRootDir)).rejects.toThrow(
328+
'❌ Configuration is invalid'
329+
)
330+
})
331+
332+
it('Handles API request error', async () => {
333+
// Create a custom error object that will be recognized as an Axios error
334+
const axiosError = new Error('API Error')
335+
Object.defineProperty(axiosError, 'isAxiosError', { value: true })
336+
Object.defineProperty(axiosError, 'response', {
337+
value: {
338+
status: 400,
339+
data: { error: 'Bad Request' }
340+
}
341+
})
342+
343+
// Mock axios.isAxiosError to return true for this error
344+
axios.isAxiosError.mockImplementation((error) => {
345+
return error && error.isAxiosError === true
346+
})
347+
348+
// Mock axios.post to reject with the error
349+
axios.post.mockRejectedValueOnce(axiosError)
350+
351+
// Expect the function to throw an error
352+
await expect(validateInzhenerkaYml(mockRootDir)).rejects.toThrow(
353+
'❌ Configuration validation failed:'
354+
)
355+
})
356+
})
180357
})

src/main.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,136 @@ export function formatPumpRoomResponse(response: PumpRoomApiResponse): string {
4646
`
4747
}
4848

49+
/**
50+
* Validates that folder names are unique (case-insensitive).
51+
*
52+
* @param rootDir - The root directory to check
53+
* @returns A promise that resolves when validation is complete
54+
* @throws Error if duplicate folder names are found
55+
*/
56+
export async function validateUniqueFolderNames(
57+
rootDir: string
58+
): Promise<void> {
59+
core.info('🔍 Validating unique folder names...')
60+
61+
try {
62+
// Get list of folders
63+
const items = fs.readdirSync(rootDir)
64+
const folders: string[] = []
65+
66+
// Filter out only directories
67+
for (const item of items) {
68+
const itemPath = path.join(rootDir, item)
69+
if (fs.statSync(itemPath).isDirectory()) {
70+
folders.push(item)
71+
}
72+
}
73+
74+
// Check if there are any folders to analyze
75+
if (folders.length === 0) {
76+
core.info('ℹ️ No folders found to validate')
77+
return
78+
}
79+
80+
// Look for duplicates (case-insensitive)
81+
const folderMap = new Map<string, string[]>()
82+
for (const folder of folders) {
83+
const lowerCaseFolder = folder.toLowerCase()
84+
if (!folderMap.has(lowerCaseFolder)) {
85+
folderMap.set(lowerCaseFolder, [])
86+
}
87+
folderMap.get(lowerCaseFolder)?.push(folder)
88+
}
89+
90+
// Find duplicates
91+
const duplicates: string[] = []
92+
for (const [lowerCaseFolder, folderVariants] of folderMap.entries()) {
93+
if (folderVariants.length > 1) {
94+
duplicates.push(lowerCaseFolder)
95+
}
96+
}
97+
98+
// Report duplicates if found
99+
if (duplicates.length > 0) {
100+
let errorMessage = '❌ Folder duplicates found:\n'
101+
for (const duplicate of duplicates) {
102+
const variants = folderMap.get(duplicate)
103+
errorMessage += ` • ${duplicate} (variants: ${variants?.join(', ')})\n`
104+
}
105+
throw new Error(errorMessage)
106+
}
107+
108+
core.info('✅ No folder duplicates found')
109+
} catch (error) {
110+
if (error instanceof Error) {
111+
throw error
112+
} else {
113+
throw new Error('Unknown error during folder validation')
114+
}
115+
}
116+
}
117+
118+
/**
119+
* Validates the .inzhenerka.yml configuration file.
120+
*
121+
* @param rootDir - The root directory containing the .inzhenerka.yml file
122+
* @returns A promise that resolves when validation is complete
123+
* @throws Error if the configuration is invalid
124+
*/
125+
export async function validateInzhenerkaYml(rootDir: string): Promise<void> {
126+
core.info('🔍 Validating .inzhenerka.yml...')
127+
128+
const configPath = path.join(rootDir, '.inzhenerka.yml')
129+
130+
try {
131+
// Check if the file exists
132+
if (!fs.existsSync(configPath)) {
133+
throw new Error('❌ .inzhenerka.yml file not found')
134+
}
135+
136+
// Read the file content
137+
const configContent = fs.readFileSync(configPath, 'utf8')
138+
139+
// Prepare the request body
140+
const jsonBody = JSON.stringify({ config_yml: configContent })
141+
142+
// Make the API request
143+
const response = await axios.post(
144+
'https://pumproom-api.inzhenerka-cloud.com/inzhenerka_schema',
145+
jsonBody,
146+
{
147+
headers: {
148+
'Content-Type': 'application/json'
149+
}
150+
}
151+
)
152+
153+
// Check the response status
154+
if (response.status === 200) {
155+
core.info('✅ Configuration is valid')
156+
} else {
157+
throw new Error(
158+
`❌ Configuration is invalid. HTTP status: ${response.status}`
159+
)
160+
}
161+
} catch (error) {
162+
if (axios.isAxiosError(error)) {
163+
let errorMessage = '❌ Configuration validation failed:\n'
164+
if (error.response) {
165+
errorMessage += `Status code: ${error.response.status}\n`
166+
errorMessage += `Response: ${JSON.stringify(error.response.data)}\n`
167+
} else {
168+
errorMessage += `Error: ${error.message}\n`
169+
}
170+
throw new Error(errorMessage)
171+
} else if (error instanceof Error) {
172+
throw error
173+
} else {
174+
throw new Error('Unknown error during configuration validation')
175+
}
176+
}
177+
}
178+
49179
/**
50180
* The main function for the action.
51181
*
@@ -71,6 +201,12 @@ export async function run(): Promise<void> {
71201
core.debug(`Root directory: ${rootDir}`)
72202
core.debug(`Ignore list: ${ignoreList.join(', ')}`)
73203

204+
// Validate unique folder names
205+
await validateUniqueFolderNames(rootDir)
206+
207+
// Validate .inzhenerka.yml configuration
208+
await validateInzhenerkaYml(rootDir)
209+
74210
// Create a temporary file for the ZIP archive
75211
const tempZipPath = path.join(process.cwd(), 'repo-archive.zip')
76212

0 commit comments

Comments
 (0)