diff --git a/package.json b/package.json index 72b6914584..65fc2c8032 100644 --- a/package.json +++ b/package.json @@ -294,6 +294,11 @@ "default": false, "description": "%githubPullRequests.hideViewedFiles.description%" }, + "githubPullRequests.showOnlyOwnedFiles": { + "type": "boolean", + "default": false, + "description": "%githubPullRequests.showOnlyOwnedFiles.description%" + }, "githubPullRequests.fileAutoReveal": { "type": "boolean", "default": true, @@ -1154,6 +1159,12 @@ "icon": "$(filter)", "category": "%command.pull.request.category%" }, + { + "command": "pr.toggleShowOnlyOwnedFiles", + "title": "%command.pr.toggleShowOnlyOwnedFiles.title%", + "icon": "$(person)", + "category": "%command.pull.request.category%" + }, { "command": "pr.refreshChanges", "title": "%command.pr.refreshChanges.title%", @@ -2279,6 +2290,10 @@ "command": "pr.toggleHideViewedFiles", "when": "false" }, + { + "command": "pr.toggleShowOnlyOwnedFiles", + "when": "false" + }, { "command": "pr.refreshChanges", "when": "false" @@ -2730,6 +2745,11 @@ "when": "view == prStatus:github", "group": "navigation1" }, + { + "command": "pr.toggleShowOnlyOwnedFiles", + "when": "view == prStatus:github", + "group": "navigation1" + }, { "command": "pr.toggleEditorCommentingOn", "when": "view == prStatus:github && !commentingEnabled", diff --git a/package.nls.json b/package.nls.json index 0808791b64..d7313c74c3 100644 --- a/package.nls.json +++ b/package.nls.json @@ -41,6 +41,7 @@ "githubPullRequests.notifications.description": "If GitHub notifications should be shown to the user.", "githubPullRequests.fileListLayout.description": "The layout to use when displaying changed files list.", "githubPullRequests.hideViewedFiles.description": "Hide files that have been marked as viewed in the pull request changes tree.", + "githubPullRequests.showOnlyOwnedFiles.description": "Show only files owned by you (via CODEOWNERS) in the pull request changes tree.", "githubPullRequests.fileAutoReveal.description": "Automatically reveal open files in the pull request changes tree.", "githubPullRequests.defaultDeletionMethod.selectLocalBranch.description": "When true, the option to delete the local branch will be selected by default when deleting a branch from a pull request.", "githubPullRequests.defaultDeletionMethod.selectRemote.description": "When true, the option to delete the remote will be selected by default when deleting a branch from a pull request.", @@ -245,6 +246,7 @@ "command.pr.setFileListLayoutAsTree.title": "View as Tree", "command.pr.setFileListLayoutAsFlat.title": "View as List", "command.pr.toggleHideViewedFiles.title": "Toggle Hide Viewed Files", + "command.pr.toggleShowOnlyOwnedFiles.title": "Toggle Show Only Files Owned by You", "command.pr.refreshChanges.title": "Refresh", "command.pr.configurePRViewlet.title": "Configure...", "command.pr.deleteLocalBranch.title": "Delete Local Branch", diff --git a/src/commands.ts b/src/commands.ts index 88e3784b06..f9d5945c39 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -11,7 +11,7 @@ import { GitErrorCodes } from './api/api1'; import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver'; import { commands } from './common/executeCommands'; import Logger from './common/logger'; -import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys'; +import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE, SHOW_ONLY_OWNED_FILES } from './common/settingKeys'; import { editQuery } from './common/settingsUtils'; import { ITelemetry } from './common/telemetry'; import { SessionLinkInfo } from './common/timelineEvent'; @@ -1518,6 +1518,14 @@ ${contents} }), ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.toggleShowOnlyOwnedFiles', _ => { + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const currentValue = config.get(SHOW_ONLY_OWNED_FILES, false); + config.update(SHOW_ONLY_OWNED_FILES, !currentValue, vscode.ConfigurationTarget.Global); + }), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => { const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel); diff --git a/src/common/codeowners.ts b/src/common/codeowners.ts new file mode 100644 index 0000000000..f3f4aad1c2 --- /dev/null +++ b/src/common/codeowners.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import Logger from './logger'; + +const CODEOWNERS_ID = 'CodeOwners'; + +export interface CodeownersEntry { + readonly pattern: string; + readonly owners: readonly string[]; +} + +/** + * Parses CODEOWNERS file content into a list of entries. + * Later entries take precedence over earlier ones (per GitHub spec). + */ +export function parseCodeownersFile(content: string): CodeownersEntry[] { + const entries: CodeownersEntry[] = []; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + const parts = line.split(/\s+/); + if (parts.length < 2) { + continue; + } + const [pattern, ...owners] = parts; + entries.push({ pattern, owners }); + } + return entries; +} + +/** + * Given a parsed CODEOWNERS file and a file path, returns the set of owners + * for that path. Returns an empty array if no rule matches. + * + * Matching follows GitHub semantics: the last matching pattern wins. + */ +export function getOwnersForPath(entries: readonly CodeownersEntry[], filePath: string): readonly string[] { + let matched: readonly string[] = []; + for (const entry of entries) { + if (matchesCodeownersPattern(entry.pattern, filePath)) { + matched = entry.owners; + } + } + return matched; +} + +/** + * Checks whether the given user login or any of the given team slugs + * (in `@org/team` format) appear among the owners list. + */ +export function isOwnedByUser( + owners: readonly string[], + userLogin: string, + teamSlugs: readonly string[], +): boolean { + const normalizedLogin = `@${userLogin.toLowerCase()}`; + const normalizedTeams = new Set(teamSlugs.map(t => t.toLowerCase())); + + return owners.some(owner => { + const normalized = owner.toLowerCase(); + return normalized === normalizedLogin || normalizedTeams.has(normalized); + }); +} + +function matchesCodeownersPattern(pattern: string, filePath: string): boolean { + try { + const regex = codeownersPatternToRegex(pattern); + return regex.test(filePath); + } catch (e) { + Logger.error(`Error matching CODEOWNERS pattern "${pattern}": ${e}`, CODEOWNERS_ID); + return false; + } +} + +/** + * Converts a CODEOWNERS pattern to a RegExp. + * + * GitHub CODEOWNERS rules: + * - A leading `/` anchors to the repo root; otherwise the pattern matches anywhere. + * - A trailing `/` means "directory and everything inside". + * - `*` matches within a single path segment; `**` matches across segments. + * - Bare filenames (no `/`) match anywhere in the tree. + * - `?` matches a single non-slash character. + */ +function codeownersPatternToRegex(pattern: string): RegExp { + let p = pattern; + const anchored = p.startsWith('/'); + if (anchored) { + p = p.slice(1); + } + + if (p.endsWith('/')) { + p = p + '**'; + } + + const hasSlash = p.includes('/'); + + let regexStr = ''; + let i = 0; + while (i < p.length) { + if (p[i] === '*') { + if (p[i + 1] === '*') { + if (p[i + 2] === '/') { + // `**/` matches zero or more directories + regexStr += '(?:.+/)?'; + i += 3; + } else { + // `**` at end or before non-slash: match everything + regexStr += '.*'; + i += 2; + } + } else { + // `*` matches anything except `/` + regexStr += '[^/]*'; + i++; + } + } else if (p[i] === '?') { + regexStr += '[^/]'; + i++; + } else if (p[i] === '.') { + regexStr += '\\.'; + i++; + } else if (p[i] === '[') { + const closeBracket = p.indexOf(']', i + 1); + if (closeBracket !== -1) { + regexStr += p.slice(i, closeBracket + 1); + i = closeBracket + 1; + } else { + regexStr += '\\['; + i++; + } + } else { + regexStr += p[i]; + i++; + } + } + + // If the pattern has no slash (bare filename) and is not anchored, + // it can match anywhere in the tree. + const prefix = (!anchored && !hasSlash) ? '(?:^|.+/)' : '^'; + + // GitHub treats patterns without glob characters as matching both the + // exact path and everything inside it (implicit directory match). + const hasGlob = /[*?\[]/.test(p); + const suffix = hasGlob ? '$' : '(?:/.*)?$'; + + return new RegExp(prefix + regexStr + suffix); +} + +/** Standard CODEOWNERS file paths in order of precedence (first found wins). */ +export const CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'] as const; diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 0b35cd539f..dd42d230fc 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -10,6 +10,7 @@ export const BRANCH_LIST_TIMEOUT = 'branchListTimeout'; export const USE_REVIEW_MODE = 'useReviewMode'; export const FILE_LIST_LAYOUT = 'fileListLayout'; export const HIDE_VIEWED_FILES = 'hideViewedFiles'; +export const SHOW_ONLY_OWNED_FILES = 'showOnlyOwnedFiles'; export const FILE_AUTO_REVEAL = 'fileAutoReveal'; export const ASSIGN_TO = 'assignCreated'; export const PUSH_BRANCH = 'pushBranch'; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index f60942ed46..52eca658a5 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -1073,6 +1073,56 @@ export class GitHubRepository extends Disposable { return await this._credentialStore.getCurrentUser(this.remote.authProviderId); } + private _codeownersCache: { ref: string; entries: import('../common/codeowners').CodeownersEntry[] } | undefined; + + async getCodeownersEntries(ref: string): Promise { + if (this._codeownersCache?.ref === ref) { + return this._codeownersCache.entries; + } + const { CODEOWNERS_PATHS, parseCodeownersFile } = await import('../common/codeowners'); + for (const filePath of CODEOWNERS_PATHS) { + try { + const content = await this.getFile(filePath, ref); + if (content.length > 0) { + const text = new TextDecoder().decode(content); + const entries = parseCodeownersFile(text); + this._codeownersCache = { ref, entries }; + Logger.debug(`Loaded CODEOWNERS from ${filePath} (${entries.length} rules)`, this.id); + return entries; + } + } catch { + // File not found at this path, try next + } + } + this._codeownersCache = { ref, entries: [] }; + return []; + } + + private _userTeamSlugsCache: string[] | undefined; + + async getAuthenticatedUserTeamSlugs(): Promise { + if (this._userTeamSlugsCache) { + return this._userTeamSlugsCache; + } + if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) { + Logger.debug('Skipping team slug fetch - no additional scopes (read:org)', this.id); + this._userTeamSlugsCache = []; + return []; + } + try { + const { octokit, remote } = await this.ensureAdditionalScopes(); + const { data } = await octokit.call(octokit.api.teams.listForAuthenticatedUser, { per_page: 100 }); + this._userTeamSlugsCache = data + .filter(team => team.organization.login.toLowerCase() === remote.owner.toLowerCase()) + .map(team => `@${team.organization.login}/${team.slug}`); + return this._userTeamSlugsCache; + } catch (e) { + Logger.debug(`Unable to fetch user teams: ${e}`, this.id); + this._userTeamSlugsCache = []; + return []; + } + } + async getAuthenticatedUserEmails(): Promise { try { Logger.debug(`Fetch authenticated user emails - enter`, this.id); diff --git a/src/view/prChangesTreeDataProvider.ts b/src/view/prChangesTreeDataProvider.ts index f262584b16..2a754f891b 100644 --- a/src/view/prChangesTreeDataProvider.ts +++ b/src/view/prChangesTreeDataProvider.ts @@ -10,7 +10,7 @@ import { GitApiImpl } from '../api/api1'; import { commands, contexts } from '../common/executeCommands'; import { Disposable } from '../common/lifecycle'; import Logger, { PR_TREE } from '../common/logger'; -import { FILE_LIST_LAYOUT, GIT, HIDE_VIEWED_FILES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { FILE_LIST_LAYOUT, GIT, HIDE_VIEWED_FILES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE, SHOW_ONLY_OWNED_FILES } from '../common/settingKeys'; import { isDescendant } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; @@ -53,6 +53,8 @@ export class PullRequestChangesTreeDataProvider extends Disposable implements vs this._onDidChangeTreeData.fire(); } else if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`)) { this._onDidChangeTreeData.fire(); + } else if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_ONLY_OWNED_FILES}`)) { + this._onDidChangeTreeData.fire(); } }), ); diff --git a/src/view/treeNodes/filesCategoryNode.ts b/src/view/treeNodes/filesCategoryNode.ts index 189b060c22..4024206143 100644 --- a/src/view/treeNodes/filesCategoryNode.ts +++ b/src/view/treeNodes/filesCategoryNode.ts @@ -6,11 +6,12 @@ import * as vscode from 'vscode'; import { ViewedState } from '../../common/comment'; import Logger, { PR_TREE } from '../../common/logger'; -import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from '../../common/settingKeys'; +import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE, SHOW_ONLY_OWNED_FILES } from '../../common/settingKeys'; import { compareIgnoreCase } from '../../common/utils'; import { PullRequestModel } from '../../github/pullRequestModel'; import { ReviewModel } from '../reviewModel'; import { DirectoryTreeNode } from './directoryTreeNode'; +import { GitFileChangeNode } from './fileChangeNode'; import { LabelOnlyNode, TreeNode, TreeNodeParent } from './treeNode'; export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { @@ -21,7 +22,7 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { constructor( parent: TreeNodeParent, private _reviewModel: ReviewModel, - _pullRequestModel: PullRequestModel + private _pullRequestModel: PullRequestModel ) { super(parent); this.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; @@ -45,8 +46,9 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { this.refresh(this); })); this.childrenDisposables.push(vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`)) { - Logger.appendLine(`Hide viewed files setting has changed, refreshing Files node`, PR_TREE); + if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`) + || e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_ONLY_OWNED_FILES}`)) { + Logger.appendLine(`File filter setting has changed, refreshing Files node`, PR_TREE); this.refresh(this); } })); @@ -56,12 +58,32 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { return this; } + private async filterByCodeowners(files: GitFileChangeNode[]): Promise { + const { getOwnersForPath, isOwnedByUser } = await import('../../common/codeowners'); + const ghRepo = this._pullRequestModel.githubRepository; + const baseRef = this._pullRequestModel.base.sha; + const [entries, user, teamSlugs] = await Promise.all([ + ghRepo.getCodeownersEntries(baseRef), + ghRepo.getAuthenticatedUser(), + ghRepo.getAuthenticatedUserTeamSlugs(), + ]); + + if (entries.length === 0) { + Logger.appendLine('No CODEOWNERS file found, showing all files', PR_TREE); + return files; + } + + return files.filter(f => { + const owners = getOwnersForPath(entries, f.fileName); + return owners.length > 0 && isOwnedByUser(owners, user.login, teamSlugs); + }); + } + override async getChildren(): Promise { super.getChildren(false); Logger.appendLine(`Getting children for Files node`, PR_TREE); if (!this._reviewModel.hasLocalFileChanges) { - // Provide loading feedback until we get the files. return new Promise(resolve => { const promiseResolver = this._reviewModel.onDidChangeLocalFileChanges(() => { resolve([]); @@ -75,23 +97,30 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { } let nodes: TreeNode[]; - const layout = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(FILE_LIST_LAYOUT); - const hideViewedFiles = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(HIDE_VIEWED_FILES, false); + const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE); + const layout = config.get(FILE_LIST_LAYOUT); + const hideViewedFiles = config.get(HIDE_VIEWED_FILES, false); + const showOnlyOwnedFiles = config.get(SHOW_ONLY_OWNED_FILES, false); - // Filter files based on hideViewedFiles setting - const filesToShow = hideViewedFiles + let filesToShow = hideViewedFiles ? this._reviewModel.localFileChanges.filter(f => f.changeModel.viewed !== ViewedState.VIEWED) - : this._reviewModel.localFileChanges; + : [...this._reviewModel.localFileChanges]; if (filesToShow.length === 0 && hideViewedFiles) { return [new LabelOnlyNode(this, vscode.l10n.t('All files viewed'))]; } + if (showOnlyOwnedFiles) { + filesToShow = await this.filterByCodeowners(filesToShow); + if (filesToShow.length === 0) { + return [new LabelOnlyNode(this, vscode.l10n.t('No files owned by you'))]; + } + } + const dirNode = new DirectoryTreeNode(this, ''); filesToShow.forEach(f => dirNode.addFile(f)); dirNode.finalize(); if (dirNode.label === '') { - // nothing on the root changed, pull children to parent this.directories = dirNode._children; } else { this.directories = [dirNode]; @@ -102,9 +131,6 @@ export class FilesCategoryNode extends TreeNode implements vscode.TreeItem { } else { const fileNodes = [...filesToShow]; fileNodes.sort((a, b) => compareIgnoreCase(a.fileChangeResourceUri.toString(), b.fileChangeResourceUri.toString())); - // In flat layout, files are rendered as direct children of this node. - // Keep parent pointers aligned with the rendered hierarchy so reveal/getParent - // don't try to walk through hidden DirectoryTreeNode instances. fileNodes.forEach(fileNode => { fileNode.parent = this; });