Skip to content

Commit 5e7741a

Browse files
authored
Add automated PR creation for Roslyn Copilot updates using a gulp task (#8584)
2 parents 1894a93 + b1cb959 commit 5e7741a

File tree

7 files changed

+288
-33
lines changed

7 files changed

+288
-33
lines changed

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"vsix/": true
1313
},
1414
"[typescript]": {
15-
"editor.defaultFormatter": "esbenp.prettier-vscode"
15+
"editor.defaultFormatter": "esbenp.prettier-vscode",
16+
"editor.formatOnSave": true
1617
},
1718
"csharp.suppressDotnetRestoreNotification": true,
1819
"typescript.tsdk": "./node_modules/typescript/lib",

azure-pipelines/publish-roslyn-copilot.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
trigger: none
22
pr: none
33

4+
variables:
5+
# Variable group contains PAT for bot account.
6+
- group: dotnet-vscode-insertion-variables
7+
48
resources:
59
repositories:
610
- repository: 1ESPipelineTemplates
@@ -29,7 +33,6 @@ extends:
2933
image: 1ESPT-Windows2022
3034
os: windows
3135
templateContext:
32-
type: releaseJob
3336
isProduction: false #change this
3437
inputs:
3538
- input: pipelineArtifact
@@ -38,7 +41,11 @@ extends:
3841
destinationPath: $(Pipeline.Workspace)/artifacts
3942

4043
steps:
41-
- checkout: none
44+
- checkout: self
45+
clean: true
46+
submodules: true
47+
fetchTags: false
48+
fetchDepth: 0
4249

4350
- task: CopyFiles@2
4451
displayName: 'Copy files from Zip folder to staging directory'
@@ -56,3 +63,13 @@ extends:
5663
Destination: "AzureBlob"
5764
storage: "$(AzStorage)"
5865
ContainerName: "$(AzContainerName)"
66+
67+
- pwsh: |
68+
npm install
69+
npm install -g gulp
70+
displayName: 'Install tools'
71+
72+
- pwsh: gulp 'publish roslyn copilot' --userName dotnet-maestro-bot --email [email protected] --stagingDirectory '$(Build.ArtifactStagingDirectory)/staging'
73+
displayName: 'Create component update PR'
74+
env:
75+
GitHubPAT: $(BotAccount-dotnet-maestro-bot-PAT)

gulpfile.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ require('./tasks/debuggerTasks');
1212
require('./tasks/snapTasks');
1313
require('./tasks/signingTasks');
1414
require('./tasks/profilingTasks');
15+
require('./tasks/componentUpdateTasks');

package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tools/updatePackageDependencies.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ function getLowercaseFileNameFromUrl(url: string): string {
270270
const secondToLastDash = fileName.lastIndexOf('-', fileName.lastIndexOf('-') - 1);
271271
fileName = fileName.substr(0, secondToLastDash);
272272
return fileName;
273+
} else if (fileName.startsWith('microsoft.visualstudio.copilot.roslyn.languageserver')) {
274+
// Copilot versions are everything after the second to last dash.
275+
// e.g. we want microsoft.visualstudio.copilot.roslyn.languageserver from microsoft.visualstudio.copilot.roslyn.languageserver-18.0.479-alpha.zip
276+
const secondToLastDash = fileName.lastIndexOf('-', fileName.lastIndexOf('-') - 1);
277+
fileName = fileName.substr(0, secondToLastDash);
278+
return fileName;
273279
} else {
274280
throw new Error(`Unexpected dependency file name '${fileName}'`);
275281
}

tasks/componentUpdateTasks.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
import * as gulp from 'gulp';
7+
import * as process from 'node:process';
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
import minimist from 'minimist';
11+
import {
12+
configureGitUser,
13+
createCommit,
14+
pushBranch,
15+
createPullRequest,
16+
doesBranchExist,
17+
findPRByTitle,
18+
} from './gitTasks';
19+
import { updatePackageDependencies } from '../src/tools/updatePackageDependencies';
20+
21+
type Options = {
22+
userName?: string;
23+
email?: string;
24+
};
25+
26+
/**
27+
* Extract version from file name using a provided regex pattern
28+
* @param fileName - The file name to extract version from
29+
* @param pattern - The regex pattern to match and extract version (should have a capture group)
30+
* @returns The extracted version string or null if not found
31+
*/
32+
function extractVersion(fileName: string, pattern: RegExp): string | null {
33+
const match = fileName.match(pattern);
34+
return match && match[1] ? match[1] : null;
35+
}
36+
37+
gulp.task('publish roslyn copilot', async () => {
38+
const parsedArgs = minimist<Options>(process.argv.slice(2));
39+
40+
if (!parsedArgs.stagingDirectory || !fs.existsSync(parsedArgs.stagingDirectory)) {
41+
throw new Error(`Staging directory not found at ${parsedArgs.stagingDirectory}; skipping package.json update.`);
42+
}
43+
44+
// Find the Roslyn zip file in the staging directory (we know it was copied here)
45+
const files = fs.readdirSync(parsedArgs.stagingDirectory);
46+
const zipFile = files.find((file) => /Roslyn\.LanguageServer.*\.zip$/i.test(file));
47+
48+
if (!zipFile) {
49+
throw new Error(`
50+
No Roslyn LanguageServer zip file found in ${parsedArgs.stagingDirectory}; skipping package.json update.`);
51+
}
52+
53+
const zipPath = path.join(parsedArgs.stagingDirectory, zipFile);
54+
console.log(`Using zip file: ${zipPath}`);
55+
const zipName = zipFile;
56+
57+
// Extract version from file name
58+
const version = extractVersion(zipName, /Microsoft\.VisualStudio\.Copilot\.Roslyn\.LanguageServer-(.+)\.zip$/i);
59+
60+
if (!version) {
61+
throw new Error(`Could not extract version from file name ${zipName}; skipping.`);
62+
}
63+
64+
console.log(`Extracted version: ${version}`);
65+
66+
const safeVersion = version.replace(/[^A-Za-z0-9_.-]/g, '-');
67+
const branch = `update/roslyn-copilot-${safeVersion}`;
68+
69+
const pat = process.env['GitHubPAT'];
70+
if (!pat) {
71+
throw 'No GitHub PAT found.';
72+
}
73+
74+
const owner = 'dotnet';
75+
const repo = 'vscode-csharp';
76+
const title = `Update RoslynCopilot url to ${version}`;
77+
const body = `Automated update of RoslynCopilot url to ${version}`;
78+
79+
// Bail out if a branch with the same name already exists or PR already exists for the insertion.
80+
if (await doesBranchExist('origin', branch)) {
81+
console.log(`##vso[task.logissue type=warning]${branch} already exists in origin. Skip pushing.`);
82+
return;
83+
}
84+
const existingPRUrl = await findPRByTitle(pat, owner, repo, title);
85+
if (existingPRUrl) {
86+
console.log(
87+
`##vso[task.logissue type=warning] Pull request with the same name already exists: ${existingPRUrl}`
88+
);
89+
return;
90+
}
91+
92+
// Set environment variables for updatePackageDependencies
93+
process.env['NEW_DEPS_ID'] = 'RoslynCopilot';
94+
process.env['NEW_DEPS_VERSION'] = version;
95+
process.env[
96+
'NEW_DEPS_URLS'
97+
] = `https://roslyn.blob.core.windows.net/releases/Microsoft.VisualStudio.Copilot.Roslyn.LanguageServer-${version}.zip`;
98+
99+
// Update package dependencies using the extracted utility
100+
await updatePackageDependencies();
101+
console.log(`Updated RoslynCopilot dependency to version ${version}`);
102+
103+
// Configure git user if provided
104+
await configureGitUser(parsedArgs.userName, parsedArgs.email);
105+
106+
// Create commit with changes
107+
await createCommit(branch, ['package.json'], `Update RoslynCopilot version to ${version}`);
108+
109+
// Push branch and create PR
110+
await pushBranch(branch, pat, owner, repo);
111+
await createPullRequest(pat, owner, repo, branch, title, body);
112+
});

tasks/gitTasks.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
import { spawnSync } from 'child_process';
7+
import { Octokit } from '@octokit/rest';
8+
9+
/**
10+
* Execute a git command with optional logging
11+
*/
12+
export async function git(args: string[], printCommand: boolean = true): Promise<string> {
13+
if (printCommand) {
14+
console.log(`git ${args.join(' ')}`);
15+
}
16+
17+
const result = spawnSync('git', args);
18+
if (result.status != 0) {
19+
const err = result.stderr ? result.stderr.toString() : '';
20+
if (printCommand) {
21+
console.error(`Failed to execute git ${args.join(' ')}.`);
22+
}
23+
throw new Error(err || `git ${args.join(' ')} failed with code ${result.status}`);
24+
}
25+
26+
const stdout = result.stdout ? result.stdout.toString() : '';
27+
if (printCommand) {
28+
console.log(stdout);
29+
}
30+
return stdout;
31+
}
32+
33+
/**
34+
* Configure git user credentials if provided
35+
*/
36+
export async function configureGitUser(userName?: string, email?: string): Promise<void> {
37+
if (userName) {
38+
await git(['config', '--local', 'user.name', userName]);
39+
}
40+
if (email) {
41+
await git(['config', '--local', 'user.email', email]);
42+
}
43+
}
44+
45+
/**
46+
* Create a new branch, add files, and commit changes
47+
*/
48+
export async function createCommit(branch: string, files: string[], commitMessage: string): Promise<void> {
49+
await git(['checkout', '-b', branch]);
50+
await git(['add', ...files]);
51+
await git(['commit', '-m', commitMessage]);
52+
}
53+
54+
/**
55+
* Check if a branch exists on the remote repository
56+
*/
57+
export async function doesBranchExist(remoteAlias: string, branch: string): Promise<boolean> {
58+
const lsRemote = await git(['ls-remote', remoteAlias, 'refs/head/' + branch]);
59+
return lsRemote.trim() !== '';
60+
}
61+
62+
/**
63+
* Push branch to remote repository with authentication
64+
*/
65+
export async function pushBranch(branch: string, pat: string, owner: string, repo: string): Promise<void> {
66+
const remoteRepoAlias = 'targetRepo';
67+
const authRemote = `https://x-access-token:${pat}@github.com/${owner}/${repo}.git`;
68+
69+
// Add authenticated remote
70+
await git(
71+
['remote', 'add', remoteRepoAlias, authRemote],
72+
false // Don't print PAT to console
73+
);
74+
75+
await git(['fetch', remoteRepoAlias]);
76+
77+
// Check if branch already exists
78+
if (await doesBranchExist(remoteRepoAlias, branch)) {
79+
console.log(`##vso[task.logissue type=error]${branch} already exists in ${owner}/${repo}. Skip pushing.`);
80+
return;
81+
}
82+
83+
await git(['push', '-u', remoteRepoAlias, branch]);
84+
}
85+
86+
/**
87+
* Find an existing pull request with the given title
88+
* @returns The PR URL if found, null otherwise
89+
*/
90+
export async function findPRByTitle(pat: string, owner: string, repo: string, title: string): Promise<string | null> {
91+
try {
92+
const octokit = new Octokit({ auth: pat });
93+
94+
const listPullRequest = await octokit.rest.pulls.list({
95+
owner,
96+
repo,
97+
});
98+
99+
if (listPullRequest.status != 200) {
100+
throw `Failed get response from GitHub, http status code: ${listPullRequest.status}`;
101+
}
102+
103+
const existingPR = listPullRequest.data.find((pr) => pr.title === title);
104+
return existingPR ? existingPR.html_url : null;
105+
} catch (e) {
106+
console.warn('Failed to find PR by title:', e);
107+
return null; // Assume PR doesn't exist if we can't check
108+
}
109+
}
110+
111+
/**
112+
* Create a GitHub pull request
113+
*/
114+
export async function createPullRequest(
115+
pat: string,
116+
owner: string,
117+
repo: string,
118+
branch: string,
119+
title: string,
120+
body: string,
121+
baseBranch: string = 'main'
122+
): Promise<string | null> {
123+
try {
124+
// Check if PR with same title already exists
125+
const existingPRUrl = await findPRByTitle(pat, owner, repo, title);
126+
if (existingPRUrl) {
127+
console.log(`Pull request with the same name already exists: ${existingPRUrl}`);
128+
return existingPRUrl;
129+
}
130+
131+
const octokit = new Octokit({ auth: pat });
132+
console.log(`Creating PR against ${owner}/${repo}...`);
133+
const pullRequest = await octokit.rest.pulls.create({
134+
owner,
135+
repo,
136+
title,
137+
head: branch,
138+
base: baseBranch,
139+
body,
140+
});
141+
142+
console.log(`Created pull request: ${pullRequest.data.html_url}`);
143+
return pullRequest.data.html_url;
144+
} catch (e) {
145+
console.warn('Failed to create PR via Octokit:', e);
146+
return null;
147+
}
148+
}

0 commit comments

Comments
 (0)