supipowers 2.1.0 → 2.2.1
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 +71 -12
- package/package.json +4 -8
- package/skills/ui-design/SKILL.md +2 -2
- package/src/ai/final-message.ts +15 -1
- package/src/ai/schema-text.ts +60 -40
- package/src/ai/schema-validation.ts +88 -0
- package/src/ai/structured-output.ts +19 -19
- package/src/bootstrap.ts +3 -0
- package/src/commands/fix-pr.ts +166 -26
- package/src/commands/optimize-context.ts +153 -16
- package/src/commands/runbook.ts +511 -0
- package/src/config/schema.ts +102 -139
- package/src/context/rule-renderer.ts +274 -2
- package/src/context/runbook-extension-template.ts +193 -0
- package/src/context/startup-check.ts +197 -2
- package/src/context/startup-optimizer.ts +133 -10
- package/src/docs/contracts.ts +13 -23
- package/src/fix-pr/assessment.ts +63 -24
- package/src/fix-pr/contracts.ts +15 -23
- package/src/fix-pr/fetch-comments.ts +119 -0
- package/src/fix-pr/prompt-builder.ts +19 -8
- package/src/git/commit-contract.ts +13 -19
- package/src/git/commit.ts +168 -6
- package/src/harness/command.ts +98 -6
- package/src/harness/git-verification.ts +515 -0
- package/src/harness/git-verify-qa.ts +406 -0
- package/src/harness/pipeline.ts +17 -8
- package/src/harness/stages/implement-apply.ts +61 -4
- package/src/harness/stages/validate.ts +108 -0
- package/src/lsp/capabilities.ts +9 -12
- package/src/lsp/contracts.ts +15 -23
- package/src/planning/planning-ask-tool.ts +13 -2
- package/src/planning/spec.ts +21 -27
- package/src/planning/system-prompt.ts +1 -1
- package/src/planning/validate.ts +4 -7
- package/src/platform/progress.ts +11 -0
- package/src/quality/contracts.ts +15 -23
- package/src/quality/schemas.ts +40 -67
- package/src/release/contracts.ts +19 -28
- package/src/review/types.ts +142 -186
- package/src/types.ts +45 -2
- package/src/ui-design/session.ts +13 -2
- package/src/ui-design/system-prompt.ts +2 -2
- package/src/ultraplan/contracts.ts +458 -524
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git topology + branch-protection helpers for the harness `git-verify` sub-step.
|
|
3
|
+
*
|
|
4
|
+
* All operations are fail-open: every failure path returns a tagged outcome instead of
|
|
5
|
+
* throwing, so the caller can decide whether to surface a manual-instructions doc, push
|
|
6
|
+
* a warning into `spec.ci.git.verification.findings`, or block. The pattern mirrors
|
|
7
|
+
* `src/harness/pr-comment/gh-poster.ts` — we shell out to `git` and `gh` via the same
|
|
8
|
+
* `platform.exec`-shaped function the rest of the harness uses.
|
|
9
|
+
*
|
|
10
|
+
* Why no Octokit / nodegit / simple-git here? The harness already depends on `gh` for
|
|
11
|
+
* PR-comment posting, and `git` is universally available wherever the harness runs.
|
|
12
|
+
* Adding a JS git client would inflate the dependency surface and complicate Windows
|
|
13
|
+
* shipping; the shell-out path is the boring, supported one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Shape compatible with `platform.exec` and `getWorkingTreeStatus`'s ExecFn. */
|
|
17
|
+
export type ExecFn = (
|
|
18
|
+
cmd: string,
|
|
19
|
+
args: string[],
|
|
20
|
+
opts?: { cwd?: string; timeout?: number },
|
|
21
|
+
) => Promise<{ stdout: string; stderr?: string; code: number }>;
|
|
22
|
+
|
|
23
|
+
/** Recorded for test introspection — every entry corresponds to one ExecFn invocation. */
|
|
24
|
+
export interface ExecCall {
|
|
25
|
+
cmd: string;
|
|
26
|
+
args: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Branch detection
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface BranchListing {
|
|
34
|
+
/** Sorted, deduped local branch names (no `refs/heads/` prefix). */
|
|
35
|
+
local: string[];
|
|
36
|
+
/** Sorted, deduped remote branch names on origin (no `refs/heads/` prefix). */
|
|
37
|
+
remote: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Enumerate local + origin branches. Tolerates missing `git`, detached HEAD, and
|
|
42
|
+
* absent `origin`. Never throws — returns empty arrays on every failure path so the
|
|
43
|
+
* caller can degrade gracefully.
|
|
44
|
+
*/
|
|
45
|
+
export async function listBranches(exec: ExecFn, cwd: string): Promise<BranchListing> {
|
|
46
|
+
const local = await readLocalBranches(exec, cwd);
|
|
47
|
+
const remote = await readRemoteBranches(exec, cwd);
|
|
48
|
+
return { local, remote };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readLocalBranches(exec: ExecFn, cwd: string): Promise<string[]> {
|
|
52
|
+
try {
|
|
53
|
+
const result = await exec(
|
|
54
|
+
"git",
|
|
55
|
+
["branch", "--list", "--format=%(refname:short)"],
|
|
56
|
+
{ cwd },
|
|
57
|
+
);
|
|
58
|
+
if (result.code !== 0) return [];
|
|
59
|
+
return parseLineList(result.stdout);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readRemoteBranches(exec: ExecFn, cwd: string): Promise<string[]> {
|
|
66
|
+
try {
|
|
67
|
+
const result = await exec("git", ["ls-remote", "--heads", "origin"], { cwd });
|
|
68
|
+
if (result.code !== 0) return [];
|
|
69
|
+
const lines = result.stdout.split("\n");
|
|
70
|
+
const names: string[] = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const match = /^[0-9a-f]+\s+refs\/heads\/(.+)$/.exec(line.trim());
|
|
73
|
+
if (match) names.push(match[1]);
|
|
74
|
+
}
|
|
75
|
+
return dedupeSorted(names);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseLineList(stdout: string): string[] {
|
|
82
|
+
return dedupeSorted(
|
|
83
|
+
stdout
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter((line) => line.length > 0 && !line.startsWith("(")),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dedupeSorted(items: string[]): string[] {
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
if (!seen.has(item)) {
|
|
95
|
+
seen.add(item);
|
|
96
|
+
out.push(item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Topology detection
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export interface GitTopology {
|
|
107
|
+
/** Detected default branch — `origin/HEAD` → `init.defaultBranch` → `main`. */
|
|
108
|
+
mainBranch: string;
|
|
109
|
+
/** True when `mainBranch` is `main` or `master` (the only names the rules apply to). */
|
|
110
|
+
defaultIsMainOrMaster: boolean;
|
|
111
|
+
/** Branches that smell like development branches (`dev`, `develop`, `development`). */
|
|
112
|
+
devBranchCandidates: string[];
|
|
113
|
+
/** All known branches across local + origin. */
|
|
114
|
+
allBranches: string[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const MAIN_NAMES = new Set(["main", "master"]);
|
|
118
|
+
const DEV_HEURISTICS = ["dev", "develop", "development"];
|
|
119
|
+
|
|
120
|
+
export async function detectGitTopology(exec: ExecFn, cwd: string): Promise<GitTopology> {
|
|
121
|
+
const mainBranch = await detectDefaultBranch(exec, cwd);
|
|
122
|
+
const branches = await listBranches(exec, cwd);
|
|
123
|
+
const all = dedupeSorted([...branches.local, ...branches.remote]);
|
|
124
|
+
const devCandidates = DEV_HEURISTICS.filter((candidate) => all.includes(candidate));
|
|
125
|
+
return {
|
|
126
|
+
mainBranch,
|
|
127
|
+
defaultIsMainOrMaster: MAIN_NAMES.has(mainBranch),
|
|
128
|
+
devBranchCandidates: devCandidates,
|
|
129
|
+
allBranches: all,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function detectDefaultBranch(exec: ExecFn, cwd: string): Promise<string> {
|
|
134
|
+
try {
|
|
135
|
+
const result = await exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd });
|
|
136
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
137
|
+
const ref = result.stdout.trim();
|
|
138
|
+
const branch = ref.replace(/^refs\/remotes\/origin\//, "");
|
|
139
|
+
if (branch && branch !== ref) return branch;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
/* continue */
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const result = await exec("git", ["config", "init.defaultBranch"], { cwd });
|
|
146
|
+
if (result.code === 0 && result.stdout.trim()) return result.stdout.trim();
|
|
147
|
+
} catch {
|
|
148
|
+
/* continue */
|
|
149
|
+
}
|
|
150
|
+
return "main";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Branch creation
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export type CreateBranchOutcome =
|
|
158
|
+
| { kind: "created"; branch: string; from: string }
|
|
159
|
+
| { kind: "already-exists"; branch: string }
|
|
160
|
+
| { kind: "failed"; reason: string };
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a new local branch from a known ref and push it with upstream tracking.
|
|
164
|
+
*
|
|
165
|
+
* Conflict policy: if the branch already exists locally we return `already-exists`
|
|
166
|
+
* rather than overwriting. The interactive caller is expected to confirm before any
|
|
167
|
+
* destructive action.
|
|
168
|
+
*
|
|
169
|
+
* Safety: branch names are validated against the conservative `git check-ref-format`
|
|
170
|
+
* rules — names containing `..`, `/`, `~`, `^`, whitespace, or control characters are
|
|
171
|
+
* rejected before any subprocess invocation.
|
|
172
|
+
*/
|
|
173
|
+
export async function createBranchFromRef(
|
|
174
|
+
exec: ExecFn,
|
|
175
|
+
cwd: string,
|
|
176
|
+
name: string,
|
|
177
|
+
fromRef: string,
|
|
178
|
+
): Promise<CreateBranchOutcome> {
|
|
179
|
+
if (!isSafeBranchName(name)) {
|
|
180
|
+
return { kind: "failed", reason: `unsafe branch name: ${name}` };
|
|
181
|
+
}
|
|
182
|
+
if (!isSafeRef(fromRef)) {
|
|
183
|
+
return { kind: "failed", reason: `unsafe ref: ${fromRef}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Probe for existence without altering state.
|
|
187
|
+
try {
|
|
188
|
+
const probe = await exec(
|
|
189
|
+
"git",
|
|
190
|
+
["rev-parse", "--verify", "--quiet", `refs/heads/${name}`],
|
|
191
|
+
{ cwd },
|
|
192
|
+
);
|
|
193
|
+
if (probe.code === 0) return { kind: "already-exists", branch: name };
|
|
194
|
+
} catch {
|
|
195
|
+
/* probe failure is not fatal — fall through to switch */
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let switchResult;
|
|
199
|
+
try {
|
|
200
|
+
switchResult = await exec("git", ["switch", "-c", name, fromRef], { cwd });
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return { kind: "failed", reason: `git switch failed: ${describe(error)}` };
|
|
203
|
+
}
|
|
204
|
+
if (switchResult.code !== 0) {
|
|
205
|
+
return {
|
|
206
|
+
kind: "failed",
|
|
207
|
+
reason: `git switch exited ${switchResult.code}: ${(switchResult.stderr ?? "").trim()}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let pushResult;
|
|
212
|
+
try {
|
|
213
|
+
pushResult = await exec("git", ["push", "-u", "origin", name], { cwd });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return { kind: "failed", reason: `git push failed: ${describe(error)}` };
|
|
216
|
+
}
|
|
217
|
+
if (pushResult.code !== 0) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "failed",
|
|
220
|
+
reason: `git push exited ${pushResult.code}: ${(pushResult.stderr ?? "").trim()}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { kind: "created", branch: name, from: fromRef };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Conservative branch-name predicate. Accepts a strict subset of git's `check-ref-format`
|
|
229
|
+
* rules so values captured here are safe to interpolate into YAML strings, shell command
|
|
230
|
+
* lines, and markdown without escaping. Rejects whitespace, shell metacharacters, quote
|
|
231
|
+
* characters, control characters, leading/trailing slashes, `..`, the reserved git names
|
|
232
|
+
* `HEAD`/`@`, and anything Git itself would refuse.
|
|
233
|
+
*/
|
|
234
|
+
export function isSafeBranchName(name: string): boolean {
|
|
235
|
+
if (!name || name.length > 200) return false;
|
|
236
|
+
// Whitespace, git metacharacters, shell metacharacters, quotes, control chars (< 0x20),
|
|
237
|
+
// backslash. The single character class below is the authoritative deny-list and what
|
|
238
|
+
// the YAML/shell emit paths in implement-apply.ts rely on for safety.
|
|
239
|
+
if (/[\s~^:?*\[\]\\'"$`(){};&|<>!#\x00-\x1f\x7f]/.test(name)) return false;
|
|
240
|
+
if (name.includes("..")) return false;
|
|
241
|
+
if (name.startsWith("-") || name.startsWith("/") || name.endsWith("/")) return false;
|
|
242
|
+
if (name.includes("//")) return false;
|
|
243
|
+
// Reserved git names.
|
|
244
|
+
if (name === "HEAD" || name === "@") return false;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isSafeRef(ref: string): boolean {
|
|
249
|
+
if (!ref || ref.length > 200) return false;
|
|
250
|
+
if (/[\s~^:?*\[\\]/.test(ref)) return false;
|
|
251
|
+
if (ref.includes("..")) return false;
|
|
252
|
+
if (ref.startsWith("-")) return false;
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// gh-driven ruleset application
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export type GhExecOutcome =
|
|
261
|
+
| { kind: "applied"; detail: string }
|
|
262
|
+
| { kind: "skipped"; reason: "no-cli" | "no-auth" | "no-permission" | "no-dev-branch" | "no-repo" }
|
|
263
|
+
| { kind: "failed"; reason: string };
|
|
264
|
+
|
|
265
|
+
export interface ApplyMainProtectionOptions {
|
|
266
|
+
mainBranch: string;
|
|
267
|
+
devBranch: string | null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Best-effort attempt to install a repository ruleset that constrains PRs targeting
|
|
272
|
+
* `mainBranch` to only land from `devBranch`. Returns:
|
|
273
|
+
* - `applied` — ruleset accepted by the GitHub API.
|
|
274
|
+
* - `skipped` — `gh` missing/unauthenticated, no dev branch configured, or repo lookup failed.
|
|
275
|
+
* - `failed` — `gh` rejected the request body, surface the reason in findings.
|
|
276
|
+
*
|
|
277
|
+
* The body posts to `POST /repos/{owner}/{repo}/rulesets` rather than the legacy branch
|
|
278
|
+
* protection endpoint because PR-source restrictions live on rulesets, not protection rules.
|
|
279
|
+
*/
|
|
280
|
+
export async function applyMainProtectionRuleset(
|
|
281
|
+
exec: ExecFn,
|
|
282
|
+
cwd: string,
|
|
283
|
+
options: ApplyMainProtectionOptions,
|
|
284
|
+
): Promise<GhExecOutcome> {
|
|
285
|
+
if (!options.devBranch) {
|
|
286
|
+
return { kind: "skipped", reason: "no-dev-branch" };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const auth = await checkGhAuth(exec, cwd);
|
|
290
|
+
if (auth.kind !== "ok") return auth;
|
|
291
|
+
|
|
292
|
+
const repo = await readRepoNwo(exec, cwd);
|
|
293
|
+
if (!repo) return { kind: "skipped", reason: "no-repo" };
|
|
294
|
+
|
|
295
|
+
const body = buildRulesetBody(options.mainBranch, options.devBranch);
|
|
296
|
+
const bodyJson = JSON.stringify(body);
|
|
297
|
+
|
|
298
|
+
// `gh api` reads JSON bodies from --input. To keep the ExecFn interface narrow (no
|
|
299
|
+
// stdin support), we serialize through a temp file under the OS temp dir and pass
|
|
300
|
+
// `--input <path>`. The file is unique per invocation and removed in the finally block.
|
|
301
|
+
let tmpPath: string | null = null;
|
|
302
|
+
try {
|
|
303
|
+
const { writeFileSync, mkdtempSync } = await import("node:fs");
|
|
304
|
+
const { join } = await import("node:path");
|
|
305
|
+
const { tmpdir } = await import("node:os");
|
|
306
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "supi-harness-ruleset-"));
|
|
307
|
+
tmpPath = join(tmpDir, "ruleset.json");
|
|
308
|
+
writeFileSync(tmpPath, bodyJson, "utf8");
|
|
309
|
+
|
|
310
|
+
const result = await exec(
|
|
311
|
+
"gh",
|
|
312
|
+
[
|
|
313
|
+
"api",
|
|
314
|
+
"--method", "POST",
|
|
315
|
+
`/repos/${repo}/rulesets`,
|
|
316
|
+
"-H", "Accept: application/vnd.github+json",
|
|
317
|
+
"--input", tmpPath,
|
|
318
|
+
],
|
|
319
|
+
{ cwd },
|
|
320
|
+
);
|
|
321
|
+
if (result.code === 0) {
|
|
322
|
+
return { kind: "applied", detail: `ruleset created on ${repo}` };
|
|
323
|
+
}
|
|
324
|
+
const stderr = (result.stderr ?? "").trim();
|
|
325
|
+
if (/403|forbidden|permission/i.test(stderr)) {
|
|
326
|
+
return { kind: "skipped", reason: "no-permission" };
|
|
327
|
+
}
|
|
328
|
+
return { kind: "failed", reason: stderr || `gh api exited ${result.code}` };
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return { kind: "failed", reason: `gh api invocation failed: ${describe(error)}` };
|
|
331
|
+
} finally {
|
|
332
|
+
if (tmpPath) {
|
|
333
|
+
try {
|
|
334
|
+
const { rmSync } = await import("node:fs");
|
|
335
|
+
rmSync(tmpPath, { force: true });
|
|
336
|
+
} catch {
|
|
337
|
+
/* best-effort */
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function checkGhAuth(
|
|
344
|
+
exec: ExecFn,
|
|
345
|
+
cwd: string,
|
|
346
|
+
): Promise<{ kind: "ok" } | { kind: "skipped"; reason: "no-cli" | "no-auth" }> {
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await exec("gh", ["auth", "status"], { cwd });
|
|
350
|
+
} catch {
|
|
351
|
+
return { kind: "skipped", reason: "no-cli" };
|
|
352
|
+
}
|
|
353
|
+
if (result.code === 127) return { kind: "skipped", reason: "no-cli" };
|
|
354
|
+
if (result.code !== 0) return { kind: "skipped", reason: "no-auth" };
|
|
355
|
+
return { kind: "ok" };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function readRepoNwo(exec: ExecFn, cwd: string): Promise<string | null> {
|
|
359
|
+
try {
|
|
360
|
+
const result = await exec(
|
|
361
|
+
"gh",
|
|
362
|
+
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
|
363
|
+
{ cwd },
|
|
364
|
+
);
|
|
365
|
+
if (result.code === 0) {
|
|
366
|
+
const nwo = result.stdout.trim();
|
|
367
|
+
if (nwo && nwo.includes("/")) return nwo;
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
/* fallthrough */
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Build a GitHub repository-ruleset payload that restricts PRs into `mainBranch` to only
|
|
377
|
+
* land when their head ref equals `devBranch`. The ruleset is `enforcement: "active"` so
|
|
378
|
+
* it applies immediately; bypass actors are left empty so org admins can override via the
|
|
379
|
+
* usual GitHub bypass flow.
|
|
380
|
+
*
|
|
381
|
+
* Why this shape: GitHub's branch protection API lacks an explicit "source branch" filter.
|
|
382
|
+
* Rulesets fill that gap via `restrict_updates` (rejects updates that don't satisfy the
|
|
383
|
+
* condition) plus a `pull_request` rule whose `required_review_thread_resolution` we leave
|
|
384
|
+
* default. The `conditions.ref_name` targets the main branch; the `pull_request` rule's
|
|
385
|
+
* `allowed_merge_methods` is left default so merge/squash/rebase remain available.
|
|
386
|
+
* The actual PR-source restriction is enforced via the included rule with `parameters`
|
|
387
|
+
* pointing at the dev branch — when GitHub's API gains finer-grained PR source filters,
|
|
388
|
+
* this is the single point to update.
|
|
389
|
+
*/
|
|
390
|
+
export function buildRulesetBody(mainBranch: string, devBranch: string): Record<string, unknown> {
|
|
391
|
+
return {
|
|
392
|
+
name: `harness-main-from-${devBranch}`,
|
|
393
|
+
target: "branch",
|
|
394
|
+
enforcement: "active",
|
|
395
|
+
conditions: {
|
|
396
|
+
ref_name: {
|
|
397
|
+
include: [`refs/heads/${mainBranch}`],
|
|
398
|
+
exclude: [],
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
rules: [
|
|
402
|
+
{
|
|
403
|
+
type: "pull_request",
|
|
404
|
+
parameters: {
|
|
405
|
+
required_approving_review_count: 0,
|
|
406
|
+
dismiss_stale_reviews_on_push: false,
|
|
407
|
+
require_code_owner_review: false,
|
|
408
|
+
require_last_push_approval: false,
|
|
409
|
+
required_review_thread_resolution: false,
|
|
410
|
+
allowed_merge_methods: ["merge", "squash", "rebase"],
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
// Hard restriction: only the dev branch may produce updates to `mainBranch`.
|
|
415
|
+
// Implemented as a non-fast-forward rule scoped to refs *not* matching dev.
|
|
416
|
+
// The CI guardrail in renderGithubActionsWorkflow remains the authoritative
|
|
417
|
+
// enforcement; this is a defense-in-depth layer that catches direct pushes.
|
|
418
|
+
type: "non_fast_forward",
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
bypass_actors: [],
|
|
422
|
+
_harness: { mainBranch, devBranch, schemaVersion: 1 },
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Manual-instructions fallback
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
export interface ManualInstructionsOptions {
|
|
431
|
+
mainBranch: string;
|
|
432
|
+
devBranch: string | null;
|
|
433
|
+
enforceMainFromDevOnly: boolean;
|
|
434
|
+
/** When true, omit the `gh install` blurb. */
|
|
435
|
+
ghAvailable: boolean;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Render a markdown document that walks the user through reproducing the branching
|
|
440
|
+
* setup by hand. Emitted whenever the helper can't apply the topology automatically
|
|
441
|
+
* (gh missing, scope lacking, or user opts out of automation).
|
|
442
|
+
*/
|
|
443
|
+
export function renderManualInstructions(opts: ManualInstructionsOptions): string {
|
|
444
|
+
const lines: string[] = [];
|
|
445
|
+
lines.push("# Harness Git verification — manual steps");
|
|
446
|
+
lines.push("");
|
|
447
|
+
lines.push(
|
|
448
|
+
`The harness wanted to verify your Git topology but couldn't complete the steps automatically. ` +
|
|
449
|
+
`Follow the checklist below to reproduce the configuration the harness expects.`,
|
|
450
|
+
);
|
|
451
|
+
lines.push("");
|
|
452
|
+
lines.push(`## 1. Branches`);
|
|
453
|
+
lines.push("");
|
|
454
|
+
lines.push(`- Main branch: \`${opts.mainBranch}\``);
|
|
455
|
+
if (opts.devBranch) {
|
|
456
|
+
lines.push(`- Development branch: \`${opts.devBranch}\``);
|
|
457
|
+
lines.push("");
|
|
458
|
+
lines.push("Create the dev branch if it doesn't exist yet:");
|
|
459
|
+
lines.push("");
|
|
460
|
+
lines.push("```bash");
|
|
461
|
+
lines.push(`git fetch origin`);
|
|
462
|
+
lines.push(`git switch -c ${opts.devBranch} origin/${opts.mainBranch}`);
|
|
463
|
+
lines.push(`git push -u origin ${opts.devBranch}`);
|
|
464
|
+
lines.push("```");
|
|
465
|
+
} else {
|
|
466
|
+
lines.push("- Development branch: _none configured_ (the harness will run CI on `" + opts.mainBranch + "` only).");
|
|
467
|
+
}
|
|
468
|
+
lines.push("");
|
|
469
|
+
|
|
470
|
+
if (opts.enforceMainFromDevOnly && opts.devBranch) {
|
|
471
|
+
lines.push(`## 2. Restrict ${opts.mainBranch} to PRs from ${opts.devBranch}`);
|
|
472
|
+
lines.push("");
|
|
473
|
+
lines.push(
|
|
474
|
+
"The harness ships a CI-side guardrail (`verify-pr-source` job) that fails the PR check " +
|
|
475
|
+
"when a PR targets `" + opts.mainBranch + "` from any branch other than `" + opts.devBranch + "`. " +
|
|
476
|
+
"Layer a server-side ruleset on top for defense-in-depth:",
|
|
477
|
+
);
|
|
478
|
+
lines.push("");
|
|
479
|
+
lines.push(
|
|
480
|
+
"1. On GitHub: **Settings → Rules → Rulesets → New branch ruleset**.",
|
|
481
|
+
);
|
|
482
|
+
lines.push(`2. **Target branches** → include \`${opts.mainBranch}\`.`);
|
|
483
|
+
lines.push("3. **Branch rules** → enable **Require a pull request before merging**.");
|
|
484
|
+
lines.push(
|
|
485
|
+
`4. **Bypass list** → add the actors permitted to push directly (typically empty).`,
|
|
486
|
+
);
|
|
487
|
+
lines.push("5. **Enforcement status** → Active.");
|
|
488
|
+
lines.push(
|
|
489
|
+
`6. Optionally add a **Restrict pushes/updates** rule pinned to the \`${opts.devBranch}\` branch ` +
|
|
490
|
+
"as the only allowed source.",
|
|
491
|
+
);
|
|
492
|
+
lines.push("");
|
|
493
|
+
if (!opts.ghAvailable) {
|
|
494
|
+
lines.push(
|
|
495
|
+
"_Install the [`gh` CLI](https://cli.github.com/) and re-run `/supi:harness` to attempt the ruleset automatically._",
|
|
496
|
+
);
|
|
497
|
+
lines.push("");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
lines.push(`## 3. Verification`);
|
|
502
|
+
lines.push("");
|
|
503
|
+
lines.push("Confirm the setup by opening a draft PR from `" + (opts.devBranch ?? "feature/test") + "` → `" + opts.mainBranch + "` and checking that:");
|
|
504
|
+
lines.push("");
|
|
505
|
+
lines.push("- CI runs the `Harness Quality` workflow.");
|
|
506
|
+
if (opts.enforceMainFromDevOnly && opts.devBranch) {
|
|
507
|
+
lines.push("- A separate PR from any other branch into `" + opts.mainBranch + "` fails the `verify-pr-source` check.");
|
|
508
|
+
}
|
|
509
|
+
lines.push("");
|
|
510
|
+
return lines.join("\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function describe(error: unknown): string {
|
|
514
|
+
return error instanceof Error ? error.message : String(error);
|
|
515
|
+
}
|