@catladder/cli 0.0.0-next-e95e1f20 → 0.0.0-next-ca8f99e2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +40 -0
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js +12 -3
  4. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js.map +1 -1
  5. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.d.ts +20 -0
  6. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +92 -0
  7. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -0
  8. package/dist/apps/cli/src/utils/github.d.ts +2 -0
  9. package/dist/apps/cli/src/utils/github.js +24 -1
  10. package/dist/apps/cli/src/utils/github.js.map +1 -1
  11. package/dist/bundles/catenv/index.js +72 -1
  12. package/dist/bundles/cli/index.js +247 -5
  13. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +12 -0
  14. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +12 -1
  15. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  16. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.d.ts +26 -0
  17. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js +52 -0
  18. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js.map +1 -0
  19. package/dist/packages/pipeline/src/backends/github/index.d.ts +1 -0
  20. package/dist/packages/pipeline/src/backends/github/index.js +1 -0
  21. package/dist/packages/pipeline/src/backends/github/index.js.map +1 -1
  22. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +12 -0
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/apps/cli/commands/project/doctor/checkGithub.ts +55 -0
  26. package/src/apps/cli/commands/project/setup/index.ts +17 -4
  27. package/src/apps/cli/commands/project/setup/setupGithub.ts +117 -0
  28. package/src/utils/github.ts +30 -0
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-e95e1f20",
56
+ "version": "0.0.0-next-ca8f99e2",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -1,5 +1,6 @@
1
1
  import type { Config } from "@catladder/pipeline";
2
2
  import {
3
+ AGGREGATE_CHECK_JOB_NAME,
3
4
  getEnabledPipelineTypes,
4
5
  getPipelineGitRemote,
5
6
  } from "@catladder/pipeline";
@@ -9,6 +10,7 @@ import {
9
10
  } from "../../../../../commands/project/commandSecretsSyncGithub";
10
11
  import { mapWithConcurrency } from "../../../../../utils/concurrency";
11
12
  import {
13
+ ghApiJson,
12
14
  isGhAuthenticated,
13
15
  listGithubEnvironments,
14
16
  listGithubSecretNames,
@@ -153,4 +155,57 @@ export const checkGithub = async (report: DoctorReport, config: Config) => {
153
155
  );
154
156
  }
155
157
  }
158
+
159
+ await checkGithubMergeGating(report, repo);
160
+ };
161
+
162
+ /**
163
+ * verifies the merge gating `project setup` configures: auto-merge
164
+ * enabled and the generated `catladder ✅` aggregate check required on
165
+ * the default branch. Without it a PR is mergeable while its checks
166
+ * are still running — gitlab ships this behavior built-in, github
167
+ * silently does not.
168
+ */
169
+ const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
170
+ let repoInfo: { default_branch: string; allow_auto_merge: boolean };
171
+ try {
172
+ repoInfo = await ghApiJson("GET", `repos/${repo}`);
173
+ } catch (e) {
174
+ report.warn(`could not read repo settings of ${repo} (${e.message})`);
175
+ return;
176
+ }
177
+ if (repoInfo.allow_auto_merge) {
178
+ report.ok("auto-merge enabled");
179
+ } else {
180
+ report.fail(
181
+ "auto-merge disabled — the merge-when-pipeline-succeeds button cannot appear",
182
+ "run: catladder project setup",
183
+ );
184
+ }
185
+
186
+ const branch = repoInfo.default_branch;
187
+ const protection = await ghApiJson<any>(
188
+ "GET",
189
+ `repos/${repo}/branches/${branch}/protection`,
190
+ ).catch(() => null);
191
+ const checks: Array<{ context: string }> =
192
+ protection?.required_status_checks?.checks ?? [];
193
+ if (checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
194
+ report.ok(`'${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}`);
195
+ } else {
196
+ report.fail(
197
+ `'${AGGREGATE_CHECK_JOB_NAME}' is not a required check on ${branch} — PRs can merge while the pipeline is red or still running`,
198
+ "run: catladder project setup",
199
+ );
200
+ }
201
+ const stale = checks.filter((c) => c.context !== AGGREGATE_CHECK_JOB_NAME);
202
+ if (stale.length > 0) {
203
+ report.warn(
204
+ `${branch} requires ${stale.length} additional named check(s) (${stale
205
+ .map((c) => c.context)
206
+ .join(
207
+ ", ",
208
+ )}) — job names change with components; prefer only '${AGGREGATE_CHECK_JOB_NAME}'`,
209
+ );
210
+ }
156
211
  };
@@ -1,4 +1,8 @@
1
- import { createCatenvContext, generateAgentSkills } from "@catladder/pipeline";
1
+ import {
2
+ createCatenvContext,
3
+ generateAgentSkills,
4
+ getEnabledPipelineTypes,
5
+ } from "@catladder/pipeline";
2
6
  import type { IO } from "../../../../../core/types";
3
7
  import {
4
8
  getAllPipelineContexts,
@@ -8,6 +12,7 @@ import { setupAccessTokens } from "./setupAccessTokens";
8
12
  import { setupContext } from "./setupContext";
9
13
  import { setupTopic } from "./setupTopic";
10
14
  import { setupAgents } from "./setupAgents";
15
+ import { setupGithub } from "./setupGithub";
11
16
  import { logSection } from "./logSection";
12
17
  import { ensureGcloudProjectNumbers } from "./ensureGcloudProjectNumbers";
13
18
 
@@ -33,17 +38,25 @@ export const setupProject = async (
33
38
  instance.log(` - ${context.name}:${context.env}`);
34
39
  });
35
40
 
41
+ const enabledPipelines = config ? getEnabledPipelineTypes(config) : [];
36
42
  await logSection(instance, "base setup", async () => {
37
- await setupAccessTokens(instance);
43
+ // gitlab-specific provisioning (access token, agent webhooks) —
44
+ // meaningless (and failing) on a github-only project
45
+ if (enabledPipelines.includes("gitlab")) {
46
+ await setupAccessTokens(instance);
47
+ await setupAgents(instance);
48
+ }
38
49
  await setupTopic(instance);
39
- await setupAgents(instance);
50
+ if (config) {
51
+ await setupGithub(instance, config);
52
+ }
40
53
  });
41
54
  for (const context of allContext) {
42
55
  await setupContext(instance, context);
43
56
  }
44
57
 
45
58
  instance.log("");
46
- instance.log("gitlab is ready! 🥂");
59
+ instance.log(`${enabledPipelines.join(" + ") || "gitlab"} is ready! 🥂`);
47
60
  instance.log("\n");
48
61
  instance.log("do not forget to make sure that:");
49
62
  [
@@ -0,0 +1,117 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ AGGREGATE_CHECK_JOB_NAME,
4
+ getEnabledPipelineTypes,
5
+ getPipelineGitRemote,
6
+ } from "@catladder/pipeline";
7
+ import { getConfiguredGithubRepo } from "../../../../../commands/project/commandSecretsSyncGithub";
8
+ import type { IO } from "../../../../../core/types";
9
+ import { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
10
+
11
+ /**
12
+ * configures github merge gating — the sane default gitlab ships
13
+ * built-in and a fresh github repository lacks entirely: without it a
14
+ * PR is mergeable the moment it opens, checks still running, and the
15
+ * auto-merge button never appears.
16
+ *
17
+ * - repo settings: allow auto-merge, delete merged head branches
18
+ * - branch protection on the default branch: require the generated
19
+ * `catladder ✅` aggregate check (the one stable context — individual
20
+ * job names change with every component add/rename)
21
+ *
22
+ * Deliberately non-destructive: existing protection settings
23
+ * (enforce-admins, required reviews, restrictions, strictness) are
24
+ * preserved; the aggregate context is merged into the required checks
25
+ * rather than replacing them. Admins can still bypass per PR — this is
26
+ * a default, not a cage.
27
+ */
28
+ export const setupGithub = async (instance: IO, config: Config) => {
29
+ if (!getEnabledPipelineTypes(config).includes("github")) {
30
+ return;
31
+ }
32
+ if (!(await isGhAuthenticated())) {
33
+ instance.log(
34
+ "⚠️ github cli (gh) not authenticated — skipping github merge-gating setup (run: gh auth login)",
35
+ );
36
+ return;
37
+ }
38
+ const repo = await getConfiguredGithubRepo();
39
+ if (!repo) {
40
+ instance.log(
41
+ `⚠️ no github repository found on the '${getPipelineGitRemote(config, "github")}' remote — skipping github merge-gating setup`,
42
+ );
43
+ return;
44
+ }
45
+
46
+ const repoInfo = await ghApiJson<{
47
+ default_branch: string;
48
+ allow_auto_merge: boolean;
49
+ delete_branch_on_merge: boolean;
50
+ }>("GET", `repos/${repo}`);
51
+
52
+ if (!repoInfo.allow_auto_merge || !repoInfo.delete_branch_on_merge) {
53
+ await ghApiJson("PATCH", `repos/${repo}`, {
54
+ allow_auto_merge: true,
55
+ delete_branch_on_merge: true,
56
+ });
57
+ instance.log("✅ auto-merge + delete-merged-branches enabled");
58
+ } else {
59
+ instance.log("✅ auto-merge already enabled");
60
+ }
61
+
62
+ const branch = repoInfo.default_branch;
63
+ const protection = await ghApiJson<any>(
64
+ "GET",
65
+ `repos/${repo}/branches/${branch}/protection`,
66
+ ).catch(() => null); // 404: branch not protected yet
67
+
68
+ const existingChecks: Array<{ context: string; app_id?: number }> =
69
+ protection?.required_status_checks?.checks ?? [];
70
+ if (existingChecks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
71
+ instance.log(
72
+ `✅ '${AGGREGATE_CHECK_JOB_NAME}' already required on ${branch}`,
73
+ );
74
+ return;
75
+ }
76
+
77
+ // merge into whatever protection exists; PUT replaces the whole rule,
78
+ // so every preserved setting must be carried over explicitly
79
+ await ghApiJson("PUT", `repos/${repo}/branches/${branch}/protection`, {
80
+ required_status_checks: {
81
+ strict: protection?.required_status_checks?.strict ?? false,
82
+ checks: [
83
+ ...existingChecks.map(({ context, app_id }) => ({
84
+ context,
85
+ ...(app_id !== undefined ? { app_id } : {}),
86
+ })),
87
+ { context: AGGREGATE_CHECK_JOB_NAME },
88
+ ],
89
+ },
90
+ enforce_admins: protection?.enforce_admins?.enabled ?? false,
91
+ required_pull_request_reviews: protection?.required_pull_request_reviews
92
+ ? {
93
+ required_approving_review_count:
94
+ protection.required_pull_request_reviews
95
+ .required_approving_review_count ?? 0,
96
+ dismiss_stale_reviews:
97
+ protection.required_pull_request_reviews.dismiss_stale_reviews ??
98
+ false,
99
+ require_code_owner_reviews:
100
+ protection.required_pull_request_reviews
101
+ .require_code_owner_reviews ?? false,
102
+ }
103
+ : null,
104
+ restrictions: protection?.restrictions
105
+ ? {
106
+ users: protection.restrictions.users.map((u: any) => u.login),
107
+ teams: protection.restrictions.teams.map((t: any) => t.slug),
108
+ apps: protection.restrictions.apps.map((a: any) => a.slug),
109
+ }
110
+ : null,
111
+ allow_force_pushes: protection?.allow_force_pushes?.enabled ?? false,
112
+ allow_deletions: protection?.allow_deletions?.enabled ?? false,
113
+ });
114
+ instance.log(
115
+ `✅ branch protection on ${branch}: '${AGGREGATE_CHECK_JOB_NAME}' required (merges now wait for the pipeline)`,
116
+ );
117
+ };
@@ -167,3 +167,33 @@ export const ensureGithubEnvironment = async (
167
167
  ),
168
168
  );
169
169
  });
170
+
171
+ /** runs `gh api` with an optional JSON body and returns the parsed response */
172
+ export const ghApiJson = async <T = any>(
173
+ method: "GET" | "PATCH" | "PUT",
174
+ path: string,
175
+ body?: unknown,
176
+ ): Promise<T> => {
177
+ const args = ["api", "-X", method, path];
178
+ const child = spawn("gh", [...args, ...(body ? ["--input", "-"] : [])], {
179
+ stdio: [body ? "pipe" : "ignore", "pipe", "pipe"],
180
+ });
181
+ let stdout = "";
182
+ let stderr = "";
183
+ child.stdout?.on("data", (data) => (stdout += data));
184
+ child.stderr?.on("data", (data) => (stderr += data));
185
+ if (body) {
186
+ child.stdin?.write(JSON.stringify(body));
187
+ child.stdin?.end();
188
+ }
189
+ return new Promise<T>((resolve, reject) => {
190
+ child.on("error", reject);
191
+ child.on("close", (code) =>
192
+ code === 0
193
+ ? resolve(stdout ? JSON.parse(stdout) : (undefined as T))
194
+ : reject(
195
+ new Error(`gh api ${method} ${path} failed: ${stderr.trim()}`),
196
+ ),
197
+ );
198
+ });
199
+ };