Skip to content

Commit 0a03700

Browse files
smithcursoragent
andcommitted
Add "Show only files owned by you" filter for CODEOWNERS
Implements the CODEOWNERS-based file filter requested in #6624. Adds a toggle button to the Changes in Pull Request tree view that filters files to show only those owned by the authenticated user according to the repository's CODEOWNERS file. - New CODEOWNERS parser (src/common/codeowners.ts) that handles GitHub's pattern syntax: anchored/unanchored paths, wildcards, directory patterns, and character classes. - GitHubRepository gains getCodeownersEntries() to fetch and cache CODEOWNERS from .github/CODEOWNERS, CODEOWNERS, or docs/CODEOWNERS, and getAuthenticatedUserTeamSlugs() for @org/team matching. - FilesCategoryNode applies the filter in getChildren(), composable with the existing "hide viewed files" filter. - New setting githubPullRequests.showOnlyOwnedFiles (default false) and command pr.toggleShowOnlyOwnedFiles with $(person) icon. Closes #6624 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 56702c6 commit 0a03700

File tree

8 files changed

+281
-16
lines changed

8 files changed

+281
-16
lines changed

package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,11 @@
294294
"default": false,
295295
"description": "%githubPullRequests.hideViewedFiles.description%"
296296
},
297+
"githubPullRequests.showOnlyOwnedFiles": {
298+
"type": "boolean",
299+
"default": false,
300+
"description": "%githubPullRequests.showOnlyOwnedFiles.description%"
301+
},
297302
"githubPullRequests.fileAutoReveal": {
298303
"type": "boolean",
299304
"default": true,
@@ -1154,6 +1159,12 @@
11541159
"icon": "$(filter)",
11551160
"category": "%command.pull.request.category%"
11561161
},
1162+
{
1163+
"command": "pr.toggleShowOnlyOwnedFiles",
1164+
"title": "%command.pr.toggleShowOnlyOwnedFiles.title%",
1165+
"icon": "$(person)",
1166+
"category": "%command.pull.request.category%"
1167+
},
11571168
{
11581169
"command": "pr.refreshChanges",
11591170
"title": "%command.pr.refreshChanges.title%",
@@ -2279,6 +2290,10 @@
22792290
"command": "pr.toggleHideViewedFiles",
22802291
"when": "false"
22812292
},
2293+
{
2294+
"command": "pr.toggleShowOnlyOwnedFiles",
2295+
"when": "false"
2296+
},
22822297
{
22832298
"command": "pr.refreshChanges",
22842299
"when": "false"
@@ -2730,6 +2745,11 @@
27302745
"when": "view == prStatus:github",
27312746
"group": "navigation1"
27322747
},
2748+
{
2749+
"command": "pr.toggleShowOnlyOwnedFiles",
2750+
"when": "view == prStatus:github",
2751+
"group": "navigation1"
2752+
},
27332753
{
27342754
"command": "pr.toggleEditorCommentingOn",
27352755
"when": "view == prStatus:github && !commentingEnabled",

package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"githubPullRequests.notifications.description": "If GitHub notifications should be shown to the user.",
4242
"githubPullRequests.fileListLayout.description": "The layout to use when displaying changed files list.",
4343
"githubPullRequests.hideViewedFiles.description": "Hide files that have been marked as viewed in the pull request changes tree.",
44+
"githubPullRequests.showOnlyOwnedFiles.description": "Show only files owned by you (via CODEOWNERS) in the pull request changes tree.",
4445
"githubPullRequests.fileAutoReveal.description": "Automatically reveal open files in the pull request changes tree.",
4546
"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.",
4647
"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 @@
245246
"command.pr.setFileListLayoutAsTree.title": "View as Tree",
246247
"command.pr.setFileListLayoutAsFlat.title": "View as List",
247248
"command.pr.toggleHideViewedFiles.title": "Toggle Hide Viewed Files",
249+
"command.pr.toggleShowOnlyOwnedFiles.title": "Toggle Show Only Files Owned by You",
248250
"command.pr.refreshChanges.title": "Refresh",
249251
"command.pr.configurePRViewlet.title": "Configure...",
250252
"command.pr.deleteLocalBranch.title": "Delete Local Branch",

src/commands.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { GitErrorCodes } from './api/api1';
1111
import { CommentReply, findActiveHandler, resolveCommentHandler } from './commentHandlerResolver';
1212
import { commands } from './common/executeCommands';
1313
import Logger from './common/logger';
14-
import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE } from './common/settingKeys';
14+
import { FILE_LIST_LAYOUT, HIDE_VIEWED_FILES, PR_SETTINGS_NAMESPACE, SHOW_ONLY_OWNED_FILES } from './common/settingKeys';
1515
import { editQuery } from './common/settingsUtils';
1616
import { ITelemetry } from './common/telemetry';
1717
import { SessionLinkInfo } from './common/timelineEvent';
@@ -1518,6 +1518,14 @@ ${contents}
15181518
}),
15191519
);
15201520

1521+
context.subscriptions.push(
1522+
vscode.commands.registerCommand('pr.toggleShowOnlyOwnedFiles', _ => {
1523+
const config = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE);
1524+
const currentValue = config.get<boolean>(SHOW_ONLY_OWNED_FILES, false);
1525+
config.update(SHOW_ONLY_OWNED_FILES, !currentValue, vscode.ConfigurationTarget.Global);
1526+
}),
1527+
);
1528+
15211529
context.subscriptions.push(
15221530
vscode.commands.registerCommand('pr.refreshPullRequest', (prNode: PRNode) => {
15231531
const folderManager = reposManager.getManagerForIssueModel(prNode.pullRequestModel);

src/common/codeowners.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import Logger from './logger';
7+
8+
const CODEOWNERS_ID = 'CodeOwners';
9+
10+
export interface CodeownersEntry {
11+
readonly pattern: string;
12+
readonly owners: readonly string[];
13+
}
14+
15+
/**
16+
* Parses CODEOWNERS file content into a list of entries.
17+
* Later entries take precedence over earlier ones (per GitHub spec).
18+
*/
19+
export function parseCodeownersFile(content: string): CodeownersEntry[] {
20+
const entries: CodeownersEntry[] = [];
21+
for (const rawLine of content.split(/\r?\n/)) {
22+
const line = rawLine.trim();
23+
if (!line || line.startsWith('#')) {
24+
continue;
25+
}
26+
const parts = line.split(/\s+/);
27+
if (parts.length < 2) {
28+
continue;
29+
}
30+
const [pattern, ...owners] = parts;
31+
entries.push({ pattern, owners });
32+
}
33+
return entries;
34+
}
35+
36+
/**
37+
* Given a parsed CODEOWNERS file and a file path, returns the set of owners
38+
* for that path. Returns an empty array if no rule matches.
39+
*
40+
* Matching follows GitHub semantics: the last matching pattern wins.
41+
*/
42+
export function getOwnersForPath(entries: readonly CodeownersEntry[], filePath: string): readonly string[] {
43+
let matched: readonly string[] = [];
44+
for (const entry of entries) {
45+
if (matchesCodeownersPattern(entry.pattern, filePath)) {
46+
matched = entry.owners;
47+
}
48+
}
49+
return matched;
50+
}
51+
52+
/**
53+
* Checks whether the given user login or any of the given team slugs
54+
* (in `@org/team` format) appear among the owners list.
55+
*/
56+
export function isOwnedByUser(
57+
owners: readonly string[],
58+
userLogin: string,
59+
teamSlugs: readonly string[],
60+
): boolean {
61+
const normalizedLogin = `@${userLogin.toLowerCase()}`;
62+
const normalizedTeams = new Set(teamSlugs.map(t => t.toLowerCase()));
63+
64+
return owners.some(owner => {
65+
const normalized = owner.toLowerCase();
66+
return normalized === normalizedLogin || normalizedTeams.has(normalized);
67+
});
68+
}
69+
70+
function matchesCodeownersPattern(pattern: string, filePath: string): boolean {
71+
try {
72+
const regex = codeownersPatternToRegex(pattern);
73+
return regex.test(filePath);
74+
} catch (e) {
75+
Logger.error(`Error matching CODEOWNERS pattern "${pattern}": ${e}`, CODEOWNERS_ID);
76+
return false;
77+
}
78+
}
79+
80+
/**
81+
* Converts a CODEOWNERS pattern to a RegExp.
82+
*
83+
* GitHub CODEOWNERS rules:
84+
* - A leading `/` anchors to the repo root; otherwise the pattern matches anywhere.
85+
* - A trailing `/` means "directory and everything inside".
86+
* - `*` matches within a single path segment; `**` matches across segments.
87+
* - Bare filenames (no `/`) match anywhere in the tree.
88+
* - `?` matches a single non-slash character.
89+
*/
90+
function codeownersPatternToRegex(pattern: string): RegExp {
91+
let p = pattern;
92+
const anchored = p.startsWith('/');
93+
if (anchored) {
94+
p = p.slice(1);
95+
}
96+
97+
if (p.endsWith('/')) {
98+
p = p + '**';
99+
}
100+
101+
const hasSlash = p.includes('/');
102+
103+
let regexStr = '';
104+
let i = 0;
105+
while (i < p.length) {
106+
if (p[i] === '*') {
107+
if (p[i + 1] === '*') {
108+
if (p[i + 2] === '/') {
109+
// `**/` matches zero or more directories
110+
regexStr += '(?:.+/)?';
111+
i += 3;
112+
} else {
113+
// `**` at end or before non-slash: match everything
114+
regexStr += '.*';
115+
i += 2;
116+
}
117+
} else {
118+
// `*` matches anything except `/`
119+
regexStr += '[^/]*';
120+
i++;
121+
}
122+
} else if (p[i] === '?') {
123+
regexStr += '[^/]';
124+
i++;
125+
} else if (p[i] === '.') {
126+
regexStr += '\\.';
127+
i++;
128+
} else if (p[i] === '[') {
129+
const closeBracket = p.indexOf(']', i + 1);
130+
if (closeBracket !== -1) {
131+
regexStr += p.slice(i, closeBracket + 1);
132+
i = closeBracket + 1;
133+
} else {
134+
regexStr += '\\[';
135+
i++;
136+
}
137+
} else {
138+
regexStr += p[i];
139+
i++;
140+
}
141+
}
142+
143+
// If the pattern has no slash (bare filename) and is not anchored,
144+
// it can match anywhere in the tree.
145+
const prefix = (!anchored && !hasSlash) ? '(?:^|.+/)' : '^';
146+
147+
// GitHub treats patterns without glob characters as matching both the
148+
// exact path and everything inside it (implicit directory match).
149+
const hasGlob = /[*?\[]/.test(p);
150+
const suffix = hasGlob ? '$' : '(?:/.*)?$';
151+
152+
return new RegExp(prefix + regexStr + suffix);
153+
}
154+
155+
/** Standard CODEOWNERS file paths in order of precedence (first found wins). */
156+
export const CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'] as const;

src/common/settingKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const BRANCH_LIST_TIMEOUT = 'branchListTimeout';
1010
export const USE_REVIEW_MODE = 'useReviewMode';
1111
export const FILE_LIST_LAYOUT = 'fileListLayout';
1212
export const HIDE_VIEWED_FILES = 'hideViewedFiles';
13+
export const SHOW_ONLY_OWNED_FILES = 'showOnlyOwnedFiles';
1314
export const FILE_AUTO_REVEAL = 'fileAutoReveal';
1415
export const ASSIGN_TO = 'assignCreated';
1516
export const PUSH_BRANCH = 'pushBranch';

src/github/githubRepository.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,56 @@ export class GitHubRepository extends Disposable {
10731073
return await this._credentialStore.getCurrentUser(this.remote.authProviderId);
10741074
}
10751075

1076+
private _codeownersCache: { ref: string; entries: import('../common/codeowners').CodeownersEntry[] } | undefined;
1077+
1078+
async getCodeownersEntries(ref: string): Promise<import('../common/codeowners').CodeownersEntry[]> {
1079+
if (this._codeownersCache?.ref === ref) {
1080+
return this._codeownersCache.entries;
1081+
}
1082+
const { CODEOWNERS_PATHS, parseCodeownersFile } = await import('../common/codeowners');
1083+
for (const filePath of CODEOWNERS_PATHS) {
1084+
try {
1085+
const content = await this.getFile(filePath, ref);
1086+
if (content.length > 0) {
1087+
const text = new TextDecoder().decode(content);
1088+
const entries = parseCodeownersFile(text);
1089+
this._codeownersCache = { ref, entries };
1090+
Logger.debug(`Loaded CODEOWNERS from ${filePath} (${entries.length} rules)`, this.id);
1091+
return entries;
1092+
}
1093+
} catch {
1094+
// File not found at this path, try next
1095+
}
1096+
}
1097+
this._codeownersCache = { ref, entries: [] };
1098+
return [];
1099+
}
1100+
1101+
private _userTeamSlugsCache: string[] | undefined;
1102+
1103+
async getAuthenticatedUserTeamSlugs(): Promise<string[]> {
1104+
if (this._userTeamSlugsCache) {
1105+
return this._userTeamSlugsCache;
1106+
}
1107+
if (!this._credentialStore.isAuthenticatedWithAdditionalScopes(this.remote.authProviderId)) {
1108+
Logger.debug('Skipping team slug fetch - no additional scopes (read:org)', this.id);
1109+
this._userTeamSlugsCache = [];
1110+
return [];
1111+
}
1112+
try {
1113+
const { octokit, remote } = await this.ensureAdditionalScopes();
1114+
const { data } = await octokit.call(octokit.api.teams.listForAuthenticatedUser, { per_page: 100 });
1115+
this._userTeamSlugsCache = data
1116+
.filter(team => team.organization.login.toLowerCase() === remote.owner.toLowerCase())
1117+
.map(team => `@${team.organization.login}/${team.slug}`);
1118+
return this._userTeamSlugsCache;
1119+
} catch (e) {
1120+
Logger.debug(`Unable to fetch user teams: ${e}`, this.id);
1121+
this._userTeamSlugsCache = [];
1122+
return [];
1123+
}
1124+
}
1125+
10761126
async getAuthenticatedUserEmails(): Promise<string[]> {
10771127
try {
10781128
Logger.debug(`Fetch authenticated user emails - enter`, this.id);

src/view/prChangesTreeDataProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { GitApiImpl } from '../api/api1';
1010
import { commands, contexts } from '../common/executeCommands';
1111
import { Disposable } from '../common/lifecycle';
1212
import Logger, { PR_TREE } from '../common/logger';
13-
import { FILE_LIST_LAYOUT, GIT, HIDE_VIEWED_FILES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE } from '../common/settingKeys';
13+
import { FILE_LIST_LAYOUT, GIT, HIDE_VIEWED_FILES, OPEN_DIFF_ON_CLICK, PR_SETTINGS_NAMESPACE, SHOW_ONLY_OWNED_FILES } from '../common/settingKeys';
1414
import { isDescendant } from '../common/utils';
1515
import { FolderRepositoryManager } from '../github/folderRepositoryManager';
1616
import { PullRequestModel } from '../github/pullRequestModel';
@@ -53,6 +53,8 @@ export class PullRequestChangesTreeDataProvider extends Disposable implements vs
5353
this._onDidChangeTreeData.fire();
5454
} else if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${HIDE_VIEWED_FILES}`)) {
5555
this._onDidChangeTreeData.fire();
56+
} else if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${SHOW_ONLY_OWNED_FILES}`)) {
57+
this._onDidChangeTreeData.fire();
5658
}
5759
}),
5860
);

0 commit comments

Comments
 (0)