Skip to content
Merged
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 19 additions & 2 deletions azure-pipelines/publish-roslyn-copilot.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -29,7 +33,6 @@ extends:
image: 1ESPT-Windows2022
os: windows
templateContext:
type: releaseJob
isProduction: false #change this
inputs:
- input: pipelineArtifact
Expand All @@ -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'
Expand All @@ -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-bot --email dotnet-bot@dotnetfoundation.org --stagingDirectory '$(Build.ArtifactStagingDirectory)/staging'
displayName: 'Create component update PR'
env:
GitHubPAT: $(BotAccount-dotnet-maestro-bot-PAT)
1 change: 1 addition & 0 deletions gulpfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ require('./tasks/debuggerTasks');
require('./tasks/snapTasks');
require('./tasks/signingTasks');
require('./tasks/profilingTasks');
require('./tasks/componentUpdateTasks');
30 changes: 0 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions tasks/componentUpdateTasks.ts
Original file line number Diff line number Diff line change
@@ -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<Options>(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);
});
148 changes: 148 additions & 0 deletions tasks/gitTasks.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<string | null> {
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<string | null> {
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;
}
}
Loading