clud-bug 0.7.0-rc.2 → 0.7.0-rc.21
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 +20 -0
- package/data/canonical-v1.json +37 -0
- package/dist/cli/configure-github.d.ts +58 -0
- package/dist/cli/configure-github.d.ts.map +1 -0
- package/dist/cli/configure-github.js +242 -0
- package/dist/cli/configure-github.js.map +1 -0
- package/dist/cli/hooks.d.ts +51 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +138 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +949 -35
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/post-check-run.d.ts +16 -0
- package/dist/cli/post-check-run.d.ts.map +1 -0
- package/dist/cli/post-check-run.js +93 -0
- package/dist/cli/post-check-run.js.map +1 -0
- package/dist/cli/review-prompt.d.ts +43 -0
- package/dist/cli/review-prompt.d.ts.map +1 -0
- package/dist/cli/review-prompt.js +287 -0
- package/dist/cli/review-prompt.js.map +1 -0
- package/dist/cli/skills.d.ts +7 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +42 -2
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/update-notifier.d.ts +17 -0
- package/dist/cli/update-notifier.d.ts.map +1 -0
- package/dist/cli/update-notifier.js +88 -0
- package/dist/cli/update-notifier.js.map +1 -0
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +60 -11
- package/dist/cli/update.js.map +1 -1
- package/dist/core/auto-resolve.d.ts +235 -0
- package/dist/core/auto-resolve.d.ts.map +1 -0
- package/dist/core/auto-resolve.js +293 -0
- package/dist/core/auto-resolve.js.map +1 -0
- package/dist/core/budget-plan.d.ts +125 -0
- package/dist/core/budget-plan.d.ts.map +1 -0
- package/dist/core/budget-plan.js +211 -0
- package/dist/core/budget-plan.js.map +1 -0
- package/dist/core/check-verdict.d.ts +31 -0
- package/dist/core/check-verdict.d.ts.map +1 -0
- package/dist/core/check-verdict.js +66 -0
- package/dist/core/check-verdict.js.map +1 -0
- package/dist/core/configure-github.d.ts +179 -0
- package/dist/core/configure-github.d.ts.map +1 -0
- package/dist/core/configure-github.js +289 -0
- package/dist/core/configure-github.js.map +1 -0
- package/dist/core/design.d.ts +37 -0
- package/dist/core/design.d.ts.map +1 -0
- package/dist/core/design.js +47 -0
- package/dist/core/design.js.map +1 -0
- package/dist/core/diff-findings.d.ts +96 -0
- package/dist/core/diff-findings.d.ts.map +1 -0
- package/dist/core/diff-findings.js +261 -0
- package/dist/core/diff-findings.js.map +1 -0
- package/dist/core/formal-review.d.ts +79 -0
- package/dist/core/formal-review.d.ts.map +1 -0
- package/dist/core/formal-review.js +71 -0
- package/dist/core/formal-review.js.map +1 -0
- package/dist/core/index.d.ts +15 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +53 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/inline-threads.d.ts +160 -0
- package/dist/core/inline-threads.d.ts.map +1 -0
- package/dist/core/inline-threads.js +369 -0
- package/dist/core/inline-threads.js.map +1 -0
- package/dist/core/multi-pass-aggregate.d.ts +161 -0
- package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
- package/dist/core/multi-pass-aggregate.js +457 -0
- package/dist/core/multi-pass-aggregate.js.map +1 -0
- package/dist/core/plan-review.d.ts +57 -0
- package/dist/core/plan-review.d.ts.map +1 -0
- package/dist/core/plan-review.js +77 -0
- package/dist/core/plan-review.js.map +1 -0
- package/dist/core/prompt-builder.d.ts +18 -0
- package/dist/core/prompt-builder.d.ts.map +1 -1
- package/dist/core/prompt-builder.js +24 -2
- package/dist/core/prompt-builder.js.map +1 -1
- package/dist/core/render.d.ts.map +1 -1
- package/dist/core/render.js +1 -19
- package/dist/core/render.js.map +1 -1
- package/dist/core/resolve-verifier.d.ts +53 -0
- package/dist/core/resolve-verifier.d.ts.map +1 -0
- package/dist/core/resolve-verifier.js +204 -0
- package/dist/core/resolve-verifier.js.map +1 -0
- package/dist/core/review-context.d.ts +39 -0
- package/dist/core/review-context.d.ts.map +1 -0
- package/dist/core/review-context.js +99 -0
- package/dist/core/review-context.js.map +1 -0
- package/dist/core/review-plan.d.ts +219 -0
- package/dist/core/review-plan.d.ts.map +1 -0
- package/dist/core/review-plan.js +274 -0
- package/dist/core/review-plan.js.map +1 -0
- package/dist/core/review-writeback.d.ts +74 -0
- package/dist/core/review-writeback.d.ts.map +1 -1
- package/dist/core/review-writeback.js +71 -4
- package/dist/core/review-writeback.js.map +1 -1
- package/dist/core/skills.d.ts +25 -0
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +100 -4
- package/dist/core/skills.js.map +1 -1
- package/dist/core/version.d.ts +2 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +4 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +11 -5
- package/src/cli/configure-github.ts +329 -0
- package/src/cli/hooks.ts +155 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/main.ts +1031 -37
- package/src/cli/post-check-run.ts +115 -0
- package/src/cli/review-prompt.ts +355 -0
- package/src/cli/skills.ts +42 -2
- package/src/cli/update-notifier.ts +100 -0
- package/src/cli/update.ts +60 -11
- package/src/core/auto-resolve.ts +488 -0
- package/src/core/budget-plan.ts +324 -0
- package/src/core/check-verdict.ts +93 -0
- package/src/core/configure-github.ts +497 -0
- package/src/core/design.ts +73 -0
- package/src/core/diff-findings.ts +323 -0
- package/src/core/formal-review.ts +168 -0
- package/src/core/index.ts +188 -0
- package/src/core/inline-threads.ts +471 -0
- package/src/core/multi-pass-aggregate.ts +591 -0
- package/src/core/plan-review.ts +136 -0
- package/src/core/prompt-builder.ts +44 -2
- package/src/core/render.ts +10 -16
- package/src/core/resolve-verifier.ts +254 -0
- package/src/core/review-context.ts +106 -0
- package/src/core/review-plan.ts +485 -0
- package/src/core/review-writeback.ts +161 -4
- package/src/core/skills.ts +128 -5
- package/src/core/version.ts +3 -0
- package/templates/clud-bug-review.md.tmpl +115 -0
- package/templates/skills/design/design-system-consistency.md +41 -0
- package/templates/skills/design/frontend-a11y.md +41 -0
- package/templates/skills/design/visual-polish.md +41 -0
- package/templates/workflow-py.yml.tmpl +92 -2
- package/templates/workflow-ts.yml.tmpl +92 -2
- package/templates/workflow.yml.tmpl +173 -2
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// SPEC §7 canonical-ruleset applier — pure core half of `configure-github`.
|
|
2
|
+
//
|
|
3
|
+
// External users installing the App expect "best practice branch protection"
|
|
4
|
+
// applied automatically. This module diffs the current GitHub state against
|
|
5
|
+
// the canonical ruleset (bundled at `data/canonical-v1.json`) and emits the
|
|
6
|
+
// minimal set of PATCH calls to converge.
|
|
7
|
+
//
|
|
8
|
+
// Architectural shape mirrors `formal-review.ts` and `review-writeback.ts`:
|
|
9
|
+
// the pure rule-table + diff logic lives in core (no Octokit dep at compile
|
|
10
|
+
// time — clud-bug-app already has `@octokit/rest` and passes its instance;
|
|
11
|
+
// the CLI side wraps `gh api` in a tiny adapter that satisfies the same
|
|
12
|
+
// structural interface). Core stays npm-side single-source-of-truth per the
|
|
13
|
+
// Bug 9 / Phase 2-4 architectural lock.
|
|
14
|
+
//
|
|
15
|
+
// Idempotent contract (HARD GUARANTEE):
|
|
16
|
+
//
|
|
17
|
+
// const a = await applyCanonicalRuleset(octokit, params);
|
|
18
|
+
// const b = await applyCanonicalRuleset(octokit, params);
|
|
19
|
+
// // b.alreadyCanonical === true; b.changes.length === 0
|
|
20
|
+
//
|
|
21
|
+
// A second `apply()` call against a freshly-converged repo MUST produce
|
|
22
|
+
// `alreadyCanonical: true` with `changes: []` and zero PATCH calls. This
|
|
23
|
+
// lets external automation (CI, dispatch loops, dashboard probes) call
|
|
24
|
+
// `apply()` defensively without rate-limiting itself out of the API.
|
|
25
|
+
//
|
|
26
|
+
// SPEC pins honored here:
|
|
27
|
+
// - canonical-v1.json schema (frozen at v1; major bumps require coordinated
|
|
28
|
+
// tool releases per the `$comment` field).
|
|
29
|
+
// - `required_status_checks.contexts` are TREATED AS A SUPERSET: if the
|
|
30
|
+
// repo already requires MORE contexts than the canonical list, we leave
|
|
31
|
+
// them alone (a repo that runs additional CI gates legitimately needs
|
|
32
|
+
// them in the required set). We only ADD the canonical contexts that
|
|
33
|
+
// are missing.
|
|
34
|
+
// - `required_approving_review_count`: canonical floor is 1. If the repo
|
|
35
|
+
// already requires MORE than 1, we leave it alone (only raise to floor).
|
|
36
|
+
//
|
|
37
|
+
// Offline-resilient bundling: the canonical ruleset ships in `data/` at
|
|
38
|
+
// the package root (alongside `bin/`, `templates/`, etc., per package.json
|
|
39
|
+
// `files`). We read it at runtime via fs.readFile so the build stays out
|
|
40
|
+
// of TypeScript's `rootDir` constraint, and so callers that re-bundle
|
|
41
|
+
// clud-bug into a single file can override the ruleset by passing one
|
|
42
|
+
// explicitly to `applyCanonicalRuleset({ ruleset })`.
|
|
43
|
+
|
|
44
|
+
import { readFile } from 'node:fs/promises';
|
|
45
|
+
import { dirname, join } from 'node:path';
|
|
46
|
+
import { fileURLToPath } from 'node:url';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The canonical-v1.json structure. Frozen at v1; major bumps require
|
|
50
|
+
* coordinated tool releases per the protocol repo's `$comment` field.
|
|
51
|
+
*/
|
|
52
|
+
export interface CanonicalRuleset {
|
|
53
|
+
version: 'v1';
|
|
54
|
+
spec_version: string;
|
|
55
|
+
branch_protection: {
|
|
56
|
+
required_status_checks: {
|
|
57
|
+
strict: boolean;
|
|
58
|
+
contexts: string[];
|
|
59
|
+
};
|
|
60
|
+
required_pull_request_reviews: {
|
|
61
|
+
required_approving_review_count: number;
|
|
62
|
+
dismiss_stale_reviews: boolean;
|
|
63
|
+
require_code_owner_reviews: boolean;
|
|
64
|
+
};
|
|
65
|
+
required_conversation_resolution: boolean;
|
|
66
|
+
enforce_admins: boolean;
|
|
67
|
+
allow_force_pushes: boolean;
|
|
68
|
+
allow_deletions: boolean;
|
|
69
|
+
required_linear_history: boolean;
|
|
70
|
+
allow_auto_merge: boolean;
|
|
71
|
+
delete_branch_on_merge: boolean;
|
|
72
|
+
squash_merge_commit_title: string;
|
|
73
|
+
squash_merge_commit_message: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the package root from this file's URL. After tsc the file lives
|
|
79
|
+
* at `<pkg>/dist/core/configure-github.js`; three dirname() climbs land on
|
|
80
|
+
* `<pkg>`, parallel to the package.json `files` map entry for `data`.
|
|
81
|
+
*/
|
|
82
|
+
const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
83
|
+
|
|
84
|
+
/** Memoized canonical ruleset — loaded once per process. */
|
|
85
|
+
let CANONICAL_V1_CACHE: CanonicalRuleset | null = null;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Loads the bundled canonical-v1 ruleset from `data/canonical-v1.json`.
|
|
89
|
+
* Memoized so repeated calls don't re-hit the filesystem.
|
|
90
|
+
*
|
|
91
|
+
* Throws a wrapped error if the JSON file is missing — that's an
|
|
92
|
+
* install-time defect (package.json `files` excluded `data/`), not a
|
|
93
|
+
* runtime concern, and the loud failure surfaces it immediately.
|
|
94
|
+
*/
|
|
95
|
+
export async function loadCanonicalV1(): Promise<CanonicalRuleset> {
|
|
96
|
+
if (CANONICAL_V1_CACHE) return CANONICAL_V1_CACHE;
|
|
97
|
+
const path = join(PKG_ROOT, 'data', 'canonical-v1.json');
|
|
98
|
+
try {
|
|
99
|
+
const raw = await readFile(path, 'utf-8');
|
|
100
|
+
CANONICAL_V1_CACHE = JSON.parse(raw) as CanonicalRuleset;
|
|
101
|
+
return CANONICAL_V1_CACHE;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
throw new Error(
|
|
105
|
+
`configure-github: failed to load bundled canonical-v1.json at ${path}: ${msg}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Minimal structural Octokit interface — only the methods we actually call.
|
|
112
|
+
*
|
|
113
|
+
* We intentionally don't import `@octokit/rest` types: that would force
|
|
114
|
+
* every consumer (App, CLI, future tools) to install Octokit at runtime
|
|
115
|
+
* even when they pass a `gh`-CLI-backed adapter. Keeping the shape
|
|
116
|
+
* structural lets the CLI's `gh`-wrapping adapter satisfy it without
|
|
117
|
+
* pulling in a 200KB dep.
|
|
118
|
+
*
|
|
119
|
+
* If `@octokit/rest`'s real method shapes ever drift, the adapter +
|
|
120
|
+
* runtime call sites are the only files that need updating; the rule
|
|
121
|
+
* table here stays Octokit-version-agnostic.
|
|
122
|
+
*/
|
|
123
|
+
export interface OctokitLike {
|
|
124
|
+
repos: {
|
|
125
|
+
/** Read branch protection state. Throws on 404 (no protection rule). */
|
|
126
|
+
getBranchProtection(params: {
|
|
127
|
+
owner: string;
|
|
128
|
+
repo: string;
|
|
129
|
+
branch: string;
|
|
130
|
+
}): Promise<{
|
|
131
|
+
data: {
|
|
132
|
+
required_status_checks?:
|
|
133
|
+
| { strict?: boolean; contexts?: string[] }
|
|
134
|
+
| null;
|
|
135
|
+
required_pull_request_reviews?:
|
|
136
|
+
| {
|
|
137
|
+
required_approving_review_count?: number;
|
|
138
|
+
dismiss_stale_reviews?: boolean;
|
|
139
|
+
require_code_owner_reviews?: boolean;
|
|
140
|
+
}
|
|
141
|
+
| null;
|
|
142
|
+
required_conversation_resolution?: { enabled?: boolean } | null;
|
|
143
|
+
enforce_admins?: { enabled?: boolean } | null;
|
|
144
|
+
allow_force_pushes?: { enabled?: boolean } | null;
|
|
145
|
+
allow_deletions?: { enabled?: boolean } | null;
|
|
146
|
+
required_linear_history?: { enabled?: boolean } | null;
|
|
147
|
+
};
|
|
148
|
+
}>;
|
|
149
|
+
/** PUT branch protection (the REST API does NOT support PATCH). */
|
|
150
|
+
updateBranchProtection(params: {
|
|
151
|
+
owner: string;
|
|
152
|
+
repo: string;
|
|
153
|
+
branch: string;
|
|
154
|
+
required_status_checks: { strict: boolean; contexts: string[] } | null;
|
|
155
|
+
enforce_admins: boolean | null;
|
|
156
|
+
required_pull_request_reviews: {
|
|
157
|
+
required_approving_review_count: number;
|
|
158
|
+
dismiss_stale_reviews: boolean;
|
|
159
|
+
require_code_owner_reviews: boolean;
|
|
160
|
+
} | null;
|
|
161
|
+
restrictions: null;
|
|
162
|
+
required_conversation_resolution?: boolean;
|
|
163
|
+
required_linear_history?: boolean;
|
|
164
|
+
allow_force_pushes?: boolean;
|
|
165
|
+
allow_deletions?: boolean;
|
|
166
|
+
}): Promise<unknown>;
|
|
167
|
+
/** Read repo-level settings (delete_branch_on_merge, allow_auto_merge, etc). */
|
|
168
|
+
get(params: { owner: string; repo: string }): Promise<{
|
|
169
|
+
data: {
|
|
170
|
+
delete_branch_on_merge?: boolean;
|
|
171
|
+
allow_auto_merge?: boolean;
|
|
172
|
+
squash_merge_commit_title?: string;
|
|
173
|
+
squash_merge_commit_message?: string;
|
|
174
|
+
};
|
|
175
|
+
}>;
|
|
176
|
+
/** PATCH repo-level settings. */
|
|
177
|
+
update(params: {
|
|
178
|
+
owner: string;
|
|
179
|
+
repo: string;
|
|
180
|
+
delete_branch_on_merge?: boolean;
|
|
181
|
+
allow_auto_merge?: boolean;
|
|
182
|
+
squash_merge_commit_title?: string;
|
|
183
|
+
squash_merge_commit_message?: string;
|
|
184
|
+
}): Promise<unknown>;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ApplyCanonicalRulesetParams {
|
|
189
|
+
owner: string;
|
|
190
|
+
repo: string;
|
|
191
|
+
/**
|
|
192
|
+
* Target branch (default: `main`). Most consumers will use `main`; this
|
|
193
|
+
* is exposed for downstream tools that converge non-`main` defaults.
|
|
194
|
+
*/
|
|
195
|
+
branch?: string;
|
|
196
|
+
/**
|
|
197
|
+
* The ruleset to apply. Defaults to the bundled `canonical-v1.json`
|
|
198
|
+
* (loaded via `loadCanonicalV1()` if absent). Future v2 callers pass
|
|
199
|
+
* an explicit ruleset; the type system pins the shape.
|
|
200
|
+
*/
|
|
201
|
+
ruleset?: CanonicalRuleset;
|
|
202
|
+
/**
|
|
203
|
+
* If true, compute diff only and skip all PATCH calls. The result still
|
|
204
|
+
* reports `changes: string[]` so the CLI can render the human-readable
|
|
205
|
+
* diff before applying.
|
|
206
|
+
*/
|
|
207
|
+
dryRun?: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ApplyResult {
|
|
211
|
+
/** Human-readable diff lines, one per detected difference. Empty when alreadyCanonical. */
|
|
212
|
+
changes: string[];
|
|
213
|
+
/** True when the live state already matches every canonical setting. */
|
|
214
|
+
alreadyCanonical: boolean;
|
|
215
|
+
/** Schema version applied (always 'canonical-v1' for this build). */
|
|
216
|
+
ruleset: 'canonical-v1';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Applies the canonical ruleset to a GitHub repo. Reads current state via
|
|
221
|
+
* the Octokit-like instance, diffs against `ruleset` (default
|
|
222
|
+
* `CANONICAL_V1`), and PATCHes only what differs. Idempotent: second call
|
|
223
|
+
* returns `alreadyCanonical: true` with no PATCH side effects.
|
|
224
|
+
*
|
|
225
|
+
* Behavior on partial mismatch:
|
|
226
|
+
*
|
|
227
|
+
* - `required_status_checks.contexts`: canonical contexts that are
|
|
228
|
+
* missing get added; extra contexts on the repo are preserved (superset
|
|
229
|
+
* contract — a repo that runs more CI gates legitimately needs them).
|
|
230
|
+
* - `required_approving_review_count`: canonical floor is 1; if the repo
|
|
231
|
+
* already requires more, we don't lower (raise-only contract).
|
|
232
|
+
* - All booleans (allow_force_pushes, allow_deletions, etc.): converge
|
|
233
|
+
* to the canonical value exactly.
|
|
234
|
+
*
|
|
235
|
+
* Throws on Octokit transport failure (auth, network, 403). The CLI
|
|
236
|
+
* wraps these with a friendly error message.
|
|
237
|
+
*/
|
|
238
|
+
export async function applyCanonicalRuleset(
|
|
239
|
+
octokit: OctokitLike,
|
|
240
|
+
params: ApplyCanonicalRulesetParams,
|
|
241
|
+
): Promise<ApplyResult> {
|
|
242
|
+
const {
|
|
243
|
+
owner,
|
|
244
|
+
repo,
|
|
245
|
+
branch = 'main',
|
|
246
|
+
dryRun = false,
|
|
247
|
+
} = params;
|
|
248
|
+
const ruleset = params.ruleset ?? (await loadCanonicalV1());
|
|
249
|
+
|
|
250
|
+
const target = ruleset.branch_protection;
|
|
251
|
+
const changes: string[] = [];
|
|
252
|
+
|
|
253
|
+
// ----- Read current branch protection state ---------------------------
|
|
254
|
+
// 404 → no base protection rule. We treat that as "every canonical
|
|
255
|
+
// setting differs" so the apply path creates the rule. Other errors
|
|
256
|
+
// bubble up — callers can't recover from a 403.
|
|
257
|
+
let current: Awaited<
|
|
258
|
+
ReturnType<OctokitLike['repos']['getBranchProtection']>
|
|
259
|
+
>['data'] = {};
|
|
260
|
+
let hasBaseProtection = true;
|
|
261
|
+
try {
|
|
262
|
+
const resp = await octokit.repos.getBranchProtection({
|
|
263
|
+
owner,
|
|
264
|
+
repo,
|
|
265
|
+
branch,
|
|
266
|
+
});
|
|
267
|
+
current = resp.data;
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (isBranchNotProtected(err)) {
|
|
270
|
+
hasBaseProtection = false;
|
|
271
|
+
} else {
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ----- Diff: required_status_checks -----------------------------------
|
|
277
|
+
// Behavior: contexts are a SUPERSET. Strict mode must match exactly.
|
|
278
|
+
const currentChecks = current.required_status_checks ?? null;
|
|
279
|
+
const currentContexts = new Set(currentChecks?.contexts ?? []);
|
|
280
|
+
const targetContexts = new Set(target.required_status_checks.contexts);
|
|
281
|
+
const missingContexts = [...targetContexts].filter(
|
|
282
|
+
(c) => !currentContexts.has(c),
|
|
283
|
+
);
|
|
284
|
+
const strictMismatch =
|
|
285
|
+
(currentChecks?.strict ?? false) !== target.required_status_checks.strict;
|
|
286
|
+
let needsChecksPatch = !hasBaseProtection;
|
|
287
|
+
if (missingContexts.length > 0) {
|
|
288
|
+
changes.push(
|
|
289
|
+
`required_status_checks.contexts: add ${JSON.stringify(missingContexts)}`,
|
|
290
|
+
);
|
|
291
|
+
needsChecksPatch = true;
|
|
292
|
+
}
|
|
293
|
+
if (strictMismatch) {
|
|
294
|
+
changes.push(
|
|
295
|
+
`required_status_checks.strict: ${currentChecks?.strict ?? false} → ${target.required_status_checks.strict}`,
|
|
296
|
+
);
|
|
297
|
+
needsChecksPatch = true;
|
|
298
|
+
}
|
|
299
|
+
// The merged context list: keep extras the repo already required.
|
|
300
|
+
const mergedContexts = [
|
|
301
|
+
...new Set([
|
|
302
|
+
...(currentChecks?.contexts ?? []),
|
|
303
|
+
...target.required_status_checks.contexts,
|
|
304
|
+
]),
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
// ----- Diff: required_pull_request_reviews ----------------------------
|
|
308
|
+
// Behavior: approving_review_count is a FLOOR (only raise). Booleans
|
|
309
|
+
// converge exactly.
|
|
310
|
+
const currentReviews = current.required_pull_request_reviews ?? null;
|
|
311
|
+
const currentCount = currentReviews?.required_approving_review_count ?? 0;
|
|
312
|
+
const targetCount =
|
|
313
|
+
target.required_pull_request_reviews.required_approving_review_count;
|
|
314
|
+
const effectiveCount = Math.max(currentCount, targetCount);
|
|
315
|
+
const targetDismiss =
|
|
316
|
+
target.required_pull_request_reviews.dismiss_stale_reviews;
|
|
317
|
+
const targetCodeOwner =
|
|
318
|
+
target.required_pull_request_reviews.require_code_owner_reviews;
|
|
319
|
+
let needsReviewsPatch = !hasBaseProtection;
|
|
320
|
+
if (effectiveCount !== currentCount) {
|
|
321
|
+
changes.push(
|
|
322
|
+
`required_pull_request_reviews.required_approving_review_count: ${currentCount} → ${effectiveCount}`,
|
|
323
|
+
);
|
|
324
|
+
needsReviewsPatch = true;
|
|
325
|
+
}
|
|
326
|
+
if ((currentReviews?.dismiss_stale_reviews ?? false) !== targetDismiss) {
|
|
327
|
+
changes.push(
|
|
328
|
+
`required_pull_request_reviews.dismiss_stale_reviews: ${currentReviews?.dismiss_stale_reviews ?? false} → ${targetDismiss}`,
|
|
329
|
+
);
|
|
330
|
+
needsReviewsPatch = true;
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
(currentReviews?.require_code_owner_reviews ?? false) !== targetCodeOwner
|
|
334
|
+
) {
|
|
335
|
+
changes.push(
|
|
336
|
+
`required_pull_request_reviews.require_code_owner_reviews: ${currentReviews?.require_code_owner_reviews ?? false} → ${targetCodeOwner}`,
|
|
337
|
+
);
|
|
338
|
+
needsReviewsPatch = true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ----- Diff: single-flag branch protection booleans -------------------
|
|
342
|
+
const conv =
|
|
343
|
+
current.required_conversation_resolution?.enabled ?? false;
|
|
344
|
+
const enforceAdmins = current.enforce_admins?.enabled ?? false;
|
|
345
|
+
const allowFP = current.allow_force_pushes?.enabled ?? false;
|
|
346
|
+
const allowDel = current.allow_deletions?.enabled ?? false;
|
|
347
|
+
const linearHistory = current.required_linear_history?.enabled ?? false;
|
|
348
|
+
if (conv !== target.required_conversation_resolution) {
|
|
349
|
+
changes.push(
|
|
350
|
+
`required_conversation_resolution: ${conv} → ${target.required_conversation_resolution}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (enforceAdmins !== target.enforce_admins) {
|
|
354
|
+
changes.push(
|
|
355
|
+
`enforce_admins: ${enforceAdmins} → ${target.enforce_admins}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (allowFP !== target.allow_force_pushes) {
|
|
359
|
+
changes.push(
|
|
360
|
+
`allow_force_pushes: ${allowFP} → ${target.allow_force_pushes}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (allowDel !== target.allow_deletions) {
|
|
364
|
+
changes.push(
|
|
365
|
+
`allow_deletions: ${allowDel} → ${target.allow_deletions}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (linearHistory !== target.required_linear_history) {
|
|
369
|
+
changes.push(
|
|
370
|
+
`required_linear_history: ${linearHistory} → ${target.required_linear_history}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ----- Diff: repo-level settings --------------------------------------
|
|
375
|
+
// delete_branch_on_merge / allow_auto_merge / squash commit shape live
|
|
376
|
+
// on the REPO, not the branch protection rule. Fetched separately.
|
|
377
|
+
const repoResp = await octokit.repos.get({ owner, repo });
|
|
378
|
+
const repoData = repoResp.data;
|
|
379
|
+
const repoPatch: Parameters<OctokitLike['repos']['update']>[0] = {
|
|
380
|
+
owner,
|
|
381
|
+
repo,
|
|
382
|
+
};
|
|
383
|
+
let needsRepoPatch = false;
|
|
384
|
+
if (
|
|
385
|
+
(repoData.delete_branch_on_merge ?? false) !== target.delete_branch_on_merge
|
|
386
|
+
) {
|
|
387
|
+
changes.push(
|
|
388
|
+
`delete_branch_on_merge: ${repoData.delete_branch_on_merge ?? false} → ${target.delete_branch_on_merge}`,
|
|
389
|
+
);
|
|
390
|
+
repoPatch.delete_branch_on_merge = target.delete_branch_on_merge;
|
|
391
|
+
needsRepoPatch = true;
|
|
392
|
+
}
|
|
393
|
+
if ((repoData.allow_auto_merge ?? false) !== target.allow_auto_merge) {
|
|
394
|
+
changes.push(
|
|
395
|
+
`allow_auto_merge: ${repoData.allow_auto_merge ?? false} → ${target.allow_auto_merge}`,
|
|
396
|
+
);
|
|
397
|
+
repoPatch.allow_auto_merge = target.allow_auto_merge;
|
|
398
|
+
needsRepoPatch = true;
|
|
399
|
+
}
|
|
400
|
+
if (
|
|
401
|
+
(repoData.squash_merge_commit_title ?? '') !==
|
|
402
|
+
target.squash_merge_commit_title
|
|
403
|
+
) {
|
|
404
|
+
changes.push(
|
|
405
|
+
`squash_merge_commit_title: ${repoData.squash_merge_commit_title ?? '(unset)'} → ${target.squash_merge_commit_title}`,
|
|
406
|
+
);
|
|
407
|
+
repoPatch.squash_merge_commit_title = target.squash_merge_commit_title;
|
|
408
|
+
needsRepoPatch = true;
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
(repoData.squash_merge_commit_message ?? '') !==
|
|
412
|
+
target.squash_merge_commit_message
|
|
413
|
+
) {
|
|
414
|
+
changes.push(
|
|
415
|
+
`squash_merge_commit_message: ${repoData.squash_merge_commit_message ?? '(unset)'} → ${target.squash_merge_commit_message}`,
|
|
416
|
+
);
|
|
417
|
+
repoPatch.squash_merge_commit_message = target.squash_merge_commit_message;
|
|
418
|
+
needsRepoPatch = true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const alreadyCanonical = changes.length === 0;
|
|
422
|
+
|
|
423
|
+
if (alreadyCanonical || dryRun) {
|
|
424
|
+
return {
|
|
425
|
+
changes,
|
|
426
|
+
alreadyCanonical,
|
|
427
|
+
ruleset: 'canonical-v1',
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ----- Apply: branch protection ---------------------------------------
|
|
432
|
+
// The REST API requires a PUT (not PATCH) with the full settings object.
|
|
433
|
+
// We always send a complete envelope; the diff above gates whether we
|
|
434
|
+
// call PUT at all.
|
|
435
|
+
if (needsChecksPatch || needsReviewsPatch || !hasBaseProtection || hasBranchProtectionLevelDiff(changes)) {
|
|
436
|
+
await octokit.repos.updateBranchProtection({
|
|
437
|
+
owner,
|
|
438
|
+
repo,
|
|
439
|
+
branch,
|
|
440
|
+
required_status_checks: {
|
|
441
|
+
strict: target.required_status_checks.strict,
|
|
442
|
+
contexts: mergedContexts,
|
|
443
|
+
},
|
|
444
|
+
enforce_admins: target.enforce_admins,
|
|
445
|
+
required_pull_request_reviews: {
|
|
446
|
+
required_approving_review_count: effectiveCount,
|
|
447
|
+
dismiss_stale_reviews: targetDismiss,
|
|
448
|
+
require_code_owner_reviews: targetCodeOwner,
|
|
449
|
+
},
|
|
450
|
+
restrictions: null,
|
|
451
|
+
required_conversation_resolution: target.required_conversation_resolution,
|
|
452
|
+
required_linear_history: target.required_linear_history,
|
|
453
|
+
allow_force_pushes: target.allow_force_pushes,
|
|
454
|
+
allow_deletions: target.allow_deletions,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ----- Apply: repo-level settings -------------------------------------
|
|
459
|
+
if (needsRepoPatch) {
|
|
460
|
+
await octokit.repos.update(repoPatch);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
changes,
|
|
465
|
+
alreadyCanonical: false,
|
|
466
|
+
ruleset: 'canonical-v1',
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Octokit (and gh's wrapping adapter) report a missing branch protection
|
|
472
|
+
* rule as a 404. We catch on the structural shape of the error so the
|
|
473
|
+
* downstream Octokit version doesn't matter.
|
|
474
|
+
*/
|
|
475
|
+
function isBranchNotProtected(err: unknown): boolean {
|
|
476
|
+
if (!err || typeof err !== 'object') return false;
|
|
477
|
+
const e = err as { status?: number; message?: string };
|
|
478
|
+
if (e.status === 404) return true;
|
|
479
|
+
if (typeof e.message === 'string') {
|
|
480
|
+
return /Branch not protected|Not Found|404/i.test(e.message);
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Detect whether any of the per-branch protection settings (conversation
|
|
487
|
+
* resolution / force-pushes / deletions / linear-history / enforce-admins)
|
|
488
|
+
* appeared in the change list. Used to decide whether the branch protection
|
|
489
|
+
* PUT needs to fire when status_checks + reviews are both in sync.
|
|
490
|
+
*/
|
|
491
|
+
function hasBranchProtectionLevelDiff(changes: string[]): boolean {
|
|
492
|
+
return changes.some((c) =>
|
|
493
|
+
/^(required_conversation_resolution|enforce_admins|allow_force_pushes|allow_deletions|required_linear_history):/.test(
|
|
494
|
+
c,
|
|
495
|
+
),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Design-critic lens config + gate (Track B, rc.15).
|
|
2
|
+
//
|
|
3
|
+
// The design-critic is an OPTIONAL, off-by-default visual review pass that
|
|
4
|
+
// renders the changed UI (light + dark) and critiques it against `kind: design`
|
|
5
|
+
// skills. It is gated tightly so it only ever runs — and only ever costs — when
|
|
6
|
+
// a repo has explicitly opted in. This module is the shared, pure brain: the
|
|
7
|
+
// local recipe (`review-prompt`) and the hosted bot both resolve the config and
|
|
8
|
+
// the run-gate here so the policy can't fork (SPEC §11.5 / §12).
|
|
9
|
+
|
|
10
|
+
import type { ReviewTrigger } from './plan-review.js';
|
|
11
|
+
|
|
12
|
+
/** How design findings interact with the merge gate. */
|
|
13
|
+
export type DesignGate = 'advisory' | 'strict';
|
|
14
|
+
|
|
15
|
+
/** Resolved `.clud-bug.json` `design` block (defaults applied). */
|
|
16
|
+
export interface DesignConfig {
|
|
17
|
+
/** Master switch. Default OFF — the design pass never runs unless this is true. */
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* `advisory` (default) — design findings post as comments, never block merge.
|
|
21
|
+
* `strict` — a design `critical` turns the check RED (opt-in).
|
|
22
|
+
*/
|
|
23
|
+
gate: DesignGate;
|
|
24
|
+
/** Themes to render + critique. Default both. */
|
|
25
|
+
themes: string[];
|
|
26
|
+
/** Viewports to render. Default a single desktop viewport. */
|
|
27
|
+
viewports: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Off-by-default builtin — the cost-control floor. */
|
|
31
|
+
export const BUILTIN_DESIGN_CONFIG: DesignConfig = {
|
|
32
|
+
enabled: false,
|
|
33
|
+
gate: 'advisory',
|
|
34
|
+
themes: ['light', 'dark'],
|
|
35
|
+
viewports: ['desktop'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read + normalize the `design` block from a parsed `.clud-bug.json` manifest.
|
|
40
|
+
* Tolerant: a missing/malformed block resolves to the off-by-default builtin,
|
|
41
|
+
* so a typo can never silently *enable* the (cost-bearing) pass.
|
|
42
|
+
*/
|
|
43
|
+
export function readDesignConfig(manifest: unknown): DesignConfig {
|
|
44
|
+
const raw = (manifest as { design?: unknown } | null | undefined)?.design;
|
|
45
|
+
if (!raw || typeof raw !== 'object') return { ...BUILTIN_DESIGN_CONFIG };
|
|
46
|
+
const d = raw as Record<string, unknown>;
|
|
47
|
+
const strArr = (v: unknown, fallback: string[]): string[] =>
|
|
48
|
+
Array.isArray(v) && v.length > 0 ? v.map(String) : [...fallback];
|
|
49
|
+
return {
|
|
50
|
+
enabled: d['enabled'] === true,
|
|
51
|
+
gate: d['gate'] === 'strict' ? 'strict' : 'advisory',
|
|
52
|
+
themes: strArr(d['themes'], BUILTIN_DESIGN_CONFIG.themes),
|
|
53
|
+
viewports: strArr(d['viewports'], BUILTIN_DESIGN_CONFIG.viewports),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Consumer-agnostic run-gate for the design-critic. Pure.
|
|
59
|
+
*
|
|
60
|
+
* True only when the repo opted in (`enabled`), at least one `kind: design`
|
|
61
|
+
* skill applies, and this is a PR-level review (the pass is too expensive for
|
|
62
|
+
* per-commit / per-push triggers). Consumers layer their own runtime
|
|
63
|
+
* preconditions on top: the local recipe defers the deploy-preview-URL check to
|
|
64
|
+
* the agent; the hosted bot additionally requires a paying tier + a resolved
|
|
65
|
+
* preview URL before it spends a render.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldRunDesign(
|
|
68
|
+
config: DesignConfig,
|
|
69
|
+
designSkillCount: number,
|
|
70
|
+
trigger: ReviewTrigger,
|
|
71
|
+
): boolean {
|
|
72
|
+
return config.enabled && designSkillCount > 0 && trigger === 'pr';
|
|
73
|
+
}
|