Skip to content

Commit e152387

Browse files
committed
fix: support codeowners files
1 parent 53eeb4b commit e152387

6 files changed

Lines changed: 232 additions & 61 deletions

File tree

scripts/github/run-github-check/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "fs";
2+
import path from "path";
13
import { showDonAscii } from "@shared/ascii/don";
24
import { validateFiles } from "@shared/file-validator";
35
import { loadConfig } from "@shared/loader";
@@ -34,7 +36,14 @@ export async function runGithubCheck() {
3436
const results = validateFiles(files, committers, config);
3537

3638
const addReviewers = config.codeReviews?.autoAssignGoodfellas ?? true;
37-
if (addReviewers || !!config.codeReviews?.autoAssignCaporegimes) {
39+
const hasCodeownersFile = fs.existsSync(
40+
path.resolve(process.cwd(), "./.github/CODEOWNERS")
41+
);
42+
43+
if (
44+
!hasCodeownersFile &&
45+
(addReviewers || !!config.codeReviews?.autoAssignCaporegimes)
46+
) {
3847
await octokit.assignReviewers(pullRequestID, files, committers, config);
3948
}
4049

scripts/github/run-github-check/run-github-check.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
import { writeFileSync, unlinkSync } from "fs";
3+
import { resolve } from "path";
24
import { showDonAscii } from "@shared/ascii/don";
35
import { validateFiles } from "@shared/file-validator";
46
import { loadConfig } from "@shared/loader";
@@ -206,6 +208,34 @@ describe("runGithubCheck", () => {
206208
expect(assignReviewersMock).not.toHaveBeenCalled();
207209
});
208210

211+
test("doesn't assign reviewers if there is a codeowner file", async () => {
212+
process.env.CI = "true";
213+
process.env.GITHUB_TOKEN = "token";
214+
const codeOwnersPath = resolve(process.cwd(), "./.github/CODEOWNERS");
215+
console.log("written in", codeOwnersPath);
216+
writeFileSync(
217+
codeOwnersPath,
218+
`
219+
/src/core @corleone/caporegimes
220+
/src/models @tomhagen @solozzo
221+
`
222+
);
223+
(loadConfig as jest.Mock).mockResolvedValue({
224+
caporegimes: [],
225+
rules: [],
226+
options: { showAscii: false },
227+
codeReviews: {
228+
autoAssignGoodfellas: true,
229+
autoAssignCaporegimes: true,
230+
},
231+
});
232+
233+
(validateFiles as jest.Mock).mockReturnValue(defaultResults);
234+
await runGithubCheck();
235+
unlinkSync(codeOwnersPath);
236+
expect(assignReviewersMock).not.toHaveBeenCalled();
237+
});
238+
209239
test("exits with error on unknown exception", async () => {
210240
process.env.CI = "true";
211241
process.env.GITHUB_TOKEN = "token";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
CodefatherConfig,
3+
CodefatherRule,
4+
CrewName,
5+
GitUser,
6+
} from "@shared/models";
7+
import fs from "fs";
8+
9+
function isCrewOwner(owner: string): owner is CrewName {
10+
return owner.includes("/");
11+
}
12+
13+
async function parseCodeowners(filePath: string): Promise<CodefatherRule[]> {
14+
const content = await fs.promises.readFile(filePath, "utf-8");
15+
const lines = content.split("\n");
16+
17+
const ownersCombos = new Map<string, CodefatherRule["match"]>();
18+
19+
for (const rawLine of lines) {
20+
const line = rawLine.trim();
21+
if (line.startsWith("#")) continue;
22+
const [match, ...owners] = line.split(/\s+/);
23+
if (match && owners.length > 0) {
24+
const ownersCombo = owners.sort().join(",");
25+
if (!ownersCombos.has(ownersCombo)) {
26+
ownersCombos.set(ownersCombo, [match]);
27+
} else {
28+
const data = ownersCombos.get(ownersCombo) || [];
29+
ownersCombos.set(ownersCombo, [...data, match]);
30+
}
31+
}
32+
}
33+
34+
const rules: CodefatherRule[] = [];
35+
36+
for (const [owners, match] of ownersCombos.entries()) {
37+
const crews: CrewName[] = [];
38+
const goodfellas: GitUser[] = [];
39+
40+
owners.split(",").forEach((owner) => {
41+
const validOwner = owner.trim();
42+
if (isCrewOwner(validOwner)) {
43+
crews.push(validOwner);
44+
} else {
45+
goodfellas.push({ name: validOwner });
46+
}
47+
});
48+
49+
rules.push({ match, goodfellas, crews });
50+
}
51+
return rules;
52+
}
53+
54+
function generateConfig(
55+
rules: CodefatherRule[],
56+
crews: CrewName[]
57+
): CodefatherConfig {
58+
return {
59+
rules,
60+
codeReviews: { autoAssignGoodfellas: true },
61+
crews: crews.reduce((acc, crew) => ({ ...acc, [crew]: [] }), {}),
62+
};
63+
}
64+
65+
export async function generateConfigFromCodeowners(
66+
codeownersFile: string
67+
): Promise<{ config: CodefatherConfig; crews: CrewName[] }> {
68+
const rules = await parseCodeowners(codeownersFile);
69+
const crews = [
70+
...new Set(rules.flatMap((rule) => rule.crews).filter(Boolean)),
71+
] as CrewName[];
72+
return { config: generateConfig(rules, crews), crews };
73+
}

scripts/init/index.ts

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,120 @@
11
#!/usr/bin/env node
22
import fs from "fs";
33
import path from "path";
4-
import { colorsMap } from "@shared/models";
4+
import { CodefatherConfig, colorsMap, CrewName } from "@shared/models";
55
import { safeJSONParse } from "@shared/parser";
6+
import { generateConfigFromCodeowners } from "./generate-from-codeowner";
67

78
const args = process.argv.slice(2);
89
const useJson = args.includes("--json");
10+
const canOverwrite = args.includes("--overwrite");
911

10-
export function runInit() {
11-
const rootDir = process.cwd();
12-
const configPath = path.join(
13-
rootDir,
14-
useJson ? "codefather.json" : "codefather.ts"
15-
);
16-
const pkgPath = path.join(rootDir, "package.json");
17-
18-
if (!fs.existsSync(configPath)) {
19-
if (useJson) {
20-
fs.writeFileSync(configPath, JSON.stringify({ rules: [] }, null, 2));
21-
} else {
22-
fs.writeFileSync(
23-
configPath,
24-
`import type { CodefatherConfig } from "@donedeal0/codefather";\n\n` +
25-
`export default { rules: [] } satisfies CodefatherConfig;\n`
26-
);
27-
}
12+
function informFileCreated(
13+
configPath: string,
14+
isCodeownersBased: boolean,
15+
crews: CrewName[]
16+
) {
17+
if (canOverwrite) {
2818
console.log(
2919
colorsMap.info,
30-
`- A ${path.basename(configPath)} config file has been created.`
20+
`- A new ${configPath} config file has been created, your former config was overwritten.`
3121
);
3222
} else {
3323
console.log(
3424
colorsMap.info,
35-
`- A ${path.basename(configPath)} file already exists.`
25+
`- A ${configPath} config file has been created.`
3626
);
3727
}
38-
39-
if (fs.existsSync(pkgPath)) {
40-
const pkg = safeJSONParse<{
41-
scripts?: Record<string, string>;
42-
}>(fs.readFileSync(pkgPath, "utf-8"));
43-
44-
pkg.scripts = pkg.scripts || {};
45-
if (!pkg.scripts.codefather) {
46-
pkg.scripts.codefather = "codefather";
47-
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
28+
if (isCodeownersBased) {
29+
console.log(
30+
colorsMap.info,
31+
`- The rules were filled with your CODEOWNER file.`
32+
);
33+
if (crews?.length > 0) {
4834
console.log(
49-
colorsMap.info,
50-
"- A codefather script has been added to your package.json."
35+
colorsMap.warning,
36+
`- The following crews were detected:\n${crews
37+
.map((crew) => ` - ${crew}`)
38+
.join("\n")}
39+
Please specify their members in the codefather config for CLI enforcement.`
5140
);
41+
}
42+
}
43+
}
44+
45+
export async function runInit() {
46+
try {
47+
const rootDir = process.cwd();
48+
const codeownersPath = path.join(rootDir, "./.github/CODEOWNERS");
49+
let baseConfig: CodefatherConfig = { rules: [] };
50+
let isCodeownersBased = false;
51+
const crews: CrewName[] = [];
52+
if (fs.existsSync(codeownersPath)) {
53+
const data = await generateConfigFromCodeowners(codeownersPath);
54+
baseConfig = data.config;
55+
isCodeownersBased = true;
56+
crews.push(...data.crews);
57+
}
58+
const configPath = path.join(
59+
rootDir,
60+
useJson ? "codefather.json" : "codefather.ts"
61+
);
62+
const pkgPath = path.join(rootDir, "package.json");
63+
const config = JSON.stringify(baseConfig, null, 2);
64+
if (!fs.existsSync(configPath) || canOverwrite) {
65+
if (useJson) {
66+
fs.writeFileSync(configPath, config);
67+
} else {
68+
fs.writeFileSync(
69+
configPath,
70+
`import type { CodefatherConfig } from "@donedeal0/codefather";\n\n` +
71+
`export default ${config} satisfies CodefatherConfig;\n`
72+
);
73+
}
74+
informFileCreated(path.basename(configPath), isCodeownersBased, crews);
5275
} else {
5376
console.log(
5477
colorsMap.info,
55-
"- A codefather script already exists in your package.json."
78+
`- A ${path.basename(configPath)} file already exists.`
5679
);
5780
}
58-
} else {
81+
82+
if (fs.existsSync(pkgPath)) {
83+
const pkg = safeJSONParse<{
84+
scripts?: Record<string, string>;
85+
}>(fs.readFileSync(pkgPath, "utf-8"));
86+
87+
pkg.scripts = pkg.scripts || {};
88+
if (!pkg.scripts.codefather) {
89+
pkg.scripts.codefather = "codefather";
90+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
91+
console.log(
92+
colorsMap.info,
93+
"- A codefather script has been added to your package.json."
94+
);
95+
} else {
96+
console.log(
97+
colorsMap.info,
98+
"- A codefather script already exists in your package.json."
99+
);
100+
}
101+
} else {
102+
return console.log(
103+
colorsMap.error,
104+
"⚠ No package.json found in the project root. Skipping script setup."
105+
);
106+
}
107+
108+
return console.log(
109+
colorsMap.success,
110+
"\n✓ Setup complete. Run `npm run codefather` to enforce your rules."
111+
);
112+
} catch (err: unknown) {
59113
return console.log(
60114
colorsMap.error,
61-
"⚠️ No package.json found in the project root. Skipping script setup."
115+
err instanceof Error ? err.message : String(err)
62116
);
63117
}
64-
65-
return console.log(
66-
colorsMap.success,
67-
"\n✓ Setup complete. Run `npm run codefather` to enforce your rules."
68-
);
69118
}
70119

71120
if (import.meta.url === new URL(import.meta.url).href) {

0 commit comments

Comments
 (0)