@scrymore/scry-deployer 0.0.4 โ†’ 0.0.6

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
package/bin/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const Sentry = require('@sentry/node');
3
4
  const yargs = require('yargs/yargs');
4
5
  const { hideBin } = require('yargs/helpers');
5
6
  const fs = require('fs');
@@ -7,12 +8,14 @@ const path = require('path');
7
8
  const os = require('os');
8
9
  const { zipDirectory } = require('../lib/archive.js');
9
10
  const { createMasterZip } = require('../lib/archiveUtils.js');
10
- const { getApiClient, uploadFileDirectly } = require('../lib/apiClient.js');
11
+ const { getApiClient, uploadBuild } = require('../lib/apiClient.js');
11
12
  const { createLogger } = require('../lib/logger.js');
12
13
  const { AppError, ApiError } = require('../lib/errors.js');
13
14
  const { loadConfig } = require('../lib/config.js');
14
15
  const { captureScreenshots } = require('../lib/screencap.js');
15
16
  const { analyzeStorybook } = require('../lib/analysis.js');
17
+ const { runCoverageAnalysis, loadCoverageReport, extractCoverageSummary } = require('../lib/coverage.js');
18
+ const { postPRComment } = require('../lib/pr-comment.js');
16
19
  const { runInit } = require('../lib/init.js');
17
20
 
18
21
  async function runAnalysis(argv) {
@@ -83,6 +86,8 @@ async function runDeployment(argv) {
83
86
  const outPath = path.join(os.tmpdir(), `storybook-deployment-${Date.now()}.zip`);
84
87
 
85
88
  try {
89
+ const { coverageReport, coverageSummary } = await resolveCoverage(argv, logger);
90
+
86
91
  if (argv.withAnalysis) {
87
92
  // Full deployment with analysis
88
93
  logger.info('Running deployment with analysis...');
@@ -117,16 +122,22 @@ async function runDeployment(argv) {
117
122
  logger.success(`โœ… Master archive created: ${outPath}`);
118
123
  logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
119
124
 
120
- // 4. Upload archive
125
+ // 4. Upload archive (+ optional coverage)
121
126
  logger.info('4/5: Uploading to deployment service...');
122
127
  const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
123
- const uploadResult = await uploadFileDirectly(apiClient, {
124
- project: argv.project,
125
- version: argv.version,
126
- }, outPath);
128
+ const uploadResult = await uploadBuild(
129
+ apiClient,
130
+ {
131
+ project: argv.project,
132
+ version: argv.version,
133
+ },
134
+ { zipPath: outPath, coverageReport }
135
+ );
127
136
  logger.success('โœ… Archive uploaded.');
128
137
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
129
138
 
139
+ await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
140
+
130
141
  logger.success('\n๐ŸŽ‰ Deployment with analysis successful! ๐ŸŽ‰');
131
142
 
132
143
  } else {
@@ -137,16 +148,22 @@ async function runDeployment(argv) {
137
148
  logger.success(`โœ… Archive created: ${outPath}`);
138
149
  logger.debug(`Archive size: ${fs.statSync(outPath).size} bytes`);
139
150
 
140
- // 2. Authenticate and upload directly
151
+ // 2. Authenticate and upload directly (+ optional coverage)
141
152
  logger.info('2/3: Uploading to deployment service...');
142
153
  const apiClient = getApiClient(argv.apiUrl, argv.apiKey);
143
- const uploadResult = await uploadFileDirectly(apiClient, {
144
- project: argv.project,
145
- version: argv.version,
146
- }, outPath);
154
+ const uploadResult = await uploadBuild(
155
+ apiClient,
156
+ {
157
+ project: argv.project,
158
+ version: argv.version,
159
+ },
160
+ { zipPath: outPath, coverageReport }
161
+ );
147
162
  logger.success('โœ… Archive uploaded.');
148
163
  logger.debug(`Upload result: ${JSON.stringify(uploadResult)}`);
149
164
 
165
+ await postPRComment(buildDeployResult(argv, coverageSummary), coverageSummary);
166
+
150
167
  logger.success('\n๐ŸŽ‰ Deployment successful! ๐ŸŽ‰');
151
168
  }
152
169
 
@@ -159,10 +176,26 @@ async function runDeployment(argv) {
159
176
  }
160
177
  }
161
178
 
162
- function handleError(error, argv) {
179
+ async function handleError(error, argv) {
163
180
  const logger = createLogger(argv || {});
164
181
  logger.error(`\nโŒ Error: ${error.message}`);
165
182
 
183
+ // Capture error in Sentry with additional context
184
+ Sentry.withScope((scope) => {
185
+ if (argv) {
186
+ scope.setTags({
187
+ project: argv.project,
188
+ version: argv.version,
189
+ command: argv._ ? argv._[0] : 'unknown',
190
+ });
191
+ scope.setExtra('argv', argv);
192
+ }
193
+ Sentry.captureException(error);
194
+ });
195
+
196
+ // Ensure the event is sent before the process exits
197
+ await Sentry.close(2000);
198
+
166
199
  if (error instanceof ApiError) {
167
200
  if (error.statusCode === 401) {
168
201
  logger.error('Suggestion: Check that your API key is correct and has not expired.');
@@ -179,6 +212,13 @@ function handleError(error, argv) {
179
212
  }
180
213
 
181
214
  async function main() {
215
+ // Initialize Sentry
216
+ Sentry.init({
217
+ dsn: "https://c66ce229a1db2289f145eebd02436d9c@o4507889391828992.ingest.us.sentry.io/4510699330732032", // Fallback to hardcoded DSN for user reporting
218
+ tracesSampleRate: 1.0,
219
+ environment: process.env.NODE_ENV || 'production',
220
+ });
221
+
182
222
  let config;
183
223
  try {
184
224
  const args = await yargs(hideBin(process.argv))
@@ -201,10 +241,35 @@ async function main() {
201
241
  type: 'string',
202
242
  })
203
243
  .option('deploy-version', {
204
- alias: 'v',
244
+ alias: ['v', 'version'],
205
245
  describe: 'Version identifier for the deployment',
206
246
  type: 'string',
207
247
  })
248
+ // Coverage options (enabled by default)
249
+ .option('coverage', {
250
+ describe: 'Run coverage analysis and upload report',
251
+ type: 'boolean',
252
+ default: true,
253
+ })
254
+ .option('coverage-report', {
255
+ describe: 'Path to coverage report JSON file (skip analysis and upload this report)',
256
+ type: 'string',
257
+ })
258
+ .option('coverage-fail-on-threshold', {
259
+ describe: 'Fail if coverage thresholds are not met',
260
+ type: 'boolean',
261
+ default: false,
262
+ })
263
+ .option('coverage-base', {
264
+ describe: 'Base branch for new code analysis',
265
+ type: 'string',
266
+ default: 'main',
267
+ })
268
+ .option('coverage-execute', {
269
+ describe: 'Execute stories during coverage analysis',
270
+ type: 'boolean',
271
+ default: false,
272
+ })
208
273
  .option('with-analysis', {
209
274
  describe: 'Include Storybook analysis (screenshots, metadata)',
210
275
  type: 'boolean',
@@ -337,6 +402,9 @@ async function main() {
337
402
 
338
403
  await runInit(initConfig);
339
404
  })
405
+ .command('debug-sentry', 'Test Sentry integration by throwing an error', () => {}, () => {
406
+ throw new Error('Sentry debug error from scry-node CLI');
407
+ })
340
408
  .env('STORYBOOK_DEPLOYER')
341
409
  .help()
342
410
  .alias('help', 'h')
@@ -344,8 +412,87 @@ async function main() {
344
412
  .parse();
345
413
 
346
414
  } catch (error) {
347
- handleError(error, config);
415
+ await handleError(error, config);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Resolve coverage settings into a report and a summary.
421
+ *
422
+ * @param {any} argv
423
+ * @param {{info:Function,debug:Function,success:Function,error:Function}} logger
424
+ */
425
+ async function resolveCoverage(argv, logger) {
426
+ const enabled = argv.coverage !== false;
427
+ if (!enabled) {
428
+ logger.info('Coverage: disabled (--no-coverage)');
429
+ return { coverageReport: null, coverageSummary: null };
348
430
  }
431
+
432
+ try {
433
+ let report = null;
434
+
435
+ if (argv.coverageReport) {
436
+ logger.info(`Coverage: using existing report at ${argv.coverageReport}`);
437
+ report = loadCoverageReport(argv.coverageReport);
438
+ } else {
439
+ report = await runCoverageAnalysis({
440
+ storybookDir: argv.dir,
441
+ baseBranch: argv.coverageBase || 'main',
442
+ failOnThreshold: Boolean(argv.coverageFailOnThreshold),
443
+ execute: Boolean(argv.coverageExecute),
444
+ });
445
+ }
446
+
447
+ const summary = extractCoverageSummary(report);
448
+ if (summary) {
449
+ logger.success('โœ… Coverage report ready');
450
+ logger.debug(`Coverage summary: ${JSON.stringify(summary.summary)}`);
451
+ } else {
452
+ logger.info('Coverage: no report generated (tool failed or report shape unexpected)');
453
+ }
454
+
455
+ return { coverageReport: report, coverageSummary: summary };
456
+ } catch (err) {
457
+ logger.error(`Coverage: failed (${err.message})`);
458
+ throw err;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Construct public URLs for view and coverage assets.
464
+ *
465
+ * @param {any} argv
466
+ * @param {any|null} coverageSummary
467
+ */
468
+ function buildDeployResult(argv, coverageSummary) {
469
+ const project = argv.project || 'main';
470
+ const version = argv.version || 'latest';
471
+ const viewBaseUrl = process.env.SCRY_VIEW_URL || 'https://view.scrymore.com';
472
+
473
+ const viewUrl = `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/`;
474
+
475
+ const coverageUrl = coverageSummary
476
+ ? `${viewBaseUrl.replace(/\/$/, '')}/${project}/${version}/coverage-report.json`
477
+ : null;
478
+
479
+ return {
480
+ project,
481
+ version,
482
+ viewUrl,
483
+ coverageUrl,
484
+ coveragePageUrl: coverageUrl,
485
+ };
486
+ }
487
+
488
+ if (require.main === module) {
489
+ main();
349
490
  }
350
491
 
351
- main();
492
+ module.exports = {
493
+ main,
494
+ runDeployment,
495
+ runAnalysis,
496
+ resolveCoverage,
497
+ buildDeployResult,
498
+ };
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,188 @@
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 cliArgs = [
40
+ '--storybook-static',
41
+ storybookDir,
42
+ '--output',
43
+ outputPath,
44
+ '--base',
45
+ `origin/${baseBranch}`,
46
+ '--verbose', // Enable verbose logging to debug component detection
47
+ ];
48
+
49
+ if (failOnThreshold) {
50
+ cliArgs.push('--ci');
51
+ }
52
+
53
+ if (execute) {
54
+ cliArgs.push('--execute');
55
+ }
56
+
57
+ // Use npx with -p flag to ensure package is installed, then run the binary
58
+ // This is more reliable than `npx @scrymore/scry-sbcov` which can fail to find the binary
59
+ const npxCommand = `npx -y -p @scrymore/scry-sbcov scry-sbcov ${cliArgs.map(shellEscape).join(' ')}`;
60
+
61
+ // Debug logging to show the exact command being executed
62
+ console.log(chalk.yellow('\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'));
63
+ console.log(chalk.yellow('DEBUG: Executing coverage command:'));
64
+ console.log(chalk.gray(npxCommand));
65
+ console.log(chalk.yellow('Working directory: ' + process.cwd()));
66
+ console.log(chalk.yellow('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'));
67
+
68
+ try {
69
+ // Determine the correct working directory
70
+ // If storybookDir is relative, resolve it from cwd
71
+ // Then use its parent directory as the project root
72
+ const absoluteStorybookDir = path.isAbsolute(storybookDir)
73
+ ? storybookDir
74
+ : path.resolve(process.cwd(), storybookDir);
75
+
76
+ const projectRoot = path.dirname(absoluteStorybookDir);
77
+
78
+ console.log(chalk.yellow('Project root: ' + projectRoot));
79
+ console.log(chalk.yellow('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'));
80
+
81
+ execSync(npxCommand, {
82
+ stdio: 'inherit',
83
+ cwd: projectRoot // Run from the project root, not scry-node directory
84
+ });
85
+
86
+ const raw = fs.readFileSync(outputPath, 'utf-8');
87
+ const report = JSON.parse(raw);
88
+
89
+ safeUnlink(outputPath);
90
+ return report;
91
+ } catch (error) {
92
+ safeUnlink(outputPath);
93
+ if (failOnThreshold) throw error;
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Load a coverage report from disk.
100
+ *
101
+ * @param {string} reportPath
102
+ * @returns {any}
103
+ */
104
+ function loadCoverageReport(reportPath) {
105
+ if (!reportPath || typeof reportPath !== 'string') {
106
+ throw new Error('loadCoverageReport: reportPath is required');
107
+ }
108
+ const raw = fs.readFileSync(reportPath, 'utf-8');
109
+ return JSON.parse(raw);
110
+ }
111
+
112
+ /**
113
+ * Extracts a stable, API-friendly subset of the full report.
114
+ *
115
+ * NOTE: This function is intentionally defensive: if the report shape changes,
116
+ * we return `null` instead of throwing to avoid breaking deployments.
117
+ *
118
+ * @param {any|null} report
119
+ * @returns {null|{
120
+ * reportUrl: string|null,
121
+ * summary: {
122
+ * componentCoverage: number,
123
+ * propCoverage: number,
124
+ * variantCoverage: number,
125
+ * passRate: number,
126
+ * totalComponents: number,
127
+ * componentsWithStories: number,
128
+ * failingStories: number
129
+ * },
130
+ * qualityGate: any,
131
+ * generatedAt: string
132
+ * }}
133
+ */
134
+ function extractCoverageSummary(report) {
135
+ if (!report) return null;
136
+
137
+ try {
138
+ return {
139
+ reportUrl: null,
140
+ summary: {
141
+ componentCoverage: report.summary.metrics.componentCoverage,
142
+ propCoverage: report.summary.metrics.propCoverage,
143
+ variantCoverage: report.summary.metrics.variantCoverage,
144
+ passRate: report.summary.health.passRate,
145
+ totalComponents: report.summary.totalComponents,
146
+ componentsWithStories: report.summary.componentsWithStories,
147
+ failingStories: report.summary.health.failingStories,
148
+ },
149
+ qualityGate: report.qualityGate,
150
+ generatedAt: report.generatedAt,
151
+ };
152
+ } catch (e) {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Best-effort deletion: ignore ENOENT and other fs errors.
159
+ *
160
+ * @param {string} filePath
161
+ */
162
+ function safeUnlink(filePath) {
163
+ try {
164
+ if (fs.existsSync(filePath)) {
165
+ fs.unlinkSync(filePath);
166
+ }
167
+ } catch (_) {
168
+ // ignore
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Minimal shell escaping for arguments.
174
+ *
175
+ * @param {string} value
176
+ * @returns {string}
177
+ */
178
+ function shellEscape(value) {
179
+ if (typeof value !== 'string') return '';
180
+ if (/^[a-zA-Z0-9_\-./:@]+$/.test(value)) return value;
181
+ return `'${value.replace(/'/g, "'\\''")}'`;
182
+ }
183
+
184
+ module.exports = {
185
+ runCoverageAnalysis,
186
+ loadCoverageReport,
187
+ extractCoverageSummary,
188
+ };
package/lib/logger.js CHANGED
@@ -8,6 +8,7 @@ const chalk = require('chalk');
8
8
  * @returns {{info: Function, error: Function, debug: Function, success: Function}}
9
9
  */
10
10
  function createLogger({ verbose = false }) {
11
+ const Sentry = require('@sentry/node');
11
12
  return {
12
13
  /**
13
14
  * Logs an informational message.
@@ -15,6 +16,11 @@ function createLogger({ verbose = false }) {
15
16
  */
16
17
  info: (message) => {
17
18
  console.log(message);
19
+ Sentry.addBreadcrumb({
20
+ category: 'log',
21
+ message: message,
22
+ level: 'info',
23
+ });
18
24
  },
19
25
 
20
26
  /**
@@ -31,6 +37,11 @@ function createLogger({ verbose = false }) {
31
37
  */
32
38
  error: (message) => {
33
39
  console.error(chalk.red(message));
40
+ Sentry.addBreadcrumb({
41
+ category: 'log',
42
+ message: message,
43
+ level: 'error',
44
+ });
34
45
  },
35
46
 
36
47
  /**
@@ -41,6 +52,11 @@ function createLogger({ verbose = false }) {
41
52
  if (verbose) {
42
53
  console.log(chalk.dim(`[debug] ${message}`));
43
54
  }
55
+ Sentry.addBreadcrumb({
56
+ category: 'log',
57
+ message: message,
58
+ level: 'debug',
59
+ });
44
60
  },
45
61
  };
46
62
  }
@@ -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,8 +161,12 @@ ${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
-
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
+
156
170
  # Construct deployment URL using VIEW_URL (where users access the deployed Storybook)
157
171
  # Defaults to https://view.scrymore.com if SCRY_VIEW_URL is not set
158
172
  PROJECT_ID="\${{ vars.SCRY_PROJECT_ID }}"
@@ -163,61 +177,7 @@ ${cache}
163
177
  STORYBOOK_DEPLOYER_API_URL: \${{ vars.SCRY_API_URL }}
164
178
  STORYBOOK_DEPLOYER_PROJECT: \${{ vars.SCRY_PROJECT_ID }}
165
179
  STORYBOOK_DEPLOYER_API_KEY: \${{ secrets.SCRY_API_KEY }}
166
-
167
- - name: Comment on PR
168
- uses: actions/github-script@v7
169
- with:
170
- script: |
171
- const deploymentUrl = '\${{ steps.deploy.outputs.deployment_url }}';
172
- const prNumber = context.issue.number;
173
- const commitSha = context.payload.pull_request.head.sha.substring(0, 7);
174
- const branch = '\${{ github.event.pull_request.head.ref }}';
175
- const deployedAt = new Date().toUTCString();
176
-
177
- const commentBody = [
178
- '## ๐Ÿš€ Storybook Preview Deployed',
179
- '',
180
- '**Preview URL:** ' + deploymentUrl,
181
- '',
182
- '๐Ÿ“Œ **Details:**',
183
- '- **Commit:** ' + commitSha,
184
- '- **Branch:** ' + branch,
185
- '- **Deployed at:** ' + deployedAt,
186
- '',
187
- '> This preview updates automatically on each commit to this PR.'
188
- ].join('\\n');
189
-
190
- // Check if a comment from this bot already exists
191
- const { data: comments } = await github.rest.issues.listComments({
192
- owner: context.repo.owner,
193
- repo: context.repo.repo,
194
- issue_number: prNumber,
195
- });
196
-
197
- const botComment = comments.find(comment =>
198
- comment.user.type === 'Bot' &&
199
- comment.body.includes('๐Ÿš€ Storybook Preview Deployed')
200
- );
201
-
202
- if (botComment) {
203
- // Update existing comment
204
- await github.rest.issues.updateComment({
205
- owner: context.repo.owner,
206
- repo: context.repo.repo,
207
- comment_id: botComment.id,
208
- body: commentBody
209
- });
210
- console.log('Updated existing PR comment');
211
- } else {
212
- // Create new comment
213
- await github.rest.issues.createComment({
214
- owner: context.repo.owner,
215
- repo: context.repo.repo,
216
- issue_number: prNumber,
217
- body: commentBody
218
- });
219
- console.log('Created new PR comment');
220
- }
180
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
221
181
  `;
222
182
  }
223
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -36,6 +36,9 @@
36
36
  "node": ">=18"
37
37
  },
38
38
  "dependencies": {
39
+ "@octokit/rest": "^20.0.0",
40
+ "@scrymore/scry-sbcov": "^0.2.1",
41
+ "@sentry/node": "^10.33.0",
39
42
  "archiver": "^7.0.1",
40
43
  "axios": "^1.12.2",
41
44
  "chalk": "^4.1.2",
@@ -49,10 +52,13 @@
49
52
  "devDependencies": {
50
53
  "@changesets/changelog-github": "^0.5.2",
51
54
  "@changesets/cli": "^2.29.8",
52
- "dotenv": "^17.2.2"
55
+ "dotenv": "^17.2.2",
56
+ "jest": "^29.7.0"
53
57
  },
54
58
  "scripts": {
55
- "test": "echo \"Error: no test specified\" && exit 1",
59
+ "test": "jest",
60
+ "test:watch": "jest --watch",
61
+ "test:coverage": "jest --coverage",
56
62
  "postinstall": "node scripts/postinstall.js",
57
63
  "changeset": "changeset",
58
64
  "version": "changeset version",