Skip to content
Merged
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
21 changes: 19 additions & 2 deletions src/common/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Buffer } from 'buffer';
import * as pathUtils from 'path';
import fetch from 'cross-fetch';
import * as vscode from 'vscode';
import { RemoteInfo } from '../../common/types';
import { Repository } from '../api/api';
import { EXTENSION_ID } from '../constants';
import { IAccount, isITeam, ITeam, reviewerId } from '../github/interface';
Expand Down Expand Up @@ -675,6 +676,24 @@ export function fromOpenPullRequestWebviewUri(uri: vscode.Uri): OpenPullRequestW
} catch (e) { }
}

export function toQueryUri(params: { remote: RemoteInfo | undefined, isCopilot?: boolean }) {
const uri = vscode.Uri.from({ scheme: Schemes.PRQuery, path: params.isCopilot ? 'copilot' : undefined, query: params.remote ? JSON.stringify({ remote: params.remote }) : undefined });
return uri;
}

export function fromQueryUri(uri: vscode.Uri): { remote: RemoteInfo | undefined, isCopilot?: boolean } | undefined {
if (uri.scheme !== Schemes.PRQuery) {
return;
}
try {
const query = uri.query ? JSON.parse(uri.query) : undefined;
return {
remote: query.remote,
isCopilot: uri.path === 'copilot'
};
} catch (e) { }
}

export enum Schemes {
File = 'file',
Review = 'review', // File content for a checked out PR
Expand All @@ -694,8 +713,6 @@ export enum Schemes {
GitHubCommit = 'githubcommit' // file content from GitHub for a commit
}

export const COPILOT_QUERY = vscode.Uri.from({ scheme: Schemes.PRQuery, path: 'copilot' });

export function resolvePath(from: vscode.Uri, to: string) {
if (from.scheme === Schemes.File) {
return pathUtils.resolve(from.fsPath, to);
Expand Down
30 changes: 19 additions & 11 deletions src/github/copilotPrWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { COPILOT_LOGINS, copilotEventToStatus, CopilotPRStatus } from '../common
import { Disposable } from '../common/lifecycle';
import Logger from '../common/logger';
import { PR_SETTINGS_NAMESPACE, QUERIES } from '../common/settingKeys';
import { FolderRepositoryManager } from './folderRepositoryManager';
import { PRType } from './interface';
import { PullRequestModel } from './pullRequestModel';
import { PullRequestOverviewPanel } from './pullRequestOverview';
Expand Down Expand Up @@ -42,7 +41,10 @@ export class CopilotStateModel extends Disposable {
this._onRefresh.fire();
}

makeKey(owner: string, repo: string, prNumber: number): string {
makeKey(owner: string, repo: string, prNumber?: number): string {
if (prNumber === undefined) {
return `${owner}/${repo}`;
}
return `${owner}/${repo}#${prNumber}`;
}

Expand Down Expand Up @@ -109,6 +111,17 @@ export class CopilotStateModel extends Disposable {
return this._showNotification;
}

getNotificationsCount(owner: string, repo: string): number {
let total = 0;
const partialKey = `${this.makeKey(owner, repo)}#`;
for (const state of this._showNotification.values()) {
if (state.startsWith(partialKey)) {
total++;
}
}
return total;
}

setInitialized() {
this._isInitialized = true;
}
Expand All @@ -117,11 +130,14 @@ export class CopilotStateModel extends Disposable {
return this._isInitialized;
}

getCounts(): { total: number; inProgress: number; error: number } {
getCounts(owner: string, repo: string): { total: number; inProgress: number; error: number } {
let inProgressCount = 0;
let errorCount = 0;

for (const state of this._states.values()) {
if (state.item.remote.owner !== owner || state.item.remote.repositoryName !== repo) {
continue;
}
if (state.status === CopilotPRStatus.Started) {
inProgressCount++;
} else if (state.status === CopilotPRStatus.Failed) {
Expand Down Expand Up @@ -221,14 +237,6 @@ export class CopilotPRWatcher extends Disposable {
}
}

private _currentUser: string | undefined;
private async _getCurrentUser(folderManager: FolderRepositoryManager): Promise<string> {
if (!this._currentUser) {
this._currentUser = (await folderManager.getCurrentUser()).login;
}
return this._currentUser;
}

private async _updateSingleState(pr: PullRequestModel): Promise<void> {
const changes: { pullRequestModel: PullRequestModel, status: CopilotPRStatus }[] = [];

Expand Down
21 changes: 16 additions & 5 deletions src/github/copilotRemoteAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,21 +743,32 @@ export class CopilotRemoteAgentManager extends Disposable {
})[0];
}

getNotificationsCount(owner: string, repo: string): number {
return this._stateModel.getNotificationsCount(owner, repo);
}

get notificationsCount(): number {
return this._stateModel.notifications.size;
}

hasNotification(owner: string, repo: string, pullRequestNumber: number): boolean {
const key = this._stateModel.makeKey(owner, repo, pullRequestNumber);
return this._stateModel.notifications.has(key);
hasNotification(owner: string, repo: string, pullRequestNumber?: number): boolean {
if (pullRequestNumber !== undefined) {
const key = this._stateModel.makeKey(owner, repo, pullRequestNumber);
return this._stateModel.notifications.has(key);
} else {
const partialKey = this._stateModel.makeKey(owner, repo);
return Array.from(this._stateModel.notifications.keys()).some(key => {
return key.startsWith(partialKey);
});
}
}

getStateForPR(owner: string, repo: string, prNumber: number): CopilotPRStatus {
return this._stateModel.get(owner, repo, prNumber);
}

getCounts(): { total: number; inProgress: number; error: number } {
return this._stateModel.getCounts();
getCounts(owner: string, repo: string): { total: number; inProgress: number; error: number } {
return this._stateModel.getCounts(owner, repo);
}

async extractHistory(history: ReadonlyArray<vscode.ChatRequestTurn | vscode.ChatResponseTurn>): Promise<string | undefined> {
Expand Down
2 changes: 1 addition & 1 deletion src/github/createPRViewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export abstract class BaseCreatePullRequestViewProvider<T extends BasePullReques
if (!configuration) {
return;
}
const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login);
const resolved = variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login);
if (!resolved) {
return;
}
Expand Down
4 changes: 2 additions & 2 deletions src/github/folderRepositoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class DetachedHeadError extends Error {
}

override get message() {
return vscode.l10n.t('{0} has a detached HEAD (create a branch first', this.repository.rootUri.toString());
return vscode.l10n.t('{0} has a detached HEAD (create a branch first)', this.repository.rootUri.toString());
}
}

Expand Down Expand Up @@ -1108,7 +1108,7 @@ export class FolderRepositoryManager extends Disposable {
pageNumber: number,
): Promise<{ items: any[]; hasMorePages: boolean, totalCount?: number } | undefined> => {
// Resolve variables in the query with each repo
const resolvedQuery = query ? await variableSubstitution(query, undefined,
const resolvedQuery = query ? variableSubstitution(query, undefined,
{ base: await githubRepository.getDefaultBranch(), owner: githubRepository.remote.owner, repo: githubRepository.remote.repositoryName }) : undefined;
switch (pagedDataType) {
case PagedDataType.PullRequest: {
Expand Down
23 changes: 21 additions & 2 deletions src/github/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import * as crypto from 'crypto';
import * as OctokitTypes from '@octokit/types';
import * as vscode from 'vscode';
import { RemoteInfo } from '../../common/types';
import { Repository } from '../api/api';
import { GitApiImpl } from '../api/api1';
import { AuthProvider, GitHubServerType } from '../common/authentication';
Expand Down Expand Up @@ -1670,12 +1671,12 @@ function computeSinceValue(sinceValue: string | undefined): string {
const COPILOT_PATTERN = /\:(Copilot|copilot)(\s|$)/g;

const VARIABLE_PATTERN = /\$\{([^-]*?)(-.*?)?\}/g;
export async function variableSubstitution(
export function variableSubstitution(
value: string,
issueModel?: IssueModel,
defaults?: PullRequestDefaults,
user?: string,
): Promise<string> {
): string {
const withVariables = value.replace(VARIABLE_PATTERN, (match: string, variable: string, extra: string) => {
let result: string;
switch (variable) {
Expand Down Expand Up @@ -1787,4 +1788,22 @@ export enum UnsatisfiedChecks {
ChangesRequested = 1 << 1,
CIFailed = 1 << 2,
CIPending = 1 << 3
}

export async function extractRepoFromQuery(folderManager: FolderRepositoryManager, query: string | undefined): Promise<RemoteInfo | undefined> {
if (!query) {
return undefined;
}

const defaults = await folderManager.getPullRequestDefaults();
// Use a fake user since we only care about pulling out the repo and repo owner
const substituted = variableSubstitution(query, undefined, defaults, 'fakeUser');

const repoRegex = /(?:^|\s)repo:(?:"?(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)"?)/i;
const repoMatch = repoRegex.exec(substituted);
if (repoMatch && repoMatch.groups) {
return { owner: repoMatch.groups.owner, repositoryName: repoMatch.groups.repo };
}

return undefined;
}
2 changes: 1 addition & 1 deletion src/issues/currentIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export class CurrentIssue extends Disposable {
}
const state: IssueState = this.stateManager.getSavedIssueState(this.issueModel.number);
this._branchName = this.shouldPromptForBranch ? undefined : state.branch;
const branchNameConfig = await variableSubstitution(
const branchNameConfig = variableSubstitution(
await this.getBranchTitle(),
this.issue,
undefined,
Expand Down
2 changes: 1 addition & 1 deletion src/issues/issueCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export class IssueCompletionProvider implements vscode.CompletionItemProvider {
.getConfiguration(ISSUES_SETTINGS_NAMESPACE)
.get(ISSUE_COMPLETION_FORMAT_SCM);
if (document.uri.path.match(/git\/scm\d\/input/) && typeof configuration === 'string') {
item.insertText = await variableSubstitution(configuration, issue, repo);
item.insertText = variableSubstitution(configuration, issue, repo);
} else {
item.insertText = `${getIssueNumberLabel(issue, repo)}`;
}
Expand Down
2 changes: 1 addition & 1 deletion src/issues/stateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ export class StateManager {
items = this.setIssues(
folderManager,
// Do not resolve pull request defaults as they will get resolved in the query later per repository
await variableSubstitution(query.query, undefined, undefined, user),
variableSubstitution(query.query, undefined, undefined, user),
).then(issues => ({ groupBy: query.groupBy ?? [], issues }));

if (items) {
Expand Down
13 changes: 0 additions & 13 deletions src/test/github/copilotRemoteAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,6 @@ describe('CopilotRemoteAgentManager', function () {
});
});

describe('getCounts()', function () {
it('should return valid counts object', function () {
const result = manager.getCounts();

assert.strictEqual(typeof result.total, 'number');
assert.strictEqual(typeof result.inProgress, 'number');
assert.strictEqual(typeof result.error, 'number');
assert(result.total >= 0);
assert(result.inProgress >= 0);
assert(result.error >= 0);
});
});

describe('notificationsCount', function () {
it('should return non-negative number', function () {
const count = manager.notificationsCount;
Expand Down
4 changes: 3 additions & 1 deletion src/test/mocks/mockRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export class MockRepository implements Repository {
}

private _state: Mutable<RepositoryState & { refs: Ref[] }> = {
HEAD: undefined,
HEAD: {
type: RefType.Head
},
refs: [],
remotes: [],
submodules: [],
Expand Down
4 changes: 3 additions & 1 deletion src/test/view/prsTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ describe('GitHub Pull Requests view', function () {
it('opens the viewlet and displays the default categories', async function () {
const repository = new MockRepository();
repository.addRemote('origin', 'git@github.com:aaa/bbb');
reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher));
const folderManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(reposManager), credentialStore, createPrHelper, mockThemeWatcher);
sinon.stub(folderManager, 'getPullRequestDefaults').returns(Promise.resolve({ owner: 'aaa', repo: 'bbb', base: 'main' }));
reposManager.insertFolderManager(folderManager);
sinon.stub(credentialStore, 'isAuthenticated').returns(true);
await reposManager.folderManagers[0].updateRepositories();
provider.initialize([], credentialStore);
Expand Down
31 changes: 21 additions & 10 deletions src/view/prStatusDecorationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as vscode from 'vscode';
import { Disposable } from '../common/lifecycle';
import { Protocol } from '../common/protocol';
import { COPILOT_QUERY, createPRNodeUri, fromPRNodeUri, parsePRNodeIdentifier, PRNodeUriParams, Schemes } from '../common/uri';
import { createPRNodeUri, fromPRNodeUri, fromQueryUri, parsePRNodeIdentifier, PRNodeUriParams, Schemes, toQueryUri } from '../common/uri';
import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent';
import { getStatusDecoration } from '../github/markdownUtils';
import { PrsTreeModel } from './prsTreeModel';
Expand All @@ -28,8 +28,14 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil
);

this._register(this._copilotManager.onDidChangeNotifications(items => {
const uris = [COPILOT_QUERY];
const repoItems = new Set<string>();
const uris: vscode.Uri[] = [];
for (const item of items) {
const queryUri = toQueryUri({ remote: { owner: item.remote.owner, repositoryName: item.remote.repositoryName }, isCopilot: true });
if (!repoItems.has(queryUri.toString())) {
repoItems.add(queryUri.toString());
uris.push(queryUri);
}
uris.push(createPRNodeUri(item));
}
this._onDidChangeFileDecorations.fire(uris);
Expand Down Expand Up @@ -81,14 +87,19 @@ export class PRStatusDecorationProvider extends Disposable implements vscode.Fil
}

private _queryDecoration(uri: vscode.Uri): vscode.ProviderResult<vscode.FileDecoration> {
if (uri.path === 'copilot') {
if (this._copilotManager.notificationsCount > 0) {
return {
tooltip: vscode.l10n.t('Coding agent has made changes', this._copilotManager.notificationsCount),
badge: new vscode.ThemeIcon('copilot') as any,
color: new vscode.ThemeColor('pullRequests.notification'),
};
}
const params = fromQueryUri(uri);
if (!params?.isCopilot || !params.remote) {
return;
}
const counts = this._copilotManager.getNotificationsCount(params.remote.owner, params.remote.repositoryName);
if (counts === 0) {
return;
}

return {
tooltip: vscode.l10n.t('Coding agent has made changes'),
badge: new vscode.ThemeIcon('copilot') as any,
color: new vscode.ThemeColor('pullRequests.notification'),
};
}
}
23 changes: 3 additions & 20 deletions src/view/prsTreeModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { PullRequestChangeEvent } from '../github/githubRepository';
import { CheckState, PRType, PullRequestChecks, PullRequestReviewRequirement } from '../github/interface';
import { PullRequestModel } from '../github/pullRequestModel';
import { RepositoriesManager } from '../github/repositoriesManager';
import { UnsatisfiedChecks, variableSubstitution } from '../github/utils';
import { extractRepoFromQuery, UnsatisfiedChecks } from '../github/utils';
import { CategoryTreeNode } from './treeNodes/categoryNode';
import { TreeNode } from './treeNodes/treeNode';

Expand Down Expand Up @@ -255,29 +255,12 @@ export class PrsTreeModel extends Disposable {
return { hasMorePages: false, hasUnsearchedRepositories: false, items: prs };
}

private async _extractRepoFromQuery(folderManager: FolderRepositoryManager, query: string): Promise<RemoteInfo | undefined> {
if (!query) {
return undefined;
}

const defaults = await folderManager.getPullRequestDefaults();
const substituted = await variableSubstitution(query, undefined, defaults, (await folderManager.getCurrentUser()).login);

const repoRegex = /(?:^|\s)repo:(?:"?(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+)"?)/i;
const repoMatch = repoRegex.exec(substituted);
if (repoMatch && repoMatch.groups) {
return { owner: repoMatch.groups.owner, repositoryName: repoMatch.groups.repo };
}

return undefined;
}

private async _testIfRefreshNeeded(cached: CachedPRs, query: string, folderManager: FolderRepositoryManager): Promise<boolean> {
if (!cached.clearRequested) {
return false;
}

const repoInfo = await this._extractRepoFromQuery(folderManager, query);
const repoInfo = await extractRepoFromQuery(folderManager, query);
if (!repoInfo) {
// Query doesn't specify a repo or org, so always refresh
// Send telemetry once indicating we couldn't find a repo in the query.
Expand Down Expand Up @@ -332,7 +315,7 @@ export class PrsTreeModel extends Disposable {
}

if (!maxKnownPR) {
const repoInfo = await this._extractRepoFromQuery(folderRepoManager, query);
const repoInfo = await extractRepoFromQuery(folderRepoManager, query);
if (repoInfo) {
maxKnownPR = await this._getMaxKnownPR(repoInfo);
}
Expand Down
Loading