diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..fc9efc2c43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: HRM Dashboard - CI + +on: + push: + branches: + - leader + pull_request: + branches: + - leader + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Type check + run: pnpm exec tsc --noEmit + + - name: Knip + run: pnpm run knip + + - name: Format check + run: pnpm run format:check + + - name: Security audit + run: pnpm audit --prod --audit-level=high + + - name: Validate PR commits + if: github.event_name == 'pull_request' + id: lint + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + + - name: Post Comment on Failure + if: failure() && steps.lint.conclusion == 'failure' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + :no_entry: **Commit Message Linting Failed** + + One or more of your commit messages do not follow the project's conventions. + Please review the [commitlint logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. + + You can also refer to the [commit message guidelines](https://github.com/conventional-changelog/commitlint/#what-is-commitlint) for help. diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml deleted file mode 100644 index e1d37efd3a..0000000000 --- a/.github/workflows/commit-lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Lint Commit Messages -on: [pull_request] - -permissions: - pull-requests: write - -jobs: - commitlint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - name: Validate PR commits - id: lint - run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose - - name: Post Comment on Failure - if: failure() - uses: peter-evans/create-or-update-comment@v4 - with: - issue-number: ${{ github.event.pull_request.number }} - body: | - :no_entry: **Commit Message Linting Failed** - - One or more of your commit messages do not follow the project's conventions. - Please review the [commitlint logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details. - - You can also refer to the [commit message guidelines](https://github.com/conventional-changelog/commitlint/#what-is-commitlint) for help. diff --git a/scripts/utils/rules.ts b/scripts/utils/rules.ts new file mode 100644 index 0000000000..fbb1e17601 --- /dev/null +++ b/scripts/utils/rules.ts @@ -0,0 +1,52 @@ +import { readFile } from 'fs/promises'; + +export function getChangedFilesFromDiff(diff: string): string[] { + const files: string[] = []; + const lines = diff.split('\n'); + for (const line of lines) { + if (line.startsWith('diff --git')) { + const pathLine = line.substring('diff --git '.length); + let filePathA; + if (pathLine.startsWith('"')) { + const endIndex = pathLine.indexOf('"', 1); + filePathA = pathLine.substring(1, endIndex); + } else { + filePathA = pathLine.split(' ')[0]; + } + + if (filePathA && filePathA.startsWith('a/')) { + files.push(filePathA.substring(2)); + } + } + } + return files; +} + +export function parseSpecializedRules(content: string): { key: string; value: string }[] { + const rules: { key: string; value: string }[] = []; + const lines = content.split('\n'); + for (const line of lines) { + if (line.startsWith('#')) { + const trimmedLine = line.substring(1).trim(); + const parts = trimmedLine.split(':'); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join(':').trim(); + if (key && value) { + rules.push({ key, value }); + } + } + } + } + return rules; +} + +export async function getSpecializedRules(filePath: string): Promise> { + const content = await readFile(filePath, 'utf-8'); + const rules = parseSpecializedRules(content); + const rulesObject: Record = {}; + for (const rule of rules) { + rulesObject[rule.key] = rule.value; + } + return rulesObject; +} diff --git a/tests/unit/scripts/utils/rules.test.ts b/tests/unit/scripts/utils/rules.test.ts new file mode 100644 index 0000000000..9519ec0d85 --- /dev/null +++ b/tests/unit/scripts/utils/rules.test.ts @@ -0,0 +1,116 @@ +import { + parseSpecializedRules, + getSpecializedRules, + getChangedFilesFromDiff, +} from '../../../../scripts/utils/rules'; +import { readFile } from 'fs/promises'; + +// Mock the fs/promises module +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), +})); + +// Type cast the mock for easier use +const mockedReadFile = readFile as jest.Mock; + +describe('Specialized Rules', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getChangedFilesFromDiff', () => { + it('should extract changed files from a standard git diff', () => { + const diff = ` +diff --git a/file1.txt b/file1.txt +index e69de29..d00491f 100644 +--- a/file1.txt ++++ b/file1.txt +@@ -0,0 +1 @@ ++hello +diff --git a/src/component.tsx b/src/component.tsx +index 8b13789..9e6a2c1 100644 +--- a/src/component.tsx ++++ b/src/component.tsx +@@ -1,1 +1,1 @@ +-
old
++
new
+`; + const expected = ['file1.txt', 'src/component.tsx']; + expect(getChangedFilesFromDiff(diff).sort()).toEqual(expected.sort()); + }); + + it('should return an empty array for an empty diff string', () => { + expect(getChangedFilesFromDiff('')).toEqual([]); + }); + + it('should handle file paths with spaces', () => { + const diff = 'diff --git "a/path with spaces/file.js" "b/path with spaces/file.js"'; + expect(getChangedFilesFromDiff(diff)).toEqual(['path with spaces/file.js']); + }); + }); + + describe('parseSpecializedRules', () => { + it('should parse rules from commented lines', () => { + const content = ` +# rule: require-tests +# level: high +# description: All new features must have tests. +const x = 1; +`; + const expected = [ + { key: 'rule', value: 'require-tests' }, + { key: 'level', value: 'high' }, + { key: 'description', value: 'All new features must have tests.' }, + ]; + expect(parseSpecializedRules(content)).toEqual(expected); + }); + + it('should return an empty array for content with no rule comments', () => { + const content = 'const y = 2;'; + expect(parseSpecializedRules(content)).toEqual([]); + }); + + it('should ignore lines that are not valid key-value pairs', () => { + const content = ` +# rule: valid +# not a rule +# another-rule: also valid +`; + const expected = [ + { key: 'rule', value: 'valid' }, + { key: 'another-rule', value: 'also valid' }, + ]; + expect(parseSpecializedRules(content)).toEqual(expected); + }); + }); + + describe('getSpecializedRules', () => { + it('should read a file and return a rule object', async () => { + const filePath = 'a/file/with/rules.md'; + const fileContent = ` +# max-length: 80 +# min-length: 10 +`; + mockedReadFile.mockResolvedValue(fileContent); + const expected = { + 'max-length': '80', + 'min-length': '10', + }; + await expect(getSpecializedRules(filePath)).resolves.toEqual(expected); + expect(readFile).toHaveBeenCalledWith(filePath, 'utf-8'); + }); + + it('should return an empty object if the file contains no rules', async () => { + const filePath = 'a/file/without/rules.txt'; + mockedReadFile.mockResolvedValue('Some content'); + await expect(getSpecializedRules(filePath)).resolves.toEqual({}); + }); + + it('should propagate errors from readFile', async () => { + const filePath = 'nonexistent/file.txt'; + const error = new Error('File not found'); + mockedReadFile.mockRejectedValue(error); + await expect(getSpecializedRules(filePath)).rejects.toThrow('File not found'); + }); + }); +});