repokeeper 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -2
- package/dist/cli.js +21 -0
- package/dist/commands/check.js +2 -1
- package/dist/commands/github.js +36 -0
- package/dist/commands/init.js +23 -4
- package/dist/commands/report.js +39 -0
- package/dist/commands/update.js +6 -2
- package/dist/config/load.js +1 -0
- package/dist/config/schema.js +35 -1
- package/dist/config/types.js +1 -0
- package/dist/duplicates.js +36 -0
- package/dist/errors.js +6 -0
- package/dist/git.js +8 -0
- package/dist/github/api.js +49 -0
- package/dist/github/settings.js +184 -0
- package/dist/platforms/github.js +11 -1
- package/dist/stacks/dotnet.js +23 -0
- package/dist/stacks/python.js +9 -3
- package/dist/sync/attributes.js +44 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,15 @@ alone. Every write command accepts `--dry-run`.
|
|
|
36
36
|
isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
|
|
37
37
|
of its own.
|
|
38
38
|
|
|
39
|
+
After writing, repokeeper prints what is left to do (installing the git hooks, and
|
|
40
|
+
`git add --renormalize .` when tracked files are stored with CRLF). `init`, `update` and `check` also
|
|
41
|
+
point out what they can't fix: `.gitattributes` lines above the repokeeper block that the block
|
|
42
|
+
overrides, and pull request workflows of your own that run the same tests as the repokeeper ci job.
|
|
43
|
+
|
|
44
|
+
Stack notes: Python runs `mypy` on the files its config names (`files = …`), or on the whole tree
|
|
45
|
+
otherwise. The dotnet stack needs SDK-style projects; `init` stops with the names of .NET Framework
|
|
46
|
+
projects, which the dotnet CLI can't build.
|
|
47
|
+
|
|
39
48
|
Requires Node.js 22.12 or newer.
|
|
40
49
|
|
|
41
50
|
## CI and releases
|
|
@@ -46,11 +55,13 @@ without a pull request. repokeeper owns the `name`, `on` and `permissions` keys
|
|
|
46
55
|
adds; jobs you add yourself are left alone, and so is the formatting of the rest of the file.
|
|
47
56
|
|
|
48
57
|
The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
|
|
49
|
-
and PSScriptAnalyzer on `*.ps1`, which fails on
|
|
50
|
-
`PSScriptAnalyzerSettings.psd1` at the repository root;
|
|
58
|
+
and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
|
|
59
|
+
choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then
|
|
60
|
+
uses it instead of its own filter, so keep `Severity` in it unless you want information-level rules too:
|
|
51
61
|
|
|
52
62
|
```powershell
|
|
53
63
|
@{
|
|
64
|
+
Severity = @('Error', 'Warning')
|
|
54
65
|
# installers print for the person running them
|
|
55
66
|
ExcludeRules = @('PSAvoidUsingWriteHost')
|
|
56
67
|
}
|
|
@@ -66,6 +77,33 @@ work:
|
|
|
66
77
|
and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
|
|
67
78
|
GitHub does not run CI on pull requests opened that way.
|
|
68
79
|
|
|
80
|
+
Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
|
|
81
|
+
the version that wrote the standard, so a pull request that edits a managed file fails until the edit
|
|
82
|
+
is resolved.
|
|
83
|
+
|
|
84
|
+
## GitHub settings
|
|
85
|
+
|
|
86
|
+
`repokeeper github apply` brings the repository's settings in line with the `github:` section of
|
|
87
|
+
`.repokeeper.yml`. Only the keys you write are managed; anything left out stays as it is.
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
github:
|
|
91
|
+
description: Keeps repositories on one standard
|
|
92
|
+
topics: [cli, conventional-commits]
|
|
93
|
+
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
|
|
94
|
+
security: { dependabot_alerts: true, dependabot_security_updates: true }
|
|
95
|
+
protect: # a ruleset named "repokeeper" on the default branch; false removes it
|
|
96
|
+
require_pull_request: true
|
|
97
|
+
required_approvals: 0
|
|
98
|
+
required_checks: ["commits / commitlint"] # check names exactly as pull requests show them
|
|
99
|
+
allow_force_push: false
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It prints every change first and applies them with `--yes`, or after you confirm in a terminal;
|
|
103
|
+
`--dry-run` only prints. The token comes from `GITHUB_TOKEN` or `gh auth token` and needs admin
|
|
104
|
+
access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
|
|
105
|
+
touched.
|
|
106
|
+
|
|
69
107
|
## License
|
|
70
108
|
|
|
71
109
|
MIT
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import { checkCommand } from "./commands/check.js";
|
|
7
|
+
import { githubApplyCommand } from "./commands/github.js";
|
|
6
8
|
import { initCommand } from "./commands/init.js";
|
|
7
9
|
import { updateCommand } from "./commands/update.js";
|
|
8
10
|
import { STACK_IDS } from "./config/types.js";
|
|
@@ -15,6 +17,7 @@ export const USAGE = [
|
|
|
15
17
|
" init detect stacks, write .repokeeper.yml and apply the standard",
|
|
16
18
|
" check report drift from the standard without writing (exit 1 on drift)",
|
|
17
19
|
" update move to the standard of this repokeeper version and resync",
|
|
20
|
+
" github apply diff the GitHub settings against .repokeeper.yml and apply them",
|
|
18
21
|
"",
|
|
19
22
|
"options:",
|
|
20
23
|
" --dry-run show what would change without writing",
|
|
@@ -24,6 +27,7 @@ export const USAGE = [
|
|
|
24
27
|
" --stack <id> stack to use instead of detection (init, repeatable)",
|
|
25
28
|
" --relock rebuild .repokeeper/lock.json from the current files (init)",
|
|
26
29
|
" --json machine-readable output (check)",
|
|
30
|
+
" -y, --yes apply without asking (github apply)",
|
|
27
31
|
" -v, --version print the version",
|
|
28
32
|
].join("\n");
|
|
29
33
|
const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -42,6 +46,7 @@ export async function run(argv, io) {
|
|
|
42
46
|
stack: { type: "string", multiple: true, default: [] },
|
|
43
47
|
relock: { type: "boolean", default: false },
|
|
44
48
|
json: { type: "boolean", default: false },
|
|
49
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
45
50
|
version: { type: "boolean", short: "v", default: false },
|
|
46
51
|
help: { type: "boolean", short: "h", default: false },
|
|
47
52
|
},
|
|
@@ -70,6 +75,7 @@ export async function run(argv, io) {
|
|
|
70
75
|
stacks: stacks,
|
|
71
76
|
relock: values.relock,
|
|
72
77
|
json: values.json,
|
|
78
|
+
yes: values.yes,
|
|
73
79
|
};
|
|
74
80
|
if (command === "init")
|
|
75
81
|
return await initCommand(io.cwd, options, io);
|
|
@@ -77,6 +83,11 @@ export async function run(argv, io) {
|
|
|
77
83
|
return await checkCommand(io.cwd, options, io);
|
|
78
84
|
if (command === "update")
|
|
79
85
|
return await updateCommand(io.cwd, options, io);
|
|
86
|
+
if (command === "github") {
|
|
87
|
+
if (positionals[1] !== "apply")
|
|
88
|
+
throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
|
|
89
|
+
return await githubApplyCommand(io.cwd, options, io);
|
|
90
|
+
}
|
|
80
91
|
throw new UsageError(`unknown command: ${command}`);
|
|
81
92
|
}
|
|
82
93
|
catch (error) {
|
|
@@ -94,12 +105,22 @@ export async function run(argv, io) {
|
|
|
94
105
|
throw error;
|
|
95
106
|
}
|
|
96
107
|
}
|
|
108
|
+
async function askYesNo(question) {
|
|
109
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
try {
|
|
111
|
+
return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
rl.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
97
117
|
const invokedPath = process.argv[1];
|
|
98
118
|
if (invokedPath !== undefined && realpathSync(invokedPath) === fileURLToPath(import.meta.url)) {
|
|
99
119
|
run(process.argv.slice(2), {
|
|
100
120
|
cwd: process.cwd(),
|
|
101
121
|
out: (line) => process.stdout.write(`${line}\n`),
|
|
102
122
|
err: (line) => process.stderr.write(`${line}\n`),
|
|
123
|
+
...(process.stdin.isTTY ? { confirm: askYesNo } : {}),
|
|
103
124
|
}).then((code) => {
|
|
104
125
|
process.exitCode = code;
|
|
105
126
|
});
|
package/dist/commands/check.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readLock } from "../sync/lock.js";
|
|
|
6
6
|
import { computeSync } from "../sync/sync.js";
|
|
7
7
|
import { compareVersions, STANDARD_VERSION } from "../version.js";
|
|
8
8
|
import { buildContext } from "./context.js";
|
|
9
|
-
import { hasDrift, printResult } from "./report.js";
|
|
9
|
+
import { hasDrift, printResult, printWarnings } from "./report.js";
|
|
10
10
|
export function assertSupportedStandard(standard) {
|
|
11
11
|
if (compareVersions(standard, STANDARD_VERSION) > 0) {
|
|
12
12
|
throw new UsageError(`this repository uses standard ${standard}, newer than ${STANDARD_VERSION}; upgrade repokeeper`);
|
|
@@ -42,6 +42,7 @@ export async function checkCommand(root, options, io) {
|
|
|
42
42
|
return clean ? 0 : 1;
|
|
43
43
|
}
|
|
44
44
|
printResult(io, result);
|
|
45
|
+
await printWarnings(root, ctx, io);
|
|
45
46
|
if (behind)
|
|
46
47
|
io.out(`standard ${config.standard} applied, ${STANDARD_VERSION} available (run \`repokeeper update\`)`);
|
|
47
48
|
io.out(clean ? "repository matches the standard" : "repository has drifted from the standard");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.js";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { repoInfo } from "../git.js";
|
|
4
|
+
import { resolveToken, restApi } from "../github/api.js";
|
|
5
|
+
import { planGithub } from "../github/settings.js";
|
|
6
|
+
/** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
|
|
7
|
+
export async function githubApplyCommand(root, options, io) {
|
|
8
|
+
const config = await loadConfig(root);
|
|
9
|
+
const repo = await repoInfo(root);
|
|
10
|
+
if (!repo.owner)
|
|
11
|
+
throw new UsageError("the origin remote is not a GitHub repository");
|
|
12
|
+
const slug = `${repo.owner}/${repo.name}`;
|
|
13
|
+
const api = io.githubApi ?? restApi(await resolveToken());
|
|
14
|
+
const changes = await planGithub(api, repo.owner, repo.name, config.github ?? {});
|
|
15
|
+
if (changes.length === 0) {
|
|
16
|
+
io.out(`GitHub settings of ${slug} match .repokeeper.yml`);
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
for (const change of changes)
|
|
20
|
+
io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
|
|
21
|
+
if (options.dryRun) {
|
|
22
|
+
io.out("dry run: nothing applied");
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const question = `apply ${changes.length} change(s) to ${slug}?`;
|
|
26
|
+
const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
|
|
27
|
+
if (!confirmed) {
|
|
28
|
+
io.out("nothing applied; pass --yes to apply");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
for (const change of changes) {
|
|
32
|
+
await change.apply(api);
|
|
33
|
+
io.out(`applied ${change.setting}`);
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -15,11 +15,27 @@ import { readCurrent } from "../sync/state.js";
|
|
|
15
15
|
import { computeSync, pathsToWrite } from "../sync/sync.js";
|
|
16
16
|
import { STANDARD_VERSION } from "../version.js";
|
|
17
17
|
import { buildContext } from "./context.js";
|
|
18
|
-
import { printResult } from "./report.js";
|
|
19
|
-
|
|
18
|
+
import { nextSteps, printResult, printWarnings } from "./report.js";
|
|
19
|
+
/** A dirty path is still safe to write when every part repokeeper manages in it is exactly what it last wrote. */
|
|
20
|
+
async function onlyRepokeeperChanges(root, path, lock) {
|
|
21
|
+
const entries = lock?.entries.filter((e) => e.target.path === path) ?? [];
|
|
22
|
+
if (entries.length === 0)
|
|
23
|
+
return false;
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const current = await readCurrent(root, entry.target);
|
|
26
|
+
if (current === null || hashText(current) !== entry.hash)
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
export async function guardUncommitted(root, result, force, lock = null) {
|
|
20
32
|
if (force)
|
|
21
33
|
return;
|
|
22
|
-
const dirty =
|
|
34
|
+
const dirty = [];
|
|
35
|
+
for (const d of await dirtyPaths(root, pathsToWrite(result))) {
|
|
36
|
+
if (!(await onlyRepokeeperChanges(root, d.path, lock)))
|
|
37
|
+
dirty.push(d);
|
|
38
|
+
}
|
|
23
39
|
if (dirty.length === 0)
|
|
24
40
|
return;
|
|
25
41
|
const names = dirty.map((d) => (d.untracked ? `${d.path} (untracked)` : d.path)).join(", ");
|
|
@@ -62,7 +78,10 @@ export async function initCommand(root, options, io) {
|
|
|
62
78
|
await writeFile(join(root, CONFIG_FILE), renderConfig(config));
|
|
63
79
|
await applySync(root, result, null, STANDARD_VERSION);
|
|
64
80
|
io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
|
|
65
|
-
|
|
81
|
+
await printWarnings(root, ctx, io);
|
|
82
|
+
for (const step of await nextSteps(root, ctx, result))
|
|
83
|
+
io.out(`next: ${step}`);
|
|
84
|
+
io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
|
|
66
85
|
return 0;
|
|
67
86
|
}
|
|
68
87
|
/** Records the current content of every planned output as repokeeper's own, rebuilding a lost or corrupt lock. */
|
package/dist/commands/report.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { duplicateTestRuns } from "../duplicates.js";
|
|
4
|
+
import { crlfTrackedFiles } from "../git.js";
|
|
1
5
|
import { describeOutput } from "../model.js";
|
|
6
|
+
import { overriddenAttributes } from "../sync/attributes.js";
|
|
7
|
+
import { TOOL_VERSIONS } from "../version.js";
|
|
2
8
|
/** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
|
|
3
9
|
function ownedName(output) {
|
|
4
10
|
if (output.kind === "yaml" || output.kind === "json")
|
|
@@ -34,6 +40,39 @@ export function printResult(io, result) {
|
|
|
34
40
|
}
|
|
35
41
|
io.out(`${unchanged} output(s) already match the standard`);
|
|
36
42
|
}
|
|
43
|
+
const WROTE = ["create", "write", "adopt"];
|
|
44
|
+
/** What the user still has to do after a write: install the hooks, renormalize line endings. */
|
|
45
|
+
export async function nextSteps(root, ctx, result) {
|
|
46
|
+
const steps = [];
|
|
47
|
+
const wrote = (path) => result.decisions.some((d) => d.output.path === path && WROTE.includes(d.action));
|
|
48
|
+
if (ctx.config.modules.hooks && wrote("lefthook.yml")) {
|
|
49
|
+
steps.push(ctx.stacks.some((s) => s.id === "node")
|
|
50
|
+
? "install dependencies; this installs the git hooks"
|
|
51
|
+
: `run \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` to enable the git hooks`);
|
|
52
|
+
}
|
|
53
|
+
if (wrote(".gitattributes")) {
|
|
54
|
+
const crlf = await crlfTrackedFiles(root);
|
|
55
|
+
if (crlf.length > 0) {
|
|
56
|
+
const files = crlf.length === 1 ? "1 file is" : `${crlf.length} files are`;
|
|
57
|
+
steps.push(`${files} stored with CRLF line endings; run \`git add --renormalize .\` so they follow .gitattributes`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return steps;
|
|
61
|
+
}
|
|
62
|
+
/** Problems repokeeper can't fix itself; printed, but not counted as drift. */
|
|
63
|
+
export async function printWarnings(root, ctx, io) {
|
|
64
|
+
if (ctx.config.modules.ci) {
|
|
65
|
+
for (const { file, command } of await duplicateTestRuns(root, ctx.stacks)) {
|
|
66
|
+
io.out(`note: ${file} also runs "${command}", which the repokeeper ci job now runs too; drop one of them to save CI time`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!ctx.config.modules.editorconfig)
|
|
70
|
+
return;
|
|
71
|
+
const text = await readFile(join(root, ".gitattributes"), "utf8").catch(() => "");
|
|
72
|
+
for (const { line, by } of overriddenAttributes(text, "editorconfig")) {
|
|
73
|
+
io.out(`warning: .gitattributes: "${line}" has no effect because repokeeper's "${by}" comes later; move it below the repokeeper block to keep it`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
37
76
|
export function hasDrift(result) {
|
|
38
77
|
return result.decisions.some((d) => d.action !== "unchanged") || result.removals.some((r) => r.action !== "release");
|
|
39
78
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -10,7 +10,7 @@ import { STANDARD_VERSION } from "../version.js";
|
|
|
10
10
|
import { assertSupportedStandard } from "./check.js";
|
|
11
11
|
import { buildContext } from "./context.js";
|
|
12
12
|
import { guardUncommitted } from "./init.js";
|
|
13
|
-
import { printResult } from "./report.js";
|
|
13
|
+
import { nextSteps, printResult, printWarnings } from "./report.js";
|
|
14
14
|
export async function updateCommand(root, options, io) {
|
|
15
15
|
const config = await loadConfig(root);
|
|
16
16
|
assertSupportedStandard(config.standard);
|
|
@@ -25,13 +25,15 @@ export async function updateCommand(root, options, io) {
|
|
|
25
25
|
owned: config.owned,
|
|
26
26
|
});
|
|
27
27
|
if (!options.dryRun)
|
|
28
|
-
await guardUncommitted(root, result, options.force);
|
|
28
|
+
await guardUncommitted(root, result, options.force, lock);
|
|
29
29
|
printResult(io, result);
|
|
30
30
|
if (options.dryRun) {
|
|
31
31
|
io.out("dry run: nothing written");
|
|
32
32
|
return 0;
|
|
33
33
|
}
|
|
34
34
|
await applySync(root, result, lock, STANDARD_VERSION);
|
|
35
|
+
const steps = await nextSteps(root, ctx, result);
|
|
36
|
+
await printWarnings(root, ctx, io);
|
|
35
37
|
if (config.standard !== STANDARD_VERSION) {
|
|
36
38
|
const path = join(root, CONFIG_FILE);
|
|
37
39
|
await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
|
|
@@ -41,6 +43,8 @@ export async function updateCommand(root, options, io) {
|
|
|
41
43
|
io.out(`${conflicts.length} file(s) kept because they were edited locally; new versions are beside them as *.repokeeper-new`);
|
|
42
44
|
return 1;
|
|
43
45
|
}
|
|
46
|
+
for (const step of steps)
|
|
47
|
+
io.out(`next: ${step}`);
|
|
44
48
|
io.out(`repository is on standard ${STANDARD_VERSION}; commit with "chore(repokeeper): update standard to ${STANDARD_VERSION}"`);
|
|
45
49
|
return 0;
|
|
46
50
|
}
|
package/dist/config/load.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -20,6 +20,7 @@ export const configSchema = {
|
|
|
20
20
|
release: flag,
|
|
21
21
|
deps: flag,
|
|
22
22
|
gitignore: flag,
|
|
23
|
+
drift: flag,
|
|
23
24
|
health: {
|
|
24
25
|
anyOf: [
|
|
25
26
|
{ const: false },
|
|
@@ -40,6 +41,39 @@ export const configSchema = {
|
|
|
40
41
|
},
|
|
41
42
|
owned: { type: "array", items: { type: "string", minLength: 1 } },
|
|
42
43
|
stack_options: { type: "object" },
|
|
43
|
-
github: {
|
|
44
|
+
github: {
|
|
45
|
+
type: "object",
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
default_branch: { type: "string", minLength: 1 },
|
|
49
|
+
description: { type: "string" },
|
|
50
|
+
topics: { type: "array", uniqueItems: true, items: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,49}$" } },
|
|
51
|
+
merge: {
|
|
52
|
+
type: "object",
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
properties: { squash: flag, merge_commit: flag, rebase: flag, delete_branch_on_merge: flag },
|
|
55
|
+
},
|
|
56
|
+
security: {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: { dependabot_alerts: flag, dependabot_security_updates: flag },
|
|
60
|
+
},
|
|
61
|
+
protect: {
|
|
62
|
+
anyOf: [
|
|
63
|
+
{ const: false },
|
|
64
|
+
{
|
|
65
|
+
type: "object",
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
require_pull_request: flag,
|
|
69
|
+
required_approvals: { type: "integer", minimum: 0, maximum: 10 },
|
|
70
|
+
required_checks: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } },
|
|
71
|
+
allow_force_push: flag,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
44
78
|
},
|
|
45
79
|
};
|
package/dist/config/types.js
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { REUSABLE_REPO } from "./version.js";
|
|
5
|
+
const WORKFLOWS = ".github/workflows";
|
|
6
|
+
const calls = (job) => {
|
|
7
|
+
const uses = job?.uses;
|
|
8
|
+
return (typeof uses === "string" && (uses.startsWith(`${REUSABLE_REPO}/`) || uses.startsWith("./.github/workflows/stack-")));
|
|
9
|
+
};
|
|
10
|
+
/** Workflows of the user's own that run a stack's test command, which repokeeper's ci job now runs as well. */
|
|
11
|
+
export async function duplicateTestRuns(root, stacks) {
|
|
12
|
+
const commands = stacks.map((s) => s.test).filter((c) => c !== null);
|
|
13
|
+
if (commands.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
const names = await readdir(join(root, WORKFLOWS)).catch(() => []);
|
|
16
|
+
const found = [];
|
|
17
|
+
for (const name of names.filter((n) => /\.ya?ml$/.test(n)).sort()) {
|
|
18
|
+
let workflow;
|
|
19
|
+
try {
|
|
20
|
+
workflow = parse(await readFile(join(root, WORKFLOWS, name), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// only pull request CI overlaps with repokeeper's ci job; a release workflow testing before it publishes doesn't
|
|
26
|
+
if (!JSON.stringify(workflow?.on ?? "").includes("pull_request"))
|
|
27
|
+
continue;
|
|
28
|
+
const jobs = workflow?.jobs ?? {};
|
|
29
|
+
const own = JSON.stringify(Object.values(jobs).filter((job) => !calls(job)));
|
|
30
|
+
for (const command of commands) {
|
|
31
|
+
if (own.includes(command))
|
|
32
|
+
found.push({ file: `${WORKFLOWS}/${name}`, command });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -18,6 +18,12 @@ export class UsageError extends RepokeeperError {
|
|
|
18
18
|
super(message, 2);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
/** A GitHub API call failed; nothing on disk was touched. */
|
|
22
|
+
export class ApiError extends RepokeeperError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message, 3);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
21
27
|
/** `.repokeeper/lock.json` is unreadable; the repository state is unknown, which counts as drift. */
|
|
22
28
|
export class LockError extends RepokeeperError {
|
|
23
29
|
constructor(message) {
|
package/dist/git.js
CHANGED
|
@@ -34,6 +34,14 @@ export async function latestReleaseVersion(root) {
|
|
|
34
34
|
const tag = tags.split("\n").find((t) => /^v\d+\.\d+\.\d+$/.test(t.trim()));
|
|
35
35
|
return tag ? tag.trim().slice(1) : null;
|
|
36
36
|
}
|
|
37
|
+
/** Tracked files git stores with CRLF line endings; `.gitattributes` only reaches them after a renormalize. */
|
|
38
|
+
export async function crlfTrackedFiles(root) {
|
|
39
|
+
const out = (await git(root, ["ls-files", "--eol"])) ?? "";
|
|
40
|
+
return out
|
|
41
|
+
.split("\n")
|
|
42
|
+
.filter((line) => line.startsWith("i/crlf"))
|
|
43
|
+
.map((line) => line.split("\t").pop());
|
|
44
|
+
}
|
|
37
45
|
export async function gitUserName(root) {
|
|
38
46
|
return (await git(root, ["config", "user.name"]))?.trim() || null;
|
|
39
47
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { ApiError } from "../errors.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
/** `GITHUB_TOKEN`, or the token of the GitHub CLI. */
|
|
6
|
+
export async function resolveToken(env = process.env) {
|
|
7
|
+
if (env.GITHUB_TOKEN)
|
|
8
|
+
return env.GITHUB_TOKEN;
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execFileAsync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
11
|
+
if (stdout.trim())
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// fall through to the error below
|
|
16
|
+
}
|
|
17
|
+
throw new ApiError("no GitHub token: set GITHUB_TOKEN or sign in with `gh auth login`");
|
|
18
|
+
}
|
|
19
|
+
export function restApi(token, base = "https://api.github.com") {
|
|
20
|
+
return {
|
|
21
|
+
async request(method, path, body) {
|
|
22
|
+
let response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetch(`${base}${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: {
|
|
27
|
+
accept: "application/vnd.github+json",
|
|
28
|
+
authorization: `Bearer ${token}`,
|
|
29
|
+
"x-github-api-version": "2022-11-28",
|
|
30
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
31
|
+
},
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new ApiError(`GitHub API ${method} ${path} failed: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
let data = null;
|
|
40
|
+
try {
|
|
41
|
+
data = text ? JSON.parse(text) : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
data = text;
|
|
45
|
+
}
|
|
46
|
+
return { status: response.status, data };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ApiError } from "../errors.js";
|
|
2
|
+
export const RULESET_NAME = "repokeeper";
|
|
3
|
+
function expect2xx(response, what, repo) {
|
|
4
|
+
if (response.status < 400)
|
|
5
|
+
return response;
|
|
6
|
+
const message = response.data?.message ?? `HTTP ${response.status}`;
|
|
7
|
+
const access = response.status === 403 || response.status === 404
|
|
8
|
+
? `; this needs admin access to ${repo} (a token with the repo scope, or Administration: write)`
|
|
9
|
+
: "";
|
|
10
|
+
throw new ApiError(`${what} failed: ${message}${access}`);
|
|
11
|
+
}
|
|
12
|
+
const show = (value) => value === undefined || value === null || value === ""
|
|
13
|
+
? "(none)"
|
|
14
|
+
: Array.isArray(value)
|
|
15
|
+
? value.join(", ") || "(none)"
|
|
16
|
+
: String(value);
|
|
17
|
+
const MERGE_FIELDS = {
|
|
18
|
+
squash: "allow_squash_merge",
|
|
19
|
+
merge_commit: "allow_merge_commit",
|
|
20
|
+
rebase: "allow_rebase_merge",
|
|
21
|
+
delete_branch_on_merge: "delete_branch_on_merge",
|
|
22
|
+
};
|
|
23
|
+
function summarize(rules) {
|
|
24
|
+
const pr = rules.find((r) => r.type === "pull_request");
|
|
25
|
+
const checks = rules.find((r) => r.type === "required_status_checks");
|
|
26
|
+
return {
|
|
27
|
+
pullRequest: pr !== undefined,
|
|
28
|
+
approvals: pr?.parameters?.required_approving_review_count ?? 0,
|
|
29
|
+
checks: (checks?.parameters?.required_status_checks ?? []).map((c) => c.context).sort(),
|
|
30
|
+
forcePush: !rules.some((r) => r.type === "non_fast_forward"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function describeRules(summary) {
|
|
34
|
+
if (summary === null)
|
|
35
|
+
return "(none)";
|
|
36
|
+
const parts = [
|
|
37
|
+
summary.pullRequest ? `pull request required (${summary.approvals} approvals)` : "no pull request required",
|
|
38
|
+
summary.checks.length > 0 ? `checks: ${summary.checks.join(", ")}` : "no required checks",
|
|
39
|
+
summary.forcePush ? "force push allowed" : "no force push",
|
|
40
|
+
];
|
|
41
|
+
return parts.join("; ");
|
|
42
|
+
}
|
|
43
|
+
function desiredRules(protect) {
|
|
44
|
+
const rules = [];
|
|
45
|
+
if (protect.require_pull_request ?? true) {
|
|
46
|
+
rules.push({
|
|
47
|
+
type: "pull_request",
|
|
48
|
+
parameters: {
|
|
49
|
+
required_approving_review_count: protect.required_approvals ?? 0,
|
|
50
|
+
dismiss_stale_reviews_on_push: false,
|
|
51
|
+
require_code_owner_review: false,
|
|
52
|
+
require_last_push_approval: false,
|
|
53
|
+
required_review_thread_resolution: false,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const checks = protect.required_checks ?? [];
|
|
58
|
+
if (checks.length > 0) {
|
|
59
|
+
rules.push({
|
|
60
|
+
type: "required_status_checks",
|
|
61
|
+
parameters: {
|
|
62
|
+
strict_required_status_checks_policy: false,
|
|
63
|
+
required_status_checks: checks.map((context) => ({ context })),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!(protect.allow_force_push ?? false))
|
|
68
|
+
rules.push({ type: "non_fast_forward" });
|
|
69
|
+
return rules;
|
|
70
|
+
}
|
|
71
|
+
const sameSummary = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
72
|
+
/** Reads the repository's current settings and lists what differs from `settings`. Reads only. */
|
|
73
|
+
export async function planGithub(api, owner, name, settings) {
|
|
74
|
+
const repo = `${owner}/${name}`;
|
|
75
|
+
const base = `/repos/${owner}/${name}`;
|
|
76
|
+
const get = async (path, what, missingOk = false) => {
|
|
77
|
+
const response = await api.request("GET", `${base}${path}`);
|
|
78
|
+
return missingOk && response.status === 404 ? response : expect2xx(response, what, repo);
|
|
79
|
+
};
|
|
80
|
+
const send = async (method, path, what, body) => {
|
|
81
|
+
expect2xx(await api.request(method, `${base}${path}`, body), what, repo);
|
|
82
|
+
};
|
|
83
|
+
const changes = [];
|
|
84
|
+
const current = (await get("", `reading ${repo}`)).data;
|
|
85
|
+
for (const [key, field] of Object.entries(MERGE_FIELDS)) {
|
|
86
|
+
const want = settings.merge?.[key];
|
|
87
|
+
if (want === undefined || current[field] === want)
|
|
88
|
+
continue;
|
|
89
|
+
changes.push({
|
|
90
|
+
setting: `merge.${key}`,
|
|
91
|
+
from: show(current[field]),
|
|
92
|
+
to: show(want),
|
|
93
|
+
apply: () => send("PATCH", "", `setting merge.${key}`, { [field]: want }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (settings.description !== undefined && (current.description ?? "") !== settings.description) {
|
|
97
|
+
const description = settings.description;
|
|
98
|
+
changes.push({
|
|
99
|
+
setting: "description",
|
|
100
|
+
from: show(current.description),
|
|
101
|
+
to: show(description),
|
|
102
|
+
apply: () => send("PATCH", "", "setting the description", { description }),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (settings.topics !== undefined) {
|
|
106
|
+
const have = ((await get("/topics", "reading topics")).data.names ?? []).slice().sort();
|
|
107
|
+
const want = [...settings.topics].sort();
|
|
108
|
+
if (JSON.stringify(have) !== JSON.stringify(want)) {
|
|
109
|
+
changes.push({
|
|
110
|
+
setting: "topics",
|
|
111
|
+
from: show(have),
|
|
112
|
+
to: show(want),
|
|
113
|
+
apply: () => send("PUT", "/topics", "setting topics", { names: want }),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const alerts = settings.security?.dependabot_alerts;
|
|
118
|
+
if (alerts !== undefined) {
|
|
119
|
+
const have = (await get("/vulnerability-alerts", "reading Dependabot alerts", true)).status === 204;
|
|
120
|
+
if (have !== alerts) {
|
|
121
|
+
changes.push({
|
|
122
|
+
setting: "security.dependabot_alerts",
|
|
123
|
+
from: show(have),
|
|
124
|
+
to: show(alerts),
|
|
125
|
+
apply: () => send(alerts ? "PUT" : "DELETE", "/vulnerability-alerts", "setting Dependabot alerts"),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const fixes = settings.security?.dependabot_security_updates;
|
|
130
|
+
if (fixes !== undefined) {
|
|
131
|
+
const response = await get("/automated-security-fixes", "reading Dependabot security updates", true);
|
|
132
|
+
const have = response.status !== 404 && response.data?.enabled === true;
|
|
133
|
+
if (have !== fixes) {
|
|
134
|
+
changes.push({
|
|
135
|
+
setting: "security.dependabot_security_updates",
|
|
136
|
+
from: show(have),
|
|
137
|
+
to: show(fixes),
|
|
138
|
+
apply: () => send(fixes ? "PUT" : "DELETE", "/automated-security-fixes", "setting Dependabot security updates"),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (settings.protect !== undefined) {
|
|
143
|
+
const list = (await get("/rulesets?includes_parents=false", "reading rulesets")).data;
|
|
144
|
+
const existing = list.find((r) => r.name === RULESET_NAME);
|
|
145
|
+
const detail = existing
|
|
146
|
+
? (await get(`/rulesets/${existing.id}`, "reading the repokeeper ruleset")).data
|
|
147
|
+
: null;
|
|
148
|
+
const have = detail ? summarize(detail.rules ?? []) : null;
|
|
149
|
+
if (settings.protect === false) {
|
|
150
|
+
if (existing) {
|
|
151
|
+
changes.push({
|
|
152
|
+
setting: "protect",
|
|
153
|
+
from: describeRules(have),
|
|
154
|
+
to: "(none)",
|
|
155
|
+
apply: () => send("DELETE", `/rulesets/${existing.id}`, "removing the repokeeper ruleset"),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const rules = desiredRules(settings.protect);
|
|
161
|
+
const want = summarize(rules);
|
|
162
|
+
const targetsDefault = detail?.conditions?.ref_name?.include?.includes("~DEFAULT_BRANCH") ?? false;
|
|
163
|
+
const upToDate = have !== null && sameSummary(have, want) && detail?.enforcement === "active" && targetsDefault;
|
|
164
|
+
if (!upToDate) {
|
|
165
|
+
const body = {
|
|
166
|
+
name: RULESET_NAME,
|
|
167
|
+
target: "branch",
|
|
168
|
+
enforcement: "active",
|
|
169
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
170
|
+
rules,
|
|
171
|
+
};
|
|
172
|
+
changes.push({
|
|
173
|
+
setting: "protect",
|
|
174
|
+
from: describeRules(have),
|
|
175
|
+
to: describeRules(want),
|
|
176
|
+
apply: () => existing
|
|
177
|
+
? send("PUT", `/rulesets/${existing.id}`, "updating the repokeeper ruleset", body)
|
|
178
|
+
: send("POST", "/rulesets", "creating the repokeeper ruleset", body),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return changes;
|
|
184
|
+
}
|
package/dist/platforms/github.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { stringify } from "yaml";
|
|
2
2
|
import { defaultBranch } from "../config/types.js";
|
|
3
3
|
import { MANAGED_HEADER } from "../model.js";
|
|
4
|
-
import { REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
|
|
4
|
+
import { PACKAGE_VERSION, REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
|
|
5
5
|
const yamlFile = (module, path, data) => ({
|
|
6
6
|
kind: "file",
|
|
7
7
|
module,
|
|
@@ -99,6 +99,10 @@ export const githubPlatform = {
|
|
|
99
99
|
directory: "/",
|
|
100
100
|
schedule: { interval: "weekly" },
|
|
101
101
|
groups: { [`${ecosystem}-minor-and-patch`]: { "update-types": ["minor", "patch"] } },
|
|
102
|
+
// @types/node majors track the Node.js line a project runs on, which the project chooses
|
|
103
|
+
...(ecosystem === "npm"
|
|
104
|
+
? { ignore: [{ "dependency-name": "@types/node", "update-types": ["version-update:semver-major"] }] }
|
|
105
|
+
: {}),
|
|
102
106
|
}));
|
|
103
107
|
return [yamlFile("deps", ".github/dependabot.yml", { version: 2, updates })];
|
|
104
108
|
},
|
|
@@ -116,6 +120,12 @@ export const githubPlatform = {
|
|
|
116
120
|
if (ctx.config.modules.commits) {
|
|
117
121
|
jobs.push(workflowKey("ci", path, ["jobs", "commits"], { uses: workflowRef(ctx, "commitlint.yml") }));
|
|
118
122
|
}
|
|
123
|
+
if (ctx.config.modules.drift) {
|
|
124
|
+
jobs.push(workflowKey("ci", path, ["jobs", "repokeeper"], {
|
|
125
|
+
uses: workflowRef(ctx, "repokeeper-check.yml"),
|
|
126
|
+
with: { version: PACKAGE_VERSION },
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
119
129
|
if (jobs.length === 0)
|
|
120
130
|
return [];
|
|
121
131
|
return [
|
package/dist/stacks/dotnet.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
1
2
|
import { checkKeys, filesMatching, readText, stringList } from "./support.js";
|
|
2
3
|
const OPTION_KEYS = ["versions", "os"];
|
|
3
4
|
const SOLUTIONS = /\.(sln|slnx)$/;
|
|
4
5
|
const PROJECTS = /\.(csproj|fsproj|vbproj)$/;
|
|
5
6
|
const VERSION_XPATH = "//Project/PropertyGroup/Version";
|
|
6
7
|
const versionOf = (xml) => (xml ? (/<Version>\s*([^<\s]+)\s*<\/Version>/.exec(xml)?.[1] ?? null) : null);
|
|
8
|
+
/** Project files the root solutions list, plus projects at the root, as posix paths. */
|
|
9
|
+
async function projectPaths(root, solutions, projects) {
|
|
10
|
+
const paths = new Set(projects);
|
|
11
|
+
for (const solution of solutions) {
|
|
12
|
+
const text = (await readText(root, solution)) ?? "";
|
|
13
|
+
const pattern = solution.endsWith(".slnx") ? /Path="([^"]+\.[cfv][sb]proj)"/g : /"([^"]+\.[cfv][sb]proj)"/g;
|
|
14
|
+
for (const match of text.matchAll(pattern))
|
|
15
|
+
paths.add(match[1].replace(/\\/g, "/"));
|
|
16
|
+
}
|
|
17
|
+
return [...paths];
|
|
18
|
+
}
|
|
19
|
+
/** SDK-style projects name an SDK; .NET Framework projects don't, and the dotnet CLI can't build them. */
|
|
20
|
+
const isSdkStyle = (xml) => /<Project[^>]*\sSdk=/.test(xml) || /<Sdk\s+Name=/.test(xml);
|
|
7
21
|
export const dotnetStack = {
|
|
8
22
|
id: "dotnet",
|
|
9
23
|
detect: (root) => filesMatching(root, ".", SOLUTIONS).length + filesMatching(root, ".", PROJECTS).length > 0,
|
|
@@ -13,6 +27,15 @@ export const dotnetStack = {
|
|
|
13
27
|
const projects = filesMatching(root, ".", PROJECTS);
|
|
14
28
|
// with several entries at the root, dotnet stops with MSB1011 unless one is named
|
|
15
29
|
const target = solutions.length + projects.length > 1 ? ` ${solutions[0] ?? projects[0]}` : "";
|
|
30
|
+
const legacy = [];
|
|
31
|
+
for (const path of await projectPaths(root, solutions, projects)) {
|
|
32
|
+
const xml = await readText(root, path);
|
|
33
|
+
if (xml !== null && /<Project[\s>]/.test(xml) && !isSdkStyle(xml))
|
|
34
|
+
legacy.push(path);
|
|
35
|
+
}
|
|
36
|
+
if (legacy.length > 0) {
|
|
37
|
+
throw new UsageError(`the dotnet stack needs SDK-style projects, and ${legacy.join(", ")} ${legacy.length > 1 ? "are" : "is"} .NET Framework projects the dotnet CLI can't build; convert them (for example with the .NET Upgrade Assistant) or leave dotnet out of stacks`);
|
|
38
|
+
}
|
|
16
39
|
let release = { type: "simple", version: null };
|
|
17
40
|
for (const file of ["Directory.Build.props", ...(projects.length === 1 ? projects : [])]) {
|
|
18
41
|
const version = versionOf(await readText(root, file));
|
package/dist/stacks/python.js
CHANGED
|
@@ -4,8 +4,11 @@ import { checkKeys, filesMatching, optionalString, readText, stringList } from "
|
|
|
4
4
|
const OPTION_KEYS = ["versions", "os", "test"];
|
|
5
5
|
/** The `version = "…"` of the `[project]` table, if any. */
|
|
6
6
|
function projectVersion(pyproject) {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
return /^version\s*=\s*"([^"]+)"/m.exec(section(pyproject, "project"))?.[1] ?? null;
|
|
8
|
+
}
|
|
9
|
+
/** The body of the `[name]` table of a TOML file, or "". */
|
|
10
|
+
function section(toml, name) {
|
|
11
|
+
return toml.split(/^\[/m).find((part) => part.startsWith(`${name}]`)) ?? "";
|
|
9
12
|
}
|
|
10
13
|
export const pythonStack = {
|
|
11
14
|
id: "python",
|
|
@@ -15,7 +18,10 @@ export const pythonStack = {
|
|
|
15
18
|
const has = (path) => existsSync(join(root, path));
|
|
16
19
|
const pyproject = (await readText(root, "pyproject.toml")) ?? "";
|
|
17
20
|
const uv = has("uv.lock");
|
|
21
|
+
const mypyIni = (await readText(root, "mypy.ini")) ?? "";
|
|
18
22
|
const mypy = has("mypy.ini") || pyproject.includes("[tool.mypy]");
|
|
23
|
+
// a config that names its files decides what mypy checks; otherwise check the whole tree
|
|
24
|
+
const mypyFiles = /^files\s*=/m.test(mypyIni) || /^files\s*=/m.test(section(pyproject, "tool.mypy"));
|
|
19
25
|
const hasTests = has("tests") || has("test") || filesMatching(root, ".", /^test_.*\.py$/).length > 0;
|
|
20
26
|
const customTest = optionalString("python", options, "test");
|
|
21
27
|
const pytest = customTest === undefined && hasTests;
|
|
@@ -37,7 +43,7 @@ export const pythonStack = {
|
|
|
37
43
|
const commands = [
|
|
38
44
|
`${run}ruff format --check .`,
|
|
39
45
|
`${run}ruff check .`,
|
|
40
|
-
...(mypy ? [`${run}mypy
|
|
46
|
+
...(mypy ? [`${run}mypy${mypyFiles ? "" : " ."}`] : []),
|
|
41
47
|
...(test ? [test] : []),
|
|
42
48
|
];
|
|
43
49
|
const ruff = uv ? "uvx ruff" : "ruff";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { endMarker, startMarker } from "./block.js";
|
|
2
|
+
import { normalizeEol } from "./hash.js";
|
|
3
|
+
/** `eol=lf` → ["eol", "lf"], `-text` → ["text", "unset"], `text` → ["text", "set"]. */
|
|
4
|
+
function attributes(fields) {
|
|
5
|
+
const map = new Map();
|
|
6
|
+
for (const field of fields) {
|
|
7
|
+
if (field.startsWith("-") || field.startsWith("!"))
|
|
8
|
+
map.set(field.slice(1), "unset");
|
|
9
|
+
else if (field.includes("="))
|
|
10
|
+
map.set(field.slice(0, field.indexOf("=")), field.slice(field.indexOf("=") + 1));
|
|
11
|
+
else
|
|
12
|
+
map.set(field, "set");
|
|
13
|
+
}
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
const parse = (line) => {
|
|
17
|
+
const [pattern, ...fields] = line.trim().split(/\s+/);
|
|
18
|
+
return { raw: line.trim(), pattern: pattern, attrs: attributes(fields) };
|
|
19
|
+
};
|
|
20
|
+
const isRule = (line) => line.trim() !== "" && !line.trim().startsWith("#");
|
|
21
|
+
/**
|
|
22
|
+
* Lines above repokeeper's block that set an attribute the block sets differently for the same
|
|
23
|
+
* pattern. gitattributes applies the last match, so those lines have no effect.
|
|
24
|
+
*/
|
|
25
|
+
export function overriddenAttributes(text, blockId) {
|
|
26
|
+
const lines = normalizeEol(text).split("\n");
|
|
27
|
+
const start = lines.indexOf(startMarker(blockId, "hash"));
|
|
28
|
+
const end = lines.indexOf(endMarker(blockId, "hash"), start + 1);
|
|
29
|
+
if (start < 0 || end < 0)
|
|
30
|
+
return [];
|
|
31
|
+
const block = lines
|
|
32
|
+
.slice(start + 1, end)
|
|
33
|
+
.filter(isRule)
|
|
34
|
+
.map(parse);
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const line of lines.slice(0, start).filter(isRule)) {
|
|
37
|
+
const mine = parse(line);
|
|
38
|
+
const theirs = block.find((rule) => rule.pattern === mine.pattern &&
|
|
39
|
+
[...mine.attrs].some(([key, value]) => rule.attrs.has(key) && rule.attrs.get(key) !== value));
|
|
40
|
+
if (theirs)
|
|
41
|
+
found.push({ line: mine.raw, by: theirs.raw });
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
3
3
|
/** The standard this build of repokeeper applies. Bump it whenever generated output changes. */
|
|
4
|
-
export const STANDARD_VERSION = "1.2.
|
|
4
|
+
export const STANDARD_VERSION = "1.2.2";
|
|
5
5
|
export const TOOL_VERSIONS = {
|
|
6
6
|
lefthook: "2.1.14",
|
|
7
7
|
commitlintCli: "21.2.3",
|
package/package.json
CHANGED