Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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%",
Expand Down Expand Up @@ -2279,6 +2290,10 @@
"command": "pr.toggleHideViewedFiles",
"when": "false"
},
{
"command": "pr.toggleShowOnlyOwnedFiles",
"when": "false"
},
{
"command": "pr.refreshChanges",
"when": "false"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean>(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);
Expand Down
156 changes: 156 additions & 0 deletions src/common/codeowners.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/common/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions src/github/githubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('../common/codeowners').CodeownersEntry[]> {
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<string[]> {
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<string[]> {
try {
Logger.debug(`Fetch authenticated user emails - enter`, this.id);
Expand Down
4 changes: 3 additions & 1 deletion src/view/prChangesTreeDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
}),
);
Expand Down
Loading