byuckchon-frontend-cli 1.9.6 → 1.9.7
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 +205 -270
- package/package.json +1 -1
- package/src/commands/adopt.js +37 -2
- package/src/constants/versions.js +1 -0
- package/src/generators/createApp.js +18 -11
- package/src/generators/createBaseFiles.js +6 -4
- package/src/generators/createFolders.js +5 -5
- package/src/generators/createMonorepo.js +4 -2
- package/src/generators/createPackageJson.js +9 -2
- package/src/generators/createProject.js +22 -19
- package/src/generators/createReadme.js +4 -4
- package/src/generators/install.js +1 -0
- package/src/generators/scaffoldReviewAutomation.js +105 -0
- package/templates/review-automation/github/workflows/eslint-convention-review.monorepo.yml +86 -0
- package/templates/review-automation/github/workflows/eslint-convention-review.single.yml +44 -0
- package/templates/review-automation/tools/eslint-rules/internal-blocking-conventions.js +1242 -0
- package/templates/review-automation/tools/eslint-rules/internal-plugin.cjs +8 -0
- package/templates/review-automation/tools/eslint-rules/internal-rdjson-formatter.js +60 -0
- package/templates/review-automation/tools/eslint-rules/internal-warning-conventions.js +57 -0
- package/templates/review-automation/tools/eslint-rules/package.json +3 -0
- package/templates/review-automation/tools/eslint-rules/review.config.mjs +22 -0
- package/templates/review-automation/tools/post-eslint-review-comments.cjs +309 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const REVIEW_COMMENT_RULE_IDS = new Set([
|
|
6
|
+
'internal/blocking-conventions',
|
|
7
|
+
'internal/warning-conventions',
|
|
8
|
+
'react/self-closing-comp',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function toRepositoryPath(filePath) {
|
|
12
|
+
const basePath = process.env.GITHUB_WORKSPACE || process.cwd();
|
|
13
|
+
const relativePath = path.relative(basePath, filePath);
|
|
14
|
+
|
|
15
|
+
return relativePath.split(path.sep).join('/');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toPosition(line, column) {
|
|
19
|
+
return {
|
|
20
|
+
line: line || 1,
|
|
21
|
+
column: column || 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = function format(results) {
|
|
26
|
+
const diagnostics = [];
|
|
27
|
+
|
|
28
|
+
for (const result of results) {
|
|
29
|
+
for (const message of result.messages) {
|
|
30
|
+
if (!REVIEW_COMMENT_RULE_IDS.has(message.ruleId)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
message: message.message,
|
|
36
|
+
location: {
|
|
37
|
+
path: toRepositoryPath(result.filePath),
|
|
38
|
+
range: {
|
|
39
|
+
start: toPosition(message.line, message.column),
|
|
40
|
+
end: toPosition(
|
|
41
|
+
message.endLine || message.line,
|
|
42
|
+
message.endColumn || message.column,
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
severity: message.severity === 2 ? 'ERROR' : 'WARNING',
|
|
47
|
+
code: {
|
|
48
|
+
value: message.ruleId,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
source: {
|
|
56
|
+
name: 'eslint-conventions',
|
|
57
|
+
},
|
|
58
|
+
diagnostics,
|
|
59
|
+
});
|
|
60
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
function normalizeFilename(filename) {
|
|
6
|
+
return filename.split(path.sep).join('/');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isApiIndexFile(filename) {
|
|
10
|
+
return /\/api\/index\.tsx?$/.test(filename);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
meta: {
|
|
15
|
+
type: 'suggestion',
|
|
16
|
+
docs: {
|
|
17
|
+
description: 'Internal warning conventions from CLAUDE.md',
|
|
18
|
+
},
|
|
19
|
+
schema: [],
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
create(context) {
|
|
23
|
+
const filename = normalizeFilename(context.getFilename());
|
|
24
|
+
|
|
25
|
+
function report(node, message) {
|
|
26
|
+
context.report({ node, message });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
ExportAllDeclaration(node) {
|
|
31
|
+
if (!isApiIndexFile(filename) || !node.source || !node.source.value) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (/\.(?:api|zod)$/.test(node.source.value)) {
|
|
36
|
+
report(
|
|
37
|
+
node.source,
|
|
38
|
+
'api index.ts에서는 service와 type 외 api/zod export에 근거가 필요합니다.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
ExportNamedDeclaration(node) {
|
|
44
|
+
if (!isApiIndexFile(filename) || !node.source || !node.source.value) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (/\.(?:api|zod)$/.test(node.source.value)) {
|
|
49
|
+
report(
|
|
50
|
+
node.source,
|
|
51
|
+
'api index.ts에서는 service와 type 외 api/zod export에 근거가 필요합니다.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ESLint Convention Review 워크플로우 전용 config.
|
|
2
|
+
// 각 패키지의 eslint.config.mjs에는 internal 플러그인이 없으므로,
|
|
3
|
+
// CI에서는 이 config로 레포 루트에서 한 번에 실행한다.
|
|
4
|
+
// (리뷰 코멘트로 게시되는 룰은 internal-rdjson-formatter.js가 필터링한다.)
|
|
5
|
+
import { reactConfig } from '../../packages/config-eslint/react.js';
|
|
6
|
+
|
|
7
|
+
import internalPlugin from './internal-plugin.cjs';
|
|
8
|
+
|
|
9
|
+
export default [
|
|
10
|
+
...reactConfig,
|
|
11
|
+
{
|
|
12
|
+
files: ['**/*.{ts,tsx}'],
|
|
13
|
+
plugins: {
|
|
14
|
+
internal: internalPlugin,
|
|
15
|
+
},
|
|
16
|
+
rules: {
|
|
17
|
+
'internal/blocking-conventions': 'error',
|
|
18
|
+
'internal/warning-conventions': 'warn',
|
|
19
|
+
'react/self-closing-comp': 'warn',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
];
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const COMMENT_MARKER = 'eslint-convention-review';
|
|
9
|
+
const USER_AGENT = 'eslint-convention-review';
|
|
10
|
+
const [rdjsonPath, sourceName = 'eslint-conventions', maybeDryRun] =
|
|
11
|
+
process.argv.slice(2);
|
|
12
|
+
const dryRun = maybeDryRun === '--dry-run';
|
|
13
|
+
|
|
14
|
+
if (!rdjsonPath) {
|
|
15
|
+
throw new Error('Usage: node post-eslint-review-comments.cjs <rdjson-path>');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readDiagnostics() {
|
|
19
|
+
const payload = JSON.parse(fs.readFileSync(rdjsonPath, 'utf8'));
|
|
20
|
+
|
|
21
|
+
return Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function severityLabel(diagnostic) {
|
|
25
|
+
const ruleId = diagnostic.code && diagnostic.code.value;
|
|
26
|
+
|
|
27
|
+
if (ruleId === 'internal/blocking-conventions') {
|
|
28
|
+
return 'BLOCKING';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (diagnostic.severity === 'ERROR') {
|
|
32
|
+
return 'BLOCKING';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return 'WARNING';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function markerKey(diagnostic) {
|
|
39
|
+
const start = diagnostic.location.range.start;
|
|
40
|
+
const identity = {
|
|
41
|
+
path: diagnostic.location.path,
|
|
42
|
+
line: start.line,
|
|
43
|
+
column: start.column,
|
|
44
|
+
rule: diagnostic.code && diagnostic.code.value,
|
|
45
|
+
message: diagnostic.message,
|
|
46
|
+
source: sourceName,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return crypto
|
|
50
|
+
.createHash('sha256')
|
|
51
|
+
.update(JSON.stringify(identity))
|
|
52
|
+
.digest('hex')
|
|
53
|
+
.slice(0, 24);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commentBody(diagnostic, key) {
|
|
57
|
+
const severity = severityLabel(diagnostic);
|
|
58
|
+
const icon = severity === 'BLOCKING' ? '🚫' : '⚠️';
|
|
59
|
+
const ruleId = diagnostic.code && diagnostic.code.value;
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
`${icon} **${severity}** — \`${ruleId}\``,
|
|
63
|
+
'',
|
|
64
|
+
diagnostic.message,
|
|
65
|
+
'',
|
|
66
|
+
`<!-- ${COMMENT_MARKER}:${key} -->`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function api(method, apiPath, body) {
|
|
71
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
72
|
+
const repo = process.env.REPO || process.env.GITHUB_REPOSITORY;
|
|
73
|
+
|
|
74
|
+
if (!token || !repo) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'GH_TOKEN/GITHUB_TOKEN and REPO/GITHUB_REPOSITORY are required',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const requestBody = body ? JSON.stringify(body) : null;
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const request = https.request(
|
|
84
|
+
{
|
|
85
|
+
hostname: 'api.github.com',
|
|
86
|
+
path: `/repos/${repo}${apiPath}`,
|
|
87
|
+
method,
|
|
88
|
+
headers: {
|
|
89
|
+
Accept: 'application/vnd.github+json',
|
|
90
|
+
Authorization: `Bearer ${token}`,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
'User-Agent': USER_AGENT,
|
|
93
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
94
|
+
...(requestBody
|
|
95
|
+
? { 'Content-Length': Buffer.byteLength(requestBody) }
|
|
96
|
+
: {}),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
(response) => {
|
|
100
|
+
let responseBody = '';
|
|
101
|
+
|
|
102
|
+
response.setEncoding('utf8');
|
|
103
|
+
response.on('data', (chunk) => {
|
|
104
|
+
responseBody += chunk;
|
|
105
|
+
});
|
|
106
|
+
response.on('end', () => {
|
|
107
|
+
const contentType = response.headers['content-type'] || '';
|
|
108
|
+
const parsedBody =
|
|
109
|
+
contentType.includes('application/json') && responseBody
|
|
110
|
+
? JSON.parse(responseBody)
|
|
111
|
+
: responseBody;
|
|
112
|
+
|
|
113
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
114
|
+
resolve(parsedBody);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
reject(
|
|
119
|
+
new Error(
|
|
120
|
+
`GitHub API ${response.statusCode} ${method} ${apiPath}: ${responseBody.slice(
|
|
121
|
+
0,
|
|
122
|
+
500,
|
|
123
|
+
)}`,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
request.on('error', reject);
|
|
131
|
+
|
|
132
|
+
if (requestBody) {
|
|
133
|
+
request.write(requestBody);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
request.end();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function listPaginated(apiPath) {
|
|
141
|
+
const items = [];
|
|
142
|
+
|
|
143
|
+
for (let page = 1; ; page += 1) {
|
|
144
|
+
const separator = apiPath.includes('?') ? '&' : '?';
|
|
145
|
+
const result = await api(
|
|
146
|
+
'GET',
|
|
147
|
+
`${apiPath}${separator}per_page=100&page=${page}`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (!Array.isArray(result)) {
|
|
151
|
+
return items;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
items.push(...result);
|
|
155
|
+
|
|
156
|
+
if (result.length < 100) {
|
|
157
|
+
return items;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isActionsBot(comment) {
|
|
163
|
+
return comment.user && comment.user.login === 'github-actions[bot]';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function main() {
|
|
167
|
+
const diagnostics = readDiagnostics();
|
|
168
|
+
const prNumber = process.env.PR_NUMBER;
|
|
169
|
+
const headSha = process.env.HEAD_SHA;
|
|
170
|
+
|
|
171
|
+
if (!prNumber || !headSha) {
|
|
172
|
+
throw new Error('PR_NUMBER and HEAD_SHA are required');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const comments = diagnostics.map((diagnostic) => {
|
|
176
|
+
const start = diagnostic.location.range.start;
|
|
177
|
+
const key = markerKey(diagnostic);
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
path: diagnostic.location.path.split(path.sep).join('/'),
|
|
181
|
+
line: start.line || 1,
|
|
182
|
+
side: 'RIGHT',
|
|
183
|
+
body: commentBody(diagnostic, key),
|
|
184
|
+
key,
|
|
185
|
+
severity: severityLabel(diagnostic),
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const counts = comments.reduce(
|
|
190
|
+
(accumulator, comment) => {
|
|
191
|
+
accumulator[comment.severity] += 1;
|
|
192
|
+
|
|
193
|
+
return accumulator;
|
|
194
|
+
},
|
|
195
|
+
{ BLOCKING: 0, WARNING: 0 },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
console.log(
|
|
199
|
+
`Prepared ${comments.length} ESLint convention comment(s): ` +
|
|
200
|
+
`${counts.BLOCKING} BLOCKING, ${counts.WARNING} WARNING`,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
function failIfBlocking() {
|
|
204
|
+
if (counts.BLOCKING === 0) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(
|
|
209
|
+
`ESLint convention review found ${counts.BLOCKING} BLOCKING issue(s).`,
|
|
210
|
+
);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (dryRun || comments.length === 0) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const existingComments = await listPaginated(`/pulls/${prNumber}/comments`);
|
|
219
|
+
const markerPattern = new RegExp(`<!-- ${COMMENT_MARKER}:([0-9a-f]{24}) -->`);
|
|
220
|
+
const existingByKey = new Map();
|
|
221
|
+
|
|
222
|
+
for (const comment of existingComments) {
|
|
223
|
+
if (!isActionsBot(comment)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const match = markerPattern.exec(comment.body || '');
|
|
228
|
+
|
|
229
|
+
if (match) {
|
|
230
|
+
existingByKey.set(match[1], comment);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const commentsToPost = [];
|
|
235
|
+
|
|
236
|
+
for (const comment of comments) {
|
|
237
|
+
const existingComment = existingByKey.get(comment.key);
|
|
238
|
+
|
|
239
|
+
if (!existingComment) {
|
|
240
|
+
commentsToPost.push(comment);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if ((existingComment.body || '').trim() !== comment.body.trim()) {
|
|
245
|
+
await api('PATCH', `/pulls/comments/${existingComment.id}`, {
|
|
246
|
+
body: comment.body,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (commentsToPost.length === 0) {
|
|
252
|
+
console.log('No new ESLint convention comments to post.');
|
|
253
|
+
failIfBlocking();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await api('POST', `/pulls/${prNumber}/reviews`, {
|
|
259
|
+
commit_id: headSha,
|
|
260
|
+
body: 'ESLint 컨벤션 리뷰 코멘트',
|
|
261
|
+
event: 'COMMENT',
|
|
262
|
+
comments: commentsToPost.map((comment) => ({
|
|
263
|
+
path: comment.path,
|
|
264
|
+
line: comment.line,
|
|
265
|
+
side: comment.side,
|
|
266
|
+
body: comment.body,
|
|
267
|
+
})),
|
|
268
|
+
});
|
|
269
|
+
console.log(
|
|
270
|
+
`Posted ${commentsToPost.length} ESLint convention review comment(s).`,
|
|
271
|
+
);
|
|
272
|
+
failIfBlocking();
|
|
273
|
+
return;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.log(`Batch review failed: ${error.message}`);
|
|
276
|
+
console.log('Retrying ESLint convention comments one by one.');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let posted = 0;
|
|
280
|
+
|
|
281
|
+
for (const comment of commentsToPost) {
|
|
282
|
+
try {
|
|
283
|
+
await api('POST', `/pulls/${prNumber}/comments`, {
|
|
284
|
+
commit_id: headSha,
|
|
285
|
+
path: comment.path,
|
|
286
|
+
line: comment.line,
|
|
287
|
+
side: comment.side,
|
|
288
|
+
body: comment.body,
|
|
289
|
+
});
|
|
290
|
+
posted += 1;
|
|
291
|
+
} catch (lineError) {
|
|
292
|
+
await api('POST', `/pulls/${prNumber}/comments`, {
|
|
293
|
+
commit_id: headSha,
|
|
294
|
+
path: comment.path,
|
|
295
|
+
subject_type: 'file',
|
|
296
|
+
body: `${comment.body}\n\n원래 위치: line ${comment.line}`,
|
|
297
|
+
});
|
|
298
|
+
posted += 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.log(`Posted ${posted} ESLint convention comment(s).`);
|
|
303
|
+
failIfBlocking();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
main().catch((error) => {
|
|
307
|
+
console.error(error);
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
});
|