@decohere/ci-tools 0.0.1-dev.10

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.
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Publish prerelease version commit via PR
4
+ * Validates working tree, pushes prerelease commit to a release branch,
5
+ * and creates/updates a PR targeting the protected base branch.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+
10
+ function run(command) {
11
+ return execSync(command, { encoding: "utf8" }).trim();
12
+ }
13
+
14
+ function runInherit(command, env) {
15
+ execSync(command, { stdio: "inherit", env });
16
+ }
17
+
18
+ try {
19
+ // Check if working tree is dirty
20
+ const status = run("git status --porcelain");
21
+ if (status.trim().length > 0) {
22
+ console.error(
23
+ "::error::Working tree is dirty after version/dependency sync; refusing to publish with uncommitted changes.",
24
+ );
25
+ console.log(status);
26
+ process.exit(1);
27
+ }
28
+
29
+ // Compare HEAD with origin and create a PR branch targeting an explicit base branch.
30
+ const head = run("git rev-parse HEAD");
31
+ const sourceBranch = process.env.GITHUB_REF_NAME || run("git rev-parse --abbrev-ref HEAD");
32
+ const channel = process.env.RELEASE_CHANNEL ?? "beta";
33
+ const baseBranch = process.env.RELEASE_BASE_BRANCH || sourceBranch;
34
+ const releasePrBranch = `release/${channel}-${sourceBranch}-to-${baseBranch}`;
35
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
36
+
37
+ try {
38
+ const origin = run(`git rev-parse origin/${sourceBranch}`);
39
+
40
+ if (head !== origin) {
41
+ console.log(
42
+ `Publishing release commit via PR branch origin/${releasePrBranch} (base: origin/${baseBranch})`,
43
+ );
44
+ runInherit(`git push origin HEAD:${releasePrBranch} --force`, process.env);
45
+
46
+ if (token == null || token.length === 0) {
47
+ throw new Error("Missing GITHUB_TOKEN/GH_TOKEN for PR upsert");
48
+ }
49
+
50
+ const version = process.env.RELEASE_VERSION ?? "(unknown)";
51
+ const ownerRepo = run("git config --get remote.origin.url")
52
+ .replace(/^.*github\.com[:/]/, "")
53
+ .replace(/\.git$/, "");
54
+
55
+ runInherit(
56
+ [
57
+ "node projects/ci-tools/src/github/create-or-update-pr.mjs",
58
+ `--title "chore(release): ${channel} ${version}"`,
59
+ `--base "${baseBranch}"`,
60
+ `--head "${releasePrBranch}"`,
61
+ `--body "Automated ${channel} version bump and dependency sync.\\n\\n- Channel: ${channel}\\n- Version: ${version}\\n- Source branch: ${sourceBranch}\\n- Base branch: ${baseBranch}\\n- Source commit: ${head}\\n\\nGenerated by pull-request workflow."`,
62
+ `--owner "${ownerRepo.split("/")[0]}"`,
63
+ `--repo "${ownerRepo.split("/")[1]}"`,
64
+ ].join(" "),
65
+ { ...process.env, GITHUB_TOKEN: token },
66
+ );
67
+ } else {
68
+ console.log("No prerelease version commit to push.");
69
+ }
70
+ } catch (err) {
71
+ const message = err?.message ?? "";
72
+ if (
73
+ message.includes(`origin/${sourceBranch}`) &&
74
+ message.includes("Needed a single revision")
75
+ ) {
76
+ console.log(`Remote branch origin/${sourceBranch} not found, skipping push.`);
77
+ } else {
78
+ console.error("::error::Failed to publish release commit via PR branch.");
79
+ throw err;
80
+ }
81
+ }
82
+ } catch (e) {
83
+ console.error(`Push failed: ${e.message}`);
84
+ process.exit(1);
85
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sync cross-library dependency versions
4
+ * Conditionally runs sync with or without commit based on DRY_RUN flag
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+
9
+ const DRY_RUN = process.env.DRY_RUN === "true";
10
+ const TERMINAL_CLI = process.env.TERMINAL_CLI || "dist/debug/projects/terminal/src/cli.js";
11
+ const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
12
+
13
+ const baseCmd = `node "${TERMINAL_CLI}" --log-level trace --config-file "${DECOHERE_CONFIG}" forge dependencies --configuration release sync`;
14
+
15
+ try {
16
+ if (DRY_RUN) {
17
+ console.log(`[DRY RUN] ${baseCmd}`);
18
+ execSync(baseCmd, { stdio: "inherit" });
19
+ } else {
20
+ const commitCmd = `${baseCmd} --commit --message "chore: version packages and sync cross-library dependencies [skip ci]"`;
21
+ console.log(`Syncing with commit...`);
22
+ execSync(commitCmd, { stdio: "inherit" });
23
+ }
24
+ } catch (e) {
25
+ console.error(`Dependency sync failed: ${e.message}`);
26
+ process.exit(1);
27
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getProjectFromPath,
4
+ parseLcovByProject,
5
+ parseLcovFiles,
6
+ } from "../../src/lib/lcov.mjs";
7
+
8
+ const sampleLcov = `SF:projects/forge/src/index.ts
9
+ LF:10
10
+ LH:9
11
+ end_of_record
12
+ SF:projects/forge/src/build/index.ts
13
+ LF:20
14
+ LH:15
15
+ end_of_record
16
+ SF:projects/nexus/src/index.ts
17
+ LF:5
18
+ LH:5
19
+ end_of_record
20
+ `;
21
+
22
+ const sampleLcovWithLines = `SF:projects/anvil/src/index.ts
23
+ DA:1,1
24
+ DA:2,1
25
+ DA:3,0
26
+ DA:4,0
27
+ DA:5,1
28
+ LF:5
29
+ LH:3
30
+ end_of_record
31
+ `;
32
+
33
+ describe("getProjectFromPath", () => {
34
+ it("extracts project name from projects/ path", () => {
35
+ expect(getProjectFromPath("projects/forge/src/index.ts")).toBe("forge");
36
+ });
37
+
38
+ it("returns undefined for unrecognized paths", () => {
39
+ expect(getProjectFromPath("src/index.ts")).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe("parseLcovByProject", () => {
44
+ it("groups files by project and sums coverage", () => {
45
+ const result = parseLcovByProject(sampleLcov);
46
+ expect(result.get("forge")).toBeDefined();
47
+ expect(result.get("forge").totalLines).toBe(30);
48
+ expect(result.get("forge").coveredLines).toBe(24);
49
+ expect(result.get("nexus")).toBeDefined();
50
+ expect(result.get("nexus").totalLines).toBe(5);
51
+ expect(result.get("nexus").coveredLines).toBe(5);
52
+ });
53
+
54
+ it("ignores records with LF:0", () => {
55
+ const lcov = `SF:projects/forge/src/empty.ts\nLF:0\nLH:0\nend_of_record\n`;
56
+ const result = parseLcovByProject(lcov);
57
+ expect(result.size).toBe(0);
58
+ });
59
+ });
60
+
61
+ describe("parseLcovFiles", () => {
62
+ it("returns per-file coverage with uncovered line numbers", () => {
63
+ const result = parseLcovFiles(sampleLcovWithLines);
64
+ const file = result.find((f) => f.sourcePath === "projects/anvil/src/index.ts");
65
+ expect(file).toBeDefined();
66
+ expect(file.totalLines).toBe(5);
67
+ expect(file.coveredLines).toBe(3);
68
+ expect(file.uncoveredLineNumbers).toEqual([3, 4]);
69
+ expect(file.uncoveredLines).toBe(2);
70
+ });
71
+
72
+ it("includes files with 100% coverage", () => {
73
+ const lcov = `SF:projects/anvil/src/full.ts\nDA:1,1\nDA:2,1\nLF:2\nLH:2\nend_of_record\n`;
74
+ const result = parseLcovFiles(lcov);
75
+ expect(result).toHaveLength(1);
76
+ expect(result[0].uncoveredLineNumbers).toEqual([]);
77
+ });
78
+
79
+ it("excludes files with LF:0", () => {
80
+ const lcov = `SF:projects/anvil/src/empty.ts\nLF:0\nLH:0\nend_of_record\n`;
81
+ const result = parseLcovFiles(lcov);
82
+ expect(result).toHaveLength(0);
83
+ });
84
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createBadgeSvg, getColor, statusToColor, statusToText } from "../../src/lib/svg.mjs";
3
+
4
+ describe("getColor", () => {
5
+ it("returns green for >= 90%", () => {
6
+ expect(getColor(90)).toBe("#2ea043");
7
+ expect(getColor(100)).toBe("#2ea043");
8
+ });
9
+
10
+ it("returns light green for >= 80%", () => {
11
+ expect(getColor(80)).toBe("#3fb950");
12
+ expect(getColor(89)).toBe("#3fb950");
13
+ });
14
+
15
+ it("returns yellow for >= 70%", () => {
16
+ expect(getColor(70)).toBe("#9a6700");
17
+ expect(getColor(79)).toBe("#9a6700");
18
+ });
19
+
20
+ it("returns orange for >= 60%", () => {
21
+ expect(getColor(60)).toBe("#db6d28");
22
+ expect(getColor(69)).toBe("#db6d28");
23
+ });
24
+
25
+ it("returns red for < 60%", () => {
26
+ expect(getColor(59)).toBe("#cf222e");
27
+ expect(getColor(0)).toBe("#cf222e");
28
+ });
29
+ });
30
+
31
+ describe("createBadgeSvg", () => {
32
+ it("generates valid SVG with label and value", () => {
33
+ const svg = createBadgeSvg("Coverage", "92.5%", "#2ea043");
34
+ expect(svg).toContain("<svg");
35
+ expect(svg).toContain("Coverage");
36
+ expect(svg).toContain("92.5%");
37
+ expect(svg).toContain("#2ea043");
38
+ expect(svg).toContain("</svg>");
39
+ });
40
+
41
+ it("sets aria-label with label and value", () => {
42
+ const svg = createBadgeSvg("CI Main", "passing", "#2ea043");
43
+ expect(svg).toContain('aria-label="CI Main: passing"');
44
+ });
45
+
46
+ it("sets title element", () => {
47
+ const svg = createBadgeSvg("Coverage", "75%", "#9a6700");
48
+ expect(svg).toContain("<title>Coverage: 75%</title>");
49
+ });
50
+ });
51
+
52
+ describe("statusToColor", () => {
53
+ it("returns orange for in_progress", () => {
54
+ expect(statusToColor("in_progress", null)).toBe("#fb8500");
55
+ });
56
+
57
+ it("returns green for success", () => {
58
+ expect(statusToColor("completed", "success")).toBe("#2ea043");
59
+ });
60
+
61
+ it("returns red for failure", () => {
62
+ expect(statusToColor("completed", "failure")).toBe("#cf222e");
63
+ });
64
+
65
+ it("returns gray for cancelled", () => {
66
+ expect(statusToColor("completed", "cancelled")).toBe("#6e7781");
67
+ });
68
+
69
+ it("returns gray for unknown", () => {
70
+ expect(statusToColor("completed", "unknown")).toBe("#6e7781");
71
+ });
72
+ });
73
+
74
+ describe("statusToText", () => {
75
+ it("returns 'running' for in_progress", () => {
76
+ expect(statusToText("in_progress", null)).toBe("running");
77
+ });
78
+
79
+ it("returns 'passing' for success", () => {
80
+ expect(statusToText("completed", "success")).toBe("passing");
81
+ });
82
+
83
+ it("returns 'failing' for failure", () => {
84
+ expect(statusToText("completed", "failure")).toBe("failing");
85
+ });
86
+
87
+ it("returns 'cancelled' for cancelled", () => {
88
+ expect(statusToText("completed", "cancelled")).toBe("cancelled");
89
+ });
90
+
91
+ it("returns 'unknown' for unrecognized state", () => {
92
+ expect(statusToText("completed", "timed_out")).toBe("unknown");
93
+ });
94
+ });