github-settings-as-code 4.0.5 → 4.0.7

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.
Files changed (61) hide show
  1. package/README.md +9 -5
  2. package/dist/github/clear-json-files.d.mts.map +1 -1
  3. package/dist/github/clear-json-files.mjs +3 -7
  4. package/dist/github/clear-json-files.mjs.map +1 -1
  5. package/dist/github/environments/api/get-environments.d.mts.map +1 -1
  6. package/dist/github/environments/api/get-environments.mjs +5 -5
  7. package/dist/github/environments/api/get-environments.mjs.map +1 -1
  8. package/dist/github/environments/apply.d.mts.map +1 -1
  9. package/dist/github/environments/apply.mjs +4 -1
  10. package/dist/github/environments/apply.mjs.map +1 -1
  11. package/dist/github/environments/index.d.mts +1 -0
  12. package/dist/github/environments/index.d.mts.map +1 -1
  13. package/dist/github/environments/index.mjs +1 -0
  14. package/dist/github/environments/index.mjs.map +1 -1
  15. package/dist/github/environments/read-environment-contents.d.mts +5 -0
  16. package/dist/github/environments/read-environment-contents.d.mts.map +1 -1
  17. package/dist/github/environments/read-environment-contents.mjs +9 -10
  18. package/dist/github/environments/read-environment-contents.mjs.map +1 -1
  19. package/dist/github/environments/select-declared-environments.d.mts +12 -0
  20. package/dist/github/environments/select-declared-environments.d.mts.map +1 -0
  21. package/dist/github/environments/select-declared-environments.mjs +11 -0
  22. package/dist/github/environments/select-declared-environments.mjs.map +1 -0
  23. package/dist/github/index.d.mts +1 -0
  24. package/dist/github/index.d.mts.map +1 -1
  25. package/dist/github/index.mjs +1 -0
  26. package/dist/github/index.mjs.map +1 -1
  27. package/dist/github/list-json-files.d.mts +11 -0
  28. package/dist/github/list-json-files.d.mts.map +1 -0
  29. package/dist/github/list-json-files.mjs +19 -0
  30. package/dist/github/list-json-files.mjs.map +1 -0
  31. package/dist/github/resolve-target-repo.d.mts.map +1 -1
  32. package/dist/github/resolve-target-repo.mjs +37 -37
  33. package/dist/github/resolve-target-repo.mjs.map +1 -1
  34. package/dist/github/ruleset/api/create-ruleset.d.mts +1 -1
  35. package/dist/github/ruleset/api/create-ruleset.d.mts.map +1 -1
  36. package/dist/github/ruleset/api/create-ruleset.mjs +3 -1
  37. package/dist/github/ruleset/api/create-ruleset.mjs.map +1 -1
  38. package/dist/github/ruleset/apply.d.mts.map +1 -1
  39. package/dist/github/ruleset/apply.mjs +18 -5
  40. package/dist/github/ruleset/apply.mjs.map +1 -1
  41. package/dist/github/ruleset/read-rule-set-contents.d.mts +5 -0
  42. package/dist/github/ruleset/read-rule-set-contents.d.mts.map +1 -1
  43. package/dist/github/ruleset/read-rule-set-contents.mjs +9 -12
  44. package/dist/github/ruleset/read-rule-set-contents.mjs.map +1 -1
  45. package/package.json +14 -13
  46. package/src/github/clear-json-files.mts +3 -8
  47. package/src/github/environments/api/get-environments.mts +9 -9
  48. package/src/github/environments/apply.mts +8 -2
  49. package/src/github/environments/index.mts +1 -0
  50. package/src/github/environments/read-environment-contents.mts +12 -14
  51. package/src/github/environments/read-environment-contents.test.mts +31 -0
  52. package/src/github/environments/select-declared-environments.mts +18 -0
  53. package/src/github/environments/select-declared-environments.test.mts +28 -0
  54. package/src/github/index.mts +1 -0
  55. package/src/github/list-json-files.mts +22 -0
  56. package/src/github/list-json-files.test.mts +45 -0
  57. package/src/github/resolve-target-repo.mts +47 -47
  58. package/src/github/ruleset/api/create-ruleset.mts +5 -2
  59. package/src/github/ruleset/apply.mts +22 -7
  60. package/src/github/ruleset/read-rule-set-contents.mts +14 -16
  61. package/src/github/ruleset/read-rule-set-contents.test.mts +31 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-settings-as-code",
3
- "version": "4.0.5",
3
+ "version": "4.0.7",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "github",
@@ -29,23 +29,23 @@
29
29
  "dedent": "^1.7.2",
30
30
  "dotenv": "^18.0.8",
31
31
  "octokit-safe-types": "^1.2.28",
32
- "ts-data-forge": "^14.10.0",
33
- "ts-fortress": "^12.4.0",
34
- "ts-repo-utils": "^10.7.1",
32
+ "ts-data-forge": "^14.11.0",
33
+ "ts-fortress": "^12.4.4",
34
+ "ts-repo-utils": "^10.7.3",
35
35
  "ts-type-forge": "^9.2.2"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "26.6.5",
39
- "@vitest/coverage-v8": "5.0.5",
39
+ "@vitest/coverage-istanbul": "5.0.5",
40
40
  "@vitest/ui": "5.0.5",
41
41
  "cspell": "10.4.0",
42
42
  "eslint": "10.13.0",
43
- "eslint-config-typed": "5.19.2",
44
- "eslint-plugin-ts-data-forge": "0.10.3",
45
- "eslint-plugin-ts-fortress": "1.3.8",
46
- "eslint-plugin-ts-type-forge": "0.8.13",
43
+ "eslint-config-typed": "7.5.0",
44
+ "eslint-plugin-ts-data-forge": "0.10.10",
45
+ "eslint-plugin-ts-fortress": "1.3.14",
46
+ "eslint-plugin-ts-type-forge": "0.8.19",
47
47
  "jiti": "2.7.0",
48
- "npm-run-all2": "9.0.3",
48
+ "npm-run-all2": "9.0.4",
49
49
  "prettier": "3.9.10",
50
50
  "prettier-plugin-organize-imports": "4.3.0",
51
51
  "prettier-plugin-packagejson": "3.0.2",
@@ -55,11 +55,11 @@
55
55
  "vitest": "5.0.5"
56
56
  },
57
57
  "engines": {
58
- "node": ">=22.22.2",
58
+ "node": ">=22.23.4",
59
59
  "pnpm": ">=9.0.0"
60
60
  },
61
61
  "volta": {
62
- "node": "26.12.1"
62
+ "node": "26.13.0"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public",
@@ -83,9 +83,10 @@
83
83
  "fix:fmt:full": "prettier --write .",
84
84
  "fix:lint": "node ../../tools/scripts/cmd/assert-deps-built.mts && oxlint . --fix && eslint . --fix",
85
85
  "fmt": "pnpm run fix:fmt",
86
- "gen": "run-s gen:index",
86
+ "gen": "run-s gen:index gen:readme",
87
87
  "gen:index": "run-s gen:index:src fix:fmt",
88
88
  "gen:index:src": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ../../libs/ts-repo-utils/src/cmd/gen-index-ts.mts ./src/github --index-ext .mts --export-ext .mjs --target-ext .mts --exclude cmd --exclude octokit.mts",
89
+ "gen:readme": "tsx --tsconfig ../../tools/configs/tsconfig.tsx.json ./scripts/cmd/embed-examples.mts",
89
90
  "test": "pnpm run check:test",
90
91
  "test:cov:ui": "vite preview --outDir ./coverage",
91
92
  "test:ui": "pnpm run z:vitest:node --ui",
@@ -1,5 +1,5 @@
1
1
  import * as fs from 'node:fs/promises';
2
- import * as path from 'node:path';
2
+ import { listJsonFilesIn } from './list-json-files.mjs';
3
3
 
4
4
  /**
5
5
  * `dir` の直下の `*.json` を消す。ディレクトリが無ければ作る。
@@ -20,12 +20,7 @@ export const clearJsonFilesIn = async (dir: string): Promise<void> => {
20
20
  // eslint-disable-next-line security/detect-non-literal-fs-filename
21
21
  await fs.mkdir(dir, { recursive: true });
22
22
 
23
- // eslint-disable-next-line security/detect-non-literal-fs-filename
24
- const entries = await fs.readdir(dir, { withFileTypes: true });
25
-
26
- for (const entry of entries) {
27
- if (entry.isFile() && entry.name.endsWith('.json')) {
28
- await fs.rm(path.resolve(dir, entry.name));
29
- }
23
+ for (const file of await listJsonFilesIn(dir)) {
24
+ await fs.rm(file);
30
25
  }
31
26
  };
@@ -51,15 +51,6 @@ export const getAllEnvironments = async (): Promise<
51
51
  return settings.toSorted((a, b) => a.name.localeCompare(b.name));
52
52
  };
53
53
 
54
- /** 宣言ファイルに載る形。 `id` は GitHub 側の都合なので落とす。 */
55
- const getDeploymentBranchPolicyNames = async (
56
- environmentName: string,
57
- ): Promise<readonly Readonly<{ name: string; type: 'branch' | 'tag' }>[]> => {
58
- const policies = await getDeploymentBranchPolicies(environmentName);
59
-
60
- return policies.map(({ name, type }) => ({ name, type }));
61
- };
62
-
63
54
  /** 「 Selected branches and tags 」に登録されている pattern を列挙する。 */
64
55
  export const getDeploymentBranchPolicies = async (
65
56
  environmentName: string,
@@ -83,6 +74,15 @@ export const getDeploymentBranchPolicies = async (
83
74
  }));
84
75
  };
85
76
 
77
+ /** 宣言ファイルに載る形。 `id` は GitHub 側の都合なので落とす。 */
78
+ const getDeploymentBranchPolicyNames = async (
79
+ environmentName: string,
80
+ ): Promise<readonly Readonly<{ name: string; type: 'branch' | 'tag' }>[]> => {
81
+ const policies = await getDeploymentBranchPolicies(environmentName);
82
+
83
+ return policies.map(({ name, type }) => ({ name, type }));
84
+ };
85
+
86
86
  export type DeploymentBranchPolicy = Readonly<{
87
87
  id: number;
88
88
  name: string;
@@ -8,6 +8,7 @@ import { settingsFilePath } from '../settings-file-path.mjs';
8
8
  import { getAllEnvironments, setEnvironment } from './api/index.mjs';
9
9
  import type { EnvironmentSettings } from './constants.mjs';
10
10
  import { readEnvironmentFiles } from './read-environment-contents.mjs';
11
+ import { selectDeclaredEnvironments } from './select-declared-environments.mjs';
11
12
 
12
13
  /**
13
14
  * `repo-settings/environments/*.json` を Settings > Environments に反映する。
@@ -27,9 +28,14 @@ export const applyEnvironments = async (): Promise<void> => {
27
28
  await setEnvironment(environment);
28
29
  }
29
30
 
30
- // 反映後の実際の値でローカルのファイルを更新する
31
+ // 反映後の実際の値で、宣言したファイルだけを更新する。 GitHub が値を
32
+ // 正規化したり受け付けなかったりしたものが diff に出る。宣言に無い環境まで
33
+ // 書くと、 drift 検査が報せるはずのものが宣言として取り込まれてしまう。
31
34
  {
32
- const applied = await getAllEnvironments();
35
+ const applied = selectDeclaredEnvironments(
36
+ await getAllEnvironments(),
37
+ environments,
38
+ );
33
39
 
34
40
  for (const environment of applied) {
35
41
  const str = JSON.stringify(environment, undefined, 2);
@@ -3,3 +3,4 @@ export * from './apply.mjs';
3
3
  export * from './backup.mjs';
4
4
  export * from './constants.mjs';
5
5
  export * from './read-environment-contents.mjs';
6
+ export * from './select-declared-environments.mjs';
@@ -1,30 +1,28 @@
1
- import type { Dirent } from 'node:fs';
2
1
  import * as fs from 'node:fs/promises';
3
- import * as path from 'node:path';
4
2
  import * as t from 'ts-fortress';
5
3
  import { isDirectlyExecuted, Result } from 'ts-repo-utils';
6
4
  import { environmentsDir } from '../constants.mjs';
5
+ import { listJsonFilesIn } from '../list-json-files.mjs';
7
6
  import { EnvironmentSettings } from './constants.mjs';
8
7
 
9
8
  export const readEnvironmentFiles = async (): Promise<
10
9
  readonly EnvironmentSettings[]
11
- > => readFilesIn(environmentsDir);
10
+ > => readEnvironmentFilesIn(environmentsDir);
12
11
 
13
- const readFilesIn = async (
12
+ /**
13
+ * `dir` の直下の宣言(`*.json`)を読んで検証する。テストが一時ディレクトリを
14
+ * 渡せるように、ディレクトリを引数に取る。
15
+ */
16
+ export const readEnvironmentFilesIn = async (
14
17
  dir: string,
15
18
  ): Promise<readonly EnvironmentSettings[]> => {
16
- // eslint-disable-next-line security/detect-non-literal-fs-filename
17
- const files: readonly Dirent<string>[] = await fs.readdir(dir, {
18
- withFileTypes: true,
19
- });
19
+ const files = await listJsonFilesIn(dir);
20
20
 
21
21
  const fileContents: readonly string[] = await Promise.all(
22
- files
23
- .filter((d) => d.isFile())
24
- .map((d) =>
25
- // eslint-disable-next-line security/detect-non-literal-fs-filename
26
- fs.readFile(path.resolve(dir, d.name), { encoding: 'utf8' }),
27
- ),
22
+ files.map((file) =>
23
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
24
+ fs.readFile(file, { encoding: 'utf8' }),
25
+ ),
28
26
  );
29
27
 
30
28
  const validationResults = fileContents.map((content) =>
@@ -0,0 +1,31 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { readEnvironmentFilesIn } from './read-environment-contents.mjs';
5
+
6
+ // This repository's own declarations, as a known-valid fixture.
7
+ const environmentsDir = path.resolve(
8
+ import.meta.dirname,
9
+ '../../../../../repo-settings/environments',
10
+ );
11
+
12
+ describe(readEnvironmentFilesIn, () => {
13
+ test('reads the declarations and ignores a README beside them', async () => {
14
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'read-environments-'));
15
+
16
+ const declarations = await fs
17
+ .readdir(environmentsDir)
18
+ .then((names) => names.filter((n) => n.endsWith('.json')));
19
+
20
+ for (const name of declarations) {
21
+ await fs.copyFile(path.join(environmentsDir, name), path.join(dir, name));
22
+ }
23
+
24
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
25
+ await fs.writeFile(path.join(dir, 'README.md'), '# notes');
26
+
27
+ const values = await readEnvironmentFilesIn(dir);
28
+
29
+ expect(values).toHaveLength(declarations.length);
30
+ });
31
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The live environments `apply` may write back: those whose name a declaration
3
+ * file names. One that exists only on GitHub (made in the UI, or created by
4
+ * GitHub when a workflow names it) is left to the drift check rather than
5
+ * adopted as a declaration.
6
+ */
7
+ export const selectDeclaredEnvironments = <
8
+ const E extends Readonly<{ name: string }>,
9
+ >(
10
+ live: readonly E[],
11
+ declared: readonly Readonly<{ name: string }>[],
12
+ ): readonly E[] => {
13
+ const declaredNames: ReadonlySet<string> = new Set(
14
+ declared.map((e) => e.name),
15
+ );
16
+
17
+ return live.filter((e) => declaredNames.has(e.name));
18
+ };
@@ -0,0 +1,28 @@
1
+ import { selectDeclaredEnvironments } from './select-declared-environments.mjs';
2
+
3
+ describe(selectDeclaredEnvironments, () => {
4
+ test('keeps the live environments that are declared, by name', () => {
5
+ const live = [
6
+ { name: 'npm-publish', wait_timer: 0 },
7
+ { name: 'made-in-the-ui', wait_timer: 0 },
8
+ { name: 'github-pages', wait_timer: 5 },
9
+ ] as const;
10
+
11
+ const declared = [
12
+ { name: 'github-pages' },
13
+ { name: 'npm-publish' },
14
+ ] as const;
15
+
16
+ assert.deepStrictEqual(selectDeclaredEnvironments(live, declared), [
17
+ { name: 'npm-publish', wait_timer: 0 },
18
+ { name: 'github-pages', wait_timer: 5 },
19
+ ]);
20
+ });
21
+
22
+ test('writes nothing back when nothing is declared', () => {
23
+ assert.deepStrictEqual(
24
+ selectDeclaredEnvironments([{ name: 'made-in-the-ui' }], []),
25
+ [],
26
+ );
27
+ });
28
+ });
@@ -2,6 +2,7 @@ export * from './actions-settings/index.mjs';
2
2
  export * from './clear-json-files.mjs';
3
3
  export * from './constants.mjs';
4
4
  export * from './environments/index.mjs';
5
+ export * from './list-json-files.mjs';
5
6
  export * from './pages/index.mjs';
6
7
  export * from './repository/index.mjs';
7
8
  export * from './resolve-target-repo.mjs';
@@ -0,0 +1,22 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+
4
+ /**
5
+ * `dir` の直下にある `*.json` の絶対パス。宣言のディレクトリを読むところと
6
+ * 消すところは、どちらもこれで宣言のファイルを選ぶ。
7
+ *
8
+ * 拡張子で絞るのは、宣言の隣に置く README(`repo-settings/variables/README.md`
9
+ * のような、JSON でない同居ファイル)を宣言として読んだり消したりしないため。
10
+ * 読む側が絞らないと、README を置いただけで `JSON.parse` が投げ、apply が何も
11
+ * 送らずに止まる。
12
+ */
13
+ export const listJsonFilesIn = async (
14
+ dir: string,
15
+ ): Promise<readonly string[]> => {
16
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
17
+ const entries = await fs.readdir(dir, { withFileTypes: true });
18
+
19
+ return entries
20
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
21
+ .map((entry) => path.resolve(dir, entry.name));
22
+ };
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { listJsonFilesIn } from './list-json-files.mjs';
5
+
6
+ const makeDir = async (): Promise<string> =>
7
+ fs.mkdtemp(path.join(os.tmpdir(), 'list-json-files-'));
8
+
9
+ /** 途中のディレクトリごと作って書く。入れ子の fixture もこれで用意する。 */
10
+ const write = async (filePath: string, content: string): Promise<void> => {
11
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
12
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
13
+
14
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
15
+ await fs.writeFile(filePath, content);
16
+ };
17
+
18
+ describe(listJsonFilesIn, () => {
19
+ test('lists the json files directly under the directory, and nothing beside them', async () => {
20
+ const dir = await makeDir();
21
+
22
+ await write(path.join(dir, 'main.json'), '{}');
23
+
24
+ await write(path.join(dir, 'release.json'), '{}');
25
+
26
+ await write(path.join(dir, 'README.md'), '# notes');
27
+
28
+ await write(path.join(dir, 'nested', 'deep.json'), '{}');
29
+
30
+ const files = await listJsonFilesIn(dir);
31
+
32
+ assert.deepStrictEqual(files.toSorted(), [
33
+ path.join(dir, 'main.json'),
34
+ path.join(dir, 'release.json'),
35
+ ]);
36
+ });
37
+
38
+ test('is empty for a directory holding no json', async () => {
39
+ const dir = await makeDir();
40
+
41
+ await write(path.join(dir, 'README.md'), '# notes');
42
+
43
+ assert.deepStrictEqual(await listJsonFilesIn(dir), []);
44
+ });
45
+ });
@@ -10,6 +10,53 @@ export type TargetRepo = Readonly<{
10
10
  repo: string;
11
11
  }>;
12
12
 
13
+ /**
14
+ * 操作対象の repository を決める。
15
+ *
16
+ * 優先順位は以下の通り。
17
+ *
18
+ * 1. コマンドライン引数 `--owner` / `--repo`
19
+ * 2. 環境変数 `OWNER` / `REPO_NAME`
20
+ * 3. `git remote get-url origin`
21
+ * 4. `package.json` の `name` ( repo のみ )
22
+ */
23
+ export const resolveTargetRepo = async (): Promise<TargetRepo> => {
24
+ const sources: readonly Partial<TargetRepo>[] = [
25
+ fromCliArgs(),
26
+ fromEnv(),
27
+ fromGitRemote(),
28
+ await fromPackageJson(),
29
+ ] as const;
30
+
31
+ const owner = sources.map((s) => s.owner).find((v) => v !== undefined);
32
+
33
+ if (owner === undefined) {
34
+ throw new Error(
35
+ [
36
+ 'owner を特定できませんでした。次のいずれかで指定してください:',
37
+ ' --owner <owner> (コマンドライン引数)',
38
+ ' OWNER=<owner> (環境変数)',
39
+ ' git remote add origin <URL> ( origin の URL から推測されます)',
40
+ ].join('\n'),
41
+ );
42
+ }
43
+
44
+ const repo = sources.map((s) => s.repo).find((v) => v !== undefined);
45
+
46
+ if (repo === undefined) {
47
+ throw new Error(
48
+ [
49
+ 'repository 名を特定できませんでした。次のいずれかで指定してください:',
50
+ ' --repo <repo> (コマンドライン引数)',
51
+ ' REPO_NAME=<repo> (環境変数)',
52
+ ' git remote add origin <URL> ( origin の URL から推測されます)',
53
+ ].join('\n'),
54
+ );
55
+ }
56
+
57
+ return { owner, repo };
58
+ };
59
+
13
60
  /** `strict: false` の解析結果は string | boolean | 配列 になりうるため絞り込む。 */
14
61
  const pickString = (value: unknown): string | undefined =>
15
62
  value !== '' && isString(value) ? value : undefined;
@@ -95,50 +142,3 @@ const fromPackageJson = async (): Promise<Partial<TargetRepo>> => {
95
142
  return {};
96
143
  }
97
144
  };
98
-
99
- /**
100
- * 操作対象の repository を決める。
101
- *
102
- * 優先順位は以下の通り。
103
- *
104
- * 1. コマンドライン引数 `--owner` / `--repo`
105
- * 2. 環境変数 `OWNER` / `REPO_NAME`
106
- * 3. `git remote get-url origin`
107
- * 4. `package.json` の `name` ( repo のみ )
108
- */
109
- export const resolveTargetRepo = async (): Promise<TargetRepo> => {
110
- const sources: readonly Partial<TargetRepo>[] = [
111
- fromCliArgs(),
112
- fromEnv(),
113
- fromGitRemote(),
114
- await fromPackageJson(),
115
- ] as const;
116
-
117
- const owner = sources.map((s) => s.owner).find((v) => v !== undefined);
118
-
119
- if (owner === undefined) {
120
- throw new Error(
121
- [
122
- 'owner を特定できませんでした。次のいずれかで指定してください:',
123
- ' --owner <owner> (コマンドライン引数)',
124
- ' OWNER=<owner> (環境変数)',
125
- ' git remote add origin <URL> ( origin の URL から推測されます)',
126
- ].join('\n'),
127
- );
128
- }
129
-
130
- const repo = sources.map((s) => s.repo).find((v) => v !== undefined);
131
-
132
- if (repo === undefined) {
133
- throw new Error(
134
- [
135
- 'repository 名を特定できませんでした。次のいずれかで指定してください:',
136
- ' --repo <repo> (コマンドライン引数)',
137
- ' REPO_NAME=<repo> (環境変数)',
138
- ' git remote add origin <URL> ( origin の URL から推測されます)',
139
- ].join('\n'),
140
- );
141
- }
142
-
143
- return { owner, repo };
144
- };
@@ -11,9 +11,9 @@ export const createRuleset = async ({
11
11
  payload: { name, target, enforcement, bypass_actors, conditions, rules },
12
12
  }: Readonly<{
13
13
  payload: CreateRulesetRequest;
14
- }>): Promise<void> => {
14
+ }>): Promise<number> => {
15
15
  // https://docs.github.com/ja/rest/repos/rules?apiVersion=2022-11-28#create-a-repository-ruleset
16
- await octokit.request(
16
+ const { data } = await octokit.request(
17
17
  'POST /repos/{owner}/{repo}/rulesets' satisfies EndpointKeys,
18
18
  // transformer-ignore-next-line convert-to-readonly
19
19
  {
@@ -32,4 +32,7 @@ export const createRuleset = async ({
32
32
  ) as components['schemas']['repository-rule'][],
33
33
  },
34
34
  );
35
+
36
+ // 作られた ruleset の id。 apply の書き戻しは、送った ruleset をこれで特定する。
37
+ return data.id;
35
38
  };
@@ -42,8 +42,10 @@ export const applyRulesets = async (): Promise<void> => {
42
42
  });
43
43
  }
44
44
 
45
+ const mut_createdIds: number[] = [];
46
+
45
47
  for (const ruleset of rulesetsToCreate) {
46
- await createRuleset({
48
+ const createdId = await createRuleset({
47
49
  payload: {
48
50
  bypass_actors: ruleset.bypass_actors,
49
51
  conditions: ruleset.conditions ?? undefined,
@@ -53,15 +55,28 @@ export const applyRulesets = async (): Promise<void> => {
53
55
  target: ruleset.target === 'repository' ? undefined : ruleset.target,
54
56
  },
55
57
  });
58
+
59
+ mut_createdIds.push(createdId);
56
60
  }
57
61
 
58
- // update local ruleset files
59
- {
60
- const rulesetsResult = await getAllRulesets();
62
+ // 書き戻すのは、いま送った ruleset だけ — 更新したものは宣言の id で、
63
+ // 作ったものは GitHub が付けた id で特定する。宣言に無い ruleset(GUI で
64
+ // 作られたもの、 organization の ruleset)まで書くと、 drift 検査が報せる
65
+ // はずのものが宣言として取り込まれてしまう。名前では特定しない。親の
66
+ // ruleset が同じ名前を持てるので、別物が同じファイルに書かれうる。
67
+ const appliedIds: ReadonlySet<number> = new Set<number>([
68
+ ...rulesetsToUpdate.map((r) => r.id),
69
+ ...mut_createdIds,
70
+ ]);
61
71
 
62
- for (const rule of rulesetsResult) {
63
- const content = await getRuleset(rule.id);
72
+ // 反映後の実際の値で、送った ruleset のファイルを更新する。 GitHub が値を
73
+ // 正規化したり受け付けなかったりしたものが diff に出る。一覧は読み直さず
74
+ // id ごとに取る(一覧はページ分割され、親の ruleset も含む)。全部取って
75
+ // から書くので、途中で取得に失敗しても一部だけ書き換わることはない。
76
+ {
77
+ const contents = await Promise.all(Array.from(appliedIds, getRuleset));
64
78
 
79
+ for (const content of contents) {
65
80
  const str = JSON.stringify(
66
81
  Obj.pick(content, rulesetKeysToPick),
67
82
  undefined,
@@ -70,7 +85,7 @@ export const applyRulesets = async (): Promise<void> => {
70
85
 
71
86
  // `settingsFilePath` が `rulesetsDir` の直下であることを確かめている。
72
87
  // eslint-disable-next-line security/detect-non-literal-fs-filename
73
- await fs.writeFile(settingsFilePath(rulesetsDir, rule.name), str);
88
+ await fs.writeFile(settingsFilePath(rulesetsDir, content.name), str);
74
89
  }
75
90
 
76
91
  await formatUncommittedFiles();
@@ -1,29 +1,27 @@
1
- import type { Dirent } from 'node:fs';
2
1
  import * as fs from 'node:fs/promises';
3
- import * as path from 'node:path';
4
2
  import * as t from 'ts-fortress';
5
3
  import { isDirectlyExecuted, Result } from 'ts-repo-utils';
6
4
  import { rulesetsDir } from '../constants.mjs';
5
+ import { listJsonFilesIn } from '../list-json-files.mjs';
7
6
  import { RulesetPicked } from './constants.mjs';
8
7
 
9
8
  export const readRulesetFiles = async (): Promise<readonly RulesetPicked[]> =>
10
- readFilesIn(rulesetsDir);
9
+ readRulesetFilesIn(rulesetsDir);
11
10
 
12
- const readFilesIn = async (dir: string): Promise<readonly RulesetPicked[]> => {
13
- // eslint-disable-next-line security/detect-non-literal-fs-filename
14
- const files: readonly Dirent<string>[] = await fs.readdir(dir, {
15
- withFileTypes: true,
16
- });
11
+ /**
12
+ * `dir` の直下の宣言(`*.json`)を読んで検証する。テストが一時ディレクトリを
13
+ * 渡せるように、ディレクトリを引数に取る。
14
+ */
15
+ export const readRulesetFilesIn = async (
16
+ dir: string,
17
+ ): Promise<readonly RulesetPicked[]> => {
18
+ const files = await listJsonFilesIn(dir);
17
19
 
18
20
  const rulesetFileContents: readonly string[] = await Promise.all(
19
- files
20
- .filter((d) => d.isFile())
21
- .map((d) =>
22
- // eslint-disable-next-line security/detect-non-literal-fs-filename
23
- fs.readFile(path.resolve(dir, d.name), {
24
- encoding: 'utf8',
25
- }),
26
- ),
21
+ files.map((file) =>
22
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
23
+ fs.readFile(file, { encoding: 'utf8' }),
24
+ ),
27
25
  );
28
26
 
29
27
  const validationResults = rulesetFileContents.map((rule) =>
@@ -0,0 +1,31 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { readRulesetFilesIn } from './read-rule-set-contents.mjs';
5
+
6
+ // This repository's own declarations, as a known-valid fixture.
7
+ const rulesetsDir = path.resolve(
8
+ import.meta.dirname,
9
+ '../../../../../repo-settings/rulesets',
10
+ );
11
+
12
+ describe(readRulesetFilesIn, () => {
13
+ test('reads the declarations and ignores a README beside them', async () => {
14
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'read-rulesets-'));
15
+
16
+ const declarations = await fs
17
+ .readdir(rulesetsDir)
18
+ .then((names) => names.filter((n) => n.endsWith('.json')));
19
+
20
+ for (const name of declarations) {
21
+ await fs.copyFile(path.join(rulesetsDir, name), path.join(dir, name));
22
+ }
23
+
24
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
25
+ await fs.writeFile(path.join(dir, 'README.md'), '# notes');
26
+
27
+ const values = await readRulesetFilesIn(dir);
28
+
29
+ expect(values).toHaveLength(declarations.length);
30
+ });
31
+ });