claude-git-hooks 2.21.0 → 2.30.2

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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * File: check-coupling.js
3
+ * Purpose: Detect coupled PRs before release by analyzing file overlap
4
+ *
5
+ * Flow:
6
+ * 1. Parse --base (default: develop) and --json flags
7
+ * 2. Get repository owner/repo from local git remote
8
+ * 3. Fetch open PRs targeting base branch via GitHub API
9
+ * 4. Fetch changed files for each PR in parallel
10
+ * 5. Build file index and detect coupling groups (Union-Find)
11
+ * 6. Display results (human-readable or JSON)
12
+ */
13
+
14
+ import {
15
+ getGitHubToken,
16
+ parseGitHubRepo,
17
+ listOpenPullRequests,
18
+ fetchPullRequestFiles
19
+ } from '../utils/github-api.js';
20
+ import { buildFileIndex, detectCouplingGroups } from '../utils/coupling-detector.js';
21
+ import { colors, error, info, warning, checkGitRepo } from './helpers.js';
22
+ import logger from '../utils/logger.js';
23
+
24
+ /** PRs with this many or more changed files trigger a large-PR warning */
25
+ const LARGE_PR_FILE_THRESHOLD = 100;
26
+
27
+ /**
28
+ * Check coupling among open PRs targeting a base branch
29
+ * @param {string[]} args - Command arguments [--base <branch>] [--json]
30
+ */
31
+ export async function runCheckCoupling(args) {
32
+ if (!checkGitRepo()) {
33
+ error('You are not in a Git repository.');
34
+ return;
35
+ }
36
+
37
+ // Parse arguments
38
+ const baseIndex = args.indexOf('--base');
39
+ const base = baseIndex !== -1 && args[baseIndex + 1] ? args[baseIndex + 1] : 'develop';
40
+ const jsonOutput = args.includes('--json');
41
+
42
+ // Validate GitHub token
43
+ try {
44
+ getGitHubToken();
45
+ } catch {
46
+ error('GitHub token not configured. Run: claude-hooks setup-github');
47
+ return;
48
+ }
49
+
50
+ // Get repository info from local git remote
51
+ let owner, repo;
52
+ try {
53
+ const repoInfo = parseGitHubRepo();
54
+ owner = repoInfo.owner;
55
+ repo = repoInfo.repo;
56
+ } catch (e) {
57
+ error(`Could not determine repository: ${e.message}`);
58
+ return;
59
+ }
60
+
61
+ if (!jsonOutput) {
62
+ info(`Fetching open PRs targeting ${base}...`);
63
+ }
64
+
65
+ try {
66
+ // Fetch open PRs
67
+ const openPRs = await listOpenPullRequests(owner, repo, base);
68
+
69
+ if (openPRs.length === 0) {
70
+ if (jsonOutput) {
71
+ console.log(
72
+ JSON.stringify({
73
+ base,
74
+ totalPRs: 0,
75
+ coupledGroups: [],
76
+ independentPRs: [],
77
+ warnings: []
78
+ })
79
+ );
80
+ } else {
81
+ info(`No open PRs targeting ${base}.`);
82
+ }
83
+ return;
84
+ }
85
+
86
+ if (!jsonOutput) {
87
+ info(
88
+ `Found ${openPRs.length} open PR${openPRs.length !== 1 ? 's' : ''} targeting ${base}`
89
+ );
90
+ }
91
+
92
+ // Fetch files for each PR in parallel
93
+ const prFiles = new Map(); // prNumber → Set<filePath>
94
+ const warnings = [];
95
+
96
+ await Promise.all(
97
+ openPRs.map(async (pr) => {
98
+ try {
99
+ const files = await fetchPullRequestFiles(owner, repo, pr.number);
100
+ if (files.length === 0) {
101
+ warnings.push(`PR #${pr.number} has 0 changed files — skipped.`);
102
+ return;
103
+ }
104
+ if (files.length >= LARGE_PR_FILE_THRESHOLD) {
105
+ warnings.push(
106
+ `PR #${pr.number} changes ${files.length} files — large refactor, may couple with many PRs.`
107
+ );
108
+ }
109
+ prFiles.set(pr.number, new Set(files.map((f) => f.filename)));
110
+ } catch (e) {
111
+ logger.debug('check-coupling', `Could not fetch files for PR #${pr.number}`, {
112
+ error: e.message
113
+ });
114
+ warnings.push(`Could not fetch files for PR #${pr.number}: ${e.message}`);
115
+ }
116
+ })
117
+ );
118
+
119
+ // Build index and detect coupling groups
120
+ const prNumbersWithFiles = [...prFiles.keys()];
121
+ const fileIndex = buildFileIndex(prFiles);
122
+ const { coupledGroups, independentPRNumbers } = detectCouplingGroups(
123
+ prNumbersWithFiles,
124
+ fileIndex,
125
+ prFiles
126
+ );
127
+
128
+ // Build PR lookup by number
129
+ const prByNumber = new Map(openPRs.map((pr) => [pr.number, pr]));
130
+
131
+ if (jsonOutput) {
132
+ const output = {
133
+ base,
134
+ totalPRs: openPRs.length,
135
+ coupledGroups: coupledGroups.map((group) => ({
136
+ prs: group.prNumbers.map((n) => {
137
+ const pr = prByNumber.get(n);
138
+ return { number: n, title: pr?.title || '', url: pr?.html_url || '' };
139
+ }),
140
+ sharedFiles: group.sharedFiles
141
+ })),
142
+ independentPRs: independentPRNumbers.map((n) => {
143
+ const pr = prByNumber.get(n);
144
+ return { number: n, title: pr?.title || '', url: pr?.html_url || '' };
145
+ }),
146
+ warnings
147
+ };
148
+ console.log(JSON.stringify(output, null, 2));
149
+ return;
150
+ }
151
+
152
+ // Human-readable output
153
+ console.log('');
154
+ console.log(
155
+ `\uD83D\uDCCB Open PRs targeting ${colors.blue}${base}${colors.reset}: ${openPRs.length}`
156
+ );
157
+
158
+ for (const w of warnings) {
159
+ warning(w);
160
+ }
161
+
162
+ console.log('');
163
+
164
+ if (coupledGroups.length === 0) {
165
+ console.log(
166
+ `${colors.green}\u2705 All PRs are independent (no coupling detected)${colors.reset}`
167
+ );
168
+ } else {
169
+ console.log(
170
+ `${colors.yellow}\uD83D\uDD17 Coupled groups (ship together or pull together):${colors.reset}`
171
+ );
172
+ console.log('');
173
+
174
+ coupledGroups.forEach((group, index) => {
175
+ console.log(` Group ${index + 1}:`);
176
+ for (const prNumber of group.prNumbers) {
177
+ const pr = prByNumber.get(prNumber);
178
+ console.log(` - PR #${prNumber} ${pr?.title || '(unknown title)'}`);
179
+ }
180
+ if (group.sharedFiles.length > 0) {
181
+ console.log(' Shared files:');
182
+ for (const file of group.sharedFiles) {
183
+ console.log(` ${file}`);
184
+ }
185
+ }
186
+ console.log('');
187
+ });
188
+ }
189
+
190
+ if (independentPRNumbers.length > 0) {
191
+ console.log(`${colors.green}\u2705 Independent PRs (no coupling):${colors.reset}`);
192
+ for (const prNumber of independentPRNumbers) {
193
+ const pr = prByNumber.get(prNumber);
194
+ console.log(` - PR #${prNumber} ${pr?.title || '(unknown title)'}`);
195
+ }
196
+ console.log('');
197
+ }
198
+ } catch (e) {
199
+ if (e.name === 'GitHubAPIError') {
200
+ error(`GitHub API error: ${e.message}`);
201
+ } else {
202
+ error(`Error checking coupling: ${e.message}`);
203
+ logger.debug('check-coupling', 'Full error', {
204
+ message: e.message,
205
+ stack: e.stack
206
+ });
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,485 @@
1
+ /**
2
+ * File: close-release.js
3
+ * Purpose: close-release command — release-candidate finalization
4
+ *
5
+ * Workflow:
6
+ * 1. Detect active release-candidate/V* branch (current or remote)
7
+ * 2. Validate preconditions (clean dir, on RC, remote up-to-date)
8
+ * 3. Collect feature list (commit subjects since origin/main)
9
+ * 4. Resolve description: CLI arg > --auto-describe (Claude) > interactive prompt
10
+ * 5. [--dry-run] Preview + return
11
+ * 6. Confirm with user
12
+ * 7. git reset --soft origin/main — all RC changes staged
13
+ * 8. git commit "Release v{version}: {description}" + feature list body (--no-verify)
14
+ * 9. git push --force-with-lease
15
+ * 10. [unless --no-pr] Create PR to main with merge-commit label + reminder
16
+ * 11. Display summary
17
+ */
18
+
19
+ import { execSync } from 'child_process';
20
+ import {
21
+ getCurrentBranch,
22
+ getActiveBranch,
23
+ isWorkingDirectoryClean,
24
+ fetchRemote,
25
+ getDivergence,
26
+ getCommitsBetweenRefs,
27
+ resetBranch,
28
+ forcePush
29
+ } from '../utils/git-operations.js';
30
+ import {
31
+ showInfo,
32
+ showSuccess,
33
+ showWarning,
34
+ promptConfirmation,
35
+ promptEditField
36
+ } from '../utils/interactive-ui.js';
37
+ import logger from '../utils/logger.js';
38
+ import { colors, error, checkGitRepo } from './helpers.js';
39
+
40
+ /** Prefix for release-candidate branches */
41
+ const RC_PREFIX = 'release-candidate';
42
+
43
+ /** Target branch for the release PR */
44
+ const TARGET_BRANCH = 'main';
45
+
46
+ /** Label added to the created PR to signal merge-commit strategy */
47
+ const MERGE_COMMIT_LABEL = 'merge-strategy:merge-commit';
48
+
49
+ /** Body reminder prepended to PR description */
50
+ const MERGE_COMMIT_REMINDER =
51
+ '> ⚠️ This PR must be merged with **merge commit** (not squash)\n\n';
52
+
53
+ // ─── Argument parsing ────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Parse CLI arguments into structured options
57
+ *
58
+ * @param {string[]} args - Raw CLI args (args.slice(1) from router)
59
+ * @returns {{ description: string|null, autoDescribe: boolean, dryRun: boolean, noPr: boolean }}
60
+ */
61
+ function _parseArgs(args) {
62
+ const flags = args.filter((a) => a.startsWith('--'));
63
+ const positional = args.filter((a) => !a.startsWith('--'));
64
+ return {
65
+ description: positional[0] || null,
66
+ autoDescribe: flags.includes('--auto-describe'),
67
+ dryRun: flags.includes('--dry-run'),
68
+ noPr: flags.includes('--no-pr')
69
+ };
70
+ }
71
+
72
+ // ─── Version extraction ──────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Extract semver string from a release-candidate branch name.
76
+ * Supports both capital-V ("release-candidate/V2.6.0") and bare semver.
77
+ *
78
+ * @param {string} branchName - e.g. "release-candidate/V2.6.0"
79
+ * @returns {string|null} e.g. "2.6.0", or null if not parseable
80
+ */
81
+ export function _extractVersion(branchName) {
82
+ const match = branchName.match(/release-candidate\/V?(\d+\.\d+\.\d+[^\s]*)/i);
83
+ return match ? match[1] : null;
84
+ }
85
+
86
+ // ─── Feature list ────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Collect commit subjects between origin/main and HEAD.
90
+ *
91
+ * @returns {string[]} Array of subject lines (empty array if none or on error)
92
+ */
93
+ export function _collectFeatureList() {
94
+ try {
95
+ const raw = getCommitsBetweenRefs('origin/main', 'HEAD', { format: '%s' });
96
+ if (!raw) return [];
97
+ return raw
98
+ .split(/\r?\n/)
99
+ .map((l) => l.trim())
100
+ .filter(Boolean);
101
+ } catch (e) {
102
+ logger.debug('close-release - _collectFeatureList', 'Failed to collect feature list', {
103
+ error: e.message
104
+ });
105
+ return [];
106
+ }
107
+ }
108
+
109
+ // ─── Description resolution ──────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Generate a 1-line release description using Claude (--auto-describe).
113
+ *
114
+ * @param {string[]} featureList - Commit subjects
115
+ * @returns {Promise<string>} Short phrase describing the release
116
+ */
117
+ async function _autoDescribe(featureList) {
118
+ const { executeClaudeWithRetry } = await import('../utils/claude-client.js');
119
+ const commitLines = featureList.map((f) => `- ${f}`).join('\n');
120
+ const prompt = `You are summarizing a software release for a Git commit message.\n\nGiven the following list of commit messages included in this release, write a single short phrase (5–10 words, no punctuation at end) that captures what this release contains.\n\nCommits:\n${commitLines}\n\nReturn only the phrase, nothing else. No quotes, no period.`;
121
+
122
+ const result = await executeClaudeWithRetry(prompt, { timeout: 60000, model: 'haiku' });
123
+ return result.trim().replace(/^["']|["']$/g, '');
124
+ }
125
+
126
+ /**
127
+ * Resolve the release description from all possible sources.
128
+ * Priority: CLI arg → --auto-describe → interactive prompt
129
+ *
130
+ * @param {{ description: string|null, autoDescribe: boolean }} options
131
+ * @param {string[]} featureList
132
+ * @returns {Promise<string>}
133
+ */
134
+ export async function _resolveDescription(options, featureList) {
135
+ // 1. CLI argument — use directly
136
+ if (options.description) {
137
+ return options.description;
138
+ }
139
+
140
+ // 2. Claude auto-describe
141
+ if (options.autoDescribe) {
142
+ if (featureList.length === 0) {
143
+ showWarning('No commits found for auto-describe — falling back to interactive prompt');
144
+ } else {
145
+ showInfo('Generating description with Claude...');
146
+ try {
147
+ const desc = await _autoDescribe(featureList);
148
+ showSuccess(`Generated: "${desc}"`);
149
+ return desc;
150
+ } catch (e) {
151
+ showWarning(`Auto-describe failed: ${e.message} — falling back to prompt`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // 3. Interactive prompt — show feature list then ask TL to accept or override
157
+ console.log('');
158
+ showInfo('Feature commits in this release:');
159
+ if (featureList.length === 0) {
160
+ console.log(' (no commits found since origin/main)');
161
+ } else {
162
+ featureList.forEach((f) => console.log(` - ${f}`));
163
+ }
164
+ console.log('');
165
+
166
+ const defaultDesc =
167
+ featureList.length > 0 ? featureList[0] : 'maintenance release';
168
+ return promptEditField('description', defaultDesc);
169
+ }
170
+
171
+ // ─── Dry-run preview ─────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Print a preview table of what close-release would do, then return.
175
+ *
176
+ * @param {Object} p
177
+ * @param {string} p.rcBranch
178
+ * @param {string} p.version
179
+ * @param {string} p.description
180
+ * @param {string[]} p.featureList
181
+ * @param {{ noPr: boolean }} p.options
182
+ */
183
+ function _showDryRunPreview({ rcBranch, version, description, featureList, options }) {
184
+ const commitTitle = `Release v${version}: ${description}`;
185
+
186
+ console.log('');
187
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
188
+ console.log(`${colors.yellow} DRY RUN — No changes will be made ${colors.reset}`);
189
+ console.log(`${colors.yellow}═════════════════════════════════════════════════${colors.reset}`);
190
+ console.log('');
191
+ console.log(`${colors.blue}Branch:${colors.reset} ${rcBranch}`);
192
+ console.log(`${colors.blue}Version:${colors.reset} v${version}`);
193
+ console.log(`${colors.blue}Description:${colors.reset} ${description}`);
194
+ console.log('');
195
+ console.log('Planned actions:');
196
+ console.log(' 1. git reset --soft origin/main');
197
+ console.log(` 2. git commit --no-verify -F - <<< "${commitTitle}"`);
198
+ if (featureList.length > 0) {
199
+ console.log(` (body: ${featureList.length} feature line(s))`);
200
+ }
201
+ console.log(` 3. git push --force-with-lease origin ${rcBranch}`);
202
+ if (!options.noPr) {
203
+ console.log(` 4. Create PR: ${rcBranch} → ${TARGET_BRANCH}`);
204
+ console.log(` Label: ${MERGE_COMMIT_LABEL}`);
205
+ } else {
206
+ console.log(' 4. PR creation skipped (--no-pr)');
207
+ }
208
+ console.log('');
209
+ console.log(`${colors.yellow}Run without --dry-run to apply these changes${colors.reset}`);
210
+ console.log('');
211
+ }
212
+
213
+ // ─── Main command ─────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * close-release command.
217
+ * Finalizes the active release-candidate: soft-reset onto main,
218
+ * single clean commit, force-push, and optionally creates the PR.
219
+ *
220
+ * @param {string[]} args - CLI arguments (as passed by the router)
221
+ */
222
+ export async function runCloseRelease(args) {
223
+ logger.debug('close-release', 'Starting close-release command', { args });
224
+
225
+ const options = _parseArgs(args);
226
+
227
+ if (!checkGitRepo()) {
228
+ error('Not a Git repository');
229
+ process.exit(1);
230
+ }
231
+
232
+ // ── Step 1: Detect active RC branch ──────────────────────────────────────
233
+
234
+ let rcBranch = null;
235
+ const currentBranch = getCurrentBranch();
236
+
237
+ if (currentBranch && currentBranch.startsWith(`${RC_PREFIX}/`)) {
238
+ rcBranch = currentBranch;
239
+ showInfo(`Using current branch: ${rcBranch}`);
240
+ } else {
241
+ // Look for a remote RC branch
242
+ const remoteBranch = getActiveBranch(RC_PREFIX);
243
+ if (!remoteBranch) {
244
+ error(
245
+ `No active ${RC_PREFIX}/V* branch found.\n` +
246
+ ' Create one first: claude-hooks create-release minor'
247
+ );
248
+ process.exit(1);
249
+ }
250
+
251
+ rcBranch = remoteBranch;
252
+ console.log('');
253
+ showInfo(`Found release-candidate branch: ${rcBranch}`);
254
+ const shouldSwitch = await promptConfirmation(`Switch to ${rcBranch}?`, true);
255
+ if (!shouldSwitch) {
256
+ showInfo('Cancelled');
257
+ process.exit(0);
258
+ }
259
+
260
+ try {
261
+ execSync(`git checkout ${rcBranch}`, {
262
+ encoding: 'utf8',
263
+ stdio: ['pipe', 'pipe', 'pipe']
264
+ });
265
+ showSuccess(`Switched to ${rcBranch}`);
266
+ } catch (e) {
267
+ error(`Could not checkout ${rcBranch}: ${e.stderr || e.message}`);
268
+ process.exit(1);
269
+ }
270
+ }
271
+
272
+ // ── Extract version from branch name ─────────────────────────────────────
273
+
274
+ const version = _extractVersion(rcBranch);
275
+ if (!version) {
276
+ error(`Could not extract version from branch name: ${rcBranch}`);
277
+ process.exit(1);
278
+ }
279
+
280
+ console.log('');
281
+ showInfo(`Version: v${version}`);
282
+ console.log('');
283
+
284
+ // ── Step 2: Validate preconditions ───────────────────────────────────────
285
+
286
+ if (!isWorkingDirectoryClean()) {
287
+ error(
288
+ 'Working directory has uncommitted changes.\n' +
289
+ ' Commit or stash them before closing a release.'
290
+ );
291
+ process.exit(1);
292
+ }
293
+
294
+ try {
295
+ fetchRemote();
296
+ } catch (e) {
297
+ showWarning(`Could not fetch from remote: ${e.message} — results may be stale`);
298
+ }
299
+
300
+ let divergence = null;
301
+ try {
302
+ divergence = getDivergence(rcBranch, `origin/${rcBranch}`);
303
+ } catch (e) {
304
+ showWarning(`Could not check remote divergence: ${e.message}`);
305
+ }
306
+
307
+ if (divergence && divergence.behind > 0) {
308
+ error(
309
+ `Local branch is ${divergence.behind} commit(s) behind remote.\n` +
310
+ ` Pull first: git pull origin ${rcBranch}`
311
+ );
312
+ process.exit(1);
313
+ }
314
+
315
+ // ── Step 3: Collect feature list ─────────────────────────────────────────
316
+
317
+ const featureList = _collectFeatureList();
318
+ logger.debug('close-release', 'Feature list collected', { count: featureList.length });
319
+
320
+ if (featureList.length === 0) {
321
+ showWarning('No commits found since origin/main — the RC may already be squashed or empty');
322
+ }
323
+
324
+ // ── Step 4: Resolve description ──────────────────────────────────────────
325
+
326
+ const description = await _resolveDescription(options, featureList);
327
+ if (!description || !description.trim()) {
328
+ error('Description cannot be empty');
329
+ process.exit(1);
330
+ }
331
+
332
+ // ── Step 5: Dry-run ───────────────────────────────────────────────────────
333
+
334
+ if (options.dryRun) {
335
+ _showDryRunPreview({ rcBranch, version, description, featureList, options });
336
+ return;
337
+ }
338
+
339
+ // ── Step 6: Confirm ───────────────────────────────────────────────────────
340
+
341
+ const commitTitle = `Release v${version}: ${description}`;
342
+ console.log('');
343
+ showInfo(`Commit message: "${commitTitle}"`);
344
+ if (featureList.length > 0) {
345
+ showInfo(`Includes ${featureList.length} feature commit(s)`);
346
+ }
347
+ console.log('');
348
+
349
+ const confirmed = await promptConfirmation(
350
+ 'Reset RC to origin/main, commit, and force-push?',
351
+ true
352
+ );
353
+ if (!confirmed) {
354
+ showInfo('Cancelled');
355
+ process.exit(0);
356
+ }
357
+
358
+ console.log('');
359
+
360
+ try {
361
+ // ── Step 7: git reset --soft origin/main ─────────────────────────────
362
+
363
+ showInfo('Resetting to origin/main (--soft)...');
364
+ resetBranch('origin/main', { mode: 'soft' });
365
+ showSuccess('✓ Soft reset complete — all changes staged');
366
+ console.log('');
367
+
368
+ // ── Step 8: Commit with subject + body ───────────────────────────────
369
+
370
+ showInfo('Creating release commit...');
371
+ const bodySection =
372
+ featureList.length > 0
373
+ ? `\nIncludes:\n${featureList.map((f) => `- ${f}`).join('\n')}\n`
374
+ : '';
375
+ const fullMessage = `${commitTitle}${bodySection}`;
376
+
377
+ try {
378
+ execSync('git commit --no-verify -F -', {
379
+ input: fullMessage,
380
+ encoding: 'utf8',
381
+ stdio: ['pipe', 'pipe', 'pipe']
382
+ });
383
+ } catch (e) {
384
+ error(`Failed to create commit: ${e.stderr || e.message}`);
385
+ process.exit(1);
386
+ }
387
+
388
+ showSuccess(`✓ Committed: "${commitTitle}"`);
389
+ console.log('');
390
+
391
+ // ── Step 9: Force-push with lease ────────────────────────────────────
392
+
393
+ showInfo(`Force-pushing ${rcBranch}...`);
394
+ const pushResult = forcePush(rcBranch, { lease: true });
395
+ if (!pushResult.success) {
396
+ error(
397
+ `Force-push failed: ${pushResult.error}\n` +
398
+ ` Manual push: git push --force-with-lease origin ${rcBranch}`
399
+ );
400
+ process.exit(1);
401
+ }
402
+ showSuccess(`✓ Force-pushed ${rcBranch}`);
403
+ console.log('');
404
+
405
+ // ── Step 10: Create PR to main ───────────────────────────────────────
406
+
407
+ let prUrl = null;
408
+ if (!options.noPr) {
409
+ showInfo('Creating PR to main...');
410
+
411
+ const { createPullRequest, validateToken } = await import('../utils/github-api.js');
412
+ const { parseGitHubRepo } = await import('../utils/github-client.js');
413
+
414
+ const tokenValidation = await validateToken();
415
+ if (!tokenValidation.valid) {
416
+ showWarning(`GitHub token invalid — PR not created: ${tokenValidation.error}`);
417
+ console.log('');
418
+ console.log('Create the PR manually:');
419
+ console.log(
420
+ ` gh pr create --base ${TARGET_BRANCH} --head ${rcBranch} --label "${MERGE_COMMIT_LABEL}"`
421
+ );
422
+ } else {
423
+ const repoInfo = parseGitHubRepo();
424
+ const featureSection =
425
+ featureList.length > 0
426
+ ? featureList.map((f) => `- ${f}`).join('\n')
427
+ : '_No commits listed_';
428
+ const prBody = `${MERGE_COMMIT_REMINDER}## Includes\n\n${featureSection}`;
429
+
430
+ try {
431
+ const pr = await createPullRequest({
432
+ owner: repoInfo.owner,
433
+ repo: repoInfo.repo,
434
+ title: commitTitle,
435
+ body: prBody,
436
+ head: rcBranch,
437
+ base: TARGET_BRANCH,
438
+ labels: [MERGE_COMMIT_LABEL]
439
+ });
440
+ prUrl = pr.html_url;
441
+ showSuccess(`✓ PR created: ${prUrl}`);
442
+ } catch (e) {
443
+ showWarning(`PR creation failed: ${e.message}`);
444
+ console.log('');
445
+ console.log('Create the PR manually:');
446
+ console.log(
447
+ ` gh pr create --base ${TARGET_BRANCH} --head ${rcBranch} --label "${MERGE_COMMIT_LABEL}"`
448
+ );
449
+ }
450
+ }
451
+ console.log('');
452
+ }
453
+
454
+ // ── Step 11: Summary ─────────────────────────────────────────────────
455
+
456
+ console.log(
457
+ `${colors.green}════════════════════════════════════════════════════${colors.reset}`
458
+ );
459
+ console.log(
460
+ `${colors.green} Release Candidate Closed ✅ ${colors.reset}`
461
+ );
462
+ console.log(
463
+ `${colors.green}════════════════════════════════════════════════════${colors.reset}`
464
+ );
465
+ console.log('');
466
+ console.log(`${colors.blue}Branch:${colors.reset} ${rcBranch}`);
467
+ console.log(`${colors.blue}Version:${colors.reset} v${version}`);
468
+ console.log(`${colors.blue}Commit:${colors.reset} "${commitTitle}"`);
469
+ console.log(`${colors.blue}Push:${colors.reset} force-pushed ✓`);
470
+ if (!options.noPr) {
471
+ console.log(
472
+ `${colors.blue}PR:${colors.reset} ${prUrl || '(failed — create manually)'}`
473
+ );
474
+ console.log('');
475
+ console.log(
476
+ `${colors.yellow}⚠️ Merge this PR using MERGE COMMIT (not squash)${colors.reset}`
477
+ );
478
+ }
479
+ console.log('');
480
+ } catch (e) {
481
+ logger.error('close-release', 'Command failed unexpectedly', e);
482
+ error(`close-release failed: ${e.message}`);
483
+ process.exit(1);
484
+ }
485
+ }