Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/nesFeedbackSubmitter.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as l10n from '@vscode/l10n';6import { env, Uri, window, workspace } from 'vscode';7import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';8import { ILogger, ILogService } from '../../../../platform/log/common/logService';9import { IFetcherService } from '../../../../platform/networking/common/fetcherService';10import { LogEntry } from '../../../../platform/workspaceRecorder/common/workspaceLog';11import { encodeBase64, VSBuffer } from '../../../../util/vs/base/common/buffer';1213/**14* Represents a feedback file with its name and content.15*/16export interface FeedbackFile {17name: string;18content: string;19}2021/**22* Configuration for the feedback repository.23*/24interface FeedbackRepoConfig {25readonly owner: string;26readonly name: string;27readonly apiUrl: string;28}2930/**31* Handles submission of NES feedback captures to a private GitHub repository.32* Responsible for file collection, user confirmation, filtering, and upload.33*/34export class NesFeedbackSubmitter {3536private static readonly DEFAULT_REPO_CONFIG: FeedbackRepoConfig = {37owner: 'microsoft',38name: 'copilot-nes-feedback',39apiUrl: 'https://api.github.com'40};4142private readonly _logger: ILogger;4344constructor(45logService: ILogService,46private readonly _authenticationService: IAuthenticationService,47private readonly _fetcherService: IFetcherService,48private readonly _repoConfig: FeedbackRepoConfig = NesFeedbackSubmitter.DEFAULT_REPO_CONFIG49) {50this._logger = logService.createSubLogger(['NES', 'FeedbackSubmitter']);51}5253/**54* Submit feedback files from the given folder to the private GitHub repository.55* Shows a preview dialog allowing users to select which files to include.56*/57public async submitFromFolder(feedbackFolderUri: Uri): Promise<void> {58try {59// Check if feedback folder exists and has files60const files = await this._collectFeedbackFiles(feedbackFolderUri);61if (files.length === 0) {62window.showInformationMessage('No NES feedback captures found to submit. Use "Copilot: Record Expected Edit (NES)" to capture feedback first.');63return;64}6566// Read file contents67const fileContents = await this._readFeedbackFiles(files, feedbackFolderUri);68if (fileContents.length === 0) {69window.showErrorMessage('Failed to read feedback files.');70return;71}7273// Extract unique document paths from the recordings to show the user74const documentPaths = this._extractDocumentPathsFromRecordings(fileContents);7576// Extract nextUserEdit paths to calculate accurate recording counts77const nextUserEditPaths = this._extractNextUserEditPaths(fileContents);7879// Show confirmation with file preview and allow filtering80// Returns excluded paths for efficiency (empty in the default case when all files are selected)81const excludedPaths = await this._showFilePreviewAndConfirm(documentPaths, nextUserEditPaths);82if (!excludedPaths) {83return;84}8586// Filter recordings to remove excluded documents87const filteredContents = this._filterRecordingsByExcludedPaths(fileContents, excludedPaths, nextUserEditPaths);88if (filteredContents.length === 0) {89window.showInformationMessage('No files to submit after filtering.');90return;91}9293// Get GitHub auth token - need permissive session for repo access94const session = await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub to submit feedback.') } });95if (!session) {96window.showErrorMessage('GitHub authentication required with repo access. Please sign in to GitHub.');97return;98}99100// Upload files to the private repo101const folderUrl = await this._uploadToPrivateRepo(filteredContents, session.accessToken);102103if (folderUrl) {104await this._showSuccessDialog(folderUrl);105this._logger.info(`Uploaded feedback to private repo: ${folderUrl}`);106}107} catch (error) {108this._logger.error(error instanceof Error ? error : String(error), 'Error submitting feedback');109window.showErrorMessage(`Failed to submit NES feedback: ${error instanceof Error ? error.message : 'Unknown error'}`);110}111}112113/**114* Show success dialog with options to open the PR in GitHub or copy the link.115*/116private async _showSuccessDialog(prUrl: string): Promise<void> {117const result = await window.showInformationMessage(118'Feedback submitted! A pull request has been created.',119'Open Pull Request',120'Copy Link'121);122123if (result === 'Open Pull Request') {124await env.openExternal(Uri.parse(prUrl));125} else if (result === 'Copy Link') {126await env.clipboard.writeText(prUrl);127window.showInformationMessage('Pull request URL copied to clipboard!');128}129}130131/**132* Collect all feedback files from the capture folder.133*/134private async _collectFeedbackFiles(folderUri: Uri): Promise<Uri[]> {135try {136const entries = await workspace.fs.readDirectory(folderUri);137return entries138.filter(([name, type]) => type === 1 && name.endsWith('.json')) // FileType.File = 1139.map(([name]) => Uri.joinPath(folderUri, name));140} catch {141return [];142}143}144145/**146* Read contents of feedback files.147*/148private async _readFeedbackFiles(fileUris: Uri[], folderUri: Uri): Promise<FeedbackFile[]> {149const results: FeedbackFile[] = [];150151for (const fileUri of fileUris) {152try {153const content = await workspace.fs.readFile(fileUri);154const textContent = new TextDecoder().decode(content);155const relativeName = fileUri.path.replace(folderUri.path + '/', '');156results.push({157name: relativeName,158content: textContent159});160} catch (e) {161this._logger.warn(`Failed to read file: ${fileUri.fsPath}: ${e}`);162}163}164165return results;166}167168/**169* Extract unique document paths from recording files.170* Parses the log entries to find all documentEncountered events.171*/172private _extractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] {173const paths = new Set<string>();174175for (const file of files) {176// Only process recording files, not metadata177if (!file.name.endsWith('.recording.w.json')) {178continue;179}180181try {182const recording = JSON.parse(file.content) as { log?: LogEntry[] };183if (recording.log) {184for (const entry of recording.log) {185if (entry.kind === 'documentEncountered') {186paths.add(entry.relativePath);187}188}189}190} catch {191// Ignore parse errors192}193}194195return Array.from(paths).sort();196}197198/**199* Extract the nextUserEdit path for each recording.200* Returns a map from recording name to its nextUserEdit relativePath (or undefined if none).201*/202private _extractNextUserEditPaths(files: FeedbackFile[]): Map<string, string | undefined> {203const result = new Map<string, string | undefined>();204205for (const file of files) {206if (!file.name.endsWith('.recording.w.json')) {207continue;208}209210try {211const recording = JSON.parse(file.content) as {212nextUserEdit?: { relativePath: string };213};214result.set(file.name, recording.nextUserEdit?.relativePath);215} catch {216// If parsing fails, assume it has no nextUserEdit217result.set(file.name, undefined);218}219}220221return result;222}223224/**225* Count how many recordings will be included after excluding certain paths.226* A recording is included only if its nextUserEdit path is not excluded.227*/228private _countIncludedRecordings(nextUserEditPaths: Map<string, string | undefined>, excludedPaths: Set<string>): number {229let count = 0;230for (const [, nextUserEditPath] of nextUserEditPaths) {231if (nextUserEditPath !== undefined && !excludedPaths.has(nextUserEditPath)) {232count++;233}234}235return count;236}237238/**239* Create a summary string for a list of file paths.240* Shows up to maxFiles paths inline, with "and N more..." for the rest.241*/242private _createFilesSummary(paths: string[], maxFiles: number = 5): string {243const sortedPaths = [...paths].sort();244if (sortedPaths.length <= maxFiles) {245return sortedPaths.join(', ');246}247const shownFiles = sortedPaths.slice(0, maxFiles).join(', ');248return `${shownFiles}, and ${sortedPaths.length - maxFiles} more...`;249}250251/**252* Show a preview of files that will be uploaded and ask for confirmation.253* Uses a QuickPick to allow users to select which files to include.254* @returns The excluded file paths (empty array if all selected), or undefined if cancelled.255*/256private async _showFilePreviewAndConfirm(257documentPaths: string[],258nextUserEditPaths: Map<string, string | undefined>259): Promise<string[] | undefined> {260const totalRecordingCount = this._countIncludedRecordings(nextUserEditPaths, new Set());261262if (documentPaths.length === 0) {263// No document paths found, just show basic confirmation264const result = await window.showInformationMessage(265`Found ${totalRecordingCount} feedback recording(s). This will upload your NES feedback to the internal feedback repository.\n\n` +266`Only team members with access to the private repo can view this data.`,267{ modal: true },268'Submit Feedback'269);270return result === 'Submit Feedback' ? [] : undefined; // Empty array = no exclusions271}272273// Create a summary of files274const filesSummary = this._createFilesSummary(documentPaths);275276const result = await window.showInformationMessage(277`Found ${totalRecordingCount} recording(s) containing ${documentPaths.length} file(s):\n${filesSummary}\n\n` +278`This will upload your NES feedback to the internal feedback repository.`,279{ modal: true },280'Submit Feedback',281'Select Files to Include'282);283284if (result === 'Submit Feedback') {285return []; // No exclusions - all files selected286}287288if (result === 'Select Files to Include') {289return this._showFileSelectionQuickPick(documentPaths, nextUserEditPaths);290}291292return undefined;293}294295/**296* Show a multi-select QuickPick for file selection.297* Loops until user confirms or cancels, allowing them to edit their selection.298* @returns The excluded file paths, or undefined if cancelled.299*/300private async _showFileSelectionQuickPick(301documentPaths: string[],302nextUserEditPaths: Map<string, string | undefined>303): Promise<string[] | undefined> {304let currentSelection = new Set(documentPaths); // Start with all selected305306while (true) {307const items = documentPaths.map(path => ({308label: path,309description: '',310picked: currentSelection.has(path)311}));312313const selected = await window.showQuickPick(items, {314title: 'Select files to include in the upload',315placeHolder: 'Deselect files you want to exclude, then press Enter to confirm',316canPickMany: true,317ignoreFocusOut: true318});319320if (!selected) {321// User cancelled QuickPick322return undefined;323}324325const selectedPaths = new Set(selected.map(item => item.label));326const excludedPaths = documentPaths.filter(path => !selectedPaths.has(path));327328if (selectedPaths.size === 0) {329window.showInformationMessage('No files selected. Upload cancelled.');330return undefined;331}332333// Calculate how many recordings will actually be included334const excludedPathSet = new Set(excludedPaths);335const includedRecordingCount = this._countIncludedRecordings(nextUserEditPaths, excludedPathSet);336337if (includedRecordingCount === 0) {338const tryAgain = await window.showInformationMessage(339'No recordings would be included with this selection (all nextUserEdit files are excluded).',340{ modal: true },341'Edit Selection'342);343if (tryAgain === 'Edit Selection') {344currentSelection = selectedPaths;345continue;346}347return undefined;348}349350// Show final confirmation with accurate recording count and file summary351const selectedPathsArray = Array.from(selectedPaths);352const filesSummary = this._createFilesSummary(selectedPathsArray);353354const confirmMessage = excludedPaths.length > 0355? `Submit ${includedRecordingCount} recording(s) with ${selectedPaths.size} file(s)? (${excludedPaths.length} excluded)\n\nIncluded: ${filesSummary}`356: `Submit ${includedRecordingCount} recording(s) containing ${selectedPaths.size} file(s)?\n\n${filesSummary}`;357358const finalResult = await window.showInformationMessage(359confirmMessage,360{ modal: true },361'Submit Feedback',362'Edit Selection'363);364365if (finalResult === 'Submit Feedback') {366return excludedPaths;367}368369if (finalResult === 'Edit Selection') {370// Update current selection and loop back to QuickPick371currentSelection = selectedPaths;372continue;373}374375// User clicked Cancel or dismissed the dialog376return undefined;377}378}379380/**381* Filter recording files to remove excluded document paths.382* Removes documentEncountered entries and all related events for excluded documents.383* Recordings whose nextUserEdit is excluded are skipped entirely,384* along with their associated metadata files.385* Optimized for the common case where excludedPaths is empty (all files selected).386*/387private _filterRecordingsByExcludedPaths(388files: FeedbackFile[],389excludedPaths: string[],390nextUserEditPaths: Map<string, string | undefined>391): FeedbackFile[] {392// Fast path: no exclusions, return files as-is393if (excludedPaths.length === 0) {394return files;395}396397const excludedPathSet = new Set(excludedPaths);398const filteredRecordings: FeedbackFile[] = [];399const skippedRecordingPrefixes = new Set<string>();400401// First pass: filter recordings and track which ones to skip402for (const file of files) {403if (!file.name.endsWith('.recording.w.json')) {404continue;405}406407// Use precomputed nextUserEditPaths to quickly skip recordings408const nextUserEditPath = nextUserEditPaths.get(file.name);409if (nextUserEditPath === undefined || excludedPathSet.has(nextUserEditPath)) {410// Skip this recording - no nextUserEdit or it's excluded411const prefix = file.name.replace('.recording.w.json', '');412skippedRecordingPrefixes.add(prefix);413this._logger.debug(`Skipping recording ${file.name}: nextUserEdit excluded or missing`);414continue;415}416417try {418const filteredFile = this._filterSingleRecording(file, excludedPathSet);419filteredRecordings.push(filteredFile);420} catch {421// If parsing fails, include the file as-is422filteredRecordings.push(file);423}424}425426// Second pass: include metadata files only if their recording wasn't skipped427const result: FeedbackFile[] = [...filteredRecordings];428for (const file of files) {429if (file.name.endsWith('.metadata.json')) {430const prefix = file.name.replace('.metadata.json', '');431if (!skippedRecordingPrefixes.has(prefix)) {432result.push(file);433} else {434this._logger.debug(`Skipping metadata ${file.name}: associated recording was skipped`);435}436}437}438439return result;440}441442/**443* Filter a single recording file based on excluded document paths.444* Assumes the recording will be included (nextUserEdit already checked).445*/446private _filterSingleRecording(file: FeedbackFile, excludedPathSet: Set<string>): FeedbackFile {447const recording = JSON.parse(file.content) as {448log?: LogEntry[];449nextUserEdit?: { relativePath: string; edit: unknown };450};451452if (!recording.log) {453return file;454}455456// Find document IDs that should be excluded457const excludedDocIds = new Set<number>();458for (const entry of recording.log) {459if (entry.kind === 'documentEncountered' && excludedPathSet.has(entry.relativePath)) {460excludedDocIds.add(entry.id);461}462}463464// Filter log entries to remove excluded documents465const filteredLog = recording.log.filter(entry => {466if (entry.kind === 'header') {467return true;468}469if ('id' in entry && typeof entry.id === 'number') {470return !excludedDocIds.has(entry.id);471}472return true;473});474475// Create filtered recording (nextUserEdit is preserved - we already checked it's not excluded)476const filteredRecording = {477...recording,478log: filteredLog479};480481return {482name: file.name,483content: JSON.stringify(filteredRecording, null, 2)484};485}486487/**488* Upload feedback files to the private GitHub repository via a pull request.489* Creates a new branch, uploads files to a timestamped folder, and opens a PR.490* @returns The URL to the pull request, or undefined on failure.491*/492private async _uploadToPrivateRepo(files: FeedbackFile[], token: string): Promise<string | undefined> {493const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);494const folderPath = `feedback/${timestamp}`;495496// Get the current user for commit attribution497const user = await this._getCurrentUser(token);498const username = user?.login ?? 'anonymous';499500// Create a unique branch name for this feedback submission501const branchName = `feedback/${username}/${timestamp}`;502503// Get the SHA of the main branch to create our branch from504const mainBranchSha = await this._getBranchSha(token, 'main');505if (!mainBranchSha) {506throw new Error('Failed to get main branch SHA');507}508509// Create the new branch510await this._createBranch(token, branchName, mainBranchSha);511512// Upload each file to the new branch513for (const file of files) {514const filePath = `${folderPath}/${file.name}`;515await this._createFileInRepo(filePath, file.content, token, username, timestamp, branchName);516}517518// Create the pull request519const prUrl = await this._createPullRequest(token, branchName, username, timestamp, files.length);520521return prUrl;522}523524/**525* Get the SHA of a branch.526*/527private async _getBranchSha(token: string, branch: string): Promise<string | undefined> {528try {529const response = await this._fetcherService.fetch(530`${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/ref/heads/${branch}`,531{532method: 'GET',533callSite: 'nes-feedback-branch-sha',534headers: {535'Authorization': `Bearer ${token}`,536'Accept': 'application/vnd.github+json',537'X-GitHub-Api-Version': '2022-11-28',538'User-Agent': this._fetcherService.getUserAgentLibrary()539}540}541);542543if (response.ok) {544const data = await response.json() as { object: { sha: string } };545return data.object.sha;546}547} catch (e) {548this._logger.error(e instanceof Error ? e : String(e), 'Failed to get branch SHA');549}550return undefined;551}552553/**554* Create a new branch in the repository.555*/556private async _createBranch(token: string, branchName: string, sha: string): Promise<void> {557const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/refs`;558559const payload = {560ref: `refs/heads/${branchName}`,561sha: sha562};563564const response = await fetch(url, {565method: 'POST',566headers: {567'Authorization': `Bearer ${token}`,568'Accept': 'application/vnd.github+json',569'Content-Type': 'application/json',570'X-GitHub-Api-Version': '2022-11-28',571'User-Agent': this._fetcherService.getUserAgentLibrary()572},573body: JSON.stringify(payload)574});575576if (!response.ok) {577const errorText = await response.text();578this._logger.error(`Failed to create branch ${branchName}: ${response.status} ${response.statusText} - ${errorText}`);579throw new Error(`Failed to create branch: ${response.statusText}`);580}581}582583/**584* Create a pull request from the feedback branch to main.585* @returns The URL to the created pull request.586*/587private async _createPullRequest(588token: string,589branchName: string,590username: string,591timestamp: string,592fileCount: number593): Promise<string | undefined> {594const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/pulls`;595596const payload = {597title: `NES Feedback from ${username} (${timestamp})`,598head: branchName,599base: 'main',600body: `## NES Feedback Submission\n\n` +601`- **Submitted by:** ${username}\n` +602`- **Timestamp:** ${timestamp}\n` +603`- **Files:** ${fileCount} file(s)\n\n` +604`This feedback was automatically submitted via the "Copilot: Submit NES Feedback" command.`605};606607const response = await fetch(url, {608method: 'POST',609headers: {610'Authorization': `Bearer ${token}`,611'Accept': 'application/vnd.github+json',612'Content-Type': 'application/json',613'X-GitHub-Api-Version': '2022-11-28',614'User-Agent': this._fetcherService.getUserAgentLibrary()615},616body: JSON.stringify(payload)617});618619if (!response.ok) {620const errorText = await response.text();621this._logger.error(`Failed to create pull request: ${response.status} ${response.statusText} - ${errorText}`);622throw new Error(`Failed to create pull request: ${response.statusText}`);623}624625const prData = await response.json() as { html_url: string };626return prData.html_url;627}628629/**630* Create a file in the private feedback repository on a specific branch.631* Uses native fetch API since IFetcherService only supports GET/POST,632* but GitHub Contents API requires PUT for file creation.633*/634private async _createFileInRepo(635path: string,636content: string,637token: string,638username: string,639timestamp: string,640branch: string641): Promise<void> {642const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/contents/${path}`;643644const payload = {645message: `NES feedback from ${username} at ${timestamp}`,646content: encodeBase64(VSBuffer.fromString(content)),647branch: branch648};649650// Use native fetch for PUT request (IFetcherService only supports GET/POST)651const response = await fetch(url, {652method: 'PUT',653headers: {654'Authorization': `Bearer ${token}`,655'Accept': 'application/vnd.github+json',656'Content-Type': 'application/json',657'X-GitHub-Api-Version': '2022-11-28',658'User-Agent': this._fetcherService.getUserAgentLibrary()659},660body: JSON.stringify(payload)661});662663if (!response.ok) {664const errorText = await response.text();665this._logger.error(`Failed to create file ${path}: ${response.status} ${response.statusText} - ${errorText}`);666throw new Error(`Failed to upload file: ${response.statusText}`);667}668}669670/**671* Get the current authenticated GitHub user.672*/673private async _getCurrentUser(token: string): Promise<{ login: string } | undefined> {674try {675const response = await this._fetcherService.fetch(676`${this._repoConfig.apiUrl}/user`,677{678method: 'GET',679callSite: 'nes-feedback-current-user',680headers: {681'Authorization': `Bearer ${token}`,682'Accept': 'application/vnd.github+json',683'X-GitHub-Api-Version': '2022-11-28',684'User-Agent': this._fetcherService.getUserAgentLibrary()685}686}687);688689if (response.ok) {690return await response.json();691}692} catch (e) {693this._logger.warn(`Failed to get current user: ${e}`);694}695return undefined;696}697}698699700