@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 +81 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +325 -0
- package/package.json +43 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|