main
js 306 lines 7.65 KB
Raw
1 'use strict';
2
3 const fs = require('fs');
4 const path = require('path');
5
6 const {execFileAsync, repoRoot, noopLogger} = require('./utils');
7
8 function readChangelogSnippet(preferredPackage) {
9 const cacheKey =
10 preferredPackage === 'eslint-plugin-react-hooks'
11 ? preferredPackage
12 : 'root';
13 if (!readChangelogSnippet.cache) {
14 readChangelogSnippet.cache = new Map();
15 }
16 const cache = readChangelogSnippet.cache;
17 if (cache.has(cacheKey)) {
18 return cache.get(cacheKey);
19 }
20
21 const targetPath =
22 preferredPackage === 'eslint-plugin-react-hooks'
23 ? path.join(
24 repoRoot,
25 'packages',
26 'eslint-plugin-react-hooks',
27 'CHANGELOG.md'
28 )
29 : path.join(repoRoot, 'CHANGELOG.md');
30
31 let content = '';
32 try {
33 content = fs.readFileSync(targetPath, 'utf8');
34 } catch {
35 content = '';
36 }
37
38 const snippet = content.slice(0, 4000);
39 cache.set(cacheKey, snippet);
40 return snippet;
41 }
42
43 function sanitizeSummary(text) {
44 if (!text) {
45 return '';
46 }
47
48 const trimmed = text.trim();
49 const withoutBullet = trimmed.replace(/^([-*]\s+|\d+\s*[\.)]\s+)/, '');
50
51 return withoutBullet.replace(/\s+/g, ' ').trim();
52 }
53
54 async function summarizePackages({
55 summarizer,
56 packageSpecs,
57 packageTargets,
58 commitsByPackage,
59 log,
60 }) {
61 const summariesByPackage = new Map();
62 if (!summarizer) {
63 packageSpecs.forEach(spec => {
64 const commits = commitsByPackage.get(spec.name) || [];
65 const summaryMap = new Map();
66 for (let i = 0; i < commits.length; i++) {
67 const commit = commits[i];
68 summaryMap.set(commit.sha, commit.subject);
69 }
70 summariesByPackage.set(spec.name, summaryMap);
71 });
72 return summariesByPackage;
73 }
74
75 const tasks = packageSpecs.map(spec => {
76 const commits = commitsByPackage.get(spec.name) || [];
77 return summarizePackageCommits({
78 summarizer,
79 spec,
80 commits,
81 packageTargets,
82 allPackageSpecs: packageSpecs,
83 log,
84 });
85 });
86
87 const results = await Promise.all(tasks);
88 results.forEach(entry => {
89 summariesByPackage.set(entry.packageName, entry.summaries);
90 });
91 return summariesByPackage;
92 }
93
94 async function summarizePackageCommits({
95 summarizer,
96 spec,
97 commits,
98 packageTargets,
99 allPackageSpecs,
100 log,
101 }) {
102 const summaries = new Map();
103 if (commits.length === 0) {
104 return {packageName: spec.name, summaries};
105 }
106
107 const rootStyle = readChangelogSnippet('root');
108 const hooksStyle = readChangelogSnippet('eslint-plugin-react-hooks');
109 const targetList = allPackageSpecs.map(
110 targetSpec =>
111 `${targetSpec.name}@${targetSpec.displayVersion || targetSpec.version}`
112 );
113 const payload = commits.map(commit => {
114 const packages = Array.from(commit.packages || []).sort();
115 const usesHooksStyle = (commit.packages || new Set()).has(
116 'eslint-plugin-react-hooks'
117 );
118 const packagesWithVersions = packages.map(pkgName => {
119 const targetSpec = packageTargets.get(pkgName);
120 if (!targetSpec) {
121 return pkgName;
122 }
123 return `${pkgName}@${targetSpec.displayVersion || targetSpec.version}`;
124 });
125 return {
126 sha: commit.sha,
127 packages,
128 packagesWithVersions,
129 style: usesHooksStyle ? 'eslint-plugin-react-hooks' : 'root',
130 subject: commit.subject,
131 body: commit.body || '',
132 };
133 });
134
135 const promptParts = [
136 `You are preparing changelog summaries for ${spec.name} ${
137 spec.displayVersion || spec.version
138 }.`,
139 'The broader release includes:',
140 ...targetList.map(line => `- ${line}`),
141 '',
142 'For each commit payload, write a single concise sentence without a leading bullet.',
143 'Match the tone and formatting of the provided style samples. Do not mention commit hashes.',
144 'Return a JSON array where each element has the shape `{ "sha": "<sha>", "summary": "<text>" }`.',
145 'The JSON must contain one entry per commit in the same order they are provided.',
146 'Use `"root"` style unless the payload specifies `"eslint-plugin-react-hooks"`, in which case use that style sample.',
147 '',
148 '--- STYLE: root ---',
149 rootStyle,
150 '--- END STYLE ---',
151 '',
152 '--- STYLE: eslint-plugin-react-hooks ---',
153 hooksStyle,
154 '--- END STYLE ---',
155 '',
156 `Commits affecting ${spec.name}:`,
157 ];
158
159 payload.forEach((item, index) => {
160 promptParts.push(
161 `Commit ${index + 1}:`,
162 `sha: ${item.sha}`,
163 `style: ${item.style}`,
164 `packages: ${item.packagesWithVersions.join(', ') || 'none'}`,
165 `subject: ${item.subject}`,
166 'body:',
167 item.body || '(empty)',
168 ''
169 );
170 });
171 promptParts.push('Return ONLY the JSON array.', '');
172
173 const prompt = promptParts.join('\n');
174 log(
175 `Invoking ${summarizer} for ${payload.length} commit summaries targeting ${spec.name}.`
176 );
177 log(`Summarizer prompt length: ${prompt.length} characters.`);
178
179 try {
180 const raw = await runSummarizer(summarizer, prompt);
181 log(`Summarizer output length: ${raw.length}`);
182 const parsed = parseSummariesResponse(raw);
183 if (!parsed) {
184 throw new Error('Unable to parse summarizer output.');
185 }
186 parsed.forEach(entry => {
187 const summary = sanitizeSummary(entry.summary || '');
188 if (summary) {
189 summaries.set(entry.sha, summary);
190 }
191 });
192 } catch (error) {
193 if (log !== noopLogger) {
194 log(
195 `Warning: failed to summarize commits for ${spec.name} with ${summarizer}. Falling back to subjects. ${error.message}`
196 );
197 if (error && error.stack) {
198 log(error.stack);
199 }
200 }
201 }
202
203 for (let i = 0; i < commits.length; i++) {
204 const commit = commits[i];
205 if (!summaries.has(commit.sha)) {
206 summaries.set(commit.sha, commit.subject);
207 }
208 }
209
210 log(`Summaries available for ${summaries.size} commit(s) for ${spec.name}.`);
211
212 return {packageName: spec.name, summaries};
213 }
214
215 async function runSummarizer(command, prompt) {
216 const options = {cwd: repoRoot, maxBuffer: 5 * 1024 * 1024};
217
218 if (command === 'codex') {
219 const {stdout} = await execFileAsync(
220 'codex',
221 ['exec', '--json', prompt],
222 options
223 );
224 return parseCodexSummary(stdout);
225 }
226
227 if (command === 'claude') {
228 const {stdout} = await execFileAsync('claude', ['-p', prompt], options);
229 return stripClaudeBanner(stdout);
230 }
231
232 throw new Error(`Unsupported summarizer command: ${command}`);
233 }
234
235 function parseCodexSummary(output) {
236 let last = '';
237 const lines = output.split('\n');
238 for (let i = 0; i < lines.length; i++) {
239 const trimmed = lines[i].trim();
240 if (!trimmed) {
241 continue;
242 }
243 try {
244 const event = JSON.parse(trimmed);
245 if (
246 event.type === 'item.completed' &&
247 event.item?.type === 'agent_message'
248 ) {
249 last = event.item.text || '';
250 }
251 } catch {
252 last = trimmed;
253 }
254 }
255 return last || output;
256 }
257
258 function stripClaudeBanner(text) {
259 return text
260 .split('\n')
261 .filter(
262 line =>
263 line.trim() !==
264 'Claude Code at Meta (https://fburl.com/claude.code.users)'
265 )
266 .join('\n')
267 .trim();
268 }
269
270 function parseSummariesResponse(output) {
271 const trimmed = output.trim();
272 const candidates = trimmed
273 .split('\n')
274 .map(line => line.trim())
275 .filter(Boolean);
276
277 for (let i = candidates.length - 1; i >= 0; i--) {
278 const candidate = candidates[i];
279 if (!candidate) {
280 continue;
281 }
282 try {
283 const parsed = JSON.parse(candidate);
284 if (Array.isArray(parsed)) {
285 return parsed;
286 }
287 } catch {
288 // Try the next candidate.
289 }
290 }
291
292 try {
293 const parsed = JSON.parse(trimmed);
294 if (Array.isArray(parsed)) {
295 return parsed;
296 }
297 } catch {
298 // Fall through.
299 }
300
301 return null;
302 }
303
304 module.exports = {
305 summarizePackages,
306 };