@foxlight/ci 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/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @foxlight/ci
2
+
3
+ CI/CD integration for [Foxlight](https://github.com/josegabrielcruz/foxlight) — the open-source front-end intelligence platform.
4
+
5
+ ## What's Inside
6
+
7
+ - **GitHub Integration** — PR comments with component/bundle/health diffs, Check Runs API with pass/fail annotations
8
+ - **GitLab Integration** — merge request notes with the same diff tables
9
+ - **Snapshot Comparator** — captures project state and detects significant changes between snapshots
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @foxlight/ci
15
+ ```
16
+
17
+ ## GitHub Actions
18
+
19
+ ```yaml
20
+ # .github/workflows/foxlight.yml
21
+ name: Foxlight
22
+ on: [pull_request]
23
+
24
+ jobs:
25
+ analyze:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: actions/setup-node@v4
30
+ with:
31
+ node-version: 20
32
+ - run: npm ci
33
+ - run: npx foxlight ci
34
+ env:
35
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36
+ ```
37
+
38
+ ## GitLab CI
39
+
40
+ ```yaml
41
+ # .gitlab-ci.yml
42
+ foxlight:
43
+ script:
44
+ - npm ci
45
+ - npx foxlight ci
46
+ rules:
47
+ - if: $CI_MERGE_REQUEST_IID
48
+ ```
49
+
50
+ ## Programmatic API
51
+
52
+ ```typescript
53
+ import {
54
+ postPRComment,
55
+ createCheckRun,
56
+ detectGitHubEnv,
57
+ postMRComment,
58
+ detectGitLabEnv,
59
+ } from '@foxlight/ci';
60
+
61
+ // GitHub — post a PR comment
62
+ const env = detectGitHubEnv();
63
+ if (env) {
64
+ await postPRComment(env, snapshotDiff);
65
+ await createCheckRun({
66
+ ...env,
67
+ name: 'Foxlight Analysis',
68
+ diff: snapshotDiff,
69
+ });
70
+ }
71
+
72
+ // GitLab — post an MR note
73
+ const gitlabEnv = detectGitLabEnv();
74
+ if (gitlabEnv) {
75
+ await postMRComment(gitlabEnv, snapshotDiff);
76
+ }
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,90 @@
1
+ import { SnapshotDiff, ProjectSnapshot } from '@foxlight/core';
2
+
3
+ /** GitHub API configuration. */
4
+ interface GitHubConfig {
5
+ /** GitHub API token */
6
+ token: string;
7
+ /** Repository owner */
8
+ owner: string;
9
+ /** Repository name */
10
+ repo: string;
11
+ /** PR number */
12
+ prNumber: number;
13
+ /** Optional: GitHub API base URL (for GitHub Enterprise) */
14
+ apiUrl?: string;
15
+ }
16
+ /**
17
+ * Detect GitHub environment from CI environment variables.
18
+ * Works in GitHub Actions automatically.
19
+ */
20
+ declare function detectGitHubEnv(): Partial<GitHubConfig>;
21
+ /**
22
+ * Post a Foxlight analysis comment on a GitHub PR.
23
+ */
24
+ declare function postPRComment(config: GitHubConfig, diff: SnapshotDiff): Promise<void>;
25
+ /**
26
+ * Generate the Markdown body for a PR comment.
27
+ */
28
+ declare function generateCommentBody(diff: SnapshotDiff): string;
29
+ /** Options for creating a GitHub check run. */
30
+ interface CheckRunOptions {
31
+ /** Name of the check (e.g., "Foxlight Analysis") */
32
+ name: string;
33
+ /** The head SHA to attach the check to */
34
+ headSha: string;
35
+ }
36
+ /**
37
+ * Create a GitHub Check Run with Foxlight analysis results.
38
+ * Check Runs appear in the PR's "Checks" tab and can report
39
+ * success/failure based on configurable thresholds.
40
+ */
41
+ declare function createCheckRun(config: GitHubConfig, diff: SnapshotDiff, options: CheckRunOptions): Promise<void>;
42
+
43
+ /** GitLab API configuration. */
44
+ interface GitLabConfig {
45
+ /** GitLab private token or CI job token */
46
+ token: string;
47
+ /** Project ID (numeric) or URL-encoded path (e.g. "group%2Fproject") */
48
+ projectId: string;
49
+ /** Merge request IID */
50
+ mergeRequestIid: number;
51
+ /** Optional: GitLab API base URL (for self-hosted instances) */
52
+ apiUrl?: string;
53
+ }
54
+ /**
55
+ * Detect GitLab environment from CI environment variables.
56
+ * Works in GitLab CI/CD pipelines automatically.
57
+ */
58
+ declare function detectGitLabEnv(): Partial<GitLabConfig>;
59
+ /**
60
+ * Post a Foxlight analysis comment on a GitLab merge request.
61
+ */
62
+ declare function postMRComment(config: GitLabConfig, diff: SnapshotDiff): Promise<void>;
63
+
64
+ interface CompareOptions {
65
+ /** Path to the base snapshot JSON file */
66
+ basePath?: string;
67
+ /** Path to save the head snapshot */
68
+ outputPath?: string;
69
+ /** Project root directory */
70
+ rootDir: string;
71
+ /** Git commit SHA for the head snapshot */
72
+ commitSha?: string;
73
+ /** Git branch name */
74
+ branch?: string;
75
+ }
76
+ interface CompareResult {
77
+ diff: SnapshotDiff;
78
+ base: ProjectSnapshot;
79
+ head: ProjectSnapshot;
80
+ }
81
+ /**
82
+ * Analyze the current state, compare against a baseline, and produce a diff.
83
+ */
84
+ declare function compareSnapshots(options: CompareOptions): Promise<CompareResult>;
85
+ /**
86
+ * Determine if a diff has significant changes that warrant reporting.
87
+ */
88
+ declare function hasSignificantChanges(diff: SnapshotDiff): boolean;
89
+
90
+ export { type CheckRunOptions, type CompareOptions, type CompareResult, type GitHubConfig, type GitLabConfig, compareSnapshots, createCheckRun, detectGitHubEnv, detectGitLabEnv, generateCommentBody, hasSignificantChanges, postMRComment, postPRComment };
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ // src/github.ts
2
+ function formatBytes(bytes) {
3
+ if (bytes === 0) return "0 B";
4
+ const units = ["B", "KB", "MB", "GB"];
5
+ const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
6
+ const value = bytes / Math.pow(1024, i);
7
+ const sign = bytes < 0 ? "-" : "";
8
+ return `${sign}${Math.abs(value).toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[i]}`;
9
+ }
10
+ function detectGitHubEnv() {
11
+ const token = process.env["GITHUB_TOKEN"];
12
+ const repository = process.env["GITHUB_REPOSITORY"] ?? "";
13
+ const [owner, repo] = repository.split("/");
14
+ const eventPath = process.env["GITHUB_EVENT_PATH"];
15
+ let prNumber;
16
+ if (eventPath) {
17
+ try {
18
+ const ref = process.env["GITHUB_REF"] ?? "";
19
+ const match = ref.match(/refs\/pull\/(\d+)/);
20
+ if (match?.[1]) {
21
+ prNumber = parseInt(match[1], 10);
22
+ }
23
+ } catch {
24
+ }
25
+ }
26
+ return {
27
+ token,
28
+ owner,
29
+ repo,
30
+ prNumber,
31
+ apiUrl: process.env["GITHUB_API_URL"] ?? "https://api.github.com"
32
+ };
33
+ }
34
+ async function postPRComment(config, diff) {
35
+ const body = generateCommentBody(diff);
36
+ const { apiUrl = "https://api.github.com" } = config;
37
+ const url = `${apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.prNumber}/comments`;
38
+ const existingCommentId = await findExistingComment(config);
39
+ if (existingCommentId) {
40
+ const updateUrl = `${apiUrl}/repos/${config.owner}/${config.repo}/issues/comments/${existingCommentId}`;
41
+ await fetch(updateUrl, {
42
+ method: "PATCH",
43
+ headers: {
44
+ Authorization: `token ${config.token}`,
45
+ "Content-Type": "application/json",
46
+ Accept: "application/vnd.github.v3+json"
47
+ },
48
+ body: JSON.stringify({ body })
49
+ });
50
+ } else {
51
+ await fetch(url, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `token ${config.token}`,
55
+ "Content-Type": "application/json",
56
+ Accept: "application/vnd.github.v3+json"
57
+ },
58
+ body: JSON.stringify({ body })
59
+ });
60
+ }
61
+ }
62
+ async function findExistingComment(config) {
63
+ const { apiUrl = "https://api.github.com" } = config;
64
+ const url = `${apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.prNumber}/comments`;
65
+ const response = await fetch(url, {
66
+ headers: {
67
+ Authorization: `token ${config.token}`,
68
+ Accept: "application/vnd.github.v3+json"
69
+ }
70
+ });
71
+ if (!response.ok) return null;
72
+ const comments = await response.json();
73
+ const foxlightComment = comments.find((c) => c.body.includes("<!-- foxlight-report -->"));
74
+ return foxlightComment?.id ?? null;
75
+ }
76
+ var COMMENT_MARKER = "<!-- foxlight-report -->";
77
+ function generateCommentBody(diff) {
78
+ const lines = [COMMENT_MARKER, "## \u{1F98A} Foxlight Report", ""];
79
+ const { added, removed, modified } = diff.components;
80
+ if (added.length > 0 || removed.length > 0 || modified.length > 0) {
81
+ lines.push("### Components", "");
82
+ if (added.length > 0) {
83
+ lines.push(
84
+ `\u{1F7E2} **${added.length} added:** ${added.map((c) => `\`${c.name}\``).join(", ")}`,
85
+ ""
86
+ );
87
+ }
88
+ if (removed.length > 0) {
89
+ lines.push(
90
+ `\u{1F534} **${removed.length} removed:** ${removed.map((c) => `\`${c.name}\``).join(", ")}`,
91
+ ""
92
+ );
93
+ }
94
+ if (modified.length > 0) {
95
+ lines.push(`\u{1F7E1} **${modified.length} modified:**`, "");
96
+ lines.push(...formatModifications(modified), "");
97
+ }
98
+ } else {
99
+ lines.push("\u2705 No component changes detected.", "");
100
+ }
101
+ if (diff.bundleDiff.length > 0) {
102
+ const significant = diff.bundleDiff.filter((b) => Math.abs(b.delta.gzip) > 100);
103
+ if (significant.length > 0) {
104
+ lines.push("### Bundle Size Changes", "");
105
+ lines.push(
106
+ "| Component | Before | After | Delta |",
107
+ "|-----------|--------|-------|-------|"
108
+ );
109
+ for (const entry of significant) {
110
+ lines.push(formatBundleRow(entry));
111
+ }
112
+ lines.push("");
113
+ }
114
+ }
115
+ if (diff.healthDiff.length > 0) {
116
+ const significant = diff.healthDiff.filter((h) => Math.abs(h.delta) >= 5);
117
+ if (significant.length > 0) {
118
+ lines.push("### Health Score Changes", "");
119
+ lines.push(
120
+ "| Component | Before | After | Delta |",
121
+ "|-----------|--------|-------|-------|"
122
+ );
123
+ for (const entry of significant) {
124
+ lines.push(formatHealthRow(entry));
125
+ }
126
+ lines.push("");
127
+ }
128
+ }
129
+ lines.push(
130
+ "---",
131
+ `*Generated by [Foxlight](https://github.com/foxlight) at ${(/* @__PURE__ */ new Date()).toISOString()}*`
132
+ );
133
+ return lines.join("\n");
134
+ }
135
+ function formatModifications(mods) {
136
+ const lines = [];
137
+ for (const mod of mods) {
138
+ const changes = [];
139
+ if (mod.propsAdded.length > 0) changes.push(`+${mod.propsAdded.length} props`);
140
+ if (mod.propsRemoved.length > 0) changes.push(`-${mod.propsRemoved.length} props`);
141
+ if (mod.propsModified.length > 0) changes.push(`~${mod.propsModified.length} props changed`);
142
+ if (mod.changes.length > 0) changes.push(...mod.changes);
143
+ lines.push(` - \`${mod.componentId}\`: ${changes.join(", ")}`);
144
+ }
145
+ return lines;
146
+ }
147
+ function formatBundleRow(entry) {
148
+ const delta = entry.delta.gzip;
149
+ const emoji = delta > 0 ? "\u{1F53A}" : "\u{1F53D}";
150
+ return `| \`${entry.componentId}\` | ${formatBytes(entry.before.gzip)} | ${formatBytes(entry.after.gzip)} | ${emoji} ${formatBytes(delta)} |`;
151
+ }
152
+ function formatHealthRow(entry) {
153
+ const emoji = entry.delta > 0 ? "\u{1F4C8}" : "\u{1F4C9}";
154
+ return `| \`${entry.componentId}\` | ${entry.beforeScore} | ${entry.afterScore} | ${emoji} ${entry.delta > 0 ? "+" : ""}${entry.delta} |`;
155
+ }
156
+ async function createCheckRun(config, diff, options) {
157
+ const { apiUrl = "https://api.github.com" } = config;
158
+ const url = `${apiUrl}/repos/${config.owner}/${config.repo}/check-runs`;
159
+ const { conclusion, summary, annotations } = evaluateCheckResult(diff);
160
+ const body = {
161
+ name: options.name,
162
+ head_sha: options.headSha,
163
+ status: "completed",
164
+ conclusion,
165
+ output: {
166
+ title: "Foxlight Analysis",
167
+ summary,
168
+ text: generateCommentBody(diff),
169
+ annotations
170
+ }
171
+ };
172
+ await fetch(url, {
173
+ method: "POST",
174
+ headers: {
175
+ Authorization: `token ${config.token}`,
176
+ "Content-Type": "application/json",
177
+ Accept: "application/vnd.github.v3+json"
178
+ },
179
+ body: JSON.stringify(body)
180
+ });
181
+ }
182
+ function evaluateCheckResult(diff) {
183
+ const annotations = [];
184
+ const issues = [];
185
+ const { added, removed, modified } = diff.components;
186
+ for (const entry of diff.bundleDiff) {
187
+ if (entry.delta.gzip > 10240) {
188
+ issues.push(
189
+ `Bundle size regression: ${entry.componentId} grew by ${formatBytes(entry.delta.gzip)} (gzip)`
190
+ );
191
+ }
192
+ }
193
+ for (const entry of diff.healthDiff) {
194
+ if (entry.delta < -15) {
195
+ issues.push(
196
+ `Health regression: ${entry.componentId} dropped from ${entry.beforeScore} to ${entry.afterScore}`
197
+ );
198
+ }
199
+ }
200
+ const summaryParts = [];
201
+ if (added.length > 0) summaryParts.push(`${added.length} component(s) added`);
202
+ if (removed.length > 0) summaryParts.push(`${removed.length} component(s) removed`);
203
+ if (modified.length > 0) summaryParts.push(`${modified.length} component(s) modified`);
204
+ if (issues.length > 0) summaryParts.push(`${issues.length} issue(s) detected`);
205
+ const summary = summaryParts.length > 0 ? summaryParts.join(", ") : "No significant changes detected.";
206
+ const conclusion = issues.length > 0 ? "failure" : added.length + removed.length + modified.length > 0 ? "success" : "neutral";
207
+ return { conclusion, summary, annotations };
208
+ }
209
+
210
+ // src/gitlab.ts
211
+ function detectGitLabEnv() {
212
+ const token = process.env["GITLAB_TOKEN"] ?? process.env["CI_JOB_TOKEN"];
213
+ const projectId = process.env["CI_PROJECT_ID"];
214
+ const apiUrl = process.env["CI_API_V4_URL"] ?? "https://gitlab.com/api/v4";
215
+ let mergeRequestIid;
216
+ const mrIid = process.env["CI_MERGE_REQUEST_IID"];
217
+ if (mrIid) {
218
+ mergeRequestIid = parseInt(mrIid, 10);
219
+ }
220
+ return {
221
+ token,
222
+ projectId,
223
+ mergeRequestIid,
224
+ apiUrl
225
+ };
226
+ }
227
+ async function postMRComment(config, diff) {
228
+ const body = generateCommentBody(diff);
229
+ const { apiUrl = "https://gitlab.com/api/v4" } = config;
230
+ const existingNoteId = await findExistingNote(config);
231
+ if (existingNoteId) {
232
+ const updateUrl = `${apiUrl}/projects/${encodeURIComponent(config.projectId)}/merge_requests/${config.mergeRequestIid}/notes/${existingNoteId}`;
233
+ await fetch(updateUrl, {
234
+ method: "PUT",
235
+ headers: {
236
+ "PRIVATE-TOKEN": config.token,
237
+ "Content-Type": "application/json"
238
+ },
239
+ body: JSON.stringify({ body })
240
+ });
241
+ } else {
242
+ const url = `${apiUrl}/projects/${encodeURIComponent(config.projectId)}/merge_requests/${config.mergeRequestIid}/notes`;
243
+ await fetch(url, {
244
+ method: "POST",
245
+ headers: {
246
+ "PRIVATE-TOKEN": config.token,
247
+ "Content-Type": "application/json"
248
+ },
249
+ body: JSON.stringify({ body })
250
+ });
251
+ }
252
+ }
253
+ async function findExistingNote(config) {
254
+ const { apiUrl = "https://gitlab.com/api/v4" } = config;
255
+ const url = `${apiUrl}/projects/${encodeURIComponent(config.projectId)}/merge_requests/${config.mergeRequestIid}/notes?sort=desc&order_by=created_at`;
256
+ const response = await fetch(url, {
257
+ headers: {
258
+ "PRIVATE-TOKEN": config.token
259
+ }
260
+ });
261
+ if (!response.ok) return null;
262
+ const notes = await response.json();
263
+ const foxlightNote = notes.find((n) => n.body.includes("<!-- foxlight-report -->"));
264
+ return foxlightNote?.id ?? null;
265
+ }
266
+
267
+ // src/snapshot-comparator.ts
268
+ import { readFile, writeFile, mkdir } from "fs/promises";
269
+ import { existsSync } from "fs";
270
+ import { dirname } from "path";
271
+ import { ComponentRegistry } from "@foxlight/core";
272
+ import { analyzeProject } from "@foxlight/analyzer";
273
+ async function compareSnapshots(options) {
274
+ const { rootDir, commitSha = "unknown", branch = "unknown" } = options;
275
+ const analysis = await analyzeProject(rootDir);
276
+ const head = analysis.registry.createSnapshot(commitSha, branch);
277
+ if (options.outputPath) {
278
+ const dir = dirname(options.outputPath);
279
+ if (!existsSync(dir)) {
280
+ await mkdir(dir, { recursive: true });
281
+ }
282
+ await writeFile(options.outputPath, JSON.stringify(head, null, 2));
283
+ }
284
+ let base;
285
+ if (options.basePath && existsSync(options.basePath)) {
286
+ const raw = await readFile(options.basePath, "utf-8");
287
+ base = JSON.parse(raw);
288
+ } else {
289
+ base = createEmptySnapshot();
290
+ }
291
+ const diff = ComponentRegistry.diff(base, head);
292
+ return { diff, base, head };
293
+ }
294
+ function createEmptySnapshot() {
295
+ return {
296
+ id: "empty",
297
+ commitSha: "0000000",
298
+ branch: "none",
299
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
300
+ components: [],
301
+ imports: [],
302
+ bundleInfo: [],
303
+ health: []
304
+ };
305
+ }
306
+ function hasSignificantChanges(diff) {
307
+ const { added, removed, modified } = diff.components;
308
+ if (added.length > 0 || removed.length > 0) return true;
309
+ if (modified.length > 0) return true;
310
+ const hasBundleChange = diff.bundleDiff.some((b) => Math.abs(b.delta.gzip) > 1024);
311
+ if (hasBundleChange) return true;
312
+ const hasHealthChange = diff.healthDiff.some((h) => Math.abs(h.delta) > 10);
313
+ if (hasHealthChange) return true;
314
+ return false;
315
+ }
316
+ export {
317
+ compareSnapshots,
318
+ createCheckRun,
319
+ detectGitHubEnv,
320
+ detectGitLabEnv,
321
+ generateCommentBody,
322
+ hasSignificantChanges,
323
+ postMRComment,
324
+ postPRComment
325
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@foxlight/ci",
3
+ "version": "0.1.0",
4
+ "description": "CI/CD integration for Foxlight — GitHub PR comments, check runs, and snapshot comparison.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "dev": "tsup --watch"
17
+ },
18
+ "dependencies": {
19
+ "@foxlight/core": "*",
20
+ "@foxlight/analyzer": "*"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Jose Cruz",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/josegabrielcruz/foxlight.git",
30
+ "directory": "packages/ci"
31
+ },
32
+ "homepage": "https://github.com/josegabrielcruz/foxlight/tree/master/packages/ci#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/josegabrielcruz/foxlight/issues"
35
+ },
36
+ "keywords": [
37
+ "foxlight",
38
+ "ci",
39
+ "github-actions",
40
+ "gitlab",
41
+ "pr-comments"
42
+ ]
43
+ }