@scrymore/scry-deployer 0.0.3 โ†’ 0.0.5

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 CHANGED
@@ -50,6 +50,7 @@ This tool is designed for execution within a CI/CD pipeline (such as GitHub Acti
50
50
  - ๐Ÿ” Auto-detection of `.stories.*` files
51
51
  - ๐Ÿ“Š Story metadata extraction and analysis
52
52
  - ๐Ÿ“ธ Automated screenshot capture with storycap
53
+ - ๐Ÿงช Storybook coverage analysis + PR summary comments (see `docs/COVERAGE.md`)
53
54
  - ๐Ÿ“ฆ Organized master ZIP packaging (staticsite, images, metadata)
54
55
  - โš™๏ธ Flexible configuration (CLI, env vars, config file)
55
56
  - ๐Ÿ”’ Secure presigned URL uploads
@@ -406,7 +407,7 @@ If you're installing from GitHub, the workflow file is already included.
406
407
  | `SCRY_API_URL` | Backend API endpoint for uploads | `https://api.scrymore.com` |
407
408
  | `SCRY_VIEW_URL` | Base URL where users view deployed Storybooks | `https://view.scrymore.com` |
408
409
 
409
- **Note:** The `SCRY_VIEW_URL` is where users will access your deployed Storybook (e.g., `https://view.scrymore.com/{project}/pr-{number}`). This is separate from `SCRY_API_URL`, which is the backend API endpoint used for uploads.
410
+ **Note:** The `SCRY_VIEW_URL` is where users will access your deployed Storybook (e.g., `https://view.scrymore.com/{project}/pr-{number}/`). This is separate from `SCRY_API_URL`, which is the backend API endpoint used for uploads.
410
411
 
411
412
  **Step 3: Configure GitHub Actions Secrets (Optional)**
412
413
 
@@ -444,14 +445,14 @@ If your build command is different, update line 29 in `.github/workflows/deploy-
444
445
 
445
446
  The workflow constructs deployment URLs using the `SCRY_VIEW_URL` variable:
446
447
  ```
447
- {SCRY_VIEW_URL}/{PROJECT_ID}/pr-{PR_NUMBER}
448
+ {SCRY_VIEW_URL}/{PROJECT_ID}/pr-{PR_NUMBER}/
448
449
  ```
449
450
 
450
451
  **Default:** If `SCRY_VIEW_URL` is not set, it defaults to `https://view.scrymore.com`
451
452
 
452
453
  **Example URLs:**
453
- - With default: `https://view.scrymore.com/my-project/pr-123`
454
- - With custom domain: `https://storybooks.mycompany.com/my-project/pr-123`
454
+ - With default: `https://view.scrymore.com/my-project/pr-123/`
455
+ - With custom domain: `https://storybooks.mycompany.com/my-project/pr-123/`
455
456
 
456
457
  To use a custom domain, add `SCRY_VIEW_URL` as a repository variable (see Step 2).
457
458
 
@@ -515,7 +516,7 @@ The CLI also supports these environment variables for backward compatibility:
515
516
  ```markdown
516
517
  ## ๐Ÿš€ Storybook Preview Deployed
517
518
 
518
- **Preview URL:** https://view.scrymore.com/my-project/pr-123
519
+ **Preview URL:** https://view.scrymore.com/my-project/pr-123/
519
520
 
520
521
  ๐Ÿ“Œ **Details:**
521
522
  - **Commit:** `abc1234`
package/bin/cli.js CHANGED
@@ -7,12 +7,14 @@ const path = require('path');
7
7
  const os = require('os');
8
8
  const { zipDirectory } = require('../lib/archive.js');
9
9
  const { createMasterZip } = require('../lib/archiveUtils.js');
10
- const { getApiClient, uploadFileDirectly } = require('../lib/apiClient.js');
10
+ const { getApiClient, uploadBuild } = require('../lib/apiClient.js');
11
11
  const { createLogger } = require('../lib/logger.js');
12
12
  const { AppError, ApiError } = require('../lib/errors.js');
13
13
  const { loadConfig } = require('../lib/config.js');
14
14
  const { captureScreenshots } = require('../lib/screencap.js');
15
15
  const { analyzeStorybook } = require('../lib/analysis.js');
16
+ const { runCoverageAnalysis, loadCoverageReport, extractCoverageSummary } = require('../lib/coverage.js');
17
+ const { postPRComment } = require('../lib/pr-comment.js');
16
18
  const { runInit } = require('../lib/init.js');
17
19
 
18
20
  async function runAnalysis(argv) {
@@ -83,6 +85,8 @@ async function runDeployment(argv) {
83
85
  const outPath = path.join(os.tmpdir(), `storybook-deployment-${Date.now()}.zip`);
84
86
 
85
87
  try {
88
+ const { coverageReport, coverageSummary } = await resolveCoverage(argv, logger);
89
+
86
90
  if (argv.withAnalysis) {
87
91
  // Full deployment with analysis
88
92
  logger.info('Running deployment with analysis...');
@@ -117,16 +121,22 @@ async function runDeployment(argv) {
117
121
  logger.success(`โœ… Master archive created: ${outPath}`);
118
122
  logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
119
123
 
120
- // 4. Upload archive
124
+ // 4. Upload archive (+ optional coverage)
121
125
  logger.info('4/5: Uploading to deployment service...');
122
126
  const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
123
- const uploadResult = await uploadFileDirectly(apiClient, {
124
- project: argv.project,
125
- version: argv.version,
126
- }, outPath);
127
+ const uploadResult = await uploadBuild(
128
+ apiClient,
129
+ {
130
+ project: argv.project,
131
+ version: argv.version,
132
+ },
133
+ { zipPath: outPath, coverageReport }
134
+ );
127
135
  logger.success('โœ… Archive uploaded.');
128
136
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
129
137
 
138
+ await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
139
+
130
140
  logger.success('\n๐ŸŽ‰ Deployment with analysis successful! ๐ŸŽ‰');
131
141
 
132
142
  } else {
@@ -137,16 +147,22 @@ async function runDeployment(argv) {
137
147
  logger.success(`โœ… Archive created: ${outPath}`);
138
148
  logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
139
149
 
140
- // 2. Authenticate and upload directly
150
+ // 2. Authenticate and upload directly (+ optional coverage)
141
151
  logger.info('2/3: Uploading to deployment service...');
142
152
  const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
143
- const uploadResult = await uploadFileDirectly(apiClient, {
144
- project: argv.project,
145
- version: argv.version,
146
- }, outPath);
153
+ const uploadResult = await uploadBuild(
154
+ apiClient,
155
+ {
156
+ project: argv.project,
157
+ version: argv.version,
158
+ },
159
+ { zipPath: outPath, coverageReport }
160
+ );
147
161
  logger.success('โœ… Archive uploaded.');
148
162
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
149
163
 
164
+ await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
165
+
150
166
  logger.success('\n๐ŸŽ‰ Deployment successful! ๐ŸŽ‰');
151
167
  }
152
168
 
@@ -201,10 +217,35 @@ async function main() {
201
217
  type: 'string',
202
218
  })
203
219
  .option('deploy-version', {
204
- alias: 'v',
220
+ alias: ['v', 'version'],
205
221
  describe: 'Version identifier for the deployment',
206
222
  type: 'string',
207
223
  })
224
+ // Coverage options (enabled by default)
225
+ .option('coverage', {
226
+ describe: 'Run coverage analysis and upload report',
227
+ type: 'boolean',
228
+ default: true,
229
+ })
230
+ .option('coverage-report', {
231
+ describe: 'Path to coverage report JSON file (skip analysis and upload this report)',
232
+ type: 'string',
233
+ })
234
+ .option('coverage-fail-on-threshold', {
235
+ describe: 'Fail if coverage thresholds are not met',
236
+ type: 'boolean',
237
+ default: false,
238
+ })
239
+ .option('coverage-base', {
240
+ describe: 'Base branch for new code analysis',
241
+ type: 'string',
242
+ default: 'main',
243
+ })
244
+ .option('coverage-execute', {
245
+ describe: 'Execute stories during coverage analysis',
246
+ type: 'boolean',
247
+ default: false,
248
+ })
208
249
  .option('with-analysis', {
209
250
  describe: 'Include Storybook analysis (screenshots, metadata)',
210
251
  type: 'boolean',
@@ -348,4 +389,83 @@ async function main() {
348
389
  }
349
390
  }
350
391
 
351
- main();
392
+ /**
393
+ * Resolve coverage settings into a report and a summary.
394
+ *
395
+ * @param {any} argv
396
+ * @param {{info:Function,debug:Function,success:Function,error:Function}} logger
397
+ */
398
+ async function resolveCoverage(argv, logger) {
399
+ const enabled = argv.coverage !== false;
400
+ if (!enabled) {
401
+ logger.info('Coverage: disabled (--no-coverage)');
402
+ return { coverageReport: null, coverageSummary: null };
403
+ }
404
+
405
+ try {
406
+ let report = null;
407
+
408
+ if (argv.coverageReport) {
409
+ logger.info(`Coverage: using existing report at ${argv.coverageReport}`);
410
+ report = loadCoverageReport(argv.coverageReport);
411
+ } else {
412
+ report = await runCoverageAnalysis({
413
+ storybookDir: argv.dir,
414
+ baseBranch: argv.coverageBase || 'main',
415
+ failOnThreshold: Boolean(argv.coverageFailOnThreshold),
416
+ execute: Boolean(argv.coverageExecute),
417
+ });
418
+ }
419
+
420
+ const summary = extractCoverageSummary(report);
421
+ if (summary) {
422
+ logger.success('โœ… Coverage report ready');
423
+ logger.debug(`Coverage summary: ${JSON.stringify(summary.summary)}`);
424
+ } else {
425
+ logger.info('Coverage: no report generated (tool failed or report shape unexpected)');
426
+ }
427
+
428
+ return { coverageReport: report, coverageSummary: summary };
429
+ } catch (err) {
430
+ logger.error(`Coverage: failed (${err.message})`);
431
+ throw err;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Construct public URLs for view and coverage assets.
437
+ *
438
+ * @param {any} argv
439
+ * @param {any|null} coverageSummary
440
+ */
441
+ function buildDeployResult(argv, coverageSummary) {
442
+ const project = argv.project || 'main';
443
+ const version = argv.version || 'latest';
444
+ const viewBaseUrl = process.env.SCRY_VIEW_URL || 'https://view.scrymore.com';
445
+
446
+ const viewUrl = `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/`;
447
+
448
+ const coverageUrl = coverageSummary
449
+ ? `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/coverage-report.json`
450
+ : null;
451
+
452
+ return {
453
+ project,
454
+ version,
455
+ viewUrl,
456
+ coverageUrl,
457
+ coveragePageUrl: coverageUrl,
458
+ };
459
+ }
460
+
461
+ if (require.main === module) {
462
+ main();
463
+ }
464
+
465
+ module.exports = {
466
+ main,
467
+ runDeployment,
468
+ runAnalysis,
469
+ resolveCoverage,
470
+ buildDeployResult,
471
+ };
package/lib/apiClient.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const axios = require('axios');
2
2
  const fs = require('fs');
3
- const { ApiError, UploadError } = require('./errors.js');
3
+ const { ApiError } = require('./errors.js');
4
4
 
5
5
  /**
6
6
  * Creates a pre-configured axios instance for making API calls.
@@ -30,101 +30,176 @@ function getApiClient(apiUrl, apiKey) {
30
30
  });
31
31
  }
32
32
 
33
+ /**
34
+ * Request a presigned URL from the backend.
35
+ *
36
+ * @param {axios.AxiosInstance} apiClient
37
+ * @param {{project: string, version: string}} target
38
+ * @param {{fileName: string, contentType: string}} file
39
+ * @returns {Promise<string>} presigned URL
40
+ */
41
+ async function requestPresignedUrl(apiClient, target, file) {
42
+ const projectName = target.project || 'main';
43
+ const versionName = target.version || 'latest';
44
+
45
+ const presignedResponse = await apiClient.post(
46
+ `/presigned-url/${projectName}/${versionName}/${file.fileName}`,
47
+ { contentType: file.contentType },
48
+ {
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ }
53
+ );
54
+
55
+ const presignedUrl = presignedResponse.data?.url;
56
+ if (!presignedUrl || typeof presignedUrl !== 'string' || presignedUrl.trim() === '') {
57
+ throw new ApiError(
58
+ `Failed to get valid presigned URL from server response. Received: ${JSON.stringify(presignedResponse.data)}`
59
+ );
60
+ }
61
+
62
+ // Validate URL format
63
+ try {
64
+ new URL(presignedUrl);
65
+ } catch (urlError) {
66
+ throw new ApiError(`Received invalid URL format from server: "${presignedUrl}". URL validation error: ${urlError.message}`);
67
+ }
68
+
69
+ return presignedUrl;
70
+ }
71
+
72
+ /**
73
+ * Upload a buffer to a presigned URL.
74
+ *
75
+ * @param {string} presignedUrl
76
+ * @param {Buffer} buffer
77
+ * @param {string} contentType
78
+ * @returns {Promise<{status:number}>}
79
+ */
80
+ async function putToPresignedUrl(presignedUrl, buffer, contentType) {
81
+ const uploadResponse = await axios.put(presignedUrl, buffer, {
82
+ headers: {
83
+ 'Content-Type': contentType,
84
+ },
85
+ maxContentLength: Infinity,
86
+ maxBodyLength: Infinity,
87
+ });
88
+
89
+ return { status: uploadResponse.status };
90
+ }
91
+
33
92
  /**
34
93
  * Uploads a file using a presigned URL workflow.
94
+ *
35
95
  * @param {axios.AxiosInstance} apiClient The configured axios instance.
36
96
  * @param {object} payload The metadata for the deployment.
37
97
  * @param {string} payload.project The project name/identifier.
38
98
  * @param {string} payload.version The version identifier.
39
99
  * @param {string} filePath The local path to the file to upload.
100
+ * @param {{fileName?: string, contentType?: string}} [file] Optional overrides
40
101
  * @returns {Promise<object>} A promise that resolves to the upload result.
41
102
  */
42
- async function uploadFileDirectly(apiClient, { project, version }, filePath) {
103
+ async function uploadFileDirectly(apiClient, { project, version }, filePath, file = {}) {
43
104
  // This is a mock check to allow testing of a 500 server error.
44
105
  if (project === 'fail-me-500') {
45
106
  throw new ApiError('The deployment service encountered an internal error.', 500);
46
107
  }
47
108
 
48
- // Default to 'main' and 'latest' if not provided
49
- const projectName = project || 'main';
50
- const versionName = version || 'latest';
51
-
52
109
  const fileBuffer = fs.readFileSync(filePath);
53
- const fileName = 'storybook.zip';
110
+ const fileName = file.fileName || 'storybook.zip';
111
+ const contentType = file.contentType || 'application/zip';
54
112
 
55
113
  try {
56
- // Step 1: Request a presigned URL
57
- const presignedUrlStartTime = Date.now();
58
- console.log(`[DEBUG] Requesting presigned URL for /presigned-url/${projectName}/${versionName}/${fileName}`);
59
- console.log(`[DEBUG] API Base URL: ${apiClient.defaults.baseURL}`);
60
-
61
- // The server expects contentType in the JSON body, NOT as a header
62
- // This contentType must match the Content-Type header used during the actual upload
63
- const presignedResponse = await apiClient.post(
64
- `/presigned-url/${projectName}/${versionName}/${fileName}`,
65
- { contentType: 'application/zip' }, // Send contentType in the body
66
- {
67
- headers: {
68
- 'Content-Type': 'application/json', // Request body is JSON
69
- },
70
- }
71
- );
72
-
73
- const presignedUrlDuration = Date.now() - presignedUrlStartTime;
74
- console.log(`[TIMING] Presigned URL request: ${presignedUrlDuration}ms`);
75
- console.log(`[DEBUG] Presigned URL response status: ${presignedResponse.status}`);
76
- console.log(`[DEBUG] Presigned URL response data: ${JSON.stringify(presignedResponse.data)}`);
77
-
78
- const presignedUrl = presignedResponse.data.url;
79
- if (!presignedUrl || typeof presignedUrl !== 'string' || presignedUrl.trim() === '') {
114
+ const presignedUrl = await requestPresignedUrl(apiClient, { project, version }, { fileName, contentType });
115
+ const upload = await putToPresignedUrl(presignedUrl, fileBuffer, contentType);
116
+ return { success: true, url: presignedUrl, status: upload.status };
117
+ } catch (error) {
118
+ if (error.response) {
80
119
  throw new ApiError(
81
- `Failed to get valid presigned URL from server response. ` +
82
- `Received: ${JSON.stringify(presignedResponse.data)}`
120
+ `Failed to upload file: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`,
121
+ error.response.status
83
122
  );
123
+ } else if (error.request) {
124
+ throw new ApiError(`Failed to upload file: No response from server at ${apiClient.defaults.baseURL}`);
125
+ } else {
126
+ throw new ApiError(`Failed to upload file: ${error.message}`);
84
127
  }
128
+ }
129
+ }
85
130
 
86
- // Validate URL format
87
- try {
88
- new URL(presignedUrl);
89
- } catch (urlError) {
90
- throw new ApiError(
91
- `Received invalid URL format from server: "${presignedUrl}". ` +
92
- `URL validation error: ${urlError.message}`
93
- );
94
- }
131
+ /**
132
+ * Upload coverage report via the coverage attach endpoint.
133
+ * This uploads the JSON to R2 and attaches normalized coverage to the build.
134
+ *
135
+ * @param {axios.AxiosInstance} apiClient
136
+ * @param {{project: string, version: string}} target
137
+ * @param {any} coverageReport
138
+ * @returns {Promise<{success: boolean, buildId?: string, coverageUrl?: string}>}
139
+ */
140
+ async function uploadCoverageReportDirectly(apiClient, target, coverageReport) {
141
+ const projectName = target.project || 'main';
142
+ const versionName = target.version || 'latest';
95
143
 
96
- console.log(`[DEBUG] Received presigned URL, uploading file...`);
97
- console.log(`[DEBUG] Presigned URL: ${presignedUrl.substring(0, 100)}...`);
98
- console.log(`[DEBUG] File size: ${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB`);
144
+ try {
145
+ const response = await apiClient.post(
146
+ `/upload/${projectName}/${versionName}/coverage`,
147
+ coverageReport,
148
+ {
149
+ headers: {
150
+ 'Content-Type': 'application/json',
151
+ },
152
+ }
153
+ );
99
154
 
100
- // Step 2: Upload the file to the presigned URL using PUT
101
- const uploadStartTime = Date.now();
102
- const uploadResponse = await axios.put(presignedUrl, fileBuffer, {
103
- headers: {
104
- 'Content-Type': 'application/zip',
105
- },
106
- maxContentLength: Infinity,
107
- maxBodyLength: Infinity,
108
- });
109
-
110
- const uploadDuration = Date.now() - uploadStartTime;
111
- const uploadSpeedMbps = ((fileBuffer.length / 1024 / 1024) / (uploadDuration / 1000)).toFixed(2);
112
- console.log(`[TIMING] File upload: ${uploadDuration}ms (${uploadSpeedMbps} MB/s)`);
113
- console.log(`[DEBUG] File uploaded successfully`);
114
-
115
- return { success: true, url: presignedUrl, status: uploadResponse.status };
155
+ return {
156
+ success: response.data?.success ?? true,
157
+ buildId: response.data?.buildId,
158
+ coverageUrl: response.data?.coverageUrl,
159
+ };
116
160
  } catch (error) {
117
161
  if (error.response) {
118
- throw new ApiError(`Failed to upload file: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`, error.response.status);
162
+ throw new ApiError(
163
+ `Failed to upload coverage: ${error.response.status} ${error.response.statusText}${error.response.data ? ` - ${JSON.stringify(error.response.data)}` : ''}`,
164
+ error.response.status
165
+ );
119
166
  } else if (error.request) {
120
- throw new ApiError(`Failed to upload file: No response from server at ${apiClient.defaults.baseURL}`);
167
+ throw new ApiError(`Failed to upload coverage: No response from server at ${apiClient.defaults.baseURL}`);
121
168
  } else {
122
- throw new ApiError(`Failed to upload file: ${error.message}`);
169
+ throw new ApiError(`Failed to upload coverage: ${error.message}`);
123
170
  }
124
171
  }
125
172
  }
126
173
 
174
+ /**
175
+ * Upload storybook zip plus optional coverage report.
176
+ *
177
+ * NOTE: The backend currently supports uploads via presigned URLs only.
178
+ * This helper keeps the orchestration in one place.
179
+ *
180
+ * @param {axios.AxiosInstance} apiClient
181
+ * @param {{project: string, version: string}} target
182
+ * @param {{zipPath: string, coverageReport?: any|null}} options
183
+ */
184
+ async function uploadBuild(apiClient, target, options) {
185
+ const zipUpload = await uploadFileDirectly(apiClient, target, options.zipPath, {
186
+ fileName: 'storybook.zip',
187
+ contentType: 'application/zip',
188
+ });
189
+
190
+ let coverageUpload = null;
191
+ if (options.coverageReport) {
192
+ coverageUpload = await uploadCoverageReportDirectly(apiClient, target, options.coverageReport);
193
+ }
194
+
195
+ return { zipUpload, coverageUpload };
196
+ }
197
+
127
198
  module.exports = {
128
199
  getApiClient,
129
200
  uploadFileDirectly,
201
+ uploadCoverageReportDirectly,
202
+ uploadBuild,
203
+ requestPresignedUrl,
204
+ putToPresignedUrl,
130
205
  };
package/lib/config.js CHANGED
@@ -27,7 +27,12 @@ const DEFAULT_CONFIG = {
27
27
  include: undefined,
28
28
  exclude: undefined,
29
29
  omitBackground: true
30
- }
30
+ },
31
+ // Coverage options (enabled by default)
32
+ coverage: true,
33
+ coverageReport: null,
34
+ coverageFailOnThreshold: false,
35
+ coverageBase: 'main'
31
36
  };
32
37
 
33
38
  /**
@@ -102,7 +107,7 @@ function detectGitHubActionsContext() {
102
107
  */
103
108
  function loadEnvConfig() {
104
109
  const envConfig = {};
105
-
110
+
106
111
  // Map environment variable names to config keys
107
112
  const envMapping = {
108
113
  'API_URL': 'apiUrl',
@@ -116,14 +121,14 @@ function loadEnvConfig() {
116
121
  'SCREENSHOTS_DIR': 'screenshotsDir',
117
122
  'STORYBOOK_URL': 'storybookUrl'
118
123
  };
119
-
124
+
120
125
  Object.keys(envMapping).forEach(envKey => {
121
126
  const configKey = envMapping[envKey];
122
-
127
+
123
128
  // Check SCRY_ prefix first (new standard), then fall back to STORYBOOK_DEPLOYER_ for backward compatibility
124
129
  const scryEnvKey = 'SCRY_' + envKey;
125
130
  const legacyEnvKey = 'STORYBOOK_DEPLOYER_' + envKey;
126
-
131
+
127
132
  // Handle special case for PROJECT_ID vs PROJECT
128
133
  let envValue = process.env[scryEnvKey];
129
134
  if (!envValue && envKey === 'PROJECT') {
@@ -132,7 +137,7 @@ function loadEnvConfig() {
132
137
  if (!envValue) {
133
138
  envValue = process.env[legacyEnvKey];
134
139
  }
135
-
140
+
136
141
  // Filter out undefined and empty string values to prevent overriding CLI args
137
142
  if (envValue !== undefined && envValue !== '') {
138
143
  // Convert string values to appropriate types
@@ -143,7 +148,27 @@ function loadEnvConfig() {
143
148
  }
144
149
  }
145
150
  });
146
-
151
+
152
+ // Coverage env vars (spec names)
153
+ if (process.env.SCRY_COVERAGE_ENABLED !== undefined && process.env.SCRY_COVERAGE_ENABLED !== '') {
154
+ envConfig.coverage = process.env.SCRY_COVERAGE_ENABLED.toLowerCase() !== 'false';
155
+ }
156
+
157
+ if (process.env.SCRY_COVERAGE_REPORT !== undefined && process.env.SCRY_COVERAGE_REPORT !== '') {
158
+ envConfig.coverageReport = process.env.SCRY_COVERAGE_REPORT;
159
+ }
160
+
161
+ if (
162
+ process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD !== undefined &&
163
+ process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD !== ''
164
+ ) {
165
+ envConfig.coverageFailOnThreshold = process.env.SCRY_COVERAGE_FAIL_ON_THRESHOLD.toLowerCase() === 'true';
166
+ }
167
+
168
+ if (process.env.SCRY_COVERAGE_BASE !== undefined && process.env.SCRY_COVERAGE_BASE !== '') {
169
+ envConfig.coverageBase = process.env.SCRY_COVERAGE_BASE;
170
+ }
171
+
147
172
  return envConfig;
148
173
  }
149
174
 
@@ -153,7 +178,7 @@ function loadEnvConfig() {
153
178
  function loadConfig(cliArgs = {}) {
154
179
  const configPath = getConfigPath();
155
180
  let fileConfig = {};
156
-
181
+
157
182
  if (fs.existsSync(configPath)) {
158
183
  try {
159
184
  const configContent = fs.readFileSync(configPath, 'utf8');
@@ -162,31 +187,23 @@ function loadConfig(cliArgs = {}) {
162
187
  console.warn(`โš ๏ธ Could not read config file at ${configPath}: ${error.message}`);
163
188
  }
164
189
  }
165
-
190
+
166
191
  // Load environment variables
167
192
  const envConfig = loadEnvConfig();
168
-
193
+
169
194
  // Detect GitHub Actions context
170
195
  const githubContext = detectGitHubActionsContext();
171
-
172
- // Log configuration sources for debugging
173
- console.log('[CONFIG] Configuration sources:');
174
- console.log(`[CONFIG] - Default apiUrl: ${DEFAULT_CONFIG.apiUrl}`);
175
- console.log(`[CONFIG] - File config apiUrl: ${fileConfig.apiUrl || 'not set'}`);
176
- console.log(`[CONFIG] - Env SCRY_API_URL: ${process.env.SCRY_API_URL || 'not set'}`);
177
- console.log(`[CONFIG] - Env STORYBOOK_DEPLOYER_API_URL: ${process.env.STORYBOOK_DEPLOYER_API_URL || 'not set'}`);
178
- console.log(`[CONFIG] - CLI args apiUrl: ${cliArgs.apiUrl || 'not set'}`);
179
-
196
+
180
197
  // Precedence: CLI arguments > GitHub Actions context > environment variables > file config > defaults
181
198
  // This ensures GitHub Actions auto-detection works even if env vars are set to empty strings
182
-
199
+
183
200
  // Map 'deployVersion' (from yargs --deploy-version) to 'version' for internal use
184
201
  const normalizedCliArgs = { ...cliArgs };
185
202
  if (normalizedCliArgs.deployVersion) {
186
203
  normalizedCliArgs.version = normalizedCliArgs.deployVersion;
187
204
  delete normalizedCliArgs.deployVersion;
188
205
  }
189
-
206
+
190
207
  const finalConfig = {
191
208
  ...DEFAULT_CONFIG,
192
209
  ...fileConfig,
@@ -194,11 +211,22 @@ function loadConfig(cliArgs = {}) {
194
211
  ...githubContext,
195
212
  ...normalizedCliArgs
196
213
  };
197
-
198
- console.log(`[CONFIG] Final apiUrl: ${finalConfig.apiUrl}`);
199
- console.log(`[CONFIG] Final project: ${finalConfig.project}`);
200
- console.log(`[CONFIG] Final version: ${finalConfig.version}`);
201
-
214
+
215
+ const debugConfig = Boolean(finalConfig.verbose);
216
+ if (debugConfig) {
217
+ // Log configuration sources for debugging
218
+ console.log('[CONFIG] Configuration sources:');
219
+ console.log(`[CONFIG] - Default apiUrl: ${DEFAULT_CONFIG.apiUrl}`);
220
+ console.log(`[CONFIG] - File config apiUrl: ${fileConfig.apiUrl || 'not set'}`);
221
+ console.log(`[CONFIG] - Env SCRY_API_URL: ${process.env.SCRY_API_URL || 'not set'}`);
222
+ console.log(`[CONFIG] - Env STORYBOOK_DEPLOYER_API_URL: ${process.env.STORYBOOK_DEPLOYER_API_URL || 'not set'}`);
223
+ console.log(`[CONFIG] - CLI args apiUrl: ${cliArgs.apiUrl || 'not set'}`);
224
+ console.log(`[CONFIG] Final apiUrl: ${finalConfig.apiUrl}`);
225
+ console.log(`[CONFIG] Final project: ${finalConfig.project}`);
226
+ console.log(`[CONFIG] Final version: ${finalConfig.version}`);
227
+ console.log(`[CONFIG] Final coverage: ${finalConfig.coverage}`);
228
+ }
229
+
202
230
  return finalConfig;
203
231
  }
204
232
 
@@ -0,0 +1,185 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const chalk = require('chalk');
5
+
6
+ /**
7
+ * @typedef {Object} RunCoverageOptions
8
+ * @property {string} storybookDir Path to a built Storybook static directory (e.g. ./storybook-static)
9
+ * @property {string} [baseBranch='main'] Base branch name to compare for "new code" analysis
10
+ * @property {boolean} [failOnThreshold=false] If true, pass "--ci" to the coverage tool and rethrow errors
11
+ */
12
+
13
+ /**
14
+ * Run Storybook coverage analysis via `@scrymore/scry-sbcov`.
15
+ *
16
+ * Behavior:
17
+ * - Writes a temporary report file in the current working directory
18
+ * - Executes the `@scrymore/scry-sbcov` CLI
19
+ * - Reads and returns the parsed JSON report
20
+ * - Deletes the temporary report file
21
+ *
22
+ * If the underlying tool fails and `failOnThreshold` is false, returns `null`.
23
+ *
24
+ * @param {RunCoverageOptions} options
25
+ * @returns {Promise<any|null>} The full coverage report JSON, or null if skipped/failed (non-fatal)
26
+ */
27
+ async function runCoverageAnalysis(options) {
28
+ const { storybookDir, baseBranch = 'main', failOnThreshold = false, execute = false } = options || {};
29
+
30
+ if (!storybookDir || typeof storybookDir !== 'string') {
31
+ throw new Error('runCoverageAnalysis: options.storybookDir is required');
32
+ }
33
+
34
+ console.log(chalk.blue('Running Storybook coverage analysis...'));
35
+
36
+ const outputPath = path.join(process.cwd(), `.scry-coverage-report-${Date.now()}.json`);
37
+
38
+ /** @type {string[]} */
39
+ const args = [
40
+ '@scrymore/scry-sbcov',
41
+ '--storybook-static',
42
+ storybookDir,
43
+ '--output',
44
+ outputPath,
45
+ '--base',
46
+ `origin/${baseBranch}`,
47
+ '--verbose', // Enable verbose logging to debug component detection
48
+ ];
49
+
50
+ if (failOnThreshold) {
51
+ args.push('--ci');
52
+ }
53
+
54
+ if (execute) {
55
+ args.push('--execute');
56
+ }
57
+
58
+ // Debug logging to show the exact command being executed
59
+ console.log(chalk.yellow('\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'));
60
+ console.log(chalk.yellow('DEBUG: Executing coverage command:'));
61
+ console.log(chalk.gray(`npx ${args.join(' ')}`));
62
+ console.log(chalk.yellow('Working directory: ' + process.cwd()));
63
+ console.log(chalk.yellow('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'));
64
+
65
+ try {
66
+ // Determine the correct working directory
67
+ // If storybookDir is relative, resolve it from cwd
68
+ // Then use its parent directory as the project root
69
+ const absoluteStorybookDir = path.isAbsolute(storybookDir)
70
+ ? storybookDir
71
+ : path.resolve(process.cwd(), storybookDir);
72
+
73
+ const projectRoot = path.dirname(absoluteStorybookDir);
74
+
75
+ console.log(chalk.yellow('Project root: ' + projectRoot));
76
+ console.log(chalk.yellow('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'));
77
+
78
+ execSync(`npx ${args.map(shellEscape).join(' ')}`, {
79
+ stdio: 'inherit',
80
+ cwd: projectRoot // Run from the project root, not scry-node directory
81
+ });
82
+
83
+ const raw = fs.readFileSync(outputPath, 'utf-8');
84
+ const report = JSON.parse(raw);
85
+
86
+ safeUnlink(outputPath);
87
+ return report;
88
+ } catch (error) {
89
+ safeUnlink(outputPath);
90
+ if (failOnThreshold) throw error;
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Load a coverage report from disk.
97
+ *
98
+ * @param {string} reportPath
99
+ * @returns {any}
100
+ */
101
+ function loadCoverageReport(reportPath) {
102
+ if (!reportPath || typeof reportPath !== 'string') {
103
+ throw new Error('loadCoverageReport: reportPath is required');
104
+ }
105
+ const raw = fs.readFileSync(reportPath, 'utf-8');
106
+ return JSON.parse(raw);
107
+ }
108
+
109
+ /**
110
+ * Extracts a stable, API-friendly subset of the full report.
111
+ *
112
+ * NOTE: This function is intentionally defensive: if the report shape changes,
113
+ * we return `null` instead of throwing to avoid breaking deployments.
114
+ *
115
+ * @param {any|null} report
116
+ * @returns {null|{
117
+ * reportUrl: string|null,
118
+ * summary: {
119
+ * componentCoverage: number,
120
+ * propCoverage: number,
121
+ * variantCoverage: number,
122
+ * passRate: number,
123
+ * totalComponents: number,
124
+ * componentsWithStories: number,
125
+ * failingStories: number
126
+ * },
127
+ * qualityGate: any,
128
+ * generatedAt: string
129
+ * }}
130
+ */
131
+ function extractCoverageSummary(report) {
132
+ if (!report) return null;
133
+
134
+ try {
135
+ return {
136
+ reportUrl: null,
137
+ summary: {
138
+ componentCoverage: report.summary.metrics.componentCoverage,
139
+ propCoverage: report.summary.metrics.propCoverage,
140
+ variantCoverage: report.summary.metrics.variantCoverage,
141
+ passRate: report.summary.health.passRate,
142
+ totalComponents: report.summary.totalComponents,
143
+ componentsWithStories: report.summary.componentsWithStories,
144
+ failingStories: report.summary.health.failingStories,
145
+ },
146
+ qualityGate: report.qualityGate,
147
+ generatedAt: report.generatedAt,
148
+ };
149
+ } catch (e) {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Best-effort deletion: ignore ENOENT and other fs errors.
156
+ *
157
+ * @param {string} filePath
158
+ */
159
+ function safeUnlink(filePath) {
160
+ try {
161
+ if (fs.existsSync(filePath)) {
162
+ fs.unlinkSync(filePath);
163
+ }
164
+ } catch (_) {
165
+ // ignore
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Minimal shell escaping for arguments.
171
+ *
172
+ * @param {string} value
173
+ * @returns {string}
174
+ */
175
+ function shellEscape(value) {
176
+ if (typeof value !== 'string') return '';
177
+ if (/^[a-zA-Z0-9_\-./:@]+$/.test(value)) return value;
178
+ return `'${value.replace(/'/g, "'\\''")}'`;
179
+ }
180
+
181
+ module.exports = {
182
+ runCoverageAnalysis,
183
+ loadCoverageReport,
184
+ extractCoverageSummary,
185
+ };
package/lib/init.js CHANGED
@@ -351,6 +351,7 @@ If you haven't already, set up these repository variables:
351
351
  3. Add these Variables (Variables tab):
352
352
  โ€ข SCRY_PROJECT_ID = ${projectId}
353
353
  โ€ข SCRY_API_URL = ${apiUrl}
354
+ โ€ข SCRY_VIEW_URL = https://view.scrymore.com (optional, defaults to this)
354
355
 
355
356
  4. Add this Secret (Secrets tab):
356
357
  โ€ข SCRY_API_KEY = ${apiKey}
@@ -358,6 +359,7 @@ If you haven't already, set up these repository variables:
358
359
  Or install GitHub CLI and run:
359
360
  gh variable set SCRY_PROJECT_ID --body "${projectId}"
360
361
  gh variable set SCRY_API_URL --body "${apiUrl}"
362
+ gh variable set SCRY_VIEW_URL --body "https://view.scrymore.com"
361
363
  gh secret set SCRY_API_KEY --body "${apiKey}"
362
364
  `);
363
365
  }
@@ -465,8 +467,8 @@ ${!pushed ? `
465
467
  โ€ข Every pull request (as a preview)
466
468
 
467
469
  ๐ŸŒ Deployment URLs:
468
- โ€ข Production: ${apiUrl}/${projectId}/latest
469
- โ€ข PR Previews: ${apiUrl}/${projectId}/pr-{number}
470
+ โ€ข Production: https://view.scrymore.com/${projectId}/latest/
471
+ โ€ข PR Previews: https://view.scrymore.com/${projectId}/pr-{number}/
470
472
 
471
473
  ๐Ÿ“– Learn more: https://github.com/epinnock/scry-node
472
474
  ๐Ÿ’ฌ Need help? Open an issue on GitHub
@@ -0,0 +1,136 @@
1
+ const { Octokit } = require('@octokit/rest');
2
+
3
+ const COMMENT_MARKER = '<!-- scry-deployer -->';
4
+
5
+ /**
6
+ * Post (create or update) a PR comment with deployment and optional coverage info.
7
+ *
8
+ * This function is no-op unless:
9
+ * - `GITHUB_TOKEN` is set
10
+ * - The workflow context includes a pull request number
11
+ *
12
+ * @param {object} deployResult
13
+ * @param {any|null} coverageSummary Coverage summary (typically from extractCoverageSummary())
14
+ * @returns {Promise<void>}
15
+ */
16
+ async function postPRComment(deployResult, coverageSummary) {
17
+ const token = process.env.GITHUB_TOKEN;
18
+ if (!token) return;
19
+
20
+ const eventPath = process.env.GITHUB_EVENT_PATH;
21
+ if (!eventPath) return;
22
+
23
+ // eslint-disable-next-line import/no-dynamic-require, global-require
24
+ const event = require(eventPath);
25
+ const prNumber = event.pull_request?.number;
26
+ if (!prNumber) return;
27
+
28
+ const repoFull = process.env.GITHUB_REPOSITORY;
29
+ if (!repoFull || !repoFull.includes('/')) return;
30
+
31
+ const [owner, repo] = repoFull.split('/');
32
+ const octokit = new Octokit({ auth: token });
33
+
34
+ const body = formatPRComment(deployResult, coverageSummary);
35
+
36
+ // Upsert: update existing marker comment if present, else create new
37
+ const existing = await findExistingMarkerComment(octokit, owner, repo, prNumber);
38
+
39
+ if (existing) {
40
+ await octokit.rest.issues.updateComment({
41
+ owner,
42
+ repo,
43
+ comment_id: existing.id,
44
+ body,
45
+ });
46
+ } else {
47
+ await octokit.rest.issues.createComment({
48
+ owner,
49
+ repo,
50
+ issue_number: prNumber,
51
+ body,
52
+ });
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Build a deterministic markdown comment body.
58
+ *
59
+ * @param {object} deployResult
60
+ * @param {any|null} coverageSummary
61
+ * @returns {string}
62
+ */
63
+ function formatPRComment(deployResult, coverageSummary) {
64
+ const viewUrl = deployResult?.viewUrl;
65
+ const coveragePageUrl = deployResult?.coveragePageUrl || deployResult?.coverageUrl;
66
+
67
+ let body = `${COMMENT_MARKER}
68
+ ## Storybook Deployed
69
+
70
+ ${viewUrl ? `[View Storybook](${viewUrl})` : 'Storybook deployed successfully.'}`;
71
+
72
+ if (coverageSummary) {
73
+ const qualityGate = coverageSummary.qualityGate;
74
+ const passed = Boolean(qualityGate?.passed);
75
+ const statusIcon = passed ? 'โœ…' : 'โŒ';
76
+
77
+ const m = coverageSummary.summary;
78
+
79
+ body += `
80
+
81
+ ---
82
+
83
+ ## Coverage Report
84
+
85
+ | Metric | Value |
86
+ |--------|-------|
87
+ | Component Coverage | ${formatPercent(m?.componentCoverage)} |
88
+ | Prop Coverage | ${formatPercent(m?.propCoverage)} |
89
+ | Variant Coverage | ${formatPercent(m?.variantCoverage)} |
90
+ | Pass Rate | ${formatPercent(m?.passRate)} |
91
+
92
+ **Quality Gate:** ${statusIcon} ${passed ? 'PASSED' : 'FAILED'}`;
93
+
94
+ if (coveragePageUrl) {
95
+ body += `
96
+
97
+ [View Coverage Report](${coveragePageUrl})`;
98
+ }
99
+ }
100
+
101
+ return body;
102
+ }
103
+
104
+ /**
105
+ * Find a previously-posted comment containing our marker.
106
+ *
107
+ * @param {import('@octokit/rest').Octokit} octokit
108
+ * @param {string} owner
109
+ * @param {string} repo
110
+ * @param {number} prNumber
111
+ */
112
+ async function findExistingMarkerComment(octokit, owner, repo, prNumber) {
113
+ const { data: comments } = await octokit.rest.issues.listComments({
114
+ owner,
115
+ repo,
116
+ issue_number: prNumber,
117
+ per_page: 100,
118
+ });
119
+
120
+ return comments.find((c) => typeof c.body === 'string' && c.body.includes(COMMENT_MARKER)) || null;
121
+ }
122
+
123
+ /**
124
+ * @param {number|undefined|null} value
125
+ */
126
+ function formatPercent(value) {
127
+ if (typeof value !== 'number' || Number.isNaN(value)) return 'n/a';
128
+ return `${value.toFixed(1)}%`;
129
+ }
130
+
131
+ module.exports = {
132
+ postPRComment,
133
+ formatPRComment,
134
+ findExistingMarkerComment,
135
+ COMMENT_MARKER,
136
+ };
package/lib/templates.js CHANGED
@@ -81,7 +81,9 @@ jobs:
81
81
  steps:
82
82
  - name: Checkout code
83
83
  uses: actions/checkout@v4
84
-
84
+ with:
85
+ fetch-depth: 0 # Required for coverage new-code analysis
86
+
85
87
  ${pmSetup} - name: Setup Node.js
86
88
  uses: actions/setup-node@v4
87
89
  with:
@@ -95,11 +97,17 @@ ${cache}
95
97
  run: ${runCmd}
96
98
 
97
99
  - name: Deploy to Scry
98
- run: npx @scrymore/scry-deployer --dir ./storybook-static
100
+ run: |
101
+ npx @scrymore/scry-deployer \\
102
+ --dir ./storybook-static \\
103
+ \${{ vars.SCRY_COVERAGE_ENABLED == 'false' && '--no-coverage' || '' }} \\
104
+ \${{ vars.SCRY_COVERAGE_FAIL_ON_THRESHOLD == 'true' && '--coverage-fail-on-threshold' || '' }} \\
105
+ --coverage-base \${{ vars.SCRY_COVERAGE_BASE || 'main' }}
99
106
  env:
100
107
  STORYBOOK_DEPLOYER_API_URL: \${{ vars.SCRY_API_URL }}
101
108
  STORYBOOK_DEPLOYER_PROJECT: \${{ vars.SCRY_PROJECT_ID }}
102
109
  STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
110
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
103
111
  `;
104
112
  }
105
113
 
@@ -132,7 +140,9 @@ jobs:
132
140
  steps:
133
141
  - name: Checkout code
134
142
  uses: actions/checkout@v4
135
-
143
+ with:
144
+ fetch-depth: 0 # Required for coverage new-code analysis
145
+
136
146
  ${pmSetup} - name: Setup Node.js
137
147
  uses: actions/setup-node@v4
138
148
  with:
@@ -151,72 +161,23 @@ ${cache}
151
161
  # Deploy with PR-specific version
152
162
  npx @scrymore/scry-deployer \\
153
163
  --dir ./storybook-static \\
154
- --version pr-\${{ github.event.pull_request.number }}
155
-
156
- # Construct deployment URL
164
+ --version pr-\${{ github.event.pull_request.number }} \\
165
+ \${{ github.event.pull_request.draft == true && '--no-coverage' || '' }} \\
166
+ \${{ vars.SCRY_COVERAGE_ENABLED == 'false' && '--no-coverage' || '' }} \\
167
+ \${{ vars.SCRY_COVERAGE_FAIL_ON_THRESHOLD == 'true' && '--coverage-fail-on-threshold' || '' }} \\
168
+ --coverage-base \${{ vars.SCRY_COVERAGE_BASE || 'main' }}
169
+
170
+ # Construct deployment URL using VIEW_URL (where users access the deployed Storybook)
171
+ # Defaults to https://view.scrymore.com if SCRY_VIEW_URL is not set
157
172
  PROJECT_ID="\${{ vars.SCRY_PROJECT_ID }}"
158
- API_URL="\${{ vars.SCRY_API_URL }}"
159
- DEPLOY_URL="\${API_URL}/\${PROJECT_ID}/pr-\${{ github.event.pull_request.number }}"
173
+ VIEW_URL="\${{ vars.SCRY_VIEW_URL || 'https://view.scrymore.com' }}"
174
+ DEPLOY_URL="\${VIEW_URL}/\${PROJECT_ID}/pr-\${{ github.event.pull_request.number }}/"
160
175
  echo "deployment_url=\$DEPLOY_URL" >> $GITHUB_OUTPUT
161
176
  env:
162
177
  STORYBOOK_DEPLOYER_API_URL: \${{ vars.SCRY_API_URL }}
163
178
  STORYBOOK_DEPLOYER_PROJECT: \${{ vars.SCRY_PROJECT_ID }}
164
179
  STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
165
-
166
- - name: Comment on PR
167
- uses: actions/github-script@v7
168
- with:
169
- script: |
170
- const deploymentUrl = '\${{ steps.deploy.outputs.deployment_url }}';
171
- const prNumber = context.issue.number;
172
- const commitSha = context.payload.pull_request.head.sha.substring(0, 7);
173
- const branch = '\${{ github.event.pull_request.head.ref }}';
174
- const deployedAt = new Date().toUTCString();
175
-
176
- const commentBody = [
177
- '## ๐Ÿš€ Storybook Preview Deployed',
178
- '',
179
- '**Preview URL:** ' + deploymentUrl,
180
- '',
181
- '๐Ÿ“Œ **Details:**',
182
- '- **Commit:** ' + commitSha,
183
- '- **Branch:** ' + branch,
184
- '- **Deployed at:** ' + deployedAt,
185
- '',
186
- '> This preview updates automatically on each commit to this PR.'
187
- ].join('\\n');
188
-
189
- // Check if a comment from this bot already exists
190
- const { data: comments } = await github.rest.issues.listComments({
191
- owner: context.repo.owner,
192
- repo: context.repo.repo,
193
- issue_number: prNumber,
194
- });
195
-
196
- const botComment = comments.find(comment =>
197
- comment.user.type === 'Bot' &&
198
- comment.body.includes('๐Ÿš€ Storybook Preview Deployed')
199
- );
200
-
201
- if (botComment) {
202
- // Update existing comment
203
- await github.rest.issues.updateComment({
204
- owner: context.repo.owner,
205
- repo: context.repo.repo,
206
- comment_id: botComment.id,
207
- body: commentBody
208
- });
209
- console.log('Updated existing PR comment');
210
- } else {
211
- // Create new comment
212
- await github.rest.issues.createComment({
213
- owner: context.repo.owner,
214
- repo: context.repo.repo,
215
- issue_number: prNumber,
216
- body: commentBody
217
- });
218
- console.log('Created new PR comment');
219
- }
180
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
220
181
  `;
221
182
  }
222
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -36,6 +36,8 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
+ "@octokit/rest": "^20.0.0",
40
+ "@scrymore/scry-sbcov": "^0.2.1",
39
41
  "archiver": "^7.0.1",
40
42
  "axios": "^1.12.2",
41
43
  "chalk": "^4.1.2",
@@ -49,10 +51,13 @@
49
51
  "devDependencies": {
50
52
  "@changesets/changelog-github": "^0.5.2",
51
53
  "@changesets/cli": "^2.29.8",
52
- "dotenv": "^17.2.2"
54
+ "dotenv": "^17.2.2",
55
+ "jest": "^29.7.0"
53
56
  },
54
57
  "scripts": {
55
- "test": "echo \"Error: no test specified\" && exit 1",
58
+ "test": "jest",
59
+ "test:watch": "jest --watch",
60
+ "test:coverage": "jest --coverage",
56
61
  "postinstall": "node scripts/postinstall.js",
57
62
  "changeset": "changeset",
58
63
  "version": "changeset version",