@guilz-dev/sdlc-gh 0.1.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.
- package/.github/CODEOWNERS +5 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +68 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +39 -0
- package/.github/ISSUE_TEMPLATE/support.yml +56 -0
- package/.github/ISSUE_TEMPLATE/task.yml +89 -0
- package/.github/agents/implementer.agent.md +17 -0
- package/.github/agents/reviewer.agent.md +18 -0
- package/.github/agents/triager.agent.md +13 -0
- package/.github/aw/actions-lock.json +9 -0
- package/.github/copilot-instructions.md +35 -0
- package/.github/hooks/hooks.json +12 -0
- package/.github/instructions/core.instructions.md +11 -0
- package/.github/instructions/profiles/go.instructions.md +10 -0
- package/.github/instructions/profiles/php.instructions.md +11 -0
- package/.github/instructions/profiles/python.instructions.md +11 -0
- package/.github/instructions/profiles/ruby.instructions.md +11 -0
- package/.github/instructions/profiles/typescript.instructions.md +11 -0
- package/.github/labels.yml +55 -0
- package/.github/pull_request_template.md +33 -0
- package/.github/ruleset.example.json +33 -0
- package/.github/ruleset.harness-eval.example.json +29 -0
- package/.github/skills/quality-loop/SKILL.md +23 -0
- package/.github/workflows/agent-retry-orchestrator.yml +161 -0
- package/.github/workflows/copilot-setup-steps.yml +64 -0
- package/.github/workflows/eval-ci.yml +169 -0
- package/.github/workflows/eval-drift.yml +75 -0
- package/.github/workflows/gh-aw-dogfood-ci.yml +73 -0
- package/.github/workflows/harness-ci.yml +244 -0
- package/.github/workflows/harness-sync.yml +28 -0
- package/.github/workflows/l1-readiness-check.yml +45 -0
- package/.github/workflows/labels-sync.yml +24 -0
- package/.github/workflows/nightly-harness-review.lock.yml +1643 -0
- package/.github/workflows/nightly-harness-review.md +87 -0
- package/.github/workflows/nightly-harness-review.yml +63 -0
- package/.github/workflows/npm-publish.yml +49 -0
- package/.github/workflows/pr-context-comment.yml +138 -0
- package/.github/workflows/product-ci-go.yml +33 -0
- package/.github/workflows/product-ci-php.yml +39 -0
- package/.github/workflows/product-ci-python.yml +34 -0
- package/.github/workflows/product-ci-ruby.yml +35 -0
- package/.github/workflows/product-ci-ts.yml +37 -0
- package/.github/workflows/task-issue-label-sync.yml +50 -0
- package/.github/workflows/weekly-redteam.lock.yml +1571 -0
- package/.github/workflows/weekly-redteam.md +76 -0
- package/.github/zizmor.yml +11 -0
- package/AGENTS.md +54 -0
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/config/stacks.json +55 -0
- package/docs/adoption.md +126 -0
- package/docs/arch.md +535 -0
- package/docs/auth-boundaries.md +16 -0
- package/docs/coding-agent-l1.md +152 -0
- package/docs/exceptions/README.md +25 -0
- package/docs/exceptions/TEMPLATE.md +8 -0
- package/docs/failure-taxonomy.md +23 -0
- package/docs/gh-aw-dogfood.md +109 -0
- package/docs/kpi-baseline.md +9 -0
- package/docs/nightly-harness-review.md +94 -0
- package/docs/operations.md +108 -0
- package/docs/publishing.md +79 -0
- package/docs/revert-playbook.md +44 -0
- package/docs/shared-config.md +30 -0
- package/docs/telemetry-artifacts.md +78 -0
- package/docs/telemetry-schema.md +60 -0
- package/evals/.score-baseline.json +6 -0
- package/evals/e2e-bench/README.md +28 -0
- package/evals/e2e-bench/manifest.json +16 -0
- package/evals/e2e-bench/tasks/e2e-001.yml +10 -0
- package/evals/e2e-bench/tasks/e2e-002.yml +11 -0
- package/evals/e2e-bench/tasks/e2e-003.yml +10 -0
- package/evals/e2e-bench/tasks/e2e-004.yml +14 -0
- package/evals/e2e-bench/tasks/e2e-005.yml +11 -0
- package/evals/e2e-bench/tasks/e2e-006.yml +10 -0
- package/evals/e2e-bench/tasks/e2e-007.yml +10 -0
- package/evals/e2e-bench/tasks/e2e-008.yml +10 -0
- package/evals/e2e-bench/tasks/e2e-009.yml +10 -0
- package/evals/trajectories/rubric.md +12 -0
- package/evals/trajectories/test_harness_conventions.py +271 -0
- package/infra/README.md +49 -0
- package/infra/langfuse/docker-compose.yml +25 -0
- package/infra/otel/collector-config.yml +24 -0
- package/infra/samples/gh-aw-dogfood-report.json +44 -0
- package/infra/samples/harness-review-routing-plan.json +19 -0
- package/infra/samples/harness-review-summary.json +61 -0
- package/infra/samples/telemetry-artifact.json +29 -0
- package/infra/samples/telemetry-payload.json +19 -0
- package/package.json +85 -0
- package/prompts/triager-classify.prompt.yml +10 -0
- package/sample/go/add.go +5 -0
- package/sample/go/add_test.go +9 -0
- package/sample/go/go.mod +3 -0
- package/sample/php/composer.json +26 -0
- package/sample/php/composer.lock +1881 -0
- package/sample/php/phpunit.xml +8 -0
- package/sample/php/src/Add.php +13 -0
- package/sample/php/tests/AddTest.php +16 -0
- package/sample/python/requirements-dev.txt +2 -0
- package/sample/python/src/__init__.py +0 -0
- package/sample/python/src/greet.py +3 -0
- package/sample/python/tests/conftest.py +4 -0
- package/sample/python/tests/test_greet.py +5 -0
- package/sample/ruby/.rubocop.yml +10 -0
- package/sample/ruby/Gemfile +6 -0
- package/sample/ruby/Gemfile.lock +58 -0
- package/sample/ruby/lib/add.rb +9 -0
- package/sample/ruby/spec/add_spec.rb +11 -0
- package/sample/ts/biome.json +6 -0
- package/sample/ts/package-lock.json +1763 -0
- package/sample/ts/package.json +15 -0
- package/sample/ts/src/add.ts +3 -0
- package/sample/ts/tests/add.test.ts +8 -0
- package/sample/ts/tsconfig.json +12 -0
- package/scripts/aggregate-harness-review.mjs +48 -0
- package/scripts/bootstrap-harness.sh +411 -0
- package/scripts/check-diff-size.mjs +46 -0
- package/scripts/check-e2e-manifest.mjs +35 -0
- package/scripts/check-eval-score-drift.mjs +31 -0
- package/scripts/check-gh-aw-dogfood-scope.mjs +51 -0
- package/scripts/check-issue-spec.mjs +215 -0
- package/scripts/check-l1-readiness.mjs +82 -0
- package/scripts/check-open-pr-limit.mjs +34 -0
- package/scripts/doctor.mjs +177 -0
- package/scripts/emit-gh-aw-dogfood-report.mjs +112 -0
- package/scripts/emit-telemetry-artifact.mjs +99 -0
- package/scripts/fetch-telemetry-artifacts.mjs +176 -0
- package/scripts/harness-drift-report.mjs +99 -0
- package/scripts/lib/bootstrap-copy.mjs +123 -0
- package/scripts/lib/ccsd-contract.mjs +212 -0
- package/scripts/lib/diff-size.mjs +103 -0
- package/scripts/lib/doctor-local.mjs +179 -0
- package/scripts/lib/e2e-manifest.mjs +76 -0
- package/scripts/lib/gh-aw-dogfood.mjs +293 -0
- package/scripts/lib/github-config.mjs +94 -0
- package/scripts/lib/harness-ci-fragments.mjs +98 -0
- package/scripts/lib/harness-review-routing.mjs +244 -0
- package/scripts/lib/harness-review.mjs +388 -0
- package/scripts/lib/issue-form-label-sync.mjs +56 -0
- package/scripts/lib/l1-readiness.mjs +258 -0
- package/scripts/lib/merge-harness-package.mjs +36 -0
- package/scripts/lib/npm-package.mjs +129 -0
- package/scripts/lib/setup-wizard.mjs +224 -0
- package/scripts/lib/stacks.mjs +138 -0
- package/scripts/lib/telemetry-artifact.mjs +253 -0
- package/scripts/lib/template-root.mjs +39 -0
- package/scripts/merge-harness-package.mjs +14 -0
- package/scripts/route-harness-review.mjs +168 -0
- package/scripts/run-e2e-bench.mjs +216 -0
- package/scripts/sdlc-gh-cli.mjs +91 -0
- package/scripts/select-eval-jobs.mjs +41 -0
- package/scripts/setup-github.mjs +242 -0
- package/scripts/setup-github.sh +4 -0
- package/scripts/setup-wizard.mjs +426 -0
- package/scripts/test-bootstrap-guidance-scenarios.mjs +94 -0
- package/scripts/test-diff-size-scenarios.mjs +88 -0
- package/scripts/test-doctor-scenarios.mjs +70 -0
- package/scripts/test-e2e-manifest-scenarios.mjs +65 -0
- package/scripts/test-gh-aw-dogfood-scenarios.mjs +74 -0
- package/scripts/test-harness-review-routing-scenarios.mjs +130 -0
- package/scripts/test-harness-review-scenarios.mjs +92 -0
- package/scripts/test-hooks-scenarios.mjs +44 -0
- package/scripts/test-issue-form-label-sync-scenarios.mjs +48 -0
- package/scripts/test-issue-spec-scenarios.mjs +258 -0
- package/scripts/test-l1-readiness-scenarios.mjs +204 -0
- package/scripts/test-merge-harness-package-scenarios.mjs +53 -0
- package/scripts/test-npm-package-scenarios.mjs +31 -0
- package/scripts/test-sdlc-gh-cli-scenarios.mjs +54 -0
- package/scripts/test-setup-github-scenarios.mjs +103 -0
- package/scripts/test-setup-wizard-scenarios.mjs +114 -0
- package/scripts/test-telemetry-artifact-scenarios.mjs +69 -0
- package/scripts/trim-harness-ci.mjs +18 -0
- package/scripts/validate-gh-aw-compile.mjs +64 -0
- package/scripts/validate-harness.mjs +199 -0
- package/scripts/validate-telemetry.mjs +21 -0
- package/scripts/verify-bootstrap-stacks.sh +192 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawnSync, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { localChecks, result, resolveStackId } from "./doctor-local.mjs";
|
|
5
|
+
import { detectRepoProfile } from "./setup-wizard.mjs";
|
|
6
|
+
|
|
7
|
+
function resolveRepoRoot() {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
12
|
+
}).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return process.cwd();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ghJson(args, cwd) {
|
|
19
|
+
const out = spawnSync("gh", args, {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
if (out.status !== 0) {
|
|
25
|
+
throw new Error(out.stderr.trim() || "gh command failed");
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(out.stdout);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ghReady() {
|
|
31
|
+
if (spawnSync("gh", ["--version"], { stdio: "ignore" }).status !== 0) {
|
|
32
|
+
return { ok: false, reason: "gh is not installed" };
|
|
33
|
+
}
|
|
34
|
+
if (spawnSync("gh", ["auth", "status"], { stdio: "ignore" }).status !== 0) {
|
|
35
|
+
return { ok: false, reason: "gh is not authenticated" };
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, reason: "" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveGithubRepo(repoRoot, explicit) {
|
|
41
|
+
if (explicit) return explicit;
|
|
42
|
+
return ghJson(["repo", "view", "--json", "nameWithOwner"], repoRoot).nameWithOwner;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkLocalL1Assets(repoRoot) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
const requiredFiles = [
|
|
48
|
+
".github/ISSUE_TEMPLATE/task.yml",
|
|
49
|
+
".github/agents/triager.agent.md",
|
|
50
|
+
".github/agents/implementer.agent.md",
|
|
51
|
+
".github/workflows/copilot-setup-steps.yml",
|
|
52
|
+
".github/workflows/harness-ci.yml",
|
|
53
|
+
];
|
|
54
|
+
for (const file of requiredFiles) {
|
|
55
|
+
if (existsSync(join(repoRoot, file))) {
|
|
56
|
+
entries.push(result("PASS", file, "exists"));
|
|
57
|
+
} else {
|
|
58
|
+
entries.push(result("FAIL", file, "missing", "Run bootstrap/setup wizard and commit harness assets."));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return entries;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkGithubState(repoRoot, githubRepo, stackId) {
|
|
65
|
+
const entries = [];
|
|
66
|
+
|
|
67
|
+
if (!stackId) {
|
|
68
|
+
entries.push(
|
|
69
|
+
result(
|
|
70
|
+
"SKIP",
|
|
71
|
+
"L1 required checks",
|
|
72
|
+
"stack could not be resolved",
|
|
73
|
+
"Ensure exactly one product-ci-*.yml workflow exists or run setup-wizard.",
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let labels = [];
|
|
80
|
+
try {
|
|
81
|
+
labels = ghJson(["api", `repos/${githubRepo}/labels?per_page=100`], repoRoot);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
entries.push(result("SKIP", "GitHub labels", error.message, "Run `./scripts/setup-github.sh` to sync labels."));
|
|
84
|
+
}
|
|
85
|
+
if (labels.length > 0) {
|
|
86
|
+
const requiredLabels = ["task:docs", "task:test-fix", "autonomy:L1"];
|
|
87
|
+
const remote = new Set(labels.map((l) => l.name));
|
|
88
|
+
const missing = requiredLabels.filter((name) => !remote.has(name));
|
|
89
|
+
if (missing.length === 0) {
|
|
90
|
+
entries.push(result("PASS", "L1 labels", requiredLabels.join(", ")));
|
|
91
|
+
} else {
|
|
92
|
+
entries.push(result("FAIL", "L1 labels", `missing: ${missing.join(", ")}`, "Run `./scripts/setup-github.sh`."));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const rulesets = ghJson(["api", `repos/${githubRepo}/rulesets`], repoRoot);
|
|
98
|
+
const main = rulesets.find((r) => r.name === "main-protection");
|
|
99
|
+
if (!main) {
|
|
100
|
+
entries.push(result("FAIL", "main-protection ruleset", "missing", "Run `./scripts/setup-github.sh`."));
|
|
101
|
+
} else {
|
|
102
|
+
entries.push(result("PASS", "main-protection ruleset", "exists"));
|
|
103
|
+
const details = ghJson(["api", `repos/${githubRepo}/rulesets/${main.id}`], repoRoot);
|
|
104
|
+
const statusRule = (details.rules ?? []).find((rule) => rule.type === "required_status_checks");
|
|
105
|
+
const contexts = new Set((statusRule?.parameters?.required_status_checks ?? []).map((c) => c.context));
|
|
106
|
+
const expected = ["harness-static", "diff-size", "issue-spec-check", `product-ci-${stackId}`];
|
|
107
|
+
const missing = expected.filter((name) => !contexts.has(name));
|
|
108
|
+
if (missing.length === 0) {
|
|
109
|
+
entries.push(result("PASS", "L1 required checks", expected.join(", ")));
|
|
110
|
+
} else {
|
|
111
|
+
entries.push(
|
|
112
|
+
result(
|
|
113
|
+
"FAIL",
|
|
114
|
+
"L1 required checks",
|
|
115
|
+
`missing: ${missing.join(", ")}`,
|
|
116
|
+
"Update rulesets via `./scripts/setup-github.sh`.",
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
entries.push(result("SKIP", "GitHub rulesets", error.message, "Check repository Rulesets in GitHub settings."));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const runs = ghJson(
|
|
127
|
+
["api", `repos/${githubRepo}/actions/workflows/copilot-setup-steps.yml/runs?per_page=5`],
|
|
128
|
+
repoRoot,
|
|
129
|
+
);
|
|
130
|
+
const latest = runs.workflow_runs?.[0];
|
|
131
|
+
if (!latest) {
|
|
132
|
+
entries.push(
|
|
133
|
+
result(
|
|
134
|
+
"WARN",
|
|
135
|
+
"Copilot setup workflow",
|
|
136
|
+
"no runs found",
|
|
137
|
+
"Run Actions > Copilot setup > workflow_dispatch once.",
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
} else if (latest.status === "in_progress" || latest.status === "queued" || latest.conclusion == null) {
|
|
141
|
+
entries.push(
|
|
142
|
+
result(
|
|
143
|
+
"WARN",
|
|
144
|
+
"Copilot setup workflow",
|
|
145
|
+
`latest run ${latest.id} is ${latest.status ?? "in progress"}`,
|
|
146
|
+
"Wait for the Copilot setup workflow to finish, then re-run this check.",
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
} else if (latest.conclusion === "success") {
|
|
150
|
+
entries.push(result("PASS", "Copilot setup workflow", `latest run ${latest.id} is success`));
|
|
151
|
+
} else {
|
|
152
|
+
entries.push(
|
|
153
|
+
result(
|
|
154
|
+
"FAIL",
|
|
155
|
+
"Copilot setup workflow",
|
|
156
|
+
`latest run ${latest.id} conclusion=${latest.conclusion}`,
|
|
157
|
+
"Re-run the Copilot setup workflow and fix failures.",
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
entries.push(
|
|
163
|
+
result(
|
|
164
|
+
"SKIP",
|
|
165
|
+
"Copilot setup workflow",
|
|
166
|
+
error.message,
|
|
167
|
+
"Verify workflow file exists and you have Actions read permission.",
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return entries;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatReadinessSummary(report) {
|
|
176
|
+
const lines = [
|
|
177
|
+
"## L1 readiness",
|
|
178
|
+
"",
|
|
179
|
+
`- Profile: **${report.profile}**`,
|
|
180
|
+
`- Stack: **${report.stackId || "(unresolved)"}**`,
|
|
181
|
+
`- Result: **${report.hasFail ? "FAIL" : report.hasSkip && report.strict ? "SKIP (strict)" : "PASS"}**`,
|
|
182
|
+
"",
|
|
183
|
+
"| Status | Check | Detail |",
|
|
184
|
+
"| --- | --- | --- |",
|
|
185
|
+
];
|
|
186
|
+
for (const entry of report.entries) {
|
|
187
|
+
lines.push(`| ${entry.status} | ${entry.label} | ${entry.detail.replace(/\|/g, "\\|")} |`);
|
|
188
|
+
}
|
|
189
|
+
return `${lines.join("\n")}\n`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {{ githubRepo?: string, template?: boolean, strict?: boolean, json?: boolean, summary?: boolean, repoRoot?: string }} args
|
|
194
|
+
*/
|
|
195
|
+
export function runReadinessCheck(args) {
|
|
196
|
+
const repoRoot = args.repoRoot || resolveRepoRoot();
|
|
197
|
+
const profile = detectRepoProfile(repoRoot, { template: args.template });
|
|
198
|
+
const template = profile.template || args.template;
|
|
199
|
+
const local = localChecks(repoRoot, { templateMode: template });
|
|
200
|
+
const stackId = local.stackId || resolveStackId(repoRoot);
|
|
201
|
+
|
|
202
|
+
const entries = [];
|
|
203
|
+
entries.push(result("PASS", "Repository", repoRoot));
|
|
204
|
+
entries.push(result("PASS", "Profile", template ? "template" : "product"));
|
|
205
|
+
entries.push(
|
|
206
|
+
stackId
|
|
207
|
+
? result("PASS", "Stack", stackId)
|
|
208
|
+
: result("FAIL", "Stack", "could not resolve stack id", "Run setup-wizard or ensure one product-ci-*.yml exists."),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
entries.push(
|
|
212
|
+
...local.entries.map((entry) => ({
|
|
213
|
+
...entry,
|
|
214
|
+
label: `doctor:${entry.label}`,
|
|
215
|
+
})),
|
|
216
|
+
);
|
|
217
|
+
entries.push(...checkLocalL1Assets(repoRoot));
|
|
218
|
+
|
|
219
|
+
const gh = ghReady();
|
|
220
|
+
if (!gh.ok) {
|
|
221
|
+
entries.push(result("SKIP", "GitHub CLI/Auth", gh.reason, "Install/authenticate `gh` to verify remote readiness."));
|
|
222
|
+
} else {
|
|
223
|
+
try {
|
|
224
|
+
const githubRepo = resolveGithubRepo(repoRoot, args.githubRepo ?? "");
|
|
225
|
+
entries.push(result("PASS", "GitHub repository", githubRepo));
|
|
226
|
+
entries.push(...checkGithubState(repoRoot, githubRepo, stackId));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
entries.push(
|
|
229
|
+
result("SKIP", "GitHub repository", error.message, "Run from a cloned GitHub repo or pass --github-repo."),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
entries.push(
|
|
235
|
+
result(
|
|
236
|
+
"MANUAL",
|
|
237
|
+
"Copilot coding agent entitlement",
|
|
238
|
+
"cannot be verified from repository configuration alone",
|
|
239
|
+
"Ensure your org/repo has GitHub Copilot coding agent enabled.",
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const hasFail = entries.some((entry) => entry.status === "FAIL");
|
|
244
|
+
const hasSkip = entries.some((entry) => entry.status === "SKIP");
|
|
245
|
+
const hasWarn = entries.some((entry) => entry.status === "WARN");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
repoRoot,
|
|
249
|
+
profile: template ? "template" : "product",
|
|
250
|
+
stackId,
|
|
251
|
+
strict: Boolean(args.strict),
|
|
252
|
+
hasFail,
|
|
253
|
+
hasSkip,
|
|
254
|
+
hasWarn,
|
|
255
|
+
entries,
|
|
256
|
+
exitCode: hasFail || (args.strict && hasSkip) ? 1 : 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Merge harness npm scripts into an existing package.json without overwriting
|
|
5
|
+
* application metadata or dependencies.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} templatePath harness template package.json
|
|
8
|
+
* @param {string} targetPath product repository package.json
|
|
9
|
+
* @returns {{ action: "created" | "merged", scriptCount: number }}
|
|
10
|
+
*/
|
|
11
|
+
export function mergeHarnessPackageJson(templatePath, targetPath) {
|
|
12
|
+
const template = JSON.parse(readFileSync(templatePath, "utf8"));
|
|
13
|
+
const harnessScripts = template.scripts ?? {};
|
|
14
|
+
|
|
15
|
+
if (!existsSync(targetPath)) {
|
|
16
|
+
const created = {
|
|
17
|
+
private: true,
|
|
18
|
+
type: "module",
|
|
19
|
+
description: "Harness tooling scripts (Node.js required for local checks only)",
|
|
20
|
+
scripts: harnessScripts,
|
|
21
|
+
};
|
|
22
|
+
writeFileSync(targetPath, `${JSON.stringify(created, null, 2)}\n`, "utf8");
|
|
23
|
+
return { action: "created", scriptCount: Object.keys(harnessScripts).length };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const existing = JSON.parse(readFileSync(targetPath, "utf8"));
|
|
27
|
+
const merged = {
|
|
28
|
+
...existing,
|
|
29
|
+
scripts: {
|
|
30
|
+
...(existing.scripts ?? {}),
|
|
31
|
+
...harnessScripts,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
writeFileSync(targetPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
35
|
+
return { action: "merged", scriptCount: Object.keys(harnessScripts).length };
|
|
36
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import { loadStacks } from "./stacks.mjs";
|
|
5
|
+
|
|
6
|
+
/** Canonical npm `files` list — keep in sync with package.json */
|
|
7
|
+
export const NPM_PACKAGE_FILES = [
|
|
8
|
+
"AGENTS.md",
|
|
9
|
+
"config",
|
|
10
|
+
"docs",
|
|
11
|
+
"evals",
|
|
12
|
+
"infra",
|
|
13
|
+
"prompts",
|
|
14
|
+
"scripts",
|
|
15
|
+
".github",
|
|
16
|
+
"sample/go",
|
|
17
|
+
"sample/python",
|
|
18
|
+
"sample/ruby",
|
|
19
|
+
"sample/php/composer.json",
|
|
20
|
+
"sample/php/composer.lock",
|
|
21
|
+
"sample/php/phpunit.xml",
|
|
22
|
+
"sample/php/src",
|
|
23
|
+
"sample/php/tests",
|
|
24
|
+
"sample/ts/biome.json",
|
|
25
|
+
"sample/ts/package.json",
|
|
26
|
+
"sample/ts/package-lock.json",
|
|
27
|
+
"sample/ts/tsconfig.json",
|
|
28
|
+
"sample/ts/src",
|
|
29
|
+
"sample/ts/tests",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const SAMPLE_SKIP_DIRS = new Set([
|
|
33
|
+
"node_modules",
|
|
34
|
+
"vendor",
|
|
35
|
+
".bundle",
|
|
36
|
+
"__pycache__",
|
|
37
|
+
".pytest_cache",
|
|
38
|
+
".vite",
|
|
39
|
+
".phpunit.result.cache",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** @param {string} relPath repo-relative posix path */
|
|
43
|
+
export function isPathCoveredByNpmFiles(relPath, files = NPM_PACKAGE_FILES) {
|
|
44
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
45
|
+
for (const entry of files) {
|
|
46
|
+
const pattern = entry.replace(/\\/g, "/");
|
|
47
|
+
if (normalized === pattern) return true;
|
|
48
|
+
if (normalized.startsWith(`${pattern}/`)) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {string} root repository root */
|
|
54
|
+
function walkSampleFiles(root, sampleRelDir, out) {
|
|
55
|
+
const abs = join(root, sampleRelDir);
|
|
56
|
+
if (!existsSync(abs)) return;
|
|
57
|
+
|
|
58
|
+
const visit = (dir) => {
|
|
59
|
+
for (const name of readdirSync(dir)) {
|
|
60
|
+
if (SAMPLE_SKIP_DIRS.has(name)) continue;
|
|
61
|
+
const absPath = join(dir, name);
|
|
62
|
+
const relPath = relative(root, absPath).replace(/\\/g, "/");
|
|
63
|
+
if (statSync(absPath).isDirectory()) {
|
|
64
|
+
visit(absPath);
|
|
65
|
+
} else {
|
|
66
|
+
out.push(relPath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit(abs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} root repository root
|
|
75
|
+
* @returns {{ missingOnDisk: string[], notPacked: string[] }}
|
|
76
|
+
*/
|
|
77
|
+
export function validateNpmSampleCoverage(root) {
|
|
78
|
+
const missingOnDisk = [];
|
|
79
|
+
for (const entry of NPM_PACKAGE_FILES) {
|
|
80
|
+
if (!existsSync(join(root, entry))) {
|
|
81
|
+
missingOnDisk.push(entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sampleFiles = [];
|
|
86
|
+
for (const stack of loadStacks()) {
|
|
87
|
+
walkSampleFiles(root, `sample/${stack.sampleDir}`, sampleFiles);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const notPacked = sampleFiles.filter((relPath) => !isPathCoveredByNpmFiles(relPath));
|
|
91
|
+
return { missingOnDisk, notPacked };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string} root repository root */
|
|
95
|
+
export function validatePackageJsonFiles(root) {
|
|
96
|
+
const pkgPath = join(root, "package.json");
|
|
97
|
+
if (!existsSync(pkgPath)) {
|
|
98
|
+
return { ok: false, reason: "package.json not found" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
102
|
+
const actual = [...(pkg.files ?? [])].sort();
|
|
103
|
+
const expected = [...NPM_PACKAGE_FILES].sort();
|
|
104
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: "package.json files is out of sync with scripts/lib/npm-package.mjs (NPM_PACKAGE_FILES)",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, reason: "" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {string} stackId */
|
|
114
|
+
export function postInstallHint(stackId) {
|
|
115
|
+
switch (stackId) {
|
|
116
|
+
case "ts":
|
|
117
|
+
return "Run `npm install` in the repository root before opening a PR.";
|
|
118
|
+
case "python":
|
|
119
|
+
return "Run `pip install -r requirements-dev.txt` (or your preferred venv workflow) before opening a PR.";
|
|
120
|
+
case "go":
|
|
121
|
+
return "Run `go test ./...` once to fetch modules before opening a PR.";
|
|
122
|
+
case "ruby":
|
|
123
|
+
return "Run `bundle install` before opening a PR.";
|
|
124
|
+
case "php":
|
|
125
|
+
return "Run `composer install` before opening a PR.";
|
|
126
|
+
default:
|
|
127
|
+
return "Install stack dependencies before opening a PR.";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { detectStackCandidates, getStack, stackIds } from "./stacks.mjs";
|
|
6
|
+
|
|
7
|
+
export const CODEOWNERS_PLACEHOLDER = "@your-org/harness-engineers";
|
|
8
|
+
|
|
9
|
+
/** @param {string} owner */
|
|
10
|
+
export function isValidCodeownersOwner(owner) {
|
|
11
|
+
const trimmed = owner.trim();
|
|
12
|
+
if (/^@[^/\s]+\/[^/\s]+$/.test(trimmed)) return true;
|
|
13
|
+
if (/^@[\w.-]+$/.test(trimmed)) return true;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {string} repoRoot */
|
|
18
|
+
export function detectHarnessPresent(repoRoot) {
|
|
19
|
+
return (
|
|
20
|
+
existsSync(join(repoRoot, ".github/workflows/harness-ci.yml")) &&
|
|
21
|
+
existsSync(join(repoRoot, "scripts/doctor.mjs"))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} repoRoot */
|
|
26
|
+
export function countProductCiWorkflows(repoRoot) {
|
|
27
|
+
const workflowsDir = join(repoRoot, ".github/workflows");
|
|
28
|
+
if (!existsSync(workflowsDir)) return 0;
|
|
29
|
+
return readdirSync(workflowsDir).filter((name) => /^product-ci-.*\.yml$/.test(name)).length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} repoRoot
|
|
34
|
+
* @param {{ template?: boolean }} [options]
|
|
35
|
+
*/
|
|
36
|
+
export function detectRepoProfile(repoRoot, options = {}) {
|
|
37
|
+
const harnessPresent = detectHarnessPresent(repoRoot);
|
|
38
|
+
const productCiCount = countProductCiWorkflows(repoRoot);
|
|
39
|
+
const hasSampleStacks = existsSync(join(repoRoot, "sample/ts"));
|
|
40
|
+
const template =
|
|
41
|
+
options.template === true ||
|
|
42
|
+
(harnessPresent && productCiCount > 1 && hasSampleStacks);
|
|
43
|
+
|
|
44
|
+
let kind = "unknown";
|
|
45
|
+
if (!harnessPresent) kind = "needs-bootstrap";
|
|
46
|
+
else if (template) kind = "template";
|
|
47
|
+
else kind = "product";
|
|
48
|
+
|
|
49
|
+
return { kind, harnessPresent, productCiCount, hasSampleStacks, template };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} repoRoot */
|
|
53
|
+
export function readHarnessStack(repoRoot) {
|
|
54
|
+
const stackFile = join(repoRoot, ".harness-stack");
|
|
55
|
+
if (!existsSync(stackFile)) return "";
|
|
56
|
+
return readFileSync(stackFile, "utf8").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {string} repoRoot @param {string} stackId */
|
|
60
|
+
export function writeHarnessStack(repoRoot, stackId) {
|
|
61
|
+
getStack(stackId);
|
|
62
|
+
writeFileSync(join(repoRoot, ".harness-stack"), `${stackId}\n`, "utf8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {string} repoRoot */
|
|
66
|
+
export function codeownersHasPlaceholder(repoRoot) {
|
|
67
|
+
const codeownersFile = join(repoRoot, ".github/CODEOWNERS");
|
|
68
|
+
if (!existsSync(codeownersFile)) return false;
|
|
69
|
+
return readFileSync(codeownersFile, "utf8").includes(CODEOWNERS_PLACEHOLDER);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string} repoRoot @param {string} owner */
|
|
73
|
+
export function applyCodeownersOwner(repoRoot, owner) {
|
|
74
|
+
if (!isValidCodeownersOwner(owner)) {
|
|
75
|
+
throw new Error(`Invalid CODEOWNERS owner: ${owner}`);
|
|
76
|
+
}
|
|
77
|
+
const codeownersFile = join(repoRoot, ".github/CODEOWNERS");
|
|
78
|
+
if (!existsSync(codeownersFile)) {
|
|
79
|
+
throw new Error(`Missing ${codeownersFile}`);
|
|
80
|
+
}
|
|
81
|
+
const current = readFileSync(codeownersFile, "utf8");
|
|
82
|
+
writeFileSync(codeownersFile, current.replaceAll(CODEOWNERS_PLACEHOLDER, owner.trim()), "utf8");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} repoRoot */
|
|
86
|
+
export function suggestStack(repoRoot) {
|
|
87
|
+
const detected = detectStackCandidates(repoRoot);
|
|
88
|
+
if (detected.suggested) return detected.suggested;
|
|
89
|
+
if (detectRepoProfile(repoRoot).template) return "ts";
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string} repoRoot */
|
|
94
|
+
export function resolveGithubRepo(repoRoot) {
|
|
95
|
+
const result = spawnSync("gh", ["repo", "view", "--json", "nameWithOwner"], {
|
|
96
|
+
cwd: repoRoot,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
99
|
+
});
|
|
100
|
+
if (result.status !== 0) return "";
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(result.stdout).nameWithOwner ?? "";
|
|
103
|
+
} catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {{ repoRoot: string, stackId: string, owner: string, githubRepo: string, template: boolean, withEvalRuleset: boolean, yes: boolean, skipGithub: boolean, dryRun: boolean, writeHarnessStack?: boolean, patchCodeowners?: boolean }} options */
|
|
109
|
+
export function buildWizardPlan(options) {
|
|
110
|
+
const {
|
|
111
|
+
repoRoot,
|
|
112
|
+
stackId,
|
|
113
|
+
owner,
|
|
114
|
+
githubRepo,
|
|
115
|
+
template,
|
|
116
|
+
withEvalRuleset,
|
|
117
|
+
skipGithub,
|
|
118
|
+
dryRun,
|
|
119
|
+
writeHarnessStack: shouldWriteStack = true,
|
|
120
|
+
patchCodeowners = true,
|
|
121
|
+
} = options;
|
|
122
|
+
|
|
123
|
+
const steps = [];
|
|
124
|
+
if (shouldWriteStack) {
|
|
125
|
+
steps.push({ id: "harness-stack", action: "write", detail: `.harness-stack -> ${stackId}` });
|
|
126
|
+
}
|
|
127
|
+
if (patchCodeowners) {
|
|
128
|
+
steps.push({
|
|
129
|
+
id: "codeowners",
|
|
130
|
+
action: "patch",
|
|
131
|
+
detail: `replace ${CODEOWNERS_PLACEHOLDER} -> ${owner}`,
|
|
132
|
+
});
|
|
133
|
+
} else if (template) {
|
|
134
|
+
steps.push({
|
|
135
|
+
id: "codeowners",
|
|
136
|
+
action: "skip",
|
|
137
|
+
detail: `keep template placeholder (${CODEOWNERS_PLACEHOLDER})`,
|
|
138
|
+
});
|
|
139
|
+
} else if (owner && owner !== "(unchanged)") {
|
|
140
|
+
steps.push({
|
|
141
|
+
id: "codeowners",
|
|
142
|
+
action: "skip",
|
|
143
|
+
detail: "CODEOWNERS placeholder already replaced; no change",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!skipGithub) {
|
|
147
|
+
steps.push({
|
|
148
|
+
id: "setup-github",
|
|
149
|
+
action: "run",
|
|
150
|
+
detail: `sync labels + main-protection${withEvalRuleset ? " + eval ruleset" : ""} for ${githubRepo || "(auto)"}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
steps.push({
|
|
154
|
+
id: "doctor",
|
|
155
|
+
action: "run",
|
|
156
|
+
detail: `doctor --strict${template ? " --template" : ""}${dryRun ? " (skipped in dry-run)" : ""}`,
|
|
157
|
+
});
|
|
158
|
+
return { repoRoot, stackId, owner, githubRepo, template, withEvalRuleset, skipGithub, dryRun, steps };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} repoRoot */
|
|
162
|
+
export function ghReady(repoRoot) {
|
|
163
|
+
const version = spawnSync("gh", ["--version"], { stdio: "ignore" });
|
|
164
|
+
if (version.status !== 0) return { ok: false, reason: "gh is not installed" };
|
|
165
|
+
const auth = spawnSync("gh", ["auth", "status"], { stdio: "ignore" });
|
|
166
|
+
if (auth.status !== 0) return { ok: false, reason: "gh is not authenticated" };
|
|
167
|
+
return { ok: true, reason: "" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {string} repoRoot @param {string[]} args */
|
|
171
|
+
function runScript(repoRoot, scriptName, args) {
|
|
172
|
+
const scriptPath = join(repoRoot, "scripts", scriptName);
|
|
173
|
+
return spawnSync(scriptPath, args, {
|
|
174
|
+
cwd: repoRoot,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
177
|
+
shell: false,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {{ repoRoot: string, stackId: string, mode: string, owner: string, yes: boolean, templateRoot?: string }} options */
|
|
182
|
+
export function runBootstrap(options) {
|
|
183
|
+
const { repoRoot, stackId, mode, owner, yes, templateRoot } = options;
|
|
184
|
+
const root = templateRoot || repoRoot;
|
|
185
|
+
const scriptPath = join(root, "scripts/bootstrap-harness.sh");
|
|
186
|
+
const args = [
|
|
187
|
+
"--repo",
|
|
188
|
+
repoRoot,
|
|
189
|
+
"--stack",
|
|
190
|
+
stackId,
|
|
191
|
+
"--mode",
|
|
192
|
+
mode,
|
|
193
|
+
"--codeowners-team",
|
|
194
|
+
owner,
|
|
195
|
+
];
|
|
196
|
+
if (yes) args.push("--yes");
|
|
197
|
+
return spawnSync("bash", [scriptPath, ...args], {
|
|
198
|
+
cwd: root,
|
|
199
|
+
encoding: "utf8",
|
|
200
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @param {{ repoRoot: string, githubRepo: string, withEvalRuleset: boolean, yes: boolean, dryRun: boolean }} options */
|
|
205
|
+
export function runSetupGithub(options) {
|
|
206
|
+
const { repoRoot, githubRepo, withEvalRuleset, yes, dryRun } = options;
|
|
207
|
+
const args = [];
|
|
208
|
+
if (githubRepo) args.push("--github-repo", githubRepo);
|
|
209
|
+
if (withEvalRuleset) args.push("--with-eval-ruleset");
|
|
210
|
+
if (yes) args.push("--yes");
|
|
211
|
+
if (dryRun) args.push("--dry-run");
|
|
212
|
+
return runScript(repoRoot, "setup-github.mjs", args);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @param {{ repoRoot: string, template: boolean, strict: boolean }} options */
|
|
216
|
+
export function runDoctor(options) {
|
|
217
|
+
const { repoRoot, template, strict } = options;
|
|
218
|
+
const args = [];
|
|
219
|
+
if (strict) args.push("--strict");
|
|
220
|
+
if (template) args.push("--template");
|
|
221
|
+
return runScript(repoRoot, "doctor.mjs", args);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { stackIds, detectStackCandidates, getStack };
|