Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions examples/governance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// The same policy as governance.yml, as data in TypeScript. The type gives
// completion and a compile error on a misspelt key; the helper is the part
// YAML cannot express without anchors. By default warden folds this file to
// its value without running it (--config-mode fold); it is typed JSON.
import type { GovernanceConfig } from "@intentius/forgejo-warden";

const protectedMain = {
ruleName: "main",
requiredApprovals: 1,
enableStatusCheck: true,
statusCheckContexts: ["ci"],
dismissStaleApprovals: true,
};

const service = (name: string) => ({
hasWiki: false,
hasPullRequests: true,
allowSquashMerge: true,
topics: ["service", name],
branchProtection: [protectedMain],
});

export default {
orgs: {
"my-org": {
settings: {
description: "Engineering",
visibility: "limited",
},
repos: {
api: service("api"),
web: service("web"),
},
},
},
} satisfies GovernanceConfig;
36 changes: 36 additions & 0 deletions examples/governance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# The same policy as governance.ts, in YAML. Either file loads to the same
# object; src/config/load.test.ts asserts it.
orgs:
my-org:
settings:
description: Engineering
visibility: limited
repos:
api:
hasWiki: false
hasPullRequests: true
allowSquashMerge: true
topics:
- service
- api
branchProtection:
- ruleName: main
requiredApprovals: 1
enableStatusCheck: true
statusCheckContexts:
- ci
dismissStaleApprovals: true
web:
hasWiki: false
hasPullRequests: true
allowSquashMerge: true
topics:
- service
- web
branchProtection:
- ruleName: main
requiredApprovals: 1
enableStatusCheck: true
statusCheckContexts:
- ci
dismissStaleApprovals: true
11 changes: 11 additions & 0 deletions examples/not-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// A policy that is code, not data: it reads the environment. Folding refuses
// it with the line; running it accepts whatever the environment said.
export default {
orgs: {
"my-org": {
repos: {
api: { hasWiki: process.env.WIKI === "1" },
},
},
},
};
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
},
"dependencies": {
"@intentius/chant": "^0.71.1",
"yaml": "^2.9.0"
"yaml": "^2.9.0",
"@intentius/tsad-reference": "^1.4.0"
},
"devDependencies": {
"@intentius/chant-lexicon-github": "^0.71.1",
Expand Down
24 changes: 12 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
* 3 runtime error.
*/

import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { parse as parseYaml } from "yaml";
import { loadGovernanceConfig, type ConfigMode } from "./config/load.js";
import { createClient } from "./auth/client.js";
import { runReconcile, type Cycle } from "./reconcile/runner.js";
import { CYCLE_REGISTRY } from "./cli/registry.js";
Expand All @@ -37,6 +36,8 @@ export class CliError extends Error {

export interface ReconcileArgs {
config: string;
/** How a `.ts` policy is evaluated: folded without running (default), imported, or both and compared. */
configMode: ConfigMode;
mode: "dry-run" | "apply";
cycles: string[];
baseUrl: string | undefined;
Expand All @@ -49,6 +50,7 @@ export interface ReconcileArgs {

const KNOWN_FLAGS = new Set([
"--config",
"--config-mode",
"--mode",
"--cycles",
"--base-url",
Expand All @@ -62,6 +64,7 @@ const KNOWN_FLAGS = new Set([
export function parseReconcileArgs(argv: string[]): ReconcileArgs {
const args: ReconcileArgs = {
config: "",
configMode: "fold",
mode: "dry-run",
cycles: [],
baseUrl: undefined,
Expand All @@ -86,6 +89,12 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs {
case "--config":
args.config = need(++i, flag);
break;
case "--config-mode": {
const v = argv[++i];
if (v !== "fold" && v !== "run" && v !== "check") throw new CliError(2, `--config-mode must be "fold", "run" or "check", got: ${v ?? "(missing)"}`);
args.configMode = v;
break;
}
case "--mode": {
const v = argv[++i];
if (v !== "dry-run" && v !== "apply") throw new CliError(2, `--mode must be "dry-run" or "apply", got: ${v ?? "(missing)"}`);
Expand Down Expand Up @@ -146,15 +155,6 @@ function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

function loadConfig(path: string): GovernanceConfig {
const text = readFileSync(path, "utf-8");
const raw = path.toLowerCase().endsWith(".json") ? JSON.parse(text) : parseYaml(text);
if (!raw || typeof raw !== "object" || typeof (raw as { orgs?: unknown }).orgs !== "object") {
throw new Error("config must be an object with an `orgs` map");
}
return raw as GovernanceConfig;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
Expand All @@ -170,7 +170,7 @@ async function runReconcileCommand(argv: string[]): Promise<void> {

let config: GovernanceConfig;
try {
config = loadConfig(args.config);
config = await loadGovernanceConfig(args.config, args.configMode);
} catch (err) {
die(2, `invalid governance config "${args.config}": ${errMsg(err)}`);
}
Expand Down
45 changes: 45 additions & 0 deletions src/config/load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { resolve } from "node:path";
import { loadGovernanceConfig, GovernanceConfigError } from "./load.js";

const ex = (name: string) => resolve(import.meta.dirname, "..", "..", "examples", name);

describe("loadGovernanceConfig", () => {
it("a .ts policy folded, a .ts policy run, and the .yml load to the same object", async () => {
const yml = await loadGovernanceConfig(ex("governance.yml"));
const folded = await loadGovernanceConfig(ex("governance.ts"), "fold");
const ran = await loadGovernanceConfig(ex("governance.ts"), "run");
expect(folded).toEqual(yml);
expect(ran).toEqual(yml);
expect(folded.orgs["my-org"].repos?.api.branchProtection?.[0].ruleName).toBe("main");
});

it("check mode passes when folding and running agree", async () => {
await expect(loadGovernanceConfig(ex("governance.ts"), "check")).resolves.toBeTruthy();
});

it("a policy that reads the environment is refused by fold, with the line, and accepted by run", async () => {
await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(GovernanceConfigError);
await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(/not data/);
await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(/\d+:\d+/);
const ran = await loadGovernanceConfig(ex("not-data.ts"), "run");
expect(ran.orgs["my-org"].repos?.api.hasWiki).toBe(false);
});

it("an undefined property is absent, the way JSON leaves it, so selective-by-omission holds", async () => {
const dir = resolve(import.meta.dirname, "..", "..", "examples");
const { writeFileSync, rmSync } = await import("node:fs");
const p = resolve(dir, "tmp-undefined.ts");
writeFileSync(p, 'const on = false;\nexport default { orgs: { o: { repos: { r: { hasWiki: on ? true : undefined } } } } };\n');
try {
const folded = await loadGovernanceConfig(p, "fold");
expect("hasWiki" in (folded.orgs.o.repos?.r ?? {})).toBe(false);
} finally {
rmSync(p);
}
});

it("refuses a config with no orgs map", async () => {
await expect(loadGovernanceConfig(ex("governance.yml").replace("governance.yml", "../package.json"))).rejects.toThrow(/orgs/);
});
});
111 changes: 111 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* The governance policy, loaded from YAML, JSON, or TypeScript.
*
* A `.ts` policy is data: an object literal typed by `GovernanceConfig`,
* exported as `default`. By default it is *folded*, reduced
* to its value by `@intentius/tsad-reference` without being run, so the plan
* is a function of the file and nothing else and no code executes to produce
* it. `run` mode imports the file instead, for a user who wants typed JSON and
* does not care how it was evaluated; `check` mode does both and refuses if
* they differ, which is the guarantee folding provides made visible.
*
* Selective-by-omission survives either way: an `undefined`-valued property
* is treated as absent, the same as JSON would leave it.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parse as parseYaml } from "yaml";
import type { GovernanceConfig } from "./types.js";

export type ConfigMode = "fold" | "run" | "check";

export class GovernanceConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "GovernanceConfigError";
}
}

/** Every `.ts` file under the policy's directory, keyed relative to it, so the policy may import siblings. */
function projectFiles(root: string): Map<string, string> {
const files = new Map<string, string>();
const walk = (dir: string): void => {
for (const name of readdirSync(dir)) {
if (name === "node_modules" || name.startsWith(".")) continue;
const abs = join(dir, name);
if (statSync(abs).isDirectory()) walk(abs);
else if (name.endsWith(".ts") && !name.endsWith(".d.ts") && !name.endsWith(".test.ts")) {
files.set(relative(root, abs).split("\\").join("/"), readFileSync(abs, "utf-8"));
}
}
};
walk(root);
return files;
}

function policyOf(exports: Record<string, unknown>, where: string): unknown {
// `export default` is the idiom and folds under the data-host profile
// (spec 1.2, S-ExportDefault); `export const policy` is accepted too.
if ("default" in exports) return exports.default;
if ("policy" in exports) return exports.policy;
throw new GovernanceConfigError(`${where} must export the policy as \`export default\``);
}

/** Fold the policy with the reference evaluator: no execution, a located refusal if the file is not data. */
async function foldPolicy(path: string): Promise<unknown> {
// The data-host profile (spec 1.2, F-Profile-DataHost): no runtime, and a
// default export is the declarator named `default`, which is the idiom.
const { foldProject, EMPTY_HOST } = await import("@intentius/tsad-reference");
const root = dirname(resolve(path));
const key = relative(root, resolve(path)).split("\\").join("/");
const verdicts = foldProject(projectFiles(root), { ...EMPTY_HOST, profile: "data-host" }).verdicts;
const verdict = verdicts.get(key);
if (!verdict) throw new GovernanceConfigError(`${path}: not found among the project's files`);
if (verdict.kind === "run") {
throw new GovernanceConfigError(`${path} is not data (${verdict.rule}): ${verdict.reason}`);
}
return policyOf(Object.fromEntries(verdict.exports), path);
}

/** Import the policy: whatever the file does, its export is the policy. */
async function runPolicy(path: string): Promise<unknown> {
const mod = (await import(pathToFileURL(resolve(path)).href)) as Record<string, unknown>;
return policyOf(mod, path);
}

/** JSON's view of a value: `undefined` properties absent, keys sorted, so two loads compare as the policy the cycles will read. */
function canonical(value: unknown): string {
const sort = (v: unknown): unknown => {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map(sort);
return Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, sort((v as Record<string, unknown>)[k])]));
};
return JSON.stringify(sort(JSON.parse(JSON.stringify(value))));
}

function assertShape(raw: unknown, path: string): GovernanceConfig {
if (!raw || typeof raw !== "object" || typeof (raw as { orgs?: unknown }).orgs !== "object") {
throw new GovernanceConfigError(`${path}: config must be an object with an \`orgs\` map`);
}
// The cycles read through JSON's view of the policy, where an undefined
// property is absent. Normalise once here so fold and run agree by construction.
return JSON.parse(JSON.stringify(raw)) as GovernanceConfig;
}

export async function loadGovernanceConfig(path: string, mode: ConfigMode = "fold"): Promise<GovernanceConfig> {
const lower = path.toLowerCase();
if (lower.endsWith(".ts")) {
if (mode === "run") return assertShape(await runPolicy(path), path);
const folded = await foldPolicy(path);
if (mode === "check") {
const ran = await runPolicy(path);
if (canonical(folded) !== canonical(ran)) {
throw new GovernanceConfigError(`${path}: folding and running the policy disagree, so the file is not data; the run result is ${canonical(ran)} and the fold is ${canonical(folded)}`);
}
}
return assertShape(folded, path);
}
const text = readFileSync(path, "utf-8");
return assertShape(lower.endsWith(".json") ? JSON.parse(text) : parseYaml(text), path);
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@
// Forgejo REST client
export { createClient, ForgejoApiError } from "./auth/client.js";
export type { ForgejoClient, ForgejoClientOptions } from "./auth/client.js";

// The governance policy: its types, for `satisfies GovernanceConfig` in a
// `.ts` policy, and the loader that folds, runs, or checks one.
export type * from "./config/types.js";
export { loadGovernanceConfig, GovernanceConfigError } from "./config/load.js";
export type { ConfigMode } from "./config/load.js";
18 changes: 18 additions & 0 deletions tsconfig.types.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"rootDir": "src"
},
"include": [
"src/index.ts"
],
"exclude": [
"node_modules",
"dist",
"src/**/*.test.ts"
]
}
Loading