reposets 0.4.2 → 1.0.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.
Files changed (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. package/services/SyncEngine.js +0 -580
@@ -0,0 +1,149 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import { readFileSync } from "node:fs";
3
+ import { isAbsolute, resolve } from "node:path";
4
+
5
+ //#region src/sync/phases/resource.ts
6
+ /**
7
+ * Resolve one secret or variable group into named values.
8
+ *
9
+ * @remarks
10
+ * The three kinds are exclusive by schema, so this branches once rather than
11
+ * merging. Values come back {@link Redacted.Redacted} regardless of kind — a
12
+ * variable is not secret, but a uniform return type means no call site has to
13
+ * decide, and unwrapping stays a deliberate `Redacted.value` at the write.
14
+ *
15
+ * `value` entries may be a string or arbitrary JSON. A string is used as-is and
16
+ * anything else is `JSON.stringify`d, which is what v3 did and what the config
17
+ * schema's `string | Json` implies — GitHub stores a string either way.
18
+ *
19
+ * `file` paths resolve against `ctx.configDir` — the directory the config was
20
+ * loaded from, threaded by the engine — never against the working directory. A
21
+ * relative path resolved against `cwd` would read a different file depending on
22
+ * where the CLI happened to be invoked, silently, and into a secret.
23
+ *
24
+ * A file that cannot be read is reported with its **path**, never its contents:
25
+ * this runs on the secret-loading path, and the error is the most likely thing
26
+ * to reach a log.
27
+ */
28
+ const resolveOne = (group, label, ctx) => {
29
+ const entries = /* @__PURE__ */ new Map();
30
+ const errors = [];
31
+ if ("value" in group) {
32
+ for (const [name, value] of Object.entries(group.value)) entries.set(name, Redacted.make(typeof value === "string" ? value : JSON.stringify(value), { label: name }));
33
+ return {
34
+ entries,
35
+ errors
36
+ };
37
+ }
38
+ if ("resolved" in group) {
39
+ for (const [name, credentialLabel] of Object.entries(group.resolved)) {
40
+ const value = ctx.credentials.get(credentialLabel);
41
+ if (value === void 0) {
42
+ errors.push({
43
+ context: `${label}.${name}`,
44
+ message: `credential label '${credentialLabel}' is not defined in the active profile's [resolve] section`
45
+ });
46
+ continue;
47
+ }
48
+ entries.set(name, value);
49
+ }
50
+ return {
51
+ entries,
52
+ errors
53
+ };
54
+ }
55
+ for (const [name, filePath] of Object.entries(group.file)) {
56
+ const fullPath = isAbsolute(filePath) ? filePath : resolve(ctx.configDir, filePath);
57
+ try {
58
+ entries.set(name, Redacted.make(readFileSync(fullPath, "utf-8").trim(), { label: name }));
59
+ } catch (error) {
60
+ errors.push({
61
+ context: `${label}.${name}`,
62
+ message: error instanceof Error ? error.message : String(error)
63
+ });
64
+ }
65
+ }
66
+ return {
67
+ entries,
68
+ errors
69
+ };
70
+ };
71
+ /**
72
+ * Resolve every group a scope references, later groups winning.
73
+ *
74
+ * @remarks
75
+ * Last-write-wins across groups matches how every other referenced section
76
+ * composes. A reference with no matching definition is skipped rather than
77
+ * reported — `danglingReferences` owns cross-reference checking, and duplicating
78
+ * it here would report one mistake twice with less context.
79
+ *
80
+ * @public
81
+ */
82
+ const resolveGroups = (refs, definitions, ctx) => {
83
+ const entries = /* @__PURE__ */ new Map();
84
+ const errors = [];
85
+ for (const ref of refs) {
86
+ const group = definitions[ref];
87
+ if (group === void 0) continue;
88
+ const outcome = resolveOne(group, ref, ctx);
89
+ for (const [name, value] of outcome.entries) entries.set(name, value);
90
+ errors.push(...outcome.errors);
91
+ }
92
+ return {
93
+ entries,
94
+ errors
95
+ };
96
+ };
97
+ /**
98
+ * The names a set of groups declares, without resolving any value.
99
+ *
100
+ * @remarks
101
+ * `cleanup` needs to know *which* names a config declares so it can delete the
102
+ * rest. It has no use for the values, and reading them would be actively wrong:
103
+ * a `{ file }` group would open every secret file — on the path whose whole job
104
+ * is deletion — and a missing file would report an error about a resource
105
+ * cleanup was only ever going to keep.
106
+ *
107
+ * All three kinds carry their names as keys, so the names are available without
108
+ * touching a filesystem or a credential.
109
+ *
110
+ * @public
111
+ */
112
+ const declaredNames = (refs, definitions) => {
113
+ const names = /* @__PURE__ */ new Set();
114
+ for (const ref of refs) {
115
+ const group = definitions[ref];
116
+ if (group === void 0) continue;
117
+ const entries = "value" in group ? group.value : "resolved" in group ? group.resolved : group.file;
118
+ for (const name of Object.keys(entries)) names.add(name);
119
+ }
120
+ return names;
121
+ };
122
+ /**
123
+ * Read something, keeping the failure's own words if it fails.
124
+ *
125
+ * @remarks
126
+ * The alternative — `Effect.orElseSucceed(() => Option.none())` — discards a
127
+ * structured `GitHubError` and leaves the call site inventing a generic string.
128
+ * A real run showed why that matters: a 403 from a mis-scoped token, a 404 for
129
+ * a deleted repository and a network failure all printed "could not read
130
+ * current state", which tells a user nothing about what to do. `GitHubError`
131
+ * already carries `kind`, `status` and `reason`, and its `message` renders
132
+ * them; this keeps that instead of throwing it away.
133
+ *
134
+ * @public
135
+ */
136
+ const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed: error.message ?? String(error) })));
137
+ /**
138
+ * Wrap an operation so a failure becomes a reportable error rather than a
139
+ * failed run.
140
+ *
141
+ * @public
142
+ */
143
+ const capture = (context, effect) => effect.pipe(Effect.as(void 0), Effect.catch((error) => Effect.succeed({
144
+ context,
145
+ message: error.message ?? String(error)
146
+ })));
147
+
148
+ //#endregion
149
+ export { capture, declaredNames, read, resolveGroups };
@@ -0,0 +1,186 @@
1
+ import { buildRulesetPayload } from "../../schemas/ruleset.js";
2
+ import { SyncLogger } from "../../services/SyncLogger.js";
3
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
4
+ import { RepoCache } from "../../store/RepoCache.js";
5
+ import { capture, read } from "./resource.js";
6
+ import { fingerprint } from "../../lib/fingerprint.js";
7
+ import { decide, isDrift, needsApply } from "../decide.js";
8
+ import { Effect, Option, Redacted } from "effect";
9
+ import { Ruleset } from "@effected/github";
10
+
11
+ //#region src/sync/phases/rulesets.ts
12
+ const KIND = "ruleset";
13
+ /** Stands in for a ruleset that is not on the repository at all. */
14
+ const ABSENT = " absent";
15
+ /**
16
+ * Substitute `{ resolved }` references with credential values, in place.
17
+ *
18
+ * @remarks
19
+ * A ruleset can carry `{ resolved: "LABEL" }` wherever a numeric id is wanted —
20
+ * an integration id on a status check, a repository id on a required workflow.
21
+ * The substitution walks the decoded ruleset because the references can appear
22
+ * at any depth, and the shape is a discriminated union rather than a flat
23
+ * record.
24
+ *
25
+ * Values come from `ctx.credentials` and are numeric ids, not secrets. They are
26
+ * unwrapped here because they end up in the API payload as numbers; nothing
27
+ * secret should be reachable this way, and a config that puts one there has
28
+ * mis-declared it.
29
+ */
30
+ const substituteResolved = (node, ctx, missing) => {
31
+ if (Array.isArray(node)) return node.map((item) => substituteResolved(item, ctx, missing));
32
+ if (typeof node !== "object" || node === null) return node;
33
+ const record = node;
34
+ const label = record.resolved;
35
+ if (typeof label === "string" && Object.keys(record).length === 1) {
36
+ const value = ctx.credentials.get(label);
37
+ if (value === void 0) {
38
+ missing.add(label);
39
+ return node;
40
+ }
41
+ const text = Redacted.value(value);
42
+ const asNumber = Number(text);
43
+ return text.trim() === "" || Number.isNaN(asNumber) ? text : asNumber;
44
+ }
45
+ const out = {};
46
+ for (const [key, child] of Object.entries(record)) out[key] = substituteResolved(child, ctx, missing);
47
+ return out;
48
+ };
49
+ /**
50
+ * Repository rulesets.
51
+ *
52
+ * @remarks
53
+ * Drift here is **presence-level**, not value-level, and the reason is worth
54
+ * stating because it differs from both neighbours. `GET /rulesets` returns
55
+ * *summaries* — name, id, source type — not the rules themselves; the full
56
+ * configuration needs `GET /rulesets/{id}`, one request per ruleset per
57
+ * repository. So a deleted ruleset is detected and restored, and one edited in
58
+ * the UI is not.
59
+ *
60
+ * That is a cost decision rather than an API limit, unlike `secrets` where no
61
+ * value exists to read. Lifting it means a read per ruleset; worth doing if
62
+ * ruleset drift turns out to matter in practice, and cheap to change here since
63
+ * only `livePrint` would move.
64
+ *
65
+ * **A renamed ruleset creates a second one rather than renaming the first.**
66
+ * GitHub identifies a ruleset by a numeric id assigned at creation, and the
67
+ * config has only a name — so `syncRuleset` matches on name and cannot tell a
68
+ * rename from a new ruleset. That is v3's behaviour, ported deliberately; the
69
+ * `cleanup` phase is what removes the orphan. Fixing it properly needs the
70
+ * applied-state table to remember the id, which is a design change rather than
71
+ * a port.
72
+ *
73
+ * @public
74
+ */
75
+ const rulesetsPhase = Effect.gen(function* () {
76
+ const rulesets = yield* Ruleset;
77
+ const cache = yield* RepoCache;
78
+ const applied = yield* AppliedState;
79
+ const logger = yield* SyncLogger;
80
+ /** Resolve `{ team }` bypass actors to numeric ids, through the cache. */
81
+ const resolveBypassActors = (ctx, ruleset) => Effect.gen(function* () {
82
+ const actors = ruleset.bypass_actors;
83
+ if (actors === void 0) return {
84
+ ruleset,
85
+ errors: []
86
+ };
87
+ const errors = [];
88
+ const resolved = [];
89
+ for (const actor of actors) {
90
+ const id = actor.actor_id;
91
+ if (typeof id === "object" && id !== null && "resolved" in id) {
92
+ const slug = String(id.resolved);
93
+ const outcome = yield* read(cache.teamId(ctx.owner, slug, rulesets.teamId(slug)));
94
+ if ("failed" in outcome) {
95
+ errors.push({
96
+ context: `resolve team '${slug}'`,
97
+ message: outcome.failed
98
+ });
99
+ continue;
100
+ }
101
+ resolved.push({
102
+ ...actor,
103
+ actor_id: outcome.value
104
+ });
105
+ continue;
106
+ }
107
+ resolved.push(actor);
108
+ }
109
+ return {
110
+ ruleset: {
111
+ ...ruleset,
112
+ bypass_actors: resolved
113
+ },
114
+ errors
115
+ };
116
+ });
117
+ const run = (ctx) => Effect.gen(function* () {
118
+ const refs = ctx.config.groups[ctx.group]?.rulesets ?? [];
119
+ if (refs.length === 0) return {
120
+ changes: [],
121
+ errors: []
122
+ };
123
+ const changes = [];
124
+ const errors = [];
125
+ const listing = yield* read(rulesets.list());
126
+ if ("failed" in listing) return {
127
+ changes,
128
+ errors: [{
129
+ context: "list rulesets",
130
+ message: listing.failed
131
+ }]
132
+ };
133
+ const live = new Map(listing.value.filter((entry) => entry.source_type !== "Organization").map((entry) => [entry.name, entry]));
134
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
135
+ for (const ref of refs) {
136
+ const configured = ctx.config.rulesets[ref];
137
+ if (configured === void 0) continue;
138
+ const missing = /* @__PURE__ */ new Set();
139
+ const substituted = substituteResolved(configured, ctx, missing);
140
+ for (const label of missing) errors.push({
141
+ context: `ruleset ${configured.name}`,
142
+ message: `credential label '${label}' is not defined in the active profile's [resolve] section`
143
+ });
144
+ const withActors = yield* resolveBypassActors(ctx, substituted);
145
+ errors.push(...withActors.errors);
146
+ const payload = buildRulesetPayload(withActors.ruleset);
147
+ const desiredPrint = fingerprint(payload);
148
+ const baseline = Option.map(lookup(baselines, KIND, payload.name), (record) => record.fingerprint);
149
+ const livePrint = live.has(payload.name) ? Option.getOrElse(baseline, () => fingerprint(ABSENT)) : fingerprint(ABSENT);
150
+ const decision = decide(desiredPrint, livePrint, baseline);
151
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, payload.name, decision);
152
+ if (!needsApply(decision)) continue;
153
+ if (!ctx.dryRun) {
154
+ const failure = yield* capture(`ruleset ${payload.name}`, rulesets.upsert(payload));
155
+ if (failure !== void 0) {
156
+ errors.push(failure);
157
+ continue;
158
+ }
159
+ yield* applied.record({
160
+ repo: ctx.slug,
161
+ kind: KIND,
162
+ name: payload.name
163
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
164
+ }
165
+ yield* logger.syncOperation("sync", KIND, payload.name);
166
+ changes.push({
167
+ repo: ctx.slug,
168
+ kind: KIND,
169
+ name: payload.name,
170
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
171
+ });
172
+ }
173
+ return {
174
+ changes,
175
+ errors
176
+ };
177
+ });
178
+ return {
179
+ name: "rulesets",
180
+ appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.rulesets?.length ?? 0) > 0,
181
+ run
182
+ };
183
+ });
184
+
185
+ //#endregion
186
+ export { rulesetsPhase };
@@ -0,0 +1,138 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
+ import { capture, resolveGroups } from "./resource.js";
4
+ import { fingerprint } from "../../lib/fingerprint.js";
5
+ import { decide, isDrift, needsApply } from "../decide.js";
6
+ import { Effect, Option, Redacted } from "effect";
7
+ import { RepositorySecret } from "@effected/github";
8
+
9
+ //#region src/sync/phases/secrets.ts
10
+ const KIND = "secret";
11
+ /** The three repository-level stores, each with its own keys and its own paths. */
12
+ const SCOPES = [
13
+ "actions",
14
+ "dependabot",
15
+ "codespaces"
16
+ ];
17
+ /**
18
+ * Stands in for a secret that is not on the repository at all.
19
+ *
20
+ * @remarks
21
+ * Absence needs a fingerprint distinct from every real value, so a secret
22
+ * someone deleted out of band compares unequal to what we last applied and gets
23
+ * restored rather than silently matching.
24
+ */
25
+ const ABSENT = "\0absent";
26
+ /** A scoped secret's drift key. Environment secrets are namespaced by environment. */
27
+ const resourceName = (scope, name) => `${scope}/${name}`;
28
+ /**
29
+ * Repository and environment secrets.
30
+ *
31
+ * @remarks
32
+ * **Drift here is presence-only, and unlike `variables` that is not a
33
+ * limitation we can lift.** GitHub never returns a secret's value from any
34
+ * endpoint — that is the point of a secret store — so a secret edited in the UI
35
+ * is indistinguishable from one we wrote ourselves. What is detectable is a
36
+ * secret that has been *deleted*: it vanishes from the listing, compares
37
+ * unequal to the baseline, and is restored.
38
+ *
39
+ * That makes the live comparison here deliberately different from the one in
40
+ * `variables`: a present secret is taken to match the baseline, because there
41
+ * is nothing else to compare it against. `variables` used to do the same thing
42
+ * and it was a bug there — the value was available and being discarded. It is
43
+ * not available here. **Do not "fix" this to match.**
44
+ *
45
+ * Values stay {@link Redacted.Redacted} the whole way through. `Redacted.value`
46
+ * appears exactly once per write, at the call that encrypts, and the sealed box
47
+ * is what crosses the wire — the plaintext never reaches a log line, an error,
48
+ * or the journal, which records only names.
49
+ *
50
+ * @public
51
+ */
52
+ const secretsPhase = Effect.gen(function* () {
53
+ const secrets = yield* RepositorySecret;
54
+ const applied = yield* AppliedState;
55
+ const logger = yield* SyncLogger;
56
+ const run = (ctx) => Effect.gen(function* () {
57
+ const scopes = ctx.config.groups[ctx.group]?.secrets;
58
+ if (scopes === void 0) return {
59
+ changes: [],
60
+ errors: []
61
+ };
62
+ const changes = [];
63
+ const errors = [];
64
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
65
+ /**
66
+ * Sync one store.
67
+ *
68
+ * @param environment - absent for a repository-level scope, present for
69
+ * an environment's own secret store.
70
+ */
71
+ const syncStore = (entries, scope, environment) => Effect.gen(function* () {
72
+ if (entries.size === 0) return;
73
+ const label = environment === void 0 ? scope : `env:${environment}`;
74
+ const live = yield* (environment === void 0 ? secrets.list(scope) : secrets.listForEnvironment(environment)).pipe(Effect.map((list) => Option.some(new Set(list.map((entry) => entry.name)))), Effect.orElseSucceed(() => Option.none()));
75
+ if (Option.isNone(live)) {
76
+ errors.push({
77
+ context: `list secrets (${label})`,
78
+ message: "could not read current secrets"
79
+ });
80
+ return;
81
+ }
82
+ for (const [name, value] of entries) {
83
+ const resource = resourceName(label, name);
84
+ const desiredPrint = fingerprint(Redacted.value(value));
85
+ const baseline = Option.map(lookup(baselines, KIND, resource), (record) => record.fingerprint);
86
+ const livePrint = live.value.has(name) ? Option.getOrElse(baseline, () => fingerprint(ABSENT)) : fingerprint(ABSENT);
87
+ const decision = decide(desiredPrint, livePrint, baseline);
88
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, resource, decision);
89
+ if (!needsApply(decision)) continue;
90
+ if (!ctx.dryRun) {
91
+ const failure = yield* capture(`secret ${name} (${label})`, environment === void 0 ? secrets.set(name, value, scope) : secrets.setForEnvironment(environment, name, value));
92
+ if (failure !== void 0) {
93
+ errors.push(failure);
94
+ continue;
95
+ }
96
+ yield* applied.record({
97
+ repo: ctx.slug,
98
+ kind: KIND,
99
+ name: resource
100
+ }, desiredPrint, ctx.runId).pipe(Effect.ignore);
101
+ }
102
+ yield* logger.syncOperation("sync", KIND, name, `(${label})`);
103
+ changes.push({
104
+ repo: ctx.slug,
105
+ kind: KIND,
106
+ name: resource,
107
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
108
+ });
109
+ }
110
+ });
111
+ for (const scope of SCOPES) {
112
+ const resolved = resolveGroups(scopes[scope] ?? [], ctx.config.secrets, ctx);
113
+ errors.push(...resolved.errors);
114
+ yield* syncStore(resolved.entries, scope, void 0);
115
+ }
116
+ for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
117
+ const resolved = resolveGroups(refs, ctx.config.secrets, ctx);
118
+ errors.push(...resolved.errors);
119
+ yield* syncStore(resolved.entries, "actions", environment);
120
+ }
121
+ return {
122
+ changes,
123
+ errors
124
+ };
125
+ });
126
+ return {
127
+ name: "secrets",
128
+ appliesTo: (ctx) => {
129
+ const scopes = ctx.config.groups[ctx.group]?.secrets;
130
+ if (scopes === void 0) return false;
131
+ return SCOPES.some((scope) => (scopes[scope]?.length ?? 0) > 0) || Object.keys(scopes.environments ?? {}).length > 0;
132
+ },
133
+ run
134
+ };
135
+ });
136
+
137
+ //#endregion
138
+ export { secretsPhase };
@@ -0,0 +1,129 @@
1
+ import { SyncLogger } from "../../services/SyncLogger.js";
2
+ import { AppliedState, lookup } from "../../store/AppliedState.js";
3
+ import { fingerprint } from "../../lib/fingerprint.js";
4
+ import { decide, isDrift, needsApply } from "../decide.js";
5
+ import { Effect, Option } from "effect";
6
+ import { RepositorySecurity } from "@effected/github";
7
+
8
+ //#region src/sync/phases/security.ts
9
+ const KIND = "security";
10
+ /** The three toggles, and the merged config field each reads. */
11
+ const TOGGLES = [
12
+ "vulnerability_alerts",
13
+ "automated_security_fixes",
14
+ "private_vulnerability_reporting"
15
+ ];
16
+ /**
17
+ * Merge the security groups a repository's group references.
18
+ *
19
+ * @remarks
20
+ * Later groups win, matching how every other `[settings.*]`-style section
21
+ * composes. An absent field is not `false` — it means "leave alone", which is
22
+ * why this returns a partial record rather than defaulting.
23
+ */
24
+ const mergeSecurity = (ctx) => {
25
+ const refs = ctx.config.groups[ctx.group]?.security ?? [];
26
+ const merged = {};
27
+ for (const ref of refs) {
28
+ const group = ctx.config.security[ref];
29
+ if (group === void 0) continue;
30
+ for (const toggle of TOGGLES) {
31
+ const value = group[toggle];
32
+ if (value !== void 0) merged[toggle] = value;
33
+ }
34
+ }
35
+ return merged;
36
+ };
37
+ /**
38
+ * GitHub rejects automated fixes without alerts.
39
+ *
40
+ * @remarks
41
+ * Caught before any write rather than after the first one fails: applying
42
+ * `automated_security_fixes` first would leave the repository half-configured
43
+ * and the error attributed to the wrong toggle.
44
+ */
45
+ const contradicts = (merged) => merged.automated_security_fixes === true && merged.vulnerability_alerts === false;
46
+ /**
47
+ * Repository security toggles: vulnerability alerts, automated fixes, private
48
+ * vulnerability reporting.
49
+ *
50
+ * @remarks
51
+ * Each toggle is a separate resource for drift purposes, so someone flipping
52
+ * private vulnerability reporting in the UI is reported as that, not as
53
+ * "security changed".
54
+ *
55
+ * @public
56
+ */
57
+ const securityPhase = Effect.gen(function* () {
58
+ const security = yield* RepositorySecurity;
59
+ const applied = yield* AppliedState;
60
+ const logger = yield* SyncLogger;
61
+ const read = (toggle) => {
62
+ return (toggle === "vulnerability_alerts" ? security.vulnerabilityAlerts() : toggle === "automated_security_fixes" ? security.automatedSecurityFixes() : security.privateVulnerabilityReporting()).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
63
+ };
64
+ const write = (toggle, value) => {
65
+ return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(error.message ?? String(error)))));
66
+ };
67
+ const run = (ctx) => Effect.gen(function* () {
68
+ const merged = mergeSecurity(ctx);
69
+ if (contradicts(merged)) return {
70
+ changes: [],
71
+ errors: [{
72
+ context: "security merge",
73
+ message: "automated_security_fixes = true requires vulnerability_alerts to be enabled (or omitted); skipping security sync"
74
+ }]
75
+ };
76
+ const baselines = yield* applied.getMany(ctx.slug).pipe(Effect.orElseSucceed(() => /* @__PURE__ */ new Map()));
77
+ const changes = [];
78
+ const errors = [];
79
+ for (const toggle of TOGGLES) {
80
+ const desired = merged[toggle];
81
+ if (desired === void 0) continue;
82
+ const live = yield* read(toggle);
83
+ if (Option.isNone(live)) {
84
+ errors.push({
85
+ context: `get ${toggle}`,
86
+ message: "could not read current state"
87
+ });
88
+ continue;
89
+ }
90
+ const decision = decide(fingerprint(desired), fingerprint(live.value), Option.map(lookup(baselines, KIND, toggle), (record) => record.fingerprint));
91
+ if (isDrift(decision)) yield* logger.driftDetected(KIND, toggle, decision);
92
+ if (!needsApply(decision)) continue;
93
+ if (!ctx.dryRun) {
94
+ const failure = yield* write(toggle, desired);
95
+ if (Option.isSome(failure)) {
96
+ errors.push({
97
+ context: toggle,
98
+ message: failure.value
99
+ });
100
+ continue;
101
+ }
102
+ yield* applied.record({
103
+ repo: ctx.slug,
104
+ kind: KIND,
105
+ name: toggle
106
+ }, fingerprint(desired), ctx.runId).pipe(Effect.ignore);
107
+ }
108
+ yield* logger.syncOperation("sync", KIND, toggle, desired ? "enable" : "disable");
109
+ changes.push({
110
+ repo: ctx.slug,
111
+ kind: KIND,
112
+ name: toggle,
113
+ action: isDrift(decision) ? "drift-overwritten" : "updated"
114
+ });
115
+ }
116
+ return {
117
+ changes,
118
+ errors
119
+ };
120
+ });
121
+ return {
122
+ name: "security",
123
+ appliesTo: (ctx) => (ctx.config.groups[ctx.group]?.security?.length ?? 0) > 0,
124
+ run
125
+ };
126
+ });
127
+
128
+ //#endregion
129
+ export { securityPhase };