diff --git a/.vscode/settings.json b/.vscode/settings.json index ef0fc8472..3a3cab8ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,8 @@ "vsix/": true }, "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true }, "csharp.suppressDotnetRestoreNotification": true, "typescript.tsdk": "./node_modules/typescript/lib", diff --git a/azure-pipelines/publish-roslyn-copilot.yml b/azure-pipelines/publish-roslyn-copilot.yml index 8bbf16373..353e3933c 100644 --- a/azure-pipelines/publish-roslyn-copilot.yml +++ b/azure-pipelines/publish-roslyn-copilot.yml @@ -1,6 +1,10 @@ trigger: none pr: none +variables: +# Variable group contains PAT for bot account. +- group: dotnet-vscode-insertion-variables + resources: repositories: - repository: 1ESPipelineTemplates @@ -29,7 +33,6 @@ extends: image: 1ESPT-Windows2022 os: windows templateContext: - type: releaseJob isProduction: false #change this inputs: - input: pipelineArtifact @@ -38,7 +41,11 @@ extends: destinationPath: $(Pipeline.Workspace)/artifacts steps: - - checkout: none + - checkout: self + clean: true + submodules: true + fetchTags: false + fetchDepth: 0 - task: CopyFiles@2 displayName: 'Copy files from Zip folder to staging directory' @@ -56,3 +63,13 @@ extends: Destination: "AzureBlob" storage: "$(AzStorage)" ContainerName: "$(AzContainerName)" + + - pwsh: | + npm install + npm install -g gulp + displayName: 'Install tools' + + - pwsh: gulp 'publish roslyn copilot' --userName dotnet-maestro-bot --email dotnet-maestro-bot@microsoft.com --stagingDirectory '$(Build.ArtifactStagingDirectory)/staging' + displayName: 'Create component update PR' + env: + GitHubPAT: $(BotAccount-dotnet-maestro-bot-PAT) diff --git a/gulpfile.ts b/gulpfile.ts index ef52553ce..dedaf1712 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -12,3 +12,4 @@ require('./tasks/debuggerTasks'); require('./tasks/snapTasks'); require('./tasks/signingTasks'); require('./tasks/profilingTasks'); +require('./tasks/componentUpdateTasks'); diff --git a/package-lock.json b/package-lock.json index 540426363..ba512e454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5146,15 +5146,6 @@ "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -6305,15 +6296,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/encodeurl/-/encodeurl-1.0.2.tgz", @@ -18814,12 +18796,6 @@ "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" }, - "big.js": { - "version": "5.2.2", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -19672,12 +19648,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "emojis-list": { - "version": "3.0.0", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true - }, "encodeurl": { "version": "1.0.2", "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/encodeurl/-/encodeurl-1.0.2.tgz", diff --git a/src/tools/updatePackageDependencies.ts b/src/tools/updatePackageDependencies.ts index 0b9b82168..3d3a97dfe 100644 --- a/src/tools/updatePackageDependencies.ts +++ b/src/tools/updatePackageDependencies.ts @@ -270,6 +270,12 @@ function getLowercaseFileNameFromUrl(url: string): string { const secondToLastDash = fileName.lastIndexOf('-', fileName.lastIndexOf('-') - 1); fileName = fileName.substr(0, secondToLastDash); return fileName; + } else if (fileName.startsWith('microsoft.visualstudio.copilot.roslyn.languageserver')) { + // Copilot versions are everything after the second to last dash. + // e.g. we want microsoft.visualstudio.copilot.roslyn.languageserver from microsoft.visualstudio.copilot.roslyn.languageserver-18.0.479-alpha.zip + const secondToLastDash = fileName.lastIndexOf('-', fileName.lastIndexOf('-') - 1); + fileName = fileName.substr(0, secondToLastDash); + return fileName; } else { throw new Error(`Unexpected dependency file name '${fileName}'`); } diff --git a/tasks/componentUpdateTasks.ts b/tasks/componentUpdateTasks.ts new file mode 100644 index 000000000..94a0dc1c0 --- /dev/null +++ b/tasks/componentUpdateTasks.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as gulp from 'gulp'; +import * as process from 'node:process'; +import * as fs from 'fs'; +import * as path from 'path'; +import minimist from 'minimist'; +import { + configureGitUser, + createCommit, + pushBranch, + createPullRequest, + doesBranchExist, + findPRByTitle, +} from './gitTasks'; +import { updatePackageDependencies } from '../src/tools/updatePackageDependencies'; + +type Options = { + userName?: string; + email?: string; +}; + +/** + * Extract version from file name using a provided regex pattern + * @param fileName - The file name to extract version from + * @param pattern - The regex pattern to match and extract version (should have a capture group) + * @returns The extracted version string or null if not found + */ +function extractVersion(fileName: string, pattern: RegExp): string | null { + const match = fileName.match(pattern); + return match && match[1] ? match[1] : null; +} + +gulp.task('publish roslyn copilot', async () => { + const parsedArgs = minimist(process.argv.slice(2)); + + if (!parsedArgs.stagingDirectory || !fs.existsSync(parsedArgs.stagingDirectory)) { + throw new Error(`Staging directory not found at ${parsedArgs.stagingDirectory}; skipping package.json update.`); + } + + // Find the Roslyn zip file in the staging directory (we know it was copied here) + const files = fs.readdirSync(parsedArgs.stagingDirectory); + const zipFile = files.find((file) => /Roslyn\.LanguageServer.*\.zip$/i.test(file)); + + if (!zipFile) { + throw new Error(` + No Roslyn LanguageServer zip file found in ${parsedArgs.stagingDirectory}; skipping package.json update.`); + } + + const zipPath = path.join(parsedArgs.stagingDirectory, zipFile); + console.log(`Using zip file: ${zipPath}`); + const zipName = zipFile; + + // Extract version from file name + const version = extractVersion(zipName, /Microsoft\.VisualStudio\.Copilot\.Roslyn\.LanguageServer-(.+)\.zip$/i); + + if (!version) { + throw new Error(`Could not extract version from file name ${zipName}; skipping.`); + } + + console.log(`Extracted version: ${version}`); + + const safeVersion = version.replace(/[^A-Za-z0-9_.-]/g, '-'); + const branch = `update/roslyn-copilot-${safeVersion}`; + + const pat = process.env['GitHubPAT']; + if (!pat) { + throw 'No GitHub PAT found.'; + } + + const owner = 'dotnet'; + const repo = 'vscode-csharp'; + const title = `Update RoslynCopilot url to ${version}`; + const body = `Automated update of RoslynCopilot url to ${version}`; + + // Bail out if a branch with the same name already exists or PR already exists for the insertion. + if (await doesBranchExist('origin', branch)) { + console.log(`##vso[task.logissue type=warning]${branch} already exists in origin. Skip pushing.`); + return; + } + const existingPRUrl = await findPRByTitle(pat, owner, repo, title); + if (existingPRUrl) { + console.log( + `##vso[task.logissue type=warning] Pull request with the same name already exists: ${existingPRUrl}` + ); + return; + } + + // Set environment variables for updatePackageDependencies + process.env['NEW_DEPS_ID'] = 'RoslynCopilot'; + process.env['NEW_DEPS_VERSION'] = version; + process.env[ + 'NEW_DEPS_URLS' + ] = `https://roslyn.blob.core.windows.net/releases/Microsoft.VisualStudio.Copilot.Roslyn.LanguageServer-${version}.zip`; + + // Update package dependencies using the extracted utility + await updatePackageDependencies(); + console.log(`Updated RoslynCopilot dependency to version ${version}`); + + // Configure git user if provided + await configureGitUser(parsedArgs.userName, parsedArgs.email); + + // Create commit with changes + await createCommit(branch, ['package.json'], `Update RoslynCopilot version to ${version}`); + + // Push branch and create PR + await pushBranch(branch, pat, owner, repo); + await createPullRequest(pat, owner, repo, branch, title, body); +}); diff --git a/tasks/gitTasks.ts b/tasks/gitTasks.ts new file mode 100644 index 000000000..9230729e2 --- /dev/null +++ b/tasks/gitTasks.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { spawnSync } from 'child_process'; +import { Octokit } from '@octokit/rest'; + +/** + * Execute a git command with optional logging + */ +export async function git(args: string[], printCommand: boolean = true): Promise { + if (printCommand) { + console.log(`git ${args.join(' ')}`); + } + + const result = spawnSync('git', args); + if (result.status != 0) { + const err = result.stderr ? result.stderr.toString() : ''; + if (printCommand) { + console.error(`Failed to execute git ${args.join(' ')}.`); + } + throw new Error(err || `git ${args.join(' ')} failed with code ${result.status}`); + } + + const stdout = result.stdout ? result.stdout.toString() : ''; + if (printCommand) { + console.log(stdout); + } + return stdout; +} + +/** + * Configure git user credentials if provided + */ +export async function configureGitUser(userName?: string, email?: string): Promise { + if (userName) { + await git(['config', '--local', 'user.name', userName]); + } + if (email) { + await git(['config', '--local', 'user.email', email]); + } +} + +/** + * Create a new branch, add files, and commit changes + */ +export async function createCommit(branch: string, files: string[], commitMessage: string): Promise { + await git(['checkout', '-b', branch]); + await git(['add', ...files]); + await git(['commit', '-m', commitMessage]); +} + +/** + * Check if a branch exists on the remote repository + */ +export async function doesBranchExist(remoteAlias: string, branch: string): Promise { + const lsRemote = await git(['ls-remote', remoteAlias, 'refs/head/' + branch]); + return lsRemote.trim() !== ''; +} + +/** + * Push branch to remote repository with authentication + */ +export async function pushBranch(branch: string, pat: string, owner: string, repo: string): Promise { + const remoteRepoAlias = 'targetRepo'; + const authRemote = `https://x-access-token:${pat}@github.com/${owner}/${repo}.git`; + + // Add authenticated remote + await git( + ['remote', 'add', remoteRepoAlias, authRemote], + false // Don't print PAT to console + ); + + await git(['fetch', remoteRepoAlias]); + + // Check if branch already exists + if (await doesBranchExist(remoteRepoAlias, branch)) { + console.log(`##vso[task.logissue type=error]${branch} already exists in ${owner}/${repo}. Skip pushing.`); + return; + } + + await git(['push', '-u', remoteRepoAlias, branch]); +} + +/** + * Find an existing pull request with the given title + * @returns The PR URL if found, null otherwise + */ +export async function findPRByTitle(pat: string, owner: string, repo: string, title: string): Promise { + try { + const octokit = new Octokit({ auth: pat }); + + const listPullRequest = await octokit.rest.pulls.list({ + owner, + repo, + }); + + if (listPullRequest.status != 200) { + throw `Failed get response from GitHub, http status code: ${listPullRequest.status}`; + } + + const existingPR = listPullRequest.data.find((pr) => pr.title === title); + return existingPR ? existingPR.html_url : null; + } catch (e) { + console.warn('Failed to find PR by title:', e); + return null; // Assume PR doesn't exist if we can't check + } +} + +/** + * Create a GitHub pull request + */ +export async function createPullRequest( + pat: string, + owner: string, + repo: string, + branch: string, + title: string, + body: string, + baseBranch: string = 'main' +): Promise { + try { + // Check if PR with same title already exists + const existingPRUrl = await findPRByTitle(pat, owner, repo, title); + if (existingPRUrl) { + console.log(`Pull request with the same name already exists: ${existingPRUrl}`); + return existingPRUrl; + } + + const octokit = new Octokit({ auth: pat }); + console.log(`Creating PR against ${owner}/${repo}...`); + const pullRequest = await octokit.rest.pulls.create({ + owner, + repo, + title, + head: branch, + base: baseBranch, + body, + }); + + console.log(`Created pull request: ${pullRequest.data.html_url}`); + return pullRequest.data.html_url; + } catch (e) { + console.warn('Failed to create PR via Octokit:', e); + return null; + } +}