├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── .gitleaksignore ├── .pre-commit-config.yaml ├── README.md ├── catalog-info.yaml ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── cli.ts ├── commands │ ├── __fixtures__ │ │ ├── default │ │ │ ├── files.json │ │ │ ├── gitignore │ │ │ ├── gitignore-deep │ │ │ ├── index.ts │ │ │ └── owners │ │ ├── project-builder.test.helper.ts │ │ ├── types.ts │ │ └── validate │ │ │ ├── files.json │ │ │ ├── index.ts │ │ │ ├── owners │ │ │ └── owners-invalid-format │ ├── __snapshots__ │ │ ├── audit.test.int.ts.snap │ │ ├── git.test.int.ts.snap │ │ ├── validate.test.int.ts.snap │ │ └── who.test.int.ts.snap │ ├── audit.test.int.ts │ ├── audit.ts │ ├── git.test.int.ts │ ├── git.ts │ ├── validate.test.int.ts │ ├── validate.ts │ ├── who.test.int.ts │ └── who.ts ├── lib │ ├── file │ │ ├── File.ts │ │ ├── countLines.ts │ │ ├── getFilePaths.ts │ │ ├── index.ts │ │ ├── readDir.test.ts │ │ ├── readDir.ts │ │ ├── readGit.test.ts │ │ └── readGit.ts │ ├── logger │ │ ├── index.ts │ │ ├── logger.test.ts │ │ └── logger.ts │ ├── ownership │ │ ├── OwnershipEngine.test.ts │ │ ├── OwnershipEngine.ts │ │ ├── index.ts │ │ ├── ownership.test.ts │ │ ├── ownership.ts │ │ ├── types.ts │ │ └── validate.ts │ ├── stats │ │ ├── index.ts │ │ ├── stats.test.ts │ │ ├── stats.ts │ │ ├── types.ts │ │ └── writer.ts │ ├── types.ts │ └── util │ │ └── exec.ts └── test │ └── fixtures │ └── patterns.json ├── tsconfig.json └── tslint.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | prodsec: snyk/prodsec-orb@1 5 | 6 | jobs: 7 | test: 8 | docker: 9 | - image: cimg/node:14.19 10 | working_directory: ~/lib 11 | steps: 12 | - checkout 13 | - run: 14 | name: Install 15 | command: npm install 16 | - run: 17 | name: Lint 18 | command: npm run lint 19 | - run: 20 | name: Test 21 | command: npm run test 22 | release: 23 | docker: 24 | - image: cimg/node:14.19 25 | working_directory: ~/lib 26 | steps: 27 | - checkout 28 | - run: 29 | name: Install 30 | command: npm install 31 | - run: 32 | name: Release 33 | command: npx semantic-release@17 34 | 35 | workflows: 36 | version: 2 37 | test: 38 | jobs: 39 | - prodsec/secrets-scan: 40 | name: Scan repository for secrets 41 | context: 42 | - snyk-bot-slack 43 | channel: team-arch 44 | trusted-branch: main 45 | filters: 46 | branches: 47 | ignore: 48 | - main 49 | - test: 50 | name: Test 51 | context: nodejs-install 52 | filters: 53 | branches: 54 | ignore: 55 | - main 56 | release: 57 | jobs: 58 | - release: 59 | name: Release 60 | context: nodejs-lib-release 61 | filters: 62 | branches: 63 | only: 64 | - main 65 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @snyk/arch 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | tests 3 | node_modules 4 | 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # Generated files 16 | .idea/**/contentModel.xml 17 | 18 | # Sensitive or high-churn files 19 | .idea/**/dataSources/ 20 | .idea/**/dataSources.ids 21 | .idea/**/dataSources.local.xml 22 | .idea/**/sqlDataSources.xml 23 | .idea/**/dynamic.xml 24 | .idea/**/uiDesigner.xml 25 | .idea/**/dbnavigator.xml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | /junit.xml 74 | .idea/ 75 | -------------------------------------------------------------------------------- /.gitleaksignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snyk/github-codeowners/8531aa514b7a6762df42695aa106d1212e2e7168/.gitleaksignore -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/gitleaks/gitleaks 3 | rev: v8.16.2 4 | hooks: 5 | - id: gitleaks 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-codeowners 2 | [![Maintainability](https://api.codeclimate.com/v1/badges/005e2a8038aa060010dd/maintainability)](https://codeclimate.com/github/jjmschofield/github-codeowners/maintainability) 3 | [![Test Coverage](https://api.codeclimate.com/v1/badges/005e2a8038aa060010dd/test_coverage)](https://codeclimate.com/github/jjmschofield/github-codeowners/test_coverage) 4 | [![CircleCI](https://circleci.com/gh/jjmschofield/github-codeowners/tree/master.svg?style=shield)](https://circleci.com/gh/jjmschofield/github-codeowners/tree/master) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/jjmschofield/github-codeowners/badge.svg?targetFile=package.json)](https://snyk.io/test/github/jjmschofield/github-codeowners?targetFile=package.json) 6 | 7 | A CLI tool for working with GitHub CODEOWNERS. 8 | 9 | Things it does: 10 | * Calculate ownership stats 11 | * Find out who owns each and every file (ignoring files listed in `.gitignore`) 12 | * Find out who owns a single file 13 | * Find out who owns your staged files 14 | * Outputs in a bunch of script friendly handy formats for integrations (CSV and JSONL) 15 | * Validates that your CODEOWNERS file is valid 16 | 17 | ## Installation 18 | Install via npm globally then run 19 | 20 | ```shell script 21 | $ npm i -g github-codeowners 22 | $ github-codeowners --help 23 | Usage: github-codeowners [options] [command] 24 | ``` 25 | 26 | ## Commands 27 | ### Audit 28 | Compares every file in your current (or specified) directory against your CODEOWNERS rules and outputs the result of who owns each file. 29 | ```shell script 30 | $ cd 31 | $ github-codeowners audit 32 | README.md 33 | package.json 34 | src/cli.ts @jjmschofield 35 | ... 36 | ``` 37 | 38 | Ownership stats: 39 | ```shell script 40 | $ github-codeowners audit -s 41 | --- Counts --- 42 | Total: 24 files (1378 lines) 100% 43 | Loved: 10 files (494 lines) 41.6% 44 | Unloved: 14 files (884 lines) 58.4% 45 | --- Owners --- 46 | @jjmschofield: 10 files (494 lines) 41.6% 47 | ``` 48 | 49 | Only files in a specific directory: 50 | ```shell script 51 | $ github-codeowners audit -r src/ 52 | src/cli.ts @jjmschofield 53 | src/commands/audit.ts @jjmschofield 54 | ... 55 | ``` 56 | 57 | Only unowned files: 58 | ```shell script 59 | $ github-codeowners audit -u 60 | .github/CODEOWNERS 61 | .gitignore 62 | ``` 63 | 64 | Output in JSONL: 65 | ```shell script 66 | $ github-codeowners audit -o jsonl 67 | {"path":"src/commands/audit.ts","owners":["@jjmschofield"],"lines":48} 68 | ... 69 | ``` 70 | 71 | Output in CSV: 72 | ```shell script 73 | $ github-codeowners audit -o csv 74 | src/commands/audit.ts,@jjmschofield 75 | ``` 76 | 77 | Full usage information: 78 | ```shell script 79 | $ github-codeowners audit --help 80 | Usage: github-codeowners audit [options] 81 | 82 | list the owners for all files 83 | 84 | Options: 85 | -d, --dir path to VCS directory (default: "") 86 | -c, --codeowners path to codeowners file (default: "/.github/CODEOWNERS") 87 | -o, --output how to output format eg: simple, jsonl, csv (default: "simple") 88 | -u, --unloved unowned files only (default: false) 89 | -g, --only-git consider only files tracked by git (default: false) 90 | -s, --stats output stats (default: true) 91 | -r, --root the root path to filter files by (default: "") 92 | -h, --help output usage information 93 | ``` 94 | 95 | ### Who 96 | Tells you who owns a given file or files: 97 | ```shell script 98 | $ cd 99 | $ github-codeowners who 100 | @some/team 101 | @some/team 102 | ``` 103 | 104 | Full usage: 105 | ```shell script 106 | $ github-codeowners who --help 107 | Usage: github-codeowners who [options] 108 | 109 | lists owners of a specific file or files 110 | 111 | Options: 112 | -d, --dir path to VCS directory (default: "/Users/jjmschofield/projects/github/snyk/registry") 113 | -c, --codeowners path to codeowners file (default: "/.github/CODEOWNERS") 114 | -o, --output how to output format eg: simple, jsonl, csv (default: "simple") 115 | -h, --help output usage information 116 | ``` 117 | 118 | ### Git 119 | Provides a list of files with their owners between commits (against the **current** version of CODEOWNERS). 120 | 121 | Ownership of all files staged for commit: 122 | ```shell script 123 | $ cd 124 | $ github-codeowners git 125 | ``` 126 | 127 | Ownership of files existing at a specific commit: 128 | ```shell script 129 | $ github-codeowners git 130 | ``` 131 | 132 | Ownership of files changed between two commits: 133 | ```shell script 134 | $ github-codeowners git 135 | ``` 136 | 137 | Output stats: 138 | ```shell script 139 | $ github-codeowners git -s 140 | ``` 141 | 142 | Full usage: 143 | ```shell script 144 | $ github-codeowners git --help 145 | Usage: github-codeowners git [options] [shaA] [shaB] 146 | 147 | lists owners of files changed between commits, a commit against head or staged against head. 148 | 149 | Options: 150 | -d, --dir path to VCS directory (default: "/Users/jjmschofield/projects/github/snyk/registry") 151 | -c, --codeowners path to codeowners file (default: "/.github/CODEOWNERS") 152 | -o, --output how to output format eg: simple, jsonl, csv (default: "simple") 153 | -s, --stats output stats, note line counts are not available for this command (default: false) 154 | -h, --help output usage information 155 | ``` 156 | 157 | ### Validate 158 | Validates your CODEOWNERS file to find common mistakes, will throw on errors (such as malformed owners). 159 | ```shell script 160 | $ cd 161 | $ github-codeowners validate 162 | Found duplicate rules [ 'some/duplicate/rule @octocat' ] 163 | Found rules which did not match any files [ 'some/non-existent/path @octocat' ] 164 | ... 165 | ``` 166 | 167 | Full usage information: 168 | ```shell script 169 | $ github-codeowners validate --help 170 | Usage: github-codeowners validate [options] 171 | 172 | Validates a CODOWNER file and files in dir 173 | 174 | Options: 175 | -d, --dir path to VCS directory (default: "") 176 | -c, --codeowners path to codeowners file (default: "/.github/CODEOWNERS") 177 | -r, --root the root path to filter files by (default: "") 178 | -h, --help output usage information 179 | ``` 180 | 181 | ## Output Formats 182 | Check `github-codeowners --help` for support for a given command, however generally the following outputs are supported: 183 | * `simple` - tab delimited - terminal friendly output 184 | * `jsonl` - line separated json - useful for streaming data to another command 185 | * `csv` - csv delimited fields - useful to import into a spreadsheet tool of your choice 186 | 187 | ## Limits and Things to Improve 188 | * It requires node 189 | * It is not optimized 190 | * The output interface might change 191 | * Command syntax might change 192 | 193 | ## Shout outs 194 | Inspired by [codeowners](https://github.com/beaugunderson/codeowners#readme) but implemented in Typescript with extra bells and whistles. 195 | 196 | This project is a fork of [jjmschofield's version](https://github.com/jjmschofield/github-codeowners). 197 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: github-codeowners 5 | spec: 6 | type: external-tooling 7 | lifecycle: "-" 8 | owner: arch 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "./src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "globals": { 9 | "__HOST__": "localhost", 10 | "__HTTP_PORT__": "3000", 11 | "__HTTPS_PORT__": "3001", 12 | }, 13 | "testEnvironment": "node", 14 | "reporters": [ 15 | "default", 16 | "jest-junit" 17 | ], 18 | "coverageReporters": [ 19 | "html", 20 | "lcov", 21 | "text" 22 | ], 23 | "coverageDirectory": "./tests/reports/unit/coverage", 24 | "collectCoverageFrom": [ 25 | "src/**/*.ts" 26 | ], 27 | "coveragePathIgnorePatterns": [ 28 | "__mock__", 29 | ".test.int.ts" 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snyk/github-codeowners", 3 | "description": "Handy tool for working with file ownership using Githubs CODEOWNERS file", 4 | "main": "dist/cli.js", 5 | "files": [ 6 | "dist/**/*" 7 | ], 8 | "scripts": { 9 | "prepare": "npm run build", 10 | "build": "tsc", 11 | "cli": "ts-node --transpile-only src/cli.ts", 12 | "cli:dist": "tsc && node dist/cli.js", 13 | "test": "jest", 14 | "pretest:int": "rm -rf tests/scratch", 15 | "test:int": "env JEST_JUNIT_OUTPUT_DIR=./tests/reports/int jest --testMatch **/*.test.int.ts --ci" 16 | }, 17 | "author": "snyk.io", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/snyk/github-codeowners" 22 | }, 23 | "dependencies": { 24 | "commander": "^4.1.1", 25 | "ignore": "^5.1.8", 26 | "p-map": "^4.0.0" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^27.4.0", 30 | "@types/node": "^13.7.6", 31 | "@types/uuid": "^8.0.0", 32 | "jest": "^27.4.7", 33 | "jest-junit": "^11.0.1", 34 | "ts-jest": "^27.1.3", 35 | "ts-node": "^8.6.2", 36 | "typescript": "~3.8.3", 37 | "uuid": "^8.2.0" 38 | }, 39 | "release": { 40 | "branches": [ 41 | "main" 42 | ] 43 | }, 44 | "bin": "dist/cli.js", 45 | "engines": { 46 | "node": ">=8.10" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as path from 'path'; 3 | 4 | import commander from 'commander'; 5 | 6 | import { audit } from './commands/audit'; 7 | import { who } from './commands/who'; 8 | import { git } from './commands/git'; 9 | import { log } from './lib/logger'; 10 | 11 | import { OUTPUT_FORMAT } from './lib/types'; 12 | import { validate } from './commands/validate'; 13 | 14 | const { version } = require('../package.json'); 15 | 16 | commander.version(version); 17 | 18 | commander.command('audit') 19 | .description('list the owners for all files') 20 | .option('-d, --dir ', 'path to VCS directory', process.cwd()) 21 | .option('-c, --codeowners ', 'path to codeowners file (default: "/.github/CODEOWNERS")') 22 | .option('-o, --output ', `how to output format eg: ${Object.values(OUTPUT_FORMAT).join(', ')}`, OUTPUT_FORMAT.SIMPLE) 23 | .option('-u, --unloved', 'write unowned files only', false) 24 | .option('-g, --only-git', 'consider only files tracked by git', false) 25 | .option('-s, --stats', 'write output stats', false) 26 | .option('-r, --root ', 'the root path to filter files by', '') 27 | .action(async (options) => { 28 | try { 29 | if (!options.codeowners) { 30 | options.codeowners = path.resolve(options.dir, '.github/CODEOWNERS'); 31 | } 32 | 33 | if (options.root) { 34 | options.dir = path.resolve(options.dir, options.root); 35 | } 36 | 37 | await audit(options); 38 | } catch (error) { 39 | log.error('failed to run audit command', error); 40 | process.exit(1); 41 | } 42 | }); 43 | 44 | commander.command('validate') 45 | .description('Validates a CODOWNER file and files in dir') 46 | .option('-d, --dir ', 'path to VCS directory', process.cwd()) 47 | .option('-c, --codeowners ', 'path to codeowners file (default: "/.github/CODEOWNERS")') 48 | .option('-r, --root ', 'the root path to filter files by', '') 49 | .action(async (options) => { 50 | try { 51 | if (!options.codeowners) { 52 | options.codeowners = path.resolve(options.dir, '.github/CODEOWNERS'); 53 | } 54 | 55 | if (options.root) { 56 | options.dir = path.resolve(options.dir, options.root); 57 | } 58 | 59 | await validate(options); 60 | } catch (error) { 61 | log.error('failed to run validate command', error); 62 | process.exit(1); 63 | } 64 | }); 65 | 66 | 67 | commander.command('who ') 68 | .description('lists owners of a specific file or files') 69 | .option('-d, --dir ', 'path to VCS directory', process.cwd()) 70 | .option('-c, --codeowners ', 'path to codeowners file (default: "/.github/CODEOWNERS")') 71 | .option('-o, --output ', `how to output format eg: ${Object.values(OUTPUT_FORMAT).join(', ')}`, OUTPUT_FORMAT.SIMPLE) 72 | .action(async (files, options) => { 73 | try { 74 | if (files.length < 1) { 75 | throw new Error('a file must be defined'); 76 | } 77 | 78 | options.files = files; 79 | 80 | if (!options.codeowners) { 81 | options.codeowners = path.resolve(options.dir, '.github/CODEOWNERS'); 82 | } 83 | 84 | await who(options); 85 | } catch (error) { 86 | log.error('failed to run who command', error); 87 | process.exit(1); 88 | } 89 | }); 90 | 91 | commander.command('git [shaA] [shaB]') 92 | .description('lists owners of files changed between commits, a commit against head or staged against head') 93 | .option('-d, --dir ', 'path to VCS directory', process.cwd()) 94 | .option('-c, --codeowners ', 'path to codeowners file (default: "/.github/CODEOWNERS")') 95 | .option('-o, --output ', `how to output format eg: ${Object.values(OUTPUT_FORMAT).join(', ')}`, OUTPUT_FORMAT.SIMPLE) 96 | .option('-s, --stats', 'output stats, note: line counts are not available for this command', false) 97 | .action(async (shaA, shaB, options) => { 98 | try { 99 | if (!options.codeowners) { 100 | options.codeowners = path.resolve(options.dir, '.github/CODEOWNERS'); 101 | } 102 | 103 | options.shaA = shaA; 104 | options.shaB = shaB; 105 | 106 | await git(options); 107 | } catch (error) { 108 | log.error('failed to run git command', error); 109 | process.exit(1); 110 | } 111 | }); 112 | 113 | 114 | if (!process.argv.slice(2).length) { 115 | commander.outputHelp(); 116 | } 117 | 118 | commander.parse(process.argv); 119 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/default/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files" : [ 3 | { "path": "default-wildcard-owners.md" }, 4 | { "path": "src/ext-wildcard-owner.js" }, 5 | { "path": "build/logs/recursive-root-dir-owner.log" }, 6 | { "path": "build/logs/deep/recursive-root-dir-owner.log" }, 7 | { "path": "docs/non-recursive-dir-owner.md" }, 8 | { "path": "deep/apps/recursive-deep-dir-owner.ts" }, 9 | { "path": "node_modules/parent-ignored.js" }, 10 | { "path": "explicit-ignore.js" }, 11 | { "path": "overridden-ignore.js" }, 12 | { "path": "deep/nested-ignore/explicit-ignore.js" }, 13 | { "path": "deep/nested-ignore/overridden-ignore.js" }, 14 | { "path": "deep/nested-ignore/ignored-by-nested-rule.txt" }, 15 | { "path": "deep/nested-ignore/node_modules/ignored-by-inherited-rule.txt" } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/default/gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | explicit-ignore.js 3 | overridden-ignore.js 4 | override.txt 5 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/default/gitignore-deep: -------------------------------------------------------------------------------- 1 | !overridden-ignore.js 2 | ignored-by-nested-rule.txt 3 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/default/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { FixturePaths } from '../types'; 4 | 5 | // Sets up a full project which will ensure compliance against the following: 6 | // - codenames spec https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax) 7 | // - gitignore spec https://git-scm.com/docs/gitignore 8 | const paths: FixturePaths = { 9 | files: path.resolve(__dirname, 'files.json'), 10 | codeowners: path.resolve(__dirname, 'owners'), 11 | gitignores: [ 12 | { 13 | in: path.resolve(__dirname, 'gitignore'), 14 | out: '.gitignore', 15 | }, 16 | { 17 | in: path.resolve(__dirname, 'gitignore-deep'), 18 | out: 'deep/nested-ignore/.gitignore', 19 | }, 20 | ], 21 | }; 22 | 23 | export default paths; 24 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/default/owners: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @global-owner1 and @global-owner2 will be requested for 4 | # review when someone opens a pull request. 5 | * @global-owner1 @global-owner2 6 | 7 | # Order is important; the last matching pattern takes the most 8 | # precedence. When someone opens a pull request that only 9 | # modifies JS files, only @js-owner and not the global 10 | # owner(s) will be requested for a review. 11 | *.js @js-owner 12 | 13 | # In this example, @doctocat owns any files in the build/logs 14 | # directory at the root of the repository and any of its 15 | # subdirectories. 16 | /build/logs/ @doctocat 17 | 18 | # The `docs/*` pattern will match files like 19 | # `docs/getting-started.md` but not further nested files like 20 | # `docs/build-app/troubleshooting.md`. 21 | docs/* docs@example.com 22 | 23 | # In this example, @octocat owns any file in an apps directory 24 | # anywhere in your repository. 25 | apps/ @octocat 26 | 27 | # In this example, @doctocat owns any file in the `/docs` 28 | # directory in the root of your repository. 29 | /docs/ @doctocat 30 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/project-builder.test.helper.ts: -------------------------------------------------------------------------------- 1 | import { FixturePaths } from './types'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | export const generateProject = async (testId: string, fixtures: FixturePaths) => { 7 | const testDir = path.resolve('tests', 'scratch', testId); 8 | 9 | await createFiles(testDir, fixtures.files); 10 | 11 | await createCodeowners(testDir, fixtures.codeowners); 12 | 13 | await createGitIgnores(testDir, fixtures.gitignores); 14 | 15 | return testDir; 16 | }; 17 | 18 | const createFiles = async (cwd: string, fixturePath: string) => { 19 | const { files } = JSON.parse(await fs.promises.readFile(fixturePath)); 20 | 21 | 22 | for (const file of files) { 23 | const dir = path.join(cwd, path.dirname(file.path)); 24 | const fileName = path.basename(file.path); 25 | 26 | await fs.promises.mkdir(dir, { recursive: true }); 27 | await fs.promises.writeFile(path.join(dir, fileName), 'some line'); 28 | } 29 | }; 30 | 31 | const createCodeowners = async (cwd: string, fixturePath: string) => { 32 | const owners = await fs.promises.readFile(fixturePath); 33 | await fs.promises.mkdir(path.join(cwd, '.github'), { recursive: true }); 34 | await fs.promises.writeFile(path.join(cwd, '.github', 'CODEOWNERS'), owners); 35 | }; 36 | 37 | const createGitIgnores = async (cwd: string, fixturePaths: {in: string, out: string}[]) => { 38 | for(const paths of fixturePaths){ 39 | const fixture = await fs.promises.readFile(paths.in); 40 | await fs.promises.writeFile(path.join(cwd, paths.out), fixture); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/types.ts: -------------------------------------------------------------------------------- 1 | export interface FixturePaths { 2 | files: string; 3 | codeowners: string; 4 | gitignores: { in: string, out: string }[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/validate/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "files" : [ 3 | { "path": "valid.js" }, 4 | { "path": "duplicate-rule.js" } 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/validate/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { FixturePaths } from '../types'; 4 | 5 | const paths: FixturePaths = { 6 | files: path.resolve(__dirname, 'files.json'), 7 | codeowners: path.resolve(__dirname, 'owners'), 8 | gitignores: [], 9 | }; 10 | 11 | export const invalidOwnerFixtures: FixturePaths = { 12 | ...paths, 13 | codeowners: path.resolve(__dirname, 'owners-invalid-format'), 14 | }; 15 | 16 | export default paths; 17 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/validate/owners: -------------------------------------------------------------------------------- 1 | valid.js @some-owner 2 | duplicate-rule.js @some-owner 3 | file-does-not-exist.md @some-owner 4 | duplicate-rule.js @some-owner 5 | -------------------------------------------------------------------------------- /src/commands/__fixtures__/validate/owners-invalid-format: -------------------------------------------------------------------------------- 1 | file-exists-duplicate-rule.md @valid-owner not-a-valid-owner 2 | -------------------------------------------------------------------------------- /src/commands/__snapshots__/audit.test.int.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`audit csv should calculate stats when asked: stderr 1`] = `""`; 4 | 5 | exports[`audit csv should calculate stats when asked: stdout 1`] = ` 6 | "owner,files,lines 7 | total,10,35,100 8 | loved,10,35,100 9 | unloved,0,0,100 10 | @doctocat,3,0 11 | @global-owner1,4,35 12 | @global-owner2,4,35 13 | @js-owner,2,0 14 | @octocat,1,0 15 | " 16 | `; 17 | 18 | exports[`audit csv should do all commands in combination when asked: stderr 1`] = `""`; 19 | 20 | exports[`audit csv should do all commands in combination when asked: stdout 1`] = ` 21 | "owner,files,lines 22 | total,5,2,100 23 | loved,5,2,100 24 | unloved,0,0,100 25 | @global-owner1,2,2 26 | @global-owner2,2,2 27 | @js-owner,2,0 28 | @octocat,1,0 29 | " 30 | `; 31 | 32 | exports[`audit csv should list ownership for all files: stderr 1`] = `""`; 33 | 34 | exports[`audit csv should list ownership for all files: stdout 1`] = ` 35 | ".github/CODEOWNERS,@global-owner1,@global-owner2 36 | .gitignore,@global-owner1,@global-owner2 37 | build/logs/deep/recursive-root-dir-owner.log,@doctocat 38 | build/logs/recursive-root-dir-owner.log,@doctocat 39 | deep/apps/recursive-deep-dir-owner.ts,@octocat 40 | deep/nested-ignore/.gitignore,@global-owner1,@global-owner2 41 | deep/nested-ignore/overridden-ignore.js,@js-owner 42 | default-wildcard-owners.md,@global-owner1,@global-owner2 43 | docs/non-recursive-dir-owner.md,@doctocat 44 | src/ext-wildcard-owner.js,@js-owner 45 | " 46 | `; 47 | 48 | exports[`audit csv should only consider files tracked in git root when asked: stderr 1`] = `""`; 49 | 50 | exports[`audit csv should only consider files tracked in git root when asked: stdout 1`] = ` 51 | ".github/CODEOWNERS,@global-owner1,@global-owner2 52 | .gitignore,@global-owner1,@global-owner2 53 | build/logs/deep/recursive-root-dir-owner.log,@doctocat 54 | build/logs/recursive-root-dir-owner.log,@doctocat 55 | deep/apps/recursive-deep-dir-owner.ts,@octocat 56 | deep/nested-ignore/.gitignore,@global-owner1,@global-owner2 57 | deep/nested-ignore/overridden-ignore.js,@js-owner 58 | default-wildcard-owners.md,@global-owner1,@global-owner2 59 | docs/non-recursive-dir-owner.md,@doctocat 60 | src/ext-wildcard-owner.js,@js-owner 61 | " 62 | `; 63 | 64 | exports[`audit csv should show only unloved files when asked: stderr 1`] = `""`; 65 | 66 | exports[`audit csv should show only unloved files when asked: stdout 1`] = `""`; 67 | 68 | exports[`audit csv should use a specific root when asked: stderr 1`] = `""`; 69 | 70 | exports[`audit csv should use a specific root when asked: stdout 1`] = ` 71 | "deep/apps/recursive-deep-dir-owner.ts,@octocat 72 | deep/nested-ignore/.gitignore,@global-owner1,@global-owner2 73 | deep/nested-ignore/explicit-ignore.js,@js-owner 74 | deep/nested-ignore/node_modules/ignored-by-inherited-rule.txt,@global-owner1,@global-owner2 75 | deep/nested-ignore/overridden-ignore.js,@js-owner 76 | " 77 | `; 78 | 79 | exports[`audit jsonl should calculate stats when asked: stderr 1`] = `""`; 80 | 81 | exports[`audit jsonl should calculate stats when asked: stdout 1`] = ` 82 | "{\\"total\\":{\\"files\\":10,\\"lines\\":35,\\"percentage\\":100},\\"unloved\\":{\\"files\\":0,\\"lines\\":0,\\"percentage\\":0},\\"loved\\":{\\"files\\":10,\\"lines\\":35,\\"percentage\\":100},\\"owners\\":[{\\"owner\\":\\"@global-owner1\\",\\"counters\\":{\\"files\\":4,\\"lines\\":35,\\"percentage\\":40}},{\\"owner\\":\\"@global-owner2\\",\\"counters\\":{\\"files\\":4,\\"lines\\":35,\\"percentage\\":40}},{\\"owner\\":\\"@doctocat\\",\\"counters\\":{\\"files\\":3,\\"lines\\":0,\\"percentage\\":30}},{\\"owner\\":\\"@octocat\\",\\"counters\\":{\\"files\\":1,\\"lines\\":0,\\"percentage\\":10}},{\\"owner\\":\\"@js-owner\\",\\"counters\\":{\\"files\\":2,\\"lines\\":0,\\"percentage\\":20}}]} 83 | " 84 | `; 85 | 86 | exports[`audit jsonl should do all commands in combination when asked: stderr 1`] = `""`; 87 | 88 | exports[`audit jsonl should do all commands in combination when asked: stdout 1`] = ` 89 | "{\\"total\\":{\\"files\\":5,\\"lines\\":2,\\"percentage\\":100},\\"unloved\\":{\\"files\\":0,\\"lines\\":0,\\"percentage\\":0},\\"loved\\":{\\"files\\":5,\\"lines\\":2,\\"percentage\\":100},\\"owners\\":[{\\"owner\\":\\"@octocat\\",\\"counters\\":{\\"files\\":1,\\"lines\\":0,\\"percentage\\":20}},{\\"owner\\":\\"@global-owner1\\",\\"counters\\":{\\"files\\":2,\\"lines\\":2,\\"percentage\\":40}},{\\"owner\\":\\"@global-owner2\\",\\"counters\\":{\\"files\\":2,\\"lines\\":2,\\"percentage\\":40}},{\\"owner\\":\\"@js-owner\\",\\"counters\\":{\\"files\\":2,\\"lines\\":0,\\"percentage\\":40}}]} 90 | " 91 | `; 92 | 93 | exports[`audit jsonl should list ownership for all files: stderr 1`] = `""`; 94 | 95 | exports[`audit jsonl should list ownership for all files: stdout 1`] = ` 96 | "{\\"path\\":\\".github/CODEOWNERS\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 97 | {\\"path\\":\\".gitignore\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 98 | {\\"path\\":\\"build/logs/deep/recursive-root-dir-owner.log\\",\\"owners\\":[\\"@doctocat\\"]} 99 | {\\"path\\":\\"build/logs/recursive-root-dir-owner.log\\",\\"owners\\":[\\"@doctocat\\"]} 100 | {\\"path\\":\\"deep/apps/recursive-deep-dir-owner.ts\\",\\"owners\\":[\\"@octocat\\"]} 101 | {\\"path\\":\\"deep/nested-ignore/.gitignore\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 102 | {\\"path\\":\\"deep/nested-ignore/overridden-ignore.js\\",\\"owners\\":[\\"@js-owner\\"]} 103 | {\\"path\\":\\"default-wildcard-owners.md\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 104 | {\\"path\\":\\"docs/non-recursive-dir-owner.md\\",\\"owners\\":[\\"@doctocat\\"]} 105 | {\\"path\\":\\"src/ext-wildcard-owner.js\\",\\"owners\\":[\\"@js-owner\\"]} 106 | " 107 | `; 108 | 109 | exports[`audit jsonl should only consider files tracked in git root when asked: stderr 1`] = `""`; 110 | 111 | exports[`audit jsonl should only consider files tracked in git root when asked: stdout 1`] = ` 112 | "{\\"path\\":\\".github/CODEOWNERS\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 113 | {\\"path\\":\\".gitignore\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 114 | {\\"path\\":\\"build/logs/deep/recursive-root-dir-owner.log\\",\\"owners\\":[\\"@doctocat\\"]} 115 | {\\"path\\":\\"build/logs/recursive-root-dir-owner.log\\",\\"owners\\":[\\"@doctocat\\"]} 116 | {\\"path\\":\\"deep/apps/recursive-deep-dir-owner.ts\\",\\"owners\\":[\\"@octocat\\"]} 117 | {\\"path\\":\\"deep/nested-ignore/.gitignore\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 118 | {\\"path\\":\\"deep/nested-ignore/overridden-ignore.js\\",\\"owners\\":[\\"@js-owner\\"]} 119 | {\\"path\\":\\"default-wildcard-owners.md\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 120 | {\\"path\\":\\"docs/non-recursive-dir-owner.md\\",\\"owners\\":[\\"@doctocat\\"]} 121 | {\\"path\\":\\"src/ext-wildcard-owner.js\\",\\"owners\\":[\\"@js-owner\\"]} 122 | " 123 | `; 124 | 125 | exports[`audit jsonl should show only unloved files when asked: stderr 1`] = `""`; 126 | 127 | exports[`audit jsonl should show only unloved files when asked: stdout 1`] = `""`; 128 | 129 | exports[`audit jsonl should use a specific root when asked: stderr 1`] = `""`; 130 | 131 | exports[`audit jsonl should use a specific root when asked: stdout 1`] = ` 132 | "{\\"path\\":\\"deep/apps/recursive-deep-dir-owner.ts\\",\\"owners\\":[\\"@octocat\\"]} 133 | {\\"path\\":\\"deep/nested-ignore/.gitignore\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 134 | {\\"path\\":\\"deep/nested-ignore/explicit-ignore.js\\",\\"owners\\":[\\"@js-owner\\"]} 135 | {\\"path\\":\\"deep/nested-ignore/node_modules/ignored-by-inherited-rule.txt\\",\\"owners\\":[\\"@global-owner1\\",\\"@global-owner2\\"]} 136 | {\\"path\\":\\"deep/nested-ignore/overridden-ignore.js\\",\\"owners\\":[\\"@js-owner\\"]} 137 | " 138 | `; 139 | 140 | exports[`audit simple should calculate stats when asked: stderr 1`] = `""`; 141 | 142 | exports[`audit simple should calculate stats when asked: stdout 1`] = ` 143 | " 144 | --- Counts --- 145 | Total: 10 files (35 lines) 100% 146 | Loved: 10 files (35 lines) 100% 147 | Unloved: 0 files (0 lines 0% 148 | --- Owners --- 149 | @doctocat: 3 files (0 lines) 30% 150 | @global-owner1: 4 files (35 lines) 40% 151 | @global-owner2: 4 files (35 lines) 40% 152 | @js-owner: 2 files (0 lines) 20% 153 | @octocat: 1 files (0 lines) 10% 154 | " 155 | `; 156 | 157 | exports[`audit simple should do all commands in combination when asked: stderr 1`] = `""`; 158 | 159 | exports[`audit simple should do all commands in combination when asked: stdout 1`] = ` 160 | " 161 | --- Counts --- 162 | Total: 5 files (2 lines) 100% 163 | Loved: 5 files (2 lines) 100% 164 | Unloved: 0 files (0 lines 0% 165 | --- Owners --- 166 | @global-owner1: 2 files (2 lines) 40% 167 | @global-owner2: 2 files (2 lines) 40% 168 | @js-owner: 2 files (0 lines) 40% 169 | @octocat: 1 files (0 lines) 20% 170 | " 171 | `; 172 | 173 | exports[`audit simple should list ownership for all files: stderr 1`] = `""`; 174 | 175 | exports[`audit simple should list ownership for all files: stdout 1`] = ` 176 | ".github/CODEOWNERS @global-owner1 @global-owner2 177 | .gitignore @global-owner1 @global-owner2 178 | build/logs/deep/recursive-root-dir-owner.log @doctocat 179 | build/logs/recursive-root-dir-owner.log @doctocat 180 | deep/apps/recursive-deep-dir-owner.ts @octocat 181 | deep/nested-ignore/.gitignore @global-owner1 @global-owner2 182 | deep/nested-ignore/overridden-ignore.js @js-owner 183 | default-wildcard-owners.md @global-owner1 @global-owner2 184 | docs/non-recursive-dir-owner.md @doctocat 185 | src/ext-wildcard-owner.js @js-owner 186 | " 187 | `; 188 | 189 | exports[`audit simple should only consider files tracked in git root when asked: stderr 1`] = `""`; 190 | 191 | exports[`audit simple should only consider files tracked in git root when asked: stdout 1`] = ` 192 | ".github/CODEOWNERS @global-owner1 @global-owner2 193 | .gitignore @global-owner1 @global-owner2 194 | build/logs/deep/recursive-root-dir-owner.log @doctocat 195 | build/logs/recursive-root-dir-owner.log @doctocat 196 | deep/apps/recursive-deep-dir-owner.ts @octocat 197 | deep/nested-ignore/.gitignore @global-owner1 @global-owner2 198 | deep/nested-ignore/overridden-ignore.js @js-owner 199 | default-wildcard-owners.md @global-owner1 @global-owner2 200 | docs/non-recursive-dir-owner.md @doctocat 201 | src/ext-wildcard-owner.js @js-owner 202 | " 203 | `; 204 | 205 | exports[`audit simple should show only unloved files when asked: stderr 1`] = `""`; 206 | 207 | exports[`audit simple should show only unloved files when asked: stdout 1`] = `""`; 208 | 209 | exports[`audit simple should use a specific root when asked: stderr 1`] = `""`; 210 | 211 | exports[`audit simple should use a specific root when asked: stdout 1`] = ` 212 | "deep/apps/recursive-deep-dir-owner.ts @octocat 213 | deep/nested-ignore/.gitignore @global-owner1 @global-owner2 214 | deep/nested-ignore/explicit-ignore.js @js-owner 215 | deep/nested-ignore/node_modules/ignored-by-inherited-rule.txt @global-owner1 @global-owner2 216 | deep/nested-ignore/overridden-ignore.js @js-owner 217 | " 218 | `; 219 | -------------------------------------------------------------------------------- /src/commands/__snapshots__/git.test.int.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`git csv should list ownership at the specific commit when a commit sha is provided: stderr 1`] = `""`; 4 | 5 | exports[`git csv should list ownership at the specific commit when a commit sha is provided: stdout 1`] = ` 6 | ".github/CODEOWNERS,@snyk/arch 7 | .gitignore,@snyk/arch 8 | .idea/codeStyles/codeStyleConfig.xml,@snyk/arch 9 | .idea/github-codeowners.iml,@snyk/arch 10 | .idea/inspectionProfiles/Project_Default.xml,@snyk/arch 11 | .idea/jsLibraryMappings.xml,@snyk/arch 12 | .idea/misc.xml,@snyk/arch 13 | .idea/modules.xml,@snyk/arch 14 | .idea/vcs.xml,@snyk/arch 15 | README.md,@snyk/arch 16 | package-lock.json,@snyk/arch 17 | package.json,@snyk/arch 18 | src/cli.ts,@snyk/arch 19 | src/commands/audit.ts,@snyk/arch 20 | src/commands/git.ts,@snyk/arch 21 | src/commands/who.ts,@snyk/arch 22 | src/lib/OwnedFile.ts,@snyk/arch 23 | src/lib/OwnershipEngine.ts,@snyk/arch 24 | src/lib/dir.ts,@snyk/arch 25 | src/lib/stats.ts,@snyk/arch 26 | src/lib/types.ts,@snyk/arch 27 | src/lib/writers.ts,@snyk/arch 28 | tsconfig.json,@snyk/arch 29 | tslint.json,@snyk/arch 30 | " 31 | `; 32 | 33 | exports[`git csv should list ownership of files changed between specific commits: stderr 1`] = `""`; 34 | 35 | exports[`git csv should list ownership of files changed between specific commits: stdout 1`] = ` 36 | ".circleci/config.yml,@snyk/arch 37 | .gitignore,@snyk/arch 38 | .idea/runConfigurations/test_int.xml,@snyk/arch 39 | README.md,@snyk/arch 40 | __mocks__/recursive-readdir.js,@snyk/arch 41 | jest.config.js,@snyk/arch 42 | package-lock.json,@snyk/arch 43 | package.json,@snyk/arch 44 | src/cli.ts,@snyk/arch 45 | src/commands/__fixtures__/default/files.json,@snyk/arch 46 | src/commands/__fixtures__/default/gitignore,@snyk/arch 47 | src/commands/__fixtures__/default/gitignore-deep,@snyk/arch 48 | src/commands/__fixtures__/default/index.ts,@snyk/arch 49 | src/commands/__fixtures__/default/owners,@snyk/arch 50 | src/commands/__fixtures__/project-builder.test.helper.ts,@snyk/arch 51 | src/commands/__fixtures__/types.ts,@snyk/arch 52 | src/commands/__snapshots__/audit.test.int.ts.snap,@snyk/arch 53 | src/commands/audit.test.int.ts,@snyk/arch 54 | src/lib/OwnedFile.ts,@snyk/arch 55 | src/lib/OwnershipEngine.test.ts,@snyk/arch 56 | src/lib/OwnershipEngine.ts,@snyk/arch 57 | src/lib/dir.ts,@snyk/arch 58 | src/lib/gitignore.test.ts,@snyk/arch 59 | src/lib/gitignore.ts,@snyk/arch 60 | src/lib/logger.ts,@snyk/arch 61 | src/lib/writers.ts,@snyk/arch 62 | tsconfig.json,@snyk/arch 63 | tslint.json,@snyk/arch 64 | " 65 | `; 66 | 67 | exports[`git jsonl should list ownership at the specific commit when a commit sha is provided: stderr 1`] = `""`; 68 | 69 | exports[`git jsonl should list ownership at the specific commit when a commit sha is provided: stdout 1`] = ` 70 | "{\\"path\\":\\".github/CODEOWNERS\\",\\"owners\\":[\\"@snyk/arch\\"]} 71 | {\\"path\\":\\".gitignore\\",\\"owners\\":[\\"@snyk/arch\\"]} 72 | {\\"path\\":\\".idea/codeStyles/codeStyleConfig.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 73 | {\\"path\\":\\".idea/github-codeowners.iml\\",\\"owners\\":[\\"@snyk/arch\\"]} 74 | {\\"path\\":\\".idea/inspectionProfiles/Project_Default.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 75 | {\\"path\\":\\".idea/jsLibraryMappings.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 76 | {\\"path\\":\\".idea/misc.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 77 | {\\"path\\":\\".idea/modules.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 78 | {\\"path\\":\\".idea/vcs.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 79 | {\\"path\\":\\"README.md\\",\\"owners\\":[\\"@snyk/arch\\"]} 80 | {\\"path\\":\\"package-lock.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 81 | {\\"path\\":\\"package.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 82 | {\\"path\\":\\"src/cli.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 83 | {\\"path\\":\\"src/commands/audit.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 84 | {\\"path\\":\\"src/commands/git.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 85 | {\\"path\\":\\"src/commands/who.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 86 | {\\"path\\":\\"src/lib/OwnedFile.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 87 | {\\"path\\":\\"src/lib/OwnershipEngine.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 88 | {\\"path\\":\\"src/lib/dir.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 89 | {\\"path\\":\\"src/lib/stats.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 90 | {\\"path\\":\\"src/lib/types.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 91 | {\\"path\\":\\"src/lib/writers.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 92 | {\\"path\\":\\"tsconfig.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 93 | {\\"path\\":\\"tslint.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 94 | " 95 | `; 96 | 97 | exports[`git jsonl should list ownership of files changed between specific commits: stderr 1`] = `""`; 98 | 99 | exports[`git jsonl should list ownership of files changed between specific commits: stdout 1`] = ` 100 | "{\\"path\\":\\".circleci/config.yml\\",\\"owners\\":[\\"@snyk/arch\\"]} 101 | {\\"path\\":\\".gitignore\\",\\"owners\\":[\\"@snyk/arch\\"]} 102 | {\\"path\\":\\".idea/runConfigurations/test_int.xml\\",\\"owners\\":[\\"@snyk/arch\\"]} 103 | {\\"path\\":\\"README.md\\",\\"owners\\":[\\"@snyk/arch\\"]} 104 | {\\"path\\":\\"__mocks__/recursive-readdir.js\\",\\"owners\\":[\\"@snyk/arch\\"]} 105 | {\\"path\\":\\"jest.config.js\\",\\"owners\\":[\\"@snyk/arch\\"]} 106 | {\\"path\\":\\"package-lock.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 107 | {\\"path\\":\\"package.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 108 | {\\"path\\":\\"src/cli.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 109 | {\\"path\\":\\"src/commands/__fixtures__/default/files.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 110 | {\\"path\\":\\"src/commands/__fixtures__/default/gitignore\\",\\"owners\\":[\\"@snyk/arch\\"]} 111 | {\\"path\\":\\"src/commands/__fixtures__/default/gitignore-deep\\",\\"owners\\":[\\"@snyk/arch\\"]} 112 | {\\"path\\":\\"src/commands/__fixtures__/default/index.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 113 | {\\"path\\":\\"src/commands/__fixtures__/default/owners\\",\\"owners\\":[\\"@snyk/arch\\"]} 114 | {\\"path\\":\\"src/commands/__fixtures__/project-builder.test.helper.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 115 | {\\"path\\":\\"src/commands/__fixtures__/types.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 116 | {\\"path\\":\\"src/commands/__snapshots__/audit.test.int.ts.snap\\",\\"owners\\":[\\"@snyk/arch\\"]} 117 | {\\"path\\":\\"src/commands/audit.test.int.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 118 | {\\"path\\":\\"src/lib/OwnedFile.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 119 | {\\"path\\":\\"src/lib/OwnershipEngine.test.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 120 | {\\"path\\":\\"src/lib/OwnershipEngine.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 121 | {\\"path\\":\\"src/lib/dir.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 122 | {\\"path\\":\\"src/lib/gitignore.test.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 123 | {\\"path\\":\\"src/lib/gitignore.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 124 | {\\"path\\":\\"src/lib/logger.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 125 | {\\"path\\":\\"src/lib/writers.ts\\",\\"owners\\":[\\"@snyk/arch\\"]} 126 | {\\"path\\":\\"tsconfig.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 127 | {\\"path\\":\\"tslint.json\\",\\"owners\\":[\\"@snyk/arch\\"]} 128 | " 129 | `; 130 | 131 | exports[`git simple should list ownership at the specific commit when a commit sha is provided: stderr 1`] = `""`; 132 | 133 | exports[`git simple should list ownership at the specific commit when a commit sha is provided: stdout 1`] = ` 134 | ".github/CODEOWNERS @snyk/arch 135 | .gitignore @snyk/arch 136 | .idea/codeStyles/codeStyleConfig.xml @snyk/arch 137 | .idea/github-codeowners.iml @snyk/arch 138 | .idea/inspectionProfiles/Project_Default.xml @snyk/arch 139 | .idea/jsLibraryMappings.xml @snyk/arch 140 | .idea/misc.xml @snyk/arch 141 | .idea/modules.xml @snyk/arch 142 | .idea/vcs.xml @snyk/arch 143 | README.md @snyk/arch 144 | package-lock.json @snyk/arch 145 | package.json @snyk/arch 146 | src/cli.ts @snyk/arch 147 | src/commands/audit.ts @snyk/arch 148 | src/commands/git.ts @snyk/arch 149 | src/commands/who.ts @snyk/arch 150 | src/lib/OwnedFile.ts @snyk/arch 151 | src/lib/OwnershipEngine.ts @snyk/arch 152 | src/lib/dir.ts @snyk/arch 153 | src/lib/stats.ts @snyk/arch 154 | src/lib/types.ts @snyk/arch 155 | src/lib/writers.ts @snyk/arch 156 | tsconfig.json @snyk/arch 157 | tslint.json @snyk/arch 158 | " 159 | `; 160 | 161 | exports[`git simple should list ownership of files changed between specific commits: stderr 1`] = `""`; 162 | 163 | exports[`git simple should list ownership of files changed between specific commits: stdout 1`] = ` 164 | ".circleci/config.yml @snyk/arch 165 | .gitignore @snyk/arch 166 | .idea/runConfigurations/test_int.xml @snyk/arch 167 | README.md @snyk/arch 168 | __mocks__/recursive-readdir.js @snyk/arch 169 | jest.config.js @snyk/arch 170 | package-lock.json @snyk/arch 171 | package.json @snyk/arch 172 | src/cli.ts @snyk/arch 173 | src/commands/__fixtures__/default/files.json @snyk/arch 174 | src/commands/__fixtures__/default/gitignore @snyk/arch 175 | src/commands/__fixtures__/default/gitignore-deep @snyk/arch 176 | src/commands/__fixtures__/default/index.ts @snyk/arch 177 | src/commands/__fixtures__/default/owners @snyk/arch 178 | src/commands/__fixtures__/project-builder.test.helper.ts @snyk/arch 179 | src/commands/__fixtures__/types.ts @snyk/arch 180 | src/commands/__snapshots__/audit.test.int.ts.snap @snyk/arch 181 | src/commands/audit.test.int.ts @snyk/arch 182 | src/lib/OwnedFile.ts @snyk/arch 183 | src/lib/OwnershipEngine.test.ts @snyk/arch 184 | src/lib/OwnershipEngine.ts @snyk/arch 185 | src/lib/dir.ts @snyk/arch 186 | src/lib/gitignore.test.ts @snyk/arch 187 | src/lib/gitignore.ts @snyk/arch 188 | src/lib/logger.ts @snyk/arch 189 | src/lib/writers.ts @snyk/arch 190 | tsconfig.json @snyk/arch 191 | tslint.json @snyk/arch 192 | " 193 | `; 194 | -------------------------------------------------------------------------------- /src/commands/__snapshots__/validate.test.int.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`validate when all owners are valid output the result of all validation checks: stderr 1`] = ` 4 | "Found duplicate rules [ 'duplicate-rule.js @some-owner' ] 5 | Found rules which did not match any files [ 'file-does-not-exist.md @some-owner' ] 6 | " 7 | `; 8 | 9 | exports[`validate when all owners are valid output the result of all validation checks: stdout 1`] = `""`; 10 | -------------------------------------------------------------------------------- /src/commands/__snapshots__/who.test.int.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`who should list ownership for multiple files: stderr 1`] = `""`; 4 | 5 | exports[`who should list ownership for multiple files: stdout 1`] = ` 6 | "explicit-ignore.js @js-owner 7 | default-wildcard-owners.md @global-owner1 @global-owner2 8 | " 9 | `; 10 | 11 | exports[`who should list ownership for one file: stderr 1`] = `""`; 12 | 13 | exports[`who should list ownership for one file: stdout 1`] = ` 14 | "default-wildcard-owners.md @global-owner1 @global-owner2 15 | " 16 | `; 17 | -------------------------------------------------------------------------------- /src/commands/audit.test.int.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import child_process from 'child_process'; 4 | import util from 'util'; 5 | 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import fixtures from './__fixtures__/default'; 8 | import { generateProject } from './__fixtures__/project-builder.test.helper'; 9 | 10 | const exec = util.promisify(child_process.exec); 11 | const writeFile = util.promisify(fs.writeFile); 12 | 13 | describe('audit', () => { 14 | let testDir = 'not set'; 15 | 16 | const runCli = async (args: string) => { 17 | return exec(`node ../../../dist/cli.js ${args}`, { cwd: testDir }); 18 | }; 19 | 20 | const gitTrackProject = async () => { 21 | await exec(`git init`, { cwd: testDir }); 22 | await exec(`git add .`, { cwd: testDir }); 23 | await exec(`git config user.email "github-codeowners@example.com"`, { cwd: testDir }); 24 | await exec(`git config user.name "github-codeowners"`, { cwd: testDir }); 25 | await exec(`git commit -m "integration tests"`, { cwd: testDir }); 26 | }; 27 | 28 | const outputs = ['simple', 'jsonl', 'csv']; 29 | 30 | for (const output of outputs) { 31 | describe(output, () => { 32 | beforeEach(async () => { 33 | const testId = uuidv4(); 34 | testDir = await generateProject(testId, fixtures); 35 | }); 36 | 37 | it('should list ownership for all files', async () => { 38 | const { stdout, stderr } = await runCli(`audit -o ${output}`); 39 | expect(stdout).toMatchSnapshot('stdout'); 40 | expect(stderr).toMatchSnapshot('stderr'); 41 | }); 42 | 43 | it('should calculate stats when asked', async () => { 44 | const { stdout, stderr } = await runCli(`audit -s -o ${output}`); 45 | expect(stdout).toMatchSnapshot('stdout'); 46 | expect(stderr).toMatchSnapshot('stderr'); 47 | }); 48 | 49 | it('should show only unloved files when asked', async () => { 50 | const { stdout, stderr } = await runCli(`audit -u -o ${output}`); 51 | expect(stdout).toMatchSnapshot('stdout'); 52 | expect(stderr).toMatchSnapshot('stderr'); 53 | }); 54 | 55 | it('should use a specific root when asked', async () => { 56 | const { stdout, stderr } = await runCli(`audit -r deep -o ${output}`); 57 | expect(stdout).toMatchSnapshot('stdout'); 58 | expect(stderr).toMatchSnapshot('stderr'); 59 | }); 60 | 61 | it('should only consider files tracked in git root when asked', async () => { 62 | // Arrange 63 | await gitTrackProject(); 64 | await writeFile(path.resolve(testDir, 'git-untracked.txt'), 'not tracked in git'); 65 | 66 | // Act 67 | const { stdout, stderr } = await runCli(`audit -g -o ${output}`); 68 | 69 | // Assert 70 | expect(stdout).toMatchSnapshot('stdout'); 71 | expect(stderr).toMatchSnapshot('stderr'); 72 | }); 73 | 74 | it('should do all commands in combination when asked', async () => { 75 | const { stdout, stderr } = await runCli(`audit -us -r deep -o ${output}`); 76 | expect(stdout).toMatchSnapshot('stdout'); 77 | expect(stderr).toMatchSnapshot('stderr'); 78 | }); 79 | }); 80 | } 81 | }); 82 | -------------------------------------------------------------------------------- /src/commands/audit.ts: -------------------------------------------------------------------------------- 1 | import pMap from 'p-map'; 2 | import { OUTPUT_FORMAT } from '../lib/types'; 3 | import { calcFileStats, statsWriter } from '../lib/stats'; 4 | import { getOwnership } from '../lib/ownership'; 5 | import { FILE_DISCOVERY_STRATEGY, getFilePaths } from '../lib/file'; 6 | 7 | interface AuditOptions { 8 | codeowners: string; 9 | dir: string; 10 | unloved: boolean; 11 | output: OUTPUT_FORMAT; 12 | onlyGit: boolean; 13 | stats: boolean; 14 | root: string; 15 | } 16 | 17 | export const audit = async (options: AuditOptions) => { 18 | const strategy = options.onlyGit ? FILE_DISCOVERY_STRATEGY.GIT_LS : FILE_DISCOVERY_STRATEGY.FILE_SYSTEM; 19 | const filePaths = await getFilePaths(options.dir, strategy, options.root); 20 | 21 | const files = await getOwnership(options.codeowners, filePaths); 22 | 23 | if (options.stats) { 24 | await pMap(files, f => f.updateLineCount(), { concurrency: 100 }); 25 | 26 | const stats = calcFileStats(files); 27 | statsWriter(stats, options, process.stdout); 28 | return; 29 | } 30 | 31 | for (const file of files) { 32 | if (options.unloved) { 33 | if (file.owners.length < 1) { 34 | file.write(options.output, process.stdout); 35 | } 36 | } else { 37 | file.write(options.output, process.stdout); 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/commands/git.test.int.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import fixtures from './__fixtures__/default'; 3 | import { generateProject } from './__fixtures__/project-builder.test.helper'; 4 | 5 | import util from 'util'; 6 | 7 | const { version } = require('../../package.json'); 8 | 9 | const exec = util.promisify(require('child_process').exec); 10 | 11 | describe('git', () => { 12 | const testId = uuidv4(); 13 | 14 | let testDir = 'not set'; 15 | 16 | beforeAll(async () => { 17 | testDir = await generateProject(testId, fixtures); 18 | // tslint:disable-next-line:no-console 19 | console.log(`test scratch dir: ${testDir}`); 20 | }); 21 | 22 | const runCli = async (args: string) => { 23 | return exec(`node dist/cli.js ${args}`); 24 | }; 25 | 26 | const outputs = ['simple', 'jsonl', 'csv']; 27 | 28 | for (const output of outputs) { 29 | describe(output, () => { 30 | it('should list ownership at the specific commit when a commit sha is provided', async () => { 31 | const { stdout, stderr } = await runCli(`git 2d9bde975c5a5b1a20c57ce0918b0071dcd44e61 -o ${output}`); 32 | expect(stdout).toMatchSnapshot('stdout'); 33 | expect(stderr).toMatchSnapshot('stderr'); 34 | }); 35 | 36 | it('should list ownership of files changed between specific commits', async () => { 37 | const { stdout, stderr } = await runCli(`git 2d9bde975c5a5b1a20c57ce0918b0071dcd44e61 062f7fe9568b8f66ca97f67c6be9ead0eaba7b38 -o ${output}`); 38 | expect(stdout).toMatchSnapshot('stdout'); 39 | expect(stderr).toMatchSnapshot('stderr'); 40 | }); 41 | }); 42 | } 43 | 44 | describe('cli', () => { 45 | it('should print package version', async () => { 46 | const { stdout, stderr } = await runCli('--version'); 47 | expect(stdout).toEqual(`${version}\n`); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/commands/git.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import { getOwnership } from '../lib/ownership'; 3 | import { OUTPUT_FORMAT } from '../lib/types'; 4 | import { calcFileStats, statsWriter } from '../lib/stats'; 5 | 6 | interface GitOptions { 7 | dir: string; 8 | codeowners: string; 9 | shaA?: string; 10 | shaB?: string; 11 | output: OUTPUT_FORMAT; 12 | stats: boolean; 13 | } 14 | 15 | export const git = async (options: GitOptions) => { 16 | const gitCommand = calcGitCommand(options); 17 | 18 | const diff = execSync(gitCommand).toString(); 19 | 20 | const changedPaths = diff.split('\n').filter(path => path.length > 0); 21 | 22 | const files = await getOwnership(options.codeowners, changedPaths); 23 | 24 | for (const file of files) { 25 | file.write(options.output, process.stdout); 26 | } 27 | 28 | if (options.stats) { 29 | const stats = calcFileStats(files); 30 | statsWriter(stats, options, process.stdout); 31 | } 32 | }; 33 | 34 | const calcGitCommand = (options: GitOptions) => { 35 | if (options.shaA && options.shaB) { 36 | return `git diff --name-only ${options.shaA} ${options.shaB}`; 37 | } 38 | 39 | if (options.shaA) { 40 | return `git ls-tree --full-tree -r --name-only ${options.shaA}`; 41 | } 42 | 43 | return 'git diff --name-only --cached HEAD'; 44 | }; 45 | -------------------------------------------------------------------------------- /src/commands/validate.test.int.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import fixtures, { invalidOwnerFixtures } from './__fixtures__/validate'; 3 | import { generateProject } from './__fixtures__/project-builder.test.helper'; 4 | 5 | import util from 'util'; 6 | 7 | const exec = util.promisify(require('child_process').exec); 8 | 9 | describe('validate', () => { 10 | describe('when all owners are valid', () => { 11 | const testId = uuidv4(); 12 | 13 | let testDir = 'not set'; 14 | 15 | beforeAll(async () => { 16 | testDir = await generateProject(testId, fixtures); 17 | // tslint:disable-next-line:no-console 18 | console.log(`test scratch dir: ${testDir}`); 19 | }); 20 | 21 | const runCli = async (args: string) => { 22 | return exec(`node ../../../dist/cli.js ${args}`, { cwd: testDir }); 23 | }; 24 | 25 | it('output the result of all validation checks', async () => { 26 | const { stdout, stderr } = await runCli('validate'); 27 | expect(stdout).toMatchSnapshot('stdout'); 28 | expect(stderr).toMatchSnapshot('stderr'); 29 | }); 30 | }); 31 | 32 | 33 | describe('when owners are invalid', () => { 34 | const testId = uuidv4(); 35 | 36 | let testDir = 'not set'; 37 | 38 | beforeAll(async () => { 39 | testDir = await generateProject(testId, invalidOwnerFixtures); 40 | // tslint:disable-next-line:no-console 41 | console.log(`test scratch dir: ${testDir}`); 42 | }); 43 | 44 | const runCli = async (args: string) => { 45 | return exec(`node ../../../dist/cli.js ${args}`, { cwd: testDir }); 46 | }; 47 | 48 | it('should throw on invalid users', async () => { 49 | await expect(() => runCli('validate')).rejects.toThrow(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/commands/validate.ts: -------------------------------------------------------------------------------- 1 | import { validate as assertValidRules } from '../lib/ownership'; 2 | import { log } from '../lib/logger'; 3 | 4 | interface ValidateOptions { 5 | codeowners: string; 6 | dir: string; 7 | root: string; 8 | } 9 | 10 | export const validate = async (options: ValidateOptions) => { 11 | const results = await assertValidRules(options); // will throw on errors such as badly formatted rules 12 | 13 | if(results.duplicated.size > 0){ 14 | log.warn('Found duplicate rules', Array.from(results.duplicated.values())); 15 | } 16 | 17 | if(results.unmatched.size > 0){ 18 | log.warn('Found rules which did not match any files', Array.from(results.unmatched.values())); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/commands/who.test.int.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import fixtures from './__fixtures__/default'; 3 | import { generateProject } from './__fixtures__/project-builder.test.helper'; 4 | 5 | import util from 'util'; 6 | 7 | const exec = util.promisify(require('child_process').exec); 8 | 9 | describe('who', () => { 10 | const testId = uuidv4(); 11 | 12 | let testDir = 'not set'; 13 | 14 | beforeAll(async () => { 15 | testDir = await generateProject(testId, fixtures); 16 | // tslint:disable-next-line:no-console 17 | console.log(`test scratch dir: ${testDir}`); 18 | }); 19 | 20 | const runCli = async (args: string) => { 21 | return exec(`node ../../../dist/cli.js ${args}`, { cwd: testDir }); 22 | }; 23 | 24 | it('should list ownership for one file', async () => { 25 | const { stdout, stderr } = await runCli('who default-wildcard-owners.md'); 26 | expect(stdout).toMatchSnapshot('stdout'); 27 | expect(stderr).toMatchSnapshot('stderr'); 28 | }); 29 | 30 | it('should list ownership for multiple files', async () => { 31 | const { stdout, stderr } = await runCli('who explicit-ignore.js default-wildcard-owners.md'); 32 | expect(stdout).toMatchSnapshot('stdout'); 33 | expect(stderr).toMatchSnapshot('stderr'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/commands/who.ts: -------------------------------------------------------------------------------- 1 | import { getOwnership } from '../lib/ownership'; 2 | import { OUTPUT_FORMAT } from '../lib/types'; 3 | 4 | interface WhoOptions { 5 | files: string[]; 6 | dir: string; 7 | codeowners: string; 8 | output: OUTPUT_FORMAT; 9 | } 10 | 11 | export const who = async (options: WhoOptions) => { 12 | const files = await getOwnership(options.codeowners, options.files); 13 | 14 | for (const file of files) { 15 | file.write(options.output, process.stdout); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/file/File.ts: -------------------------------------------------------------------------------- 1 | import { countLines } from './countLines'; 2 | import { OUTPUT_FORMAT } from '../types'; 3 | 4 | export class File { 5 | // tslint:disable-next-line:variable-name 6 | readonly path: string; 7 | readonly owners: string[]; 8 | // tslint:disable-next-line:variable-name 9 | private _lines?: number; 10 | 11 | constructor(props: { path: string, owners: string[], lines?: number | undefined }) { 12 | this.path = props.path; 13 | this.owners = props.owners; 14 | this._lines = props.lines; 15 | } 16 | 17 | public get lines(): number | undefined { 18 | return this._lines; 19 | } 20 | 21 | async updateLineCount(): Promise { 22 | this._lines = await countLines(this.path); 23 | return this._lines; 24 | } 25 | 26 | toJsonl() { 27 | return `${JSON.stringify({ path: this.path, owners: this.owners, lines: this.lines })}\n`; 28 | } 29 | 30 | toCsv() { 31 | let line = this.path; 32 | if (this.owners.length > 0) { 33 | line += `,${this.owners.join(',')}`; 34 | } 35 | return `${line}\n`; 36 | } 37 | 38 | toTsv() { 39 | let line = this.path; 40 | if (this.owners.length > 0) { 41 | line += `\t${this.owners.join('\t')}`; 42 | } 43 | return `${line}\n`; 44 | } 45 | 46 | write(output: OUTPUT_FORMAT, stream: any) { 47 | switch (output) { 48 | case(OUTPUT_FORMAT.JSONL): 49 | stream.write(this.toJsonl()); 50 | break; 51 | case(OUTPUT_FORMAT.CSV): 52 | stream.write(this.toCsv()); 53 | break; 54 | default: 55 | stream.write(this.toTsv()); 56 | break; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/file/countLines.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { log } from '../logger'; 3 | 4 | export const countLines = async (filePath: string): Promise => { 5 | let i; 6 | let count = 0; 7 | return new Promise((resolve, reject) => { 8 | fs.createReadStream(filePath) 9 | .on('error', (e) => { 10 | log.error(`failed to read lines from file ${filePath}`, e); 11 | reject(e); 12 | }) 13 | .on('data', (chunk) => { 14 | for (i = 0; i < chunk.length; ++i) if (chunk[i] === 10) count++; 15 | }) 16 | .on('end', () => resolve(count)); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/file/getFilePaths.ts: -------------------------------------------------------------------------------- 1 | import { readGit } from './readGit'; 2 | import { readDir } from './readDir'; 3 | import * as path from 'path'; 4 | 5 | export enum FILE_DISCOVERY_STRATEGY { 6 | FILE_SYSTEM, 7 | GIT_LS, 8 | } 9 | 10 | export const getFilePaths = async (dir: string, strategy: FILE_DISCOVERY_STRATEGY, root?: string) => { 11 | let filePaths; 12 | 13 | if (strategy === FILE_DISCOVERY_STRATEGY.GIT_LS) { 14 | filePaths = await readGit(dir); 15 | } else { 16 | filePaths = await readDir(dir, ['.git']); 17 | } 18 | 19 | if (root) { // We need to re-add the root so that later ops can find the file 20 | filePaths = filePaths.map(filePath => path.join(root, filePath)); 21 | } 22 | 23 | filePaths.sort(); 24 | 25 | return filePaths; 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/file/index.ts: -------------------------------------------------------------------------------- 1 | export { getFilePaths, FILE_DISCOVERY_STRATEGY } from './getFilePaths'; 2 | export { File } from './File'; 3 | -------------------------------------------------------------------------------- /src/lib/file/readDir.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import * as underTest from './readDir'; 3 | import * as path from 'path'; 4 | 5 | jest.mock('fs'); 6 | 7 | const readdirSyncMock = fs.readdirSync as jest.Mock; 8 | const statSyncMock = fs.statSync as jest.Mock; 9 | const readFileSync = fs.readFileSync as jest.Mock; 10 | 11 | 12 | describe('readDirRecursively', () => { 13 | afterEach(() => { 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | it('should return all files in the root directory', async () => { 18 | // Arrange 19 | const expectedFiles = [ 20 | 'some-file', 21 | 'some-other-file', 22 | ]; 23 | 24 | readdirSyncMock.mockReturnValue(expectedFiles); 25 | 26 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 27 | 28 | // Act 29 | const result = await underTest.readDir('root'); 30 | 31 | // Assert 32 | expect(readdirSyncMock).toHaveBeenCalledWith(path.resolve('root')); 33 | expect(result).toEqual(expectedFiles); 34 | }); 35 | 36 | it('should return all files in child directories directory', async () => { 37 | // Arrange 38 | const expectedChildDir = 'sub-dir'; 39 | 40 | const expectedFiles = [ 41 | 'some-file', 42 | 'some-other-file', 43 | ]; 44 | 45 | readdirSyncMock.mockReturnValueOnce([expectedChildDir]); 46 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.DIR)); 47 | 48 | readdirSyncMock.mockReturnValueOnce(expectedFiles); 49 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 50 | 51 | // Act 52 | const result = await underTest.readDir('root'); 53 | 54 | // Assert 55 | expect(readdirSyncMock).toHaveBeenCalledWith(path.resolve('root')); 56 | 57 | for (const expected of expectedFiles) { 58 | expect(result).toContain(path.join(expectedChildDir, expected)); 59 | } 60 | }); 61 | 62 | it('should ignore files matching the provided patterns', async () => { 63 | // Arrange 64 | const expectedFiles = [ 65 | 'some-file', 66 | ]; 67 | 68 | readdirSyncMock.mockReturnValue([...expectedFiles, 'ignored.js']); 69 | 70 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 71 | 72 | // Act 73 | const result = await underTest.readDir('root', ['*.js']); 74 | 75 | // Assert 76 | expect(result).toEqual(expectedFiles); 77 | }); 78 | 79 | it('should filter a file which when it has a matching rule in the root .gitignore', async () => { 80 | // Arrange 81 | const expectedFiles = [ 82 | '.gitignore', 83 | 'some-file', 84 | ]; 85 | 86 | readdirSyncMock.mockReturnValue([...expectedFiles, 'ignored.js']); 87 | readFileSync.mockReturnValue(Buffer.from('*.js')); 88 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 89 | 90 | // Act 91 | const result = await underTest.readDir('root'); 92 | 93 | // Assert 94 | expect(result).toEqual(expectedFiles); 95 | }); 96 | 97 | it('should filter a file which when it has a matching rule in a nested .gitignore', async () => { 98 | // Arrange 99 | const expectedFiles = [ 100 | '.gitignore', 101 | 'child/.gitignore', 102 | 'child/some-file', 103 | ]; 104 | 105 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'child']); 106 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.FILE)); 107 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.DIR)); 108 | readFileSync.mockReturnValueOnce(Buffer.from('')); 109 | 110 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'some-file', 'ignore.js']); 111 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 112 | readFileSync.mockReturnValueOnce(Buffer.from('*.js')); 113 | 114 | 115 | // Act 116 | const result = await underTest.readDir('root'); 117 | 118 | // Assert 119 | expect(result).toEqual(expectedFiles); 120 | }); 121 | 122 | it('should respect rules in the parent .gitignore when they are not overwritten in the child .gitignore', async () => { 123 | // Arrange 124 | const expectedFiles = [ 125 | '.gitignore', 126 | 'child/.gitignore', 127 | 'child/some-file', 128 | ]; 129 | 130 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'child']); 131 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.FILE)); 132 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.DIR)); 133 | readFileSync.mockReturnValueOnce(Buffer.from('*.js')); 134 | 135 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'some-file', 'ignore.js']); 136 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 137 | readFileSync.mockReturnValueOnce(Buffer.from('')); 138 | 139 | 140 | // Act 141 | const result = await underTest.readDir('root'); 142 | 143 | // Assert 144 | expect(result).toEqual(expectedFiles); 145 | }); 146 | 147 | it('should respect precedence and allow rules to be overwritten by child .gitignores', async () => { 148 | // Arrange 149 | const expectedFiles = [ 150 | '.gitignore', 151 | 'child/.gitignore', 152 | 'child/some-file', 153 | 'child/not-ignored.js', 154 | ]; 155 | 156 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'child']); 157 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.FILE)); 158 | statSyncMock.mockReturnValueOnce(statFake(STAT_FAKE_TYPES.DIR)); 159 | readFileSync.mockReturnValueOnce(Buffer.from('*.js')); 160 | 161 | readdirSyncMock.mockReturnValueOnce(['.gitignore', 'some-file', 'not-ignored.js']); 162 | statSyncMock.mockReturnValue(statFake(STAT_FAKE_TYPES.FILE)); 163 | readFileSync.mockReturnValueOnce(Buffer.from('!*.js')); 164 | 165 | 166 | // Act 167 | const result = await underTest.readDir('root'); 168 | 169 | // Assert 170 | expect(result).toEqual(expectedFiles); 171 | }); 172 | 173 | enum STAT_FAKE_TYPES { 174 | FILE = 'FILE', 175 | DIR = 'DIR', 176 | OTHER = 'OTHER', 177 | } 178 | 179 | const statFake = (type: STAT_FAKE_TYPES) => { 180 | switch (type) { 181 | case STAT_FAKE_TYPES.DIR: 182 | return { 183 | isFile: () => false, 184 | isDirectory: () => true, 185 | }; 186 | case STAT_FAKE_TYPES.FILE: 187 | return { 188 | isFile: () => true, 189 | isDirectory: () => false, 190 | }; 191 | default: 192 | throw new Error(); 193 | } 194 | }; 195 | }); 196 | -------------------------------------------------------------------------------- /src/lib/file/readDir.ts: -------------------------------------------------------------------------------- 1 | import fs, { Stats } from 'fs'; 2 | import ignore, { Ignore } from 'ignore'; 3 | import path from 'path'; 4 | 5 | export const readDir = async (dir: string, filters: string[] = []): Promise => { 6 | return new Promise((resolve, reject) => { 7 | try { 8 | const ignores = ignore().add(filters); 9 | const files = walkDir(dir, '', ignores); 10 | resolve(files); 11 | } catch (e) { 12 | reject(e); 13 | } 14 | }); 15 | }; 16 | 17 | const walkDir = (root: string, dir: string, ignores: Ignore, files: string[] = []): string[] => { 18 | const newFiles = fs.readdirSync(path.resolve(root, dir)); 19 | 20 | const newGitIgnore = newFiles.find(file => file === '.gitignore'); 21 | 22 | let appliedIgnores = ignores; 23 | 24 | if (newGitIgnore) { 25 | const contents = fs.readFileSync(path.resolve(root, dir, newGitIgnore)).toString(); 26 | appliedIgnores = ignore().add(ignores).add(contents); 27 | } 28 | 29 | for (const file of newFiles) { 30 | if (appliedIgnores.ignores(file) || appliedIgnores.ignores(path.join(dir, file))) { 31 | continue; 32 | } 33 | 34 | let stats: Stats | undefined = undefined; 35 | 36 | try { 37 | stats = fs.statSync(path.resolve(root, dir, file)); 38 | } catch (e) { 39 | continue;// Ignore missing files and symlinks 40 | } 41 | 42 | if (stats && stats.isDirectory()) { 43 | walkDir(root, path.join(dir, file), appliedIgnores, files); 44 | } 45 | 46 | if (stats && stats.isFile()) { 47 | files.push(path.join(dir, file)); 48 | } 49 | } 50 | 51 | return files; 52 | }; 53 | -------------------------------------------------------------------------------- /src/lib/file/readGit.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from '../util/exec'; 2 | import { readGit } from './readGit'; 3 | import fs from 'fs'; 4 | 5 | jest.mock('../util/exec'); 6 | jest.mock('fs'); 7 | const fsMocked = jest.mocked(fs); 8 | const execFileMock = exec as jest.Mock; 9 | 10 | describe('readGit', () => { 11 | beforeEach(() => { 12 | fsMocked.statSync.mockImplementation((path: any) => { 13 | return { 14 | isFile() { 15 | if (!path) { return false; } 16 | return true; 17 | }, 18 | }; 19 | }); 20 | }); 21 | 22 | it('should return the expected list of files when called', async () => { 23 | execFileMock.mockResolvedValue({ stdout: 'foo\nbar\n', stderr: '' }); 24 | 25 | const result = await readGit('some/dir'); 26 | 27 | expect(result).toStrictEqual(['foo', 'bar']); 28 | }); 29 | 30 | it('should call git ls-files with the correct directory', async () => { 31 | execFileMock.mockResolvedValue({ stdout: '', stderr: '' }); 32 | 33 | const result = await readGit('some/dir'); 34 | 35 | expect(exec).toHaveBeenCalledWith( 36 | 'git ls-files', 37 | expect.objectContaining({ 38 | cwd: 'some/dir', 39 | }), 40 | ); 41 | }); 42 | 43 | it('should not return non-files', async () => { 44 | execFileMock.mockResolvedValue({ stdout: 'foo\nbar\nbaz\n', stderr: '' }); 45 | fsMocked.statSync.mockImplementation((path: any) => { 46 | return { 47 | isFile() { 48 | if (!path || path === 'baz') { return false; } 49 | return true; 50 | }, 51 | }; 52 | }); 53 | 54 | const result = await readGit('some/dir'); 55 | 56 | expect(result).toStrictEqual(['foo', 'bar']); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/lib/file/readGit.ts: -------------------------------------------------------------------------------- 1 | import fs, { Stats } from 'fs'; 2 | import { exec } from '../util/exec'; 3 | 4 | export const readGit = async (dir: string): Promise => { 5 | const { stdout } = await exec('git ls-files', { cwd: dir }); 6 | return stdout.split('\n').filter((filePath) => { 7 | let stats: Stats | undefined = undefined; 8 | try { 9 | stats = fs.statSync(filePath); 10 | } catch (e) { 11 | return false; // Ignore missing files and symlinks 12 | } 13 | 14 | // Ignore if path is not a file 15 | if (!stats.isFile()){ 16 | return false; 17 | } 18 | 19 | return true; 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/lib/logger/index.ts: -------------------------------------------------------------------------------- 1 | export { log } from './logger'; 2 | -------------------------------------------------------------------------------- /src/lib/logger/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { log } from './logger'; 2 | 3 | describe('log', () => { 4 | it('should log an error', () => { 5 | // Arrange 6 | const expectedMsg = 'expected message'; 7 | const expectedErr = new Error('expect error'); 8 | const spy = jest.spyOn(console, 'error').mockReturnValue(undefined); 9 | 10 | // Act 11 | log.error(expectedMsg, expectedErr); 12 | 13 | // Assert 14 | expect(spy).toHaveBeenCalledWith(expectedMsg, expectedErr); 15 | }); 16 | 17 | it('should log a warning', () => { 18 | // Arrange 19 | const expectedMsg = 'expected message'; 20 | const expectedErr = new Error('expect error'); 21 | const spy = jest.spyOn(console, 'warn').mockReturnValue(undefined); 22 | 23 | // Act 24 | log.warn(expectedMsg, expectedErr); 25 | 26 | // Assert 27 | expect(spy).toHaveBeenCalledWith(expectedMsg, expectedErr); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/lib/logger/logger.ts: -------------------------------------------------------------------------------- 1 | class Logger { 2 | public error(msg: string, error?: Error): void { 3 | // tslint:disable-next-line:no-console 4 | console.error(msg, error); 5 | } 6 | 7 | public warn(msg: string, obj?: Object): void { 8 | // tslint:disable-next-line:no-console 9 | console.warn(msg, obj); 10 | } 11 | } 12 | 13 | export const log = new Logger(); 14 | -------------------------------------------------------------------------------- /src/lib/ownership/OwnershipEngine.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import { OwnershipEngine } from './OwnershipEngine'; 4 | import { FileOwnershipMatcher } from './types'; 5 | 6 | const patterns: any = require('../../test/fixtures/patterns.json'); 7 | 8 | jest.mock('../logger'); 9 | 10 | jest.mock('fs'); 11 | const readFileSyncMock = fs.readFileSync as jest.Mock; 12 | 13 | describe('OwnershipEngine', () => { 14 | afterEach(() => { 15 | jest.resetAllMocks(); 16 | }); 17 | 18 | describe('calcFileOwnership', () => { 19 | const createFileOwnershipMatcher = (path: string, owners: string[]): FileOwnershipMatcher => { 20 | return { 21 | rule: `${path} ${owners.join(' ')}`, 22 | path, 23 | owners, 24 | match: (testPath: string) => { 25 | return testPath === path; 26 | }, 27 | matched: 0, 28 | }; 29 | }; 30 | 31 | it('should match a path to its owners', () => { 32 | // Arrange 33 | const expectedOwners = ['@owner-1', '@owner-2']; 34 | const path = 'my/awesome/file.ts'; 35 | 36 | const underTest = new OwnershipEngine([ 37 | createFileOwnershipMatcher('some/other/path', ['@other-1']), 38 | createFileOwnershipMatcher(path, expectedOwners), 39 | createFileOwnershipMatcher('some/other/other/path', ['@other-2']), 40 | ]); 41 | 42 | // Act 43 | const result = underTest.calcFileOwnership(path); 44 | 45 | // Assert 46 | expect(result).toEqual(expectedOwners); 47 | }); 48 | 49 | it('should count the number of times a rule is matched to a path', () => { 50 | // Arrange 51 | const owners = ['@owner-1', '@owner-2']; 52 | const path = 'my/awesome/file.ts'; 53 | const matcher = createFileOwnershipMatcher(path, owners); 54 | 55 | expect(matcher.matched).toEqual(0); 56 | 57 | const underTest = new OwnershipEngine([matcher]); 58 | 59 | // Act 60 | underTest.calcFileOwnership(path); 61 | 62 | // Assert 63 | expect(underTest.getRules()[0].matched).toEqual(1); 64 | }); 65 | 66 | it('should should take precedence from the last matching rule', () => { 67 | // Arrange 68 | const expectedOwner = '@owner-2'; 69 | const unexpectedOwner = '@owner-1'; 70 | const path = 'my/awesome/file.ts'; 71 | 72 | const underTest = new OwnershipEngine([ 73 | createFileOwnershipMatcher(path, [unexpectedOwner]), 74 | createFileOwnershipMatcher(path, [expectedOwner]), 75 | ]); 76 | 77 | // Act 78 | const result = underTest.calcFileOwnership(path); 79 | 80 | // Assert 81 | expect(result).toContainEqual(expectedOwner); 82 | expect(result).not.toContainEqual(unexpectedOwner); 83 | }); 84 | }); 85 | 86 | describe('FromCodeownersFile', () => { 87 | it('should not throw when provided valid owners', () => { 88 | // Arrange 89 | const codeowners = 'some/path @global-owner1 @org/octocat docs@example.com'; 90 | 91 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 92 | 93 | // Assert 94 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')).not.toThrow(); 95 | }); 96 | 97 | it('should throw when provided an invalid owner', () => { 98 | // Arrange 99 | const rulePath = 'some/path'; 100 | const owner = '.not@valid-owner'; 101 | 102 | const expectedError = new Error(`${owner} is not a valid owner name in rule ${rulePath} ${owner}`); 103 | 104 | const codeowners = `${rulePath} ${owner}`; 105 | 106 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 107 | 108 | // Assert 109 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')) 110 | .toThrowError(expectedError); 111 | }); 112 | 113 | it('should throw when provided an invalid github user as an owner', () => { 114 | // Arrange 115 | const rulePath = 'some/path'; 116 | const owner = 'invalid-owner'; 117 | 118 | const expectedError = new Error(`${owner} is not a valid owner name in rule ${rulePath} ${owner}`); 119 | 120 | const codeowners = `${rulePath} ${owner}`; 121 | 122 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 123 | 124 | // Assert 125 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')) 126 | .toThrowError(expectedError); 127 | }); 128 | 129 | it('should throw when provided an invalid email address as an owner', () => { 130 | // Arrange 131 | const rulePath = 'some/path'; 132 | const owner = 'invalid-owner@nowhere'; 133 | 134 | const expectedError = new Error(`${owner} is not a valid owner name in rule ${rulePath} ${owner}`); 135 | 136 | const codeowners = `${rulePath} ${owner}`; 137 | 138 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 139 | 140 | // Assert 141 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')) 142 | .toThrowError(expectedError); 143 | }); 144 | 145 | it('should throw when provided at least one invalid owner', () => { 146 | // Arrange 147 | const rulePath = 'some/path'; 148 | const valid = 'valid@owner.com'; 149 | const owner = '@invalid-owner*'; 150 | 151 | const expectedError = new Error(`${owner} is not a valid owner name in rule ${rulePath} ${valid} ${owner}`); 152 | 153 | const codeowners = `${rulePath} ${valid} ${owner}`; 154 | 155 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 156 | 157 | // Assert 158 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')) 159 | .toThrowError(expectedError); 160 | }); 161 | 162 | it('should parse CRLF files (#4)', () => { 163 | // Arrange 164 | const codeowners = 'some/path @global-owner1 @org/octocat docs@example.com\r\n'; 165 | 166 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 167 | 168 | // Assert 169 | expect(() => OwnershipEngine.FromCodeownersFile('some/codeowners/file')).not.toThrow(); 170 | }); 171 | }); 172 | 173 | describe.each(patterns)('$name: "$pattern"', ({ name, pattern, paths }) => { 174 | const tests = Object.entries(paths); 175 | // console.log(tests) 176 | test.each(tests)(`should file "%s" match? %s`, (path, expected) => { 177 | // Arrange 178 | const owner = '@user1'; 179 | const codeowners = `${pattern} ${owner}`; 180 | 181 | readFileSyncMock.mockReturnValue(Buffer.from(codeowners)); 182 | // console.log(path, expected) 183 | // Act 184 | const result = OwnershipEngine.FromCodeownersFile('some/codeowners/file').calcFileOwnership(path); 185 | 186 | // Assert 187 | expect(result.length === 1).toEqual(expected); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/lib/ownership/OwnershipEngine.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import ignore from 'ignore'; 3 | import { FileOwnershipMatcher } from './types'; 4 | import { log } from '../logger'; 5 | 6 | export class OwnershipEngine { 7 | private readonly matchers: FileOwnershipMatcher[]; 8 | 9 | /** 10 | * @param matchers : FileOwnershipMatcher Matchers should be in precedence order, with overriding rules coming last 11 | */ 12 | constructor(matchers: FileOwnershipMatcher[]) { 13 | this.matchers = matchers; 14 | } 15 | 16 | public calcFileOwnership(filePath: string): string[] { 17 | // We reverse the matchers so that the first matching rule encountered 18 | // will be the last from CODEOWNERS, respecting precedence correctly and performantly 19 | const matchers = [...this.matchers].reverse(); 20 | 21 | for (const matcher of matchers) { 22 | if (matcher.match(filePath)) { 23 | matcher.matched++; 24 | return matcher.owners; 25 | } 26 | } 27 | 28 | return []; 29 | } 30 | 31 | public getRules(): { rule: string, matched: number }[] { 32 | const status: { rule: string, matched: number }[] = []; 33 | 34 | for (const matcher of this.matchers) { 35 | status.push({ rule: matcher.rule, matched: matcher.matched }); 36 | } 37 | 38 | return status; 39 | } 40 | 41 | 42 | public static FromCodeownersFile(filePath: string) { 43 | try { 44 | const lines = fs.readFileSync(filePath).toString().replace(/\r/g, '').split('\n'); 45 | 46 | const owned: FileOwnershipMatcher[] = []; 47 | 48 | for (const line of lines) { 49 | if (!line || line.startsWith('#')) { 50 | continue; 51 | } 52 | 53 | owned.push(createMatcherCodeownersRule(line)); 54 | } 55 | 56 | return new OwnershipEngine(owned); 57 | } catch (error) { 58 | log.error(`failed to load codeowners file from ${filePath}`, error); 59 | throw error; 60 | } 61 | } 62 | } 63 | 64 | const createMatcherCodeownersRule = (rule: string): FileOwnershipMatcher => { 65 | // Split apart on spaces 66 | const parts = rule.split(/\s+/); 67 | 68 | // The first part is expected to be the path 69 | const path = parts[0]; 70 | 71 | let teamNames: string[] = []; 72 | 73 | // Remaining parts are expected to be team names (if any) 74 | if (parts.length > 1) { 75 | teamNames = parts.slice(1, parts.length); 76 | for (const name of teamNames) { 77 | if (!codeOwnerRegex.test(name)) { 78 | throw new Error(`${name} is not a valid owner name in rule ${rule}`); 79 | } 80 | } 81 | } 82 | 83 | // Create an `ignore` matcher to ape github behaviour 84 | const match: any = ignore().add(path); 85 | 86 | // Workaround for rules ending with /* 87 | // GitHub will not look for nested files, so we adjust the node-ignore regex 88 | match._rules = match._rules.map((r: any) => { 89 | if (r.pattern.endsWith('/*')) { 90 | r.regex = new RegExp(r.regex.source.replace('(?=$|\\/$)', '(?=$|[^\\/]$)'), 'i'); 91 | } 92 | return r; 93 | }); 94 | 95 | // Return our complete matcher 96 | return { 97 | rule, 98 | path, 99 | owners: teamNames, 100 | match: match.ignores.bind(match), 101 | matched: 0, 102 | }; 103 | }; 104 | 105 | // ensures that only the following patterns are allowed @octocat @octocat/kitty docs@example.com 106 | const codeOwnerRegex = /(^@[a-zA-Z0-9_\-/]*$)|(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; 107 | -------------------------------------------------------------------------------- /src/lib/ownership/index.ts: -------------------------------------------------------------------------------- 1 | export { OwnershipEngine } from './OwnershipEngine'; 2 | export { getOwnership } from './ownership'; 3 | export { validate } from './validate'; 4 | export { Matcher, FileOwnershipMatcher } from './types'; 5 | -------------------------------------------------------------------------------- /src/lib/ownership/ownership.test.ts: -------------------------------------------------------------------------------- 1 | import { getOwnership } from './ownership'; 2 | import { OwnershipEngine } from './OwnershipEngine'; 3 | import { File } from '../file'; 4 | 5 | jest.mock('./OwnershipEngine'); 6 | 7 | describe('ownership', () => { 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | describe('getOwnership', () => { 13 | it('should create an engine using the specified code owners file', async () => { 14 | // Arrange 15 | const expected = 'some/file'; 16 | 17 | // Act 18 | await getOwnership(expected, []); 19 | 20 | // Assert 21 | expect(OwnershipEngine.FromCodeownersFile).toHaveBeenLastCalledWith(expected); 22 | }); 23 | 24 | it('should return owned files', async () => { 25 | // Arrange 26 | const expected = [ 27 | new File({ path: 'is/not-owned', owners: ['@some/owner'] }), 28 | new File({ path: 'is/owned', owners: ['@some/other-owner'] }), 29 | ]; 30 | 31 | const mockEngine = OwnershipEngine as any; 32 | mockEngine.FromCodeownersFile.mockImplementation(() => { 33 | return { 34 | ...OwnershipEngine, 35 | calcFileOwnership: (path: string) => { 36 | const matching = expected.find(f => f.path === path); 37 | if (!matching) throw new Error('unexpected path'); 38 | return matching.owners; 39 | }, 40 | }; 41 | }); 42 | 43 | // Act 44 | const paths = expected.map(f => f.path); 45 | const result = await getOwnership('some/file', paths); 46 | 47 | // Assert 48 | expect(result).toEqual(expected); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/lib/ownership/ownership.ts: -------------------------------------------------------------------------------- 1 | import { File } from '../file'; 2 | import { OwnershipEngine } from './OwnershipEngine'; 3 | 4 | export const getOwnership = async (codeowners: string, filePaths: string[]): Promise => { 5 | const engine = OwnershipEngine.FromCodeownersFile(codeowners); 6 | 7 | const owned: File[] = []; 8 | 9 | for (const filePath of filePaths) { 10 | const owners = engine.calcFileOwnership(filePath); 11 | owned.push(new File({ path: filePath, owners })); 12 | } 13 | 14 | return owned; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/ownership/types.ts: -------------------------------------------------------------------------------- 1 | export type Matcher = (path: string) => boolean; 2 | 3 | export interface FileOwnershipMatcher { 4 | rule: string; 5 | path: string; 6 | owners: string[]; 7 | match: Matcher; 8 | matched: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/ownership/validate.ts: -------------------------------------------------------------------------------- 1 | import { OwnershipEngine } from './OwnershipEngine'; 2 | import { readDir } from '../file/readDir'; 3 | 4 | interface ValidationResults { 5 | duplicated: Set; 6 | unmatched: Set; 7 | } 8 | 9 | export const validate = async (options: { codeowners: string, dir: string, root?: string }): Promise => { 10 | const engine = OwnershipEngine.FromCodeownersFile(options.codeowners); // Validates code owner file 11 | 12 | const filePaths = await readDir(options.dir, ['.git']); 13 | 14 | for (const file of filePaths) { 15 | engine.calcFileOwnership(file); // Test each file against rule set 16 | } 17 | 18 | const rules = engine.getRules(); 19 | 20 | const unique: Set = new Set(); 21 | const duplicated: Set = new Set(); 22 | const hasMatches: Set = new Set(); 23 | const unmatched: Set = new Set(); 24 | 25 | for (const { rule, matched } of rules) { 26 | if (!unique.has(rule)) { 27 | unique.add(rule); 28 | } else { 29 | duplicated.add(rule); 30 | } 31 | 32 | if (matched > 0) { 33 | hasMatches.add(rule); 34 | } else { 35 | unmatched.add(rule); 36 | } 37 | } 38 | 39 | for (const rule of unmatched) { // Where we have duplicates we get an edge condition where one version of the matcher doesn't get hit - TODO - there is no doubt a nicer way to express this 40 | if (hasMatches.has(rule)) { 41 | unmatched.delete(rule); 42 | } 43 | } 44 | 45 | return { duplicated, unmatched }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/lib/stats/index.ts: -------------------------------------------------------------------------------- 1 | export { calcFileStats } from './stats'; 2 | export { Stats } from './types'; 3 | export { writer as statsWriter } from './writer'; 4 | -------------------------------------------------------------------------------- /src/lib/stats/stats.test.ts: -------------------------------------------------------------------------------- 1 | import {percentageToFixed} from "./stats" 2 | 3 | describe("stats.ts", () => { 4 | test("percentageToFixed should default to 2 fixed decimals", () => { 5 | const actual = percentageToFixed(22, 66) 6 | const expected = 33.33 7 | 8 | expect(actual).toEqual(expected) 9 | }) 10 | 11 | test("percentageToFixed should have 4 fixed decimals", () => { 12 | const actual = percentageToFixed(22, 66, 4) 13 | const expected = 33.3333 14 | 15 | expect(actual).toEqual(expected) 16 | }) 17 | 18 | test("percentageToFixed should have 0 fixed decimals", () => { 19 | const actual = percentageToFixed(22, 66, 0) 20 | const expected = 33 21 | 22 | expect(actual).toEqual(expected) 23 | }) 24 | }) -------------------------------------------------------------------------------- /src/lib/stats/stats.ts: -------------------------------------------------------------------------------- 1 | import { File } from '../file'; 2 | import { Counters, Stats } from './types'; 3 | 4 | 5 | export function percentageToFixed(dividend: number, divisor: number, precision: number = 2): number { 6 | return parseFloat(((dividend / divisor) * 100).toFixed(precision)) 7 | } 8 | 9 | export const calcFileStats = (files: File[]): Stats => { 10 | const total: Counters = { 11 | files: 0, 12 | lines: 0, 13 | percentage: 0.0 14 | }; 15 | 16 | const unloved: Counters = { 17 | files: 0, 18 | lines: 0, 19 | percentage: 0 20 | }; 21 | 22 | const ownerCount = new Map(); 23 | 24 | for (const file of files) { 25 | total.files++; 26 | if(typeof file.lines === 'number') total.lines += file.lines; 27 | 28 | if (file.owners.length < 1) { 29 | unloved.files++; 30 | if(typeof file.lines === 'number') unloved.lines += file.lines; 31 | } else { 32 | for (const owner of file.owners) { 33 | const counts = ownerCount.get(owner) || { files: 0, lines: 0, percentage: 0 }; 34 | counts.files++; 35 | if(typeof file.lines === 'number') counts.lines += file.lines; 36 | ownerCount.set(owner, counts); 37 | } 38 | } 39 | } 40 | 41 | return { 42 | total: { 43 | ...total, 44 | percentage: 100 45 | }, 46 | unloved: { 47 | ...unloved, 48 | percentage: percentageToFixed(unloved.lines, total.lines) 49 | }, 50 | loved: { 51 | files: total.files - unloved.files, 52 | lines: total.lines - unloved.lines, 53 | percentage: percentageToFixed(total.lines - unloved.lines, total.lines) 54 | }, 55 | owners: Array.from(ownerCount.keys()).map((owner) => { 56 | const counts = ownerCount.get(owner); 57 | 58 | return { 59 | owner, 60 | counters: { 61 | files: counts ? counts.files : 0, 62 | lines: counts ? counts.lines : 0, 63 | percentage: counts ? percentageToFixed(counts.lines, total.lines) : 0 64 | }, 65 | }; 66 | }), 67 | }; 68 | }; 69 | 70 | -------------------------------------------------------------------------------- /src/lib/stats/types.ts: -------------------------------------------------------------------------------- 1 | export interface Counters { 2 | files: number; 3 | lines: number; 4 | percentage: number; 5 | } 6 | 7 | export interface Stats { 8 | total: Counters; 9 | loved: Counters; 10 | unloved: Counters; 11 | owners: { owner: string, counters: Counters }[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/stats/writer.ts: -------------------------------------------------------------------------------- 1 | import { Stats } from './index'; 2 | import { OUTPUT_FORMAT } from '../types'; 3 | 4 | export const writer = (stats: Stats, options: { output: OUTPUT_FORMAT }, stream: any) => { 5 | const orderedOwners = [...stats.owners].sort((a, b) => { 6 | if (a.owner < b.owner) return -1; 7 | if (a.owner > b.owner) return 1; 8 | return 0; 9 | }); 10 | 11 | switch (options.output) { 12 | case(OUTPUT_FORMAT.JSONL): 13 | stream.write(`${JSON.stringify(stats)}\n`); 14 | break; 15 | case(OUTPUT_FORMAT.CSV): 16 | stream.write(`owner,files,lines,percentage\n`); 17 | stream.write(`total,${stats.total.files},${stats.total.lines},${stats.total.percentage}\n`); 18 | stream.write(`loved,${stats.loved.files},${stats.loved.lines},${stats.loved.percentage}\n`); 19 | stream.write(`unloved,${stats.unloved.files},${stats.unloved.lines},${stats.unloved.percentage}\n`); 20 | orderedOwners.forEach((owner) => { 21 | stream.write(`${owner.owner},${owner.counters.files},${owner.counters.lines},${owner.counters.percentage}\n`); 22 | }); 23 | break; 24 | default: 25 | stream.write('\n--- Counts ---\n'); 26 | stream.write(`Total: ${stats.total.files} files (${stats.total.lines} lines) ${stats.total.percentage}%\n`); 27 | stream.write(`Loved: ${stats.loved.files} files (${stats.loved.lines} lines) ${stats.loved.percentage}%\n`); 28 | stream.write(`Unloved: ${stats.unloved.files} files (${stats.unloved.lines} lines ${stats.unloved.percentage}%\n`); 29 | stream.write('--- Owners ---\n'); 30 | const owners = orderedOwners.map(owner => `${owner.owner}: ${owner.counters.files} files (${owner.counters.lines} lines) ${owner.counters.percentage}%`).join('\n'); 31 | stream.write(`${owners}\n`); 32 | break; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export enum OUTPUT_FORMAT { 2 | SIMPLE = 'simple', 3 | JSONL = 'jsonl', 4 | CSV = 'csv', 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/util/exec.ts: -------------------------------------------------------------------------------- 1 | import { exec as realExec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | export const exec = promisify(realExec); 5 | -------------------------------------------------------------------------------- /src/test/fixtures/patterns.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "single segment pattern with leading slash and lone wildcard", 4 | "pattern": "/*", 5 | "paths": { 6 | "foo": true, 7 | "bar": true, 8 | "foo/bar": false, 9 | "foo/bar/baz": false 10 | } 11 | }, 12 | { 13 | "name": "single segment pattern with leading slash and wildcard", 14 | "pattern": "/f*", 15 | "paths": { 16 | "foo": true, 17 | "foo/bar": true, 18 | "foo/bar/baz": true, 19 | "bar/foo": false, 20 | "bar/foo/baz": false, 21 | "bar/baz": false, 22 | "xfoo": false 23 | } 24 | }, 25 | { 26 | "name": "single segment pattern with trailing slash and wildcard", 27 | "pattern": "f*/", 28 | "paths": { 29 | "foo": false, 30 | "foo/bar": true, 31 | "bar/foo": false, 32 | "bar/foo/baz": true, 33 | "bar/baz": false, 34 | "xfoo": false 35 | } 36 | }, 37 | { 38 | "name": "single segment pattern with leading and trailing slash and lone wildcard", 39 | "pattern": "/*/", 40 | "paths": { 41 | "foo": false, 42 | "foo/bar": true, 43 | "bar/foo": true, 44 | "bar/foo/baz": true 45 | } 46 | }, 47 | { 48 | "name": "single segment pattern with leading and trailing slash and wildcard", 49 | "pattern": "/f*/", 50 | "paths": { 51 | "foo": false, 52 | "foo/bar": true, 53 | "bar/foo": false, 54 | "bar/foo/baz": false, 55 | "bar/baz": false, 56 | "xfoo": false 57 | } 58 | }, 59 | { 60 | "name": "single segment pattern with escaped wildcard", 61 | "pattern": "f\\*o", 62 | "paths": { 63 | "foo": false, 64 | "f*o": true 65 | } 66 | }, 67 | { 68 | "name": "pattern with trailing wildcard segment", 69 | "pattern": "foo/*", 70 | "paths": { 71 | "foo": false, 72 | "foo/bar": true, 73 | "foo/bar/baz": false, 74 | "bar/foo": false, 75 | "bar/foo/baz": false, 76 | "bar/baz": false, 77 | "xfoo": false 78 | } 79 | }, 80 | { 81 | "name": "multi-segment pattern with wildcard", 82 | "pattern": "foo/*.txt", 83 | "paths": { 84 | "foo": false, 85 | "foo/bar.txt": true, 86 | "foo/bar/baz.txt": false, 87 | "qux/foo/bar.txt": false, 88 | "qux/foo/bar/baz.txt": false 89 | } 90 | }, 91 | { 92 | "name": "multi-segment pattern with lone wildcard", 93 | "pattern": "foo/*/baz", 94 | "paths": { 95 | "foo": false, 96 | "foo/bar": false, 97 | "foo/baz": false, 98 | "foo/bar/baz": true, 99 | "foo/bar/baz/qux": true 100 | } 101 | }, 102 | { 103 | "name": "single segment pattern with single-character wildcard", 104 | "pattern": "f?o", 105 | "paths": { 106 | "foo": true, 107 | "fo": false, 108 | "fooo": false 109 | } 110 | }, 111 | { 112 | "name": "leading double-asterisk wildcard", 113 | "pattern": "**/foo/bar", 114 | "paths": { 115 | "foo/bar": true, 116 | "qux/foo/bar": true, 117 | "qux/foo/bar/baz": true, 118 | "foo/baz/bar": false, 119 | "qux/foo/baz/bar": false 120 | } 121 | }, 122 | { 123 | "name": "leading double-asterisk wildcard with regular wildcard", 124 | "pattern": "**/*bar*", 125 | "paths": { 126 | "bar": true, 127 | "foo/bar": true, 128 | "foo/rebar": true, 129 | "foo/barrio": true, 130 | "foo/qux/bar": true 131 | } 132 | }, 133 | { 134 | "name": "trailing double-asterisk wildcard", 135 | "pattern": "foo/bar/**", 136 | "paths": { 137 | "foo/bar": false, 138 | "foo/bar/baz": true, 139 | "foo/bar/baz/qux": true, 140 | "qux/foo/bar": false, 141 | "qux/foo/bar/baz": false 142 | } 143 | }, 144 | { 145 | "name": "middle double-asterisk wildcard", 146 | "pattern": "foo/**/bar", 147 | "paths": { 148 | "foo/bar": true, 149 | "foo/bar/baz": true, 150 | "foo/qux/bar/baz": true, 151 | "foo/qux/quux/bar/baz": true, 152 | "foo/bar/baz/qux": true, 153 | "qux/foo/bar": false, 154 | "qux/foo/bar/baz": false 155 | } 156 | }, 157 | { 158 | "name": "middle double-asterisk wildcard with trailing slash", 159 | "pattern": "foo/**/", 160 | "paths": { 161 | "foo": false, 162 | "foo/bar": true, 163 | "foo/bar/": true, 164 | "foo/bar/baz": true 165 | } 166 | }, 167 | { 168 | "name": "middle double-asterisk wildcard with trailing wildcard", 169 | "pattern": "foo/**/bar/b*", 170 | "paths": { 171 | "foo/bar": false, 172 | "foo/bar/baz": true, 173 | "foo/bar/qux": false, 174 | "foo/qux/bar": false, 175 | "foo/qux/bar/baz": true, 176 | "foo/qux/bar/qux": false 177 | } 178 | } 179 | ] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": ["es2017"], /* Specify library files to be included in the compilation. */ 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | 9 | "strict": true, 10 | 11 | "moduleResolution": "node", 12 | "esModuleInterop": true 13 | }, 14 | "exclude": [ 15 | "dist", 16 | "tests" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb-base", 3 | "rules": { 4 | "brace-style": [ 5 | "error", 6 | "stroustrup", 7 | { 8 | "allowSingleLine": true 9 | } 10 | ], 11 | "ter-arrow-body-style": ["error", "as-needed"], 12 | "no-magic-numbers": false, 13 | "ter-max-len": [ 14 | "warn", 15 | 200 16 | ] 17 | } 18 | } 19 | --------------------------------------------------------------------------------