Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/build/copilot-migrate-pr.ts
13371 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
// Migrates a pull request from microsoft/vscode-copilot-chat to microsoft/vscode.
7
//
8
// The diff is fetched, file paths are rewritten to prepend `extensions/copilot/`,
9
// and a new PR is created in microsoft/vscode via the `gh` CLI.
10
//
11
// Usage:
12
// node build/copilot-migrate-pr.ts <PR_NUMBER> [--dry-run] [--verbose]
13
//
14
// Requirements:
15
// - `gh` CLI installed and authenticated with access to both repos
16
// - Local checkout of microsoft/vscode with `main` branch up to date
17
18
import { execFileSync } from 'child_process';
19
import * as path from 'path';
20
import * as fs from 'fs';
21
import * as os from 'os';
22
import { createInterface } from 'readline/promises';
23
import { stdin as input, stdout as output } from 'process';
24
25
const SOURCE_REPO = 'microsoft/vscode-copilot-chat';
26
const TARGET_REPO = 'microsoft/vscode';
27
const PATH_PREFIX = 'extensions/copilot';
28
29
// ---------------------------------------------------------------------------
30
// CLI argument parsing
31
// ---------------------------------------------------------------------------
32
33
interface Options {
34
prNumber: number;
35
dryRun: boolean;
36
verbose: boolean;
37
}
38
39
function parseArgs(): Options {
40
const args = process.argv.slice(2);
41
let prNumber: number | undefined;
42
let dryRun = false;
43
let verbose = false;
44
45
for (const arg of args) {
46
if (arg === '--dry-run') {
47
dryRun = true;
48
} else if (arg === '--verbose') {
49
verbose = true;
50
} else if (!prNumber && /^\d+$/.test(arg)) {
51
prNumber = parseInt(arg, 10);
52
} else {
53
console.error(`Unknown argument: ${arg}`);
54
process.exit(1);
55
}
56
}
57
58
if (!prNumber) {
59
console.error('Usage: node build/copilot-migrate-pr.ts <PR_NUMBER> [--dry-run] [--verbose]');
60
process.exit(1);
61
}
62
63
return { prNumber, dryRun, verbose };
64
}
65
66
interface Logger {
67
info(message: string): void;
68
detail(message: string): void;
69
step(message: string): void;
70
warn(message: string): void;
71
success(message: string): void;
72
}
73
74
function supportsColor(): boolean {
75
return Boolean(output.isTTY) && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb';
76
}
77
78
function color(text: string, code: number, enabled: boolean): string {
79
if (!enabled) {
80
return text;
81
}
82
83
return `\u001b[${code}m${text}\u001b[0m`;
84
}
85
86
function createLogger(verbose: boolean): Logger {
87
const useColor = supportsColor();
88
const label = {
89
info: color('[INFO]', 36, useColor),
90
detail: color('[DETAIL]', 90, useColor),
91
step: color('[STEP]', 34, useColor),
92
warn: color('[WARN]', 33, useColor),
93
success: color('[DONE]', 32, useColor),
94
};
95
96
return {
97
info: message => console.log(`${label.info} ${message}`),
98
detail: message => {
99
if (verbose) {
100
console.log(`${label.detail} ${message}`);
101
}
102
},
103
step: message => console.log(`\n${label.step} ${message}`),
104
warn: message => console.log(`${label.warn} ${message}`),
105
success: message => console.log(`\n${label.success} ${message}`),
106
};
107
}
108
109
async function promptYesNo(question: string, defaultNo = true): Promise<boolean> {
110
const rl = createInterface({ input, output });
111
try {
112
const suffix = defaultNo ? ' [y/N]: ' : ' [Y/n]: ';
113
const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
114
115
if (!answer) {
116
return !defaultNo;
117
}
118
119
return answer === 'y' || answer === 'yes';
120
} finally {
121
rl.close();
122
}
123
}
124
125
async function waitForEnter(message: string): Promise<void> {
126
const rl = createInterface({ input, output });
127
try {
128
await rl.question(`${message}\nPress Enter to continue...`);
129
} finally {
130
rl.close();
131
}
132
}
133
134
// ---------------------------------------------------------------------------
135
// gh CLI helpers
136
// ---------------------------------------------------------------------------
137
138
function gh(args: string[]): string {
139
return execFileSync('gh', args, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
140
}
141
142
function git(args: string[], cwd?: string, env?: NodeJS.ProcessEnv): string {
143
return execFileSync('git', args, {
144
encoding: 'utf-8',
145
cwd,
146
env: env ? { ...process.env, ...env } : process.env,
147
maxBuffer: 50 * 1024 * 1024
148
});
149
}
150
151
function getCurrentRef(repoRoot: string): string {
152
try {
153
return git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot).trim();
154
} catch {
155
return git(['rev-parse', 'HEAD'], repoRoot).trim();
156
}
157
}
158
159
function checkoutRef(ref: string, repoRoot: string): void {
160
git(['checkout', ref], repoRoot);
161
}
162
163
function getMigrationBranchName(prNumber: number): string {
164
return `vscode-copilot-chat/migrate-${prNumber}`;
165
}
166
167
function remoteBranchExists(remote: string, branchName: string, repoRoot: string): boolean {
168
try {
169
git(['ls-remote', '--exit-code', '--heads', remote, branchName], repoRoot);
170
return true;
171
} catch (error) {
172
const status = (error as { status?: number }).status;
173
if (status === 2) {
174
return false;
175
}
176
177
throw error;
178
}
179
}
180
181
// ---------------------------------------------------------------------------
182
// PR metadata
183
// ---------------------------------------------------------------------------
184
185
interface PrMetadata {
186
title: string;
187
body: string;
188
baseRefName: string;
189
headRefName: string;
190
state: string;
191
mergedAt: string | null;
192
isDraft: boolean;
193
number: number;
194
author: { login: string };
195
labels: { name: string }[];
196
assignees: { login: string }[];
197
}
198
199
function fetchPrMetadata(prNumber: number): PrMetadata {
200
const json = gh([
201
'pr', 'view', String(prNumber),
202
'--repo', SOURCE_REPO,
203
'--json', 'title,body,baseRefName,headRefName,state,mergedAt,isDraft,number,author,labels,assignees',
204
]);
205
return JSON.parse(json);
206
}
207
208
function fetchPrDiff(prNumber: number): string {
209
return gh([
210
'pr', 'diff', String(prNumber),
211
'--repo', SOURCE_REPO,
212
]);
213
}
214
215
interface CommitPerson {
216
name: string;
217
email: string;
218
date: string;
219
}
220
221
interface SourceCommit {
222
sha: string;
223
author: CommitPerson | null;
224
committer: CommitPerson | null;
225
}
226
227
function fetchPrCommits(prNumber: number): SourceCommit[] {
228
const json = gh([
229
'api',
230
`repos/${SOURCE_REPO}/pulls/${prNumber}/commits`,
231
'--paginate',
232
]);
233
234
const commits = JSON.parse(json) as Array<{
235
sha: string;
236
commit: {
237
author: CommitPerson | null;
238
committer: CommitPerson | null;
239
};
240
}>;
241
242
return commits.map(commit => ({
243
sha: commit.sha,
244
author: commit.commit.author,
245
committer: commit.commit.committer,
246
}));
247
}
248
249
function fetchCommitPatch(sha: string): string {
250
return gh([
251
'api',
252
`repos/${SOURCE_REPO}/commits/${sha}`,
253
'-H', 'Accept: application/vnd.github.patch',
254
]);
255
}
256
257
interface DiffStats {
258
filesChanged: number;
259
insertions: number;
260
deletions: number;
261
}
262
263
function getDiffStats(diff: string): DiffStats {
264
let filesChanged = 0;
265
let insertions = 0;
266
let deletions = 0;
267
268
for (const line of diff.split('\n')) {
269
if (line.startsWith('diff --git ')) {
270
filesChanged++;
271
} else if (line.startsWith('+') && !line.startsWith('+++')) {
272
insertions++;
273
} else if (line.startsWith('-') && !line.startsWith('---')) {
274
deletions++;
275
}
276
}
277
278
return {
279
filesChanged,
280
insertions,
281
deletions,
282
};
283
}
284
285
// ---------------------------------------------------------------------------
286
// Diff path rewriting
287
// ---------------------------------------------------------------------------
288
289
/**
290
* Rewrites file paths in a unified diff to prepend `extensions/copilot/`.
291
*
292
* Handles:
293
* - `diff --git a/path b/path`
294
* - `--- a/path` / `+++ b/path`
295
* - `/dev/null` (new/deleted files) — left unchanged
296
* - `rename from path` / `rename to path`
297
* - `copy from path` / `copy to path`
298
*/
299
function rewriteDiff(diff: string): string {
300
const lines = diff.split('\n');
301
const result: string[] = [];
302
303
for (const line of lines) {
304
result.push(rewriteDiffLine(line));
305
}
306
307
return result.join('\n');
308
}
309
310
function rewriteDiffLine(line: string): string {
311
// diff --git a/path b/path
312
const diffGitMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
313
if (diffGitMatch) {
314
return `diff --git a/${PATH_PREFIX}/${diffGitMatch[1]} b/${PATH_PREFIX}/${diffGitMatch[2]}`;
315
}
316
317
// --- a/path or --- /dev/null
318
const minusMatch = line.match(/^--- a\/(.+)$/);
319
if (minusMatch) {
320
return `--- a/${PATH_PREFIX}/${minusMatch[1]}`;
321
}
322
323
// +++ b/path or +++ /dev/null
324
const plusMatch = line.match(/^\+\+\+ b\/(.+)$/);
325
if (plusMatch) {
326
return `+++ b/${PATH_PREFIX}/${plusMatch[1]}`;
327
}
328
329
// rename from path / rename to path
330
const renameFromMatch = line.match(/^rename from (.+)$/);
331
if (renameFromMatch) {
332
return `rename from ${PATH_PREFIX}/${renameFromMatch[1]}`;
333
}
334
335
const renameToMatch = line.match(/^rename to (.+)$/);
336
if (renameToMatch) {
337
return `rename to ${PATH_PREFIX}/${renameToMatch[1]}`;
338
}
339
340
// copy from path / copy to path
341
const copyFromMatch = line.match(/^copy from (.+)$/);
342
if (copyFromMatch) {
343
return `copy from ${PATH_PREFIX}/${copyFromMatch[1]}`;
344
}
345
346
const copyToMatch = line.match(/^copy to (.+)$/);
347
if (copyToMatch) {
348
return `copy to ${PATH_PREFIX}/${copyToMatch[1]}`;
349
}
350
351
// Everything else (context lines, hunk headers, /dev/null, etc.) passes through
352
return line;
353
}
354
355
// ---------------------------------------------------------------------------
356
// Branch and PR creation
357
// ---------------------------------------------------------------------------
358
359
function hasActiveRebaseApply(repoRoot: string): boolean {
360
return fs.existsSync(path.join(repoRoot, '.git', 'rebase-apply'));
361
}
362
363
async function resolveAmConflicts(repoRoot: string): Promise<void> {
364
console.log('\nA commit patch could not be applied cleanly.');
365
console.log('Resolve merge conflicts in your working tree, stage the changes, and continue.');
366
for (; ;) {
367
await waitForEnter('After resolving conflicts and staging the changes, continue.');
368
const unresolved = git(['diff', '--name-only', '--diff-filter=U'], repoRoot).trim();
369
if (unresolved) {
370
console.log('\nThese files still have unresolved conflicts:');
371
for (const file of unresolved.split('\n')) {
372
console.log(` - ${file}`);
373
}
374
continue;
375
}
376
377
try {
378
git(['am', '--continue'], repoRoot);
379
break;
380
} catch (error) {
381
console.log(`\nCould not continue apply: ${error instanceof Error ? error.message : String(error)}`);
382
console.log('Fix any remaining issues, ensure all changes are staged, then try again.');
383
}
384
}
385
}
386
387
function amendHeadCommitMetadata(commit: SourceCommit, repoRoot: string): void {
388
if (!commit.author && !commit.committer) {
389
return;
390
}
391
392
const author = commit.author ?? commit.committer;
393
const committer = commit.committer ?? commit.author;
394
if (!author || !committer) {
395
return;
396
}
397
398
git([
399
'commit',
400
'--amend',
401
'--no-edit',
402
'--author', `${author.name} <${author.email}>`,
403
'--date', author.date,
404
], repoRoot, {
405
GIT_COMMITTER_NAME: committer.name,
406
GIT_COMMITTER_EMAIL: committer.email,
407
GIT_COMMITTER_DATE: committer.date,
408
});
409
}
410
411
async function createBranchAndApplyCommits(
412
prNumber: number,
413
commits: SourceCommit[],
414
repoRoot: string,
415
): Promise<string> {
416
const branchName = getMigrationBranchName(prNumber);
417
418
// Ensure we're on a clean state based on main
419
git(['checkout', 'main'], repoRoot);
420
git(['pull', '--ff-only', 'origin', 'main'], repoRoot);
421
422
// Create and switch to the new branch
423
try {
424
git(['checkout', '-b', branchName], repoRoot);
425
} catch {
426
// Branch may already exist from a previous attempt
427
git(['checkout', branchName], repoRoot);
428
git(['reset', '--hard', 'main'], repoRoot);
429
}
430
431
for (let i = 0; i < commits.length; i++) {
432
const commit = commits[i];
433
const patch = fetchCommitPatch(commit.sha);
434
const rewrittenPatch = rewriteDiff(patch);
435
const tmpPatch = path.join(os.tmpdir(), `copilot-migrate-pr-${prNumber}-${i + 1}.patch`);
436
437
try {
438
fs.writeFileSync(tmpPatch, rewrittenPatch);
439
440
try {
441
git(['am', '--3way', tmpPatch], repoRoot);
442
} catch {
443
if (!hasActiveRebaseApply(repoRoot)) {
444
throw new Error(`Failed to apply commit ${commit.sha}.`);
445
}
446
447
await resolveAmConflicts(repoRoot);
448
}
449
} finally {
450
fs.unlinkSync(tmpPatch);
451
}
452
453
amendHeadCommitMetadata(commit, repoRoot);
454
}
455
456
if (!commits.length) {
457
git([
458
'commit',
459
'--allow-empty',
460
'-m', `Migrate ${SOURCE_REPO}#${prNumber}`,
461
], repoRoot);
462
}
463
464
return branchName;
465
}
466
467
function closeSourcePr(prNumber: number, targetPrUrl: string): void {
468
const comment = `Superseded by ${targetPrUrl}`;
469
gh([
470
'pr', 'close', String(prNumber),
471
'--repo', SOURCE_REPO,
472
'--comment', comment,
473
]);
474
}
475
476
function pushBranch(branchName: string, repoRoot: string): void {
477
git(['push', '-u', 'origin', branchName, '--force-with-lease'], repoRoot);
478
}
479
480
function createPr(meta: PrMetadata, branchName: string): string {
481
const migrationNote = [
482
`> Migrated from ${SOURCE_REPO}#${meta.number}`,
483
`> Original author: @${meta.author.login}`,
484
'',
485
].join('\n');
486
487
const body = meta.body
488
? `${migrationNote}\n---\n\n${meta.body}`
489
: migrationNote;
490
491
const args = [
492
'pr', 'create',
493
'--repo', TARGET_REPO,
494
'--head', branchName,
495
'--base', 'main',
496
'--title', meta.title,
497
'--body', body,
498
];
499
500
if (meta.isDraft) {
501
args.push('--draft');
502
}
503
504
for (const assignee of meta.assignees) {
505
args.push('--assignee', assignee.login);
506
}
507
508
for (const label of meta.labels) {
509
args.push('--label', label.name);
510
}
511
512
return gh(args).trim();
513
}
514
515
// ---------------------------------------------------------------------------
516
// Main
517
// ---------------------------------------------------------------------------
518
519
async function main() {
520
const { prNumber, dryRun, verbose } = parseArgs();
521
const logger = createLogger(verbose);
522
const repoRoot = path.dirname(import.meta.dirname);
523
const originalRef = getCurrentRef(repoRoot);
524
const targetBranchName = getMigrationBranchName(prNumber);
525
let shouldRestoreRef = false;
526
527
try {
528
logger.info(`Migrating PR #${prNumber} from ${SOURCE_REPO} to ${TARGET_REPO}`);
529
logger.detail(`Starting ref: ${originalRef}`);
530
531
if (!dryRun) {
532
logger.step('Checking whether target branch already exists on origin');
533
if (remoteBranchExists('origin', targetBranchName, repoRoot)) {
534
throw new Error(`Remote branch already exists: origin/${targetBranchName}. Delete it before rerunning.`);
535
}
536
logger.detail(`Target branch is available: origin/${targetBranchName}`);
537
}
538
539
logger.step('Fetching source PR metadata');
540
const meta = fetchPrMetadata(prNumber);
541
if (meta.state !== 'OPEN') {
542
const status = meta.mergedAt ? 'merged' : 'closed';
543
throw new Error(`Source PR #${prNumber} is ${status}. Only open PRs can be migrated.`);
544
}
545
546
logger.info(`Title: ${meta.title}`);
547
logger.detail(`Author: @${meta.author.login}`);
548
logger.detail(`Base: ${meta.baseRefName} -> Head: ${meta.headRefName}`);
549
logger.detail(`State: ${meta.state}`);
550
logger.detail(`Draft: ${meta.isDraft}`);
551
logger.detail(`Labels: ${meta.labels.map(l => l.name).join(', ') || '(none)'}`);
552
logger.detail(`Assignees: ${meta.assignees.map(a => a.login).join(', ') || '(none)'}`);
553
554
logger.step('Fetching source PR commits');
555
const commits = fetchPrCommits(prNumber);
556
logger.info(`Commit count: ${commits.length}`);
557
558
logger.step('Fetching and rewriting diff');
559
const diff = fetchPrDiff(prNumber);
560
const diffStats = getDiffStats(diff);
561
logger.detail(`Diff size: ${diff.length} bytes`);
562
logger.info(`Diff stats: ${diffStats.filesChanged} files changed, ${diffStats.insertions} insertions(+), ${diffStats.deletions} deletions(-)`);
563
564
if (dryRun) {
565
logger.info('Dry run: no changes were made.');
566
return;
567
}
568
569
logger.step('Creating branch and applying commit series');
570
const branchName = await createBranchAndApplyCommits(prNumber, commits, repoRoot);
571
shouldRestoreRef = true;
572
logger.info(`Branch: ${branchName}`);
573
574
logger.step('Pushing branch');
575
pushBranch(branchName, repoRoot);
576
577
logger.step(`Creating PR in ${TARGET_REPO}`);
578
const prUrl = createPr(meta, branchName);
579
logger.success(`PR created: ${prUrl}`);
580
581
const closeOldPr = await promptYesNo(`Close source PR #${prNumber} in ${SOURCE_REPO}?`);
582
if (closeOldPr) {
583
try {
584
closeSourcePr(prNumber, prUrl);
585
logger.info(`Closed source PR #${prNumber}`);
586
} catch (error) {
587
logger.warn(`Failed to close source PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`);
588
}
589
} else {
590
logger.info(`Left source PR #${prNumber} open`);
591
}
592
} finally {
593
if (shouldRestoreRef) {
594
logger.step(`Restoring original ref (${originalRef})`);
595
try {
596
checkoutRef(originalRef, repoRoot);
597
logger.info(`Checked out ${originalRef}`);
598
} catch (error) {
599
logger.warn(`Failed to restore original ref ${originalRef}: ${error instanceof Error ? error.message : String(error)}`);
600
}
601
}
602
}
603
}
604
605
main().catch(error => {
606
console.error(error instanceof Error ? error.message : String(error));
607
process.exit(1);
608
});
609
610