Skip to content

Commit 75ecee0

Browse files
authored
Use number badge for padawan notifications (#7070)
* Use number badge for padawan notifications also promote the "copilot on my behalf" query to be a real query * Fix tests * Fix test again * Fix tests again
1 parent e6959e5 commit 75ecee0

14 files changed

+271
-37
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@
194194
"scope": "resource",
195195
"markdownDescription": "%githubPullRequests.queries.markdownDescription%",
196196
"default": [
197+
{
198+
"label": "%githubPullRequests.queries.copilotOnMyBehalf%",
199+
"query": "repo:${owner}/${repository} is:open author:copilot involves:${user}"
200+
},
197201
{
198202
"label": "Local Pull Request Branches",
199203
"query": "default"

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"githubPullRequests.queries.markdownDescription": "Specifies what queries should be used in the GitHub Pull Requests tree. All queries are made against **the currently opened repos**. Each query object has a `label` that will be shown in the tree and a search `query` using [GitHub search syntax](https://help.github.com/en/articles/understanding-the-search-syntax). The following variables can be used: \n - `${user}` will resolve to the currently logged in user \n - `${owner}` will resolve to the owner of the current repository, ex. `microsoft` in `microsoft/vscode` \n - `${repository}` will resolve to the repository name, ex. `vscode` in `microsoft/vscode` \n - `${today-Nd}`, where `N` is the number of days ago, will resolve to a date, ex. `2025-01-04`. \n\n By default these queries define the categories \"Waiting For My Review\", \"Assigned To Me\" and \"Created By Me\". If you want to preserve these, make sure they are still in the array when you modify the setting.",
2727
"githubPullRequests.queries.label.description": "The label to display for the query in the Pull Requests tree",
2828
"githubPullRequests.queries.query.description": "The query used for searching pull requests.",
29+
"githubPullRequests.queries.copilotOnMyBehalf": "Copilot on My Behalf",
2930
"githubPullRequests.queries.waitingForMyReview": "Waiting For My Review",
3031
"githubPullRequests.queries.assignedToMe": "Assigned To Me",
3132
"githubPullRequests.queries.createdByMe": "Created By Me",

src/extension.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TemporaryState } from './common/temporaryState';
2121
import { Schemes } from './common/uri';
2222
import { EXTENSION_ID, FOCUS_REVIEW_MODE } from './constants';
2323
import { createExperimentationService, ExperimentationTelemetry } from './experimentationService';
24+
import { CopilotStateModel } from './github/copilotPrWatcher';
2425
import { CopilotRemoteAgentManager } from './github/copilotRemoteAgent';
2526
import { CredentialStore } from './github/credentials';
2627
import { FolderRepositoryManager } from './github/folderRepositoryManager';
@@ -63,7 +64,8 @@ async function init(
6364
liveshareApiPromise: Promise<LiveShare | undefined>,
6465
showPRController: ShowPullRequest,
6566
reposManager: RepositoriesManager,
66-
createPrHelper: CreatePullRequestHelper
67+
createPrHelper: CreatePullRequestHelper,
68+
copilotStateModel: CopilotStateModel
6769
): Promise<void> {
6870
context.subscriptions.push(Logger);
6971
Logger.appendLine('Git repository found, initializing review manager and pr tree view.', ACTIVATION);
@@ -163,7 +165,7 @@ async function init(
163165
context.subscriptions.push(treeDecorationProviders);
164166
treeDecorationProviders.registerProviders([new FileTypeDecorationProvider(), new CommentDecorationProvider(reposManager)]);
165167

166-
const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git);
168+
const reviewsManager = new ReviewsManager(context, reposManager, reviewManagers, tree, changesTree, telemetry, credentialStore, git, copilotStateModel);
167169
context.subscriptions.push(reviewsManager);
168170

169171
git.onDidChangeState(() => {
@@ -215,7 +217,7 @@ async function init(
215217

216218
context.subscriptions.push(new PRNotificationDecorationProvider(tree.notificationProvider));
217219

218-
const copilotRemoteAgentManager = new CopilotRemoteAgentManager(credentialStore, reposManager);
220+
const copilotRemoteAgentManager = new CopilotRemoteAgentManager(credentialStore, reposManager, copilotStateModel);
219221

220222
context.subscriptions.push(copilotRemoteAgentManager);
221223
let remoteAgentStatusBarItem: vscode.Disposable | undefined;
@@ -422,7 +424,8 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp
422424
const reposManager = new RepositoriesManager(credentialStore, telemetry);
423425
context.subscriptions.push(reposManager);
424426

425-
const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager);
427+
const copilotStateModel = new CopilotStateModel();
428+
const prTree = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotStateModel);
426429
context.subscriptions.push(prTree);
427430
context.subscriptions.push(credentialStore.onDidGetSession(() => prTree.refresh(undefined, true)));
428431
Logger.appendLine('Looking for git repository', ACTIVATION);
@@ -445,7 +448,7 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp
445448
readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] };
446449
context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage }));
447450

448-
await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper);
451+
await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper, copilotStateModel);
449452
}
450453

451454
export async function deactivate() {

src/github/copilotPrWatcher.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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 * as vscode from 'vscode';
7+
import { COPILOT_LOGINS } from '../common/copilot';
8+
import { Disposable } from '../common/lifecycle';
9+
import { PR_SETTINGS_NAMESPACE, QUERIES } from '../common/settingKeys';
10+
import { EventType, TimelineEvent } from '../common/timelineEvent';
11+
import { FolderRepositoryManager } from './folderRepositoryManager';
12+
import { RepositoriesManager } from './repositoriesManager';
13+
import { variableSubstitution } from './utils';
14+
15+
export enum CopilotPRStatus {
16+
None = 0,
17+
Started = 1,
18+
Completed = 2,
19+
Failed = 3,
20+
}
21+
22+
export function isCopilotQuery(query: string): boolean {
23+
const lowerQuery = query.toLowerCase();
24+
return COPILOT_LOGINS.some(login => lowerQuery.includes(`author:${login.toLowerCase()}`));
25+
}
26+
27+
function copilotEventToStatus(event: TimelineEvent): CopilotPRStatus {
28+
switch (event.event) {
29+
case EventType.CopilotStarted:
30+
return CopilotPRStatus.Started;
31+
case EventType.CopilotFinished:
32+
return CopilotPRStatus.Completed;
33+
case EventType.CopilotFinishedError:
34+
return CopilotPRStatus.Failed;
35+
default:
36+
return CopilotPRStatus.None;
37+
}
38+
}
39+
40+
export class CopilotStateModel extends Disposable {
41+
private _isInitialized = false;
42+
private readonly _states: Map<string, CopilotPRStatus> = new Map();
43+
private readonly _showNotification: Set<string> = new Set();
44+
private readonly _onDidChange = this._register(new vscode.EventEmitter<void>());
45+
readonly onDidChange = this._onDidChange.event;
46+
47+
makeKey(owner: string, repo: string, prNumber: number): string {
48+
return `${owner}/${repo}#${prNumber}`;
49+
}
50+
51+
delete(owner: string, repo: string, prNumber: number): void {
52+
const key = this.makeKey(owner, repo, prNumber);
53+
this.deleteKey(key);
54+
}
55+
deleteKey(key: string): void {
56+
if (this._states.has(key)) {
57+
this._states.delete(key);
58+
if (this._showNotification.has(key)) {
59+
this._showNotification.delete(key);
60+
}
61+
this._onDidChange.fire();
62+
}
63+
}
64+
65+
set(owner: string, repo: string, prNumber: number, status: CopilotPRStatus): void {
66+
const key = this.makeKey(owner, repo, prNumber);
67+
const currentStatus = this._states.get(key);
68+
if (currentStatus === status) {
69+
return;
70+
}
71+
this._states.set(key, status);
72+
if (this._isInitialized) {
73+
this._showNotification.add(key);
74+
}
75+
this._onDidChange.fire();
76+
}
77+
78+
get(owner: string, repo: string, prNumber: number): CopilotPRStatus {
79+
const key = this.makeKey(owner, repo, prNumber);
80+
return this._states.get(key) ?? CopilotPRStatus.None;
81+
}
82+
83+
keys(): string[] {
84+
return Array.from(this._states.keys());
85+
}
86+
87+
clearNotifications(): void {
88+
this._showNotification.clear();
89+
}
90+
91+
get notifications(): ReadonlySet<string> {
92+
return this._showNotification;
93+
}
94+
95+
setInitialized() {
96+
this._isInitialized = true;
97+
}
98+
99+
get isInitialized(): boolean {
100+
return this._isInitialized;
101+
}
102+
}
103+
104+
export class CopilotPRWatcher extends Disposable {
105+
106+
constructor(private readonly _reposManager: RepositoriesManager, private readonly _model: CopilotStateModel) {
107+
super();
108+
const query = this._queriesIncludeCopilot();
109+
if (query) {
110+
this._getStateChanges(query);
111+
}
112+
this._pollForChanges();
113+
114+
this._register(vscode.workspace.onDidChangeConfiguration(e => {
115+
if (e.affectsConfiguration(`${PR_SETTINGS_NAMESPACE}.${QUERIES}`)) {
116+
this._pollForChanges();
117+
}
118+
}));
119+
}
120+
121+
private _queriesIncludeCopilot(): string | undefined {
122+
const queries = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<{ label: string; query: string }[]>(QUERIES, []);
123+
return queries.find(query => isCopilotQuery(query.query))?.query;
124+
}
125+
126+
private async _pollForChanges(): Promise<void> {
127+
const query = this._queriesIncludeCopilot();
128+
if (!query) {
129+
return;
130+
}
131+
132+
await this._getStateChanges(query);
133+
134+
setTimeout(() => {
135+
this._pollForChanges();
136+
}, 60 * 1000); // Poll every minute
137+
}
138+
139+
private _currentUser: string | undefined;
140+
private async _getCurrentUser(folderManager: FolderRepositoryManager): Promise<string> {
141+
if (!this._currentUser) {
142+
this._currentUser = (await folderManager.getCurrentUser()).login;
143+
}
144+
return this._currentUser;
145+
}
146+
147+
private async _getStateChanges(query: string) {
148+
const stateChanges: { owner: string; repo: string; prNumber: number; status: CopilotPRStatus }[] = [];
149+
const unseenKeys: Set<string> = new Set(this._model.keys());
150+
let initialized = 0;
151+
152+
for (const folderManager of this._reposManager.folderManagers) {
153+
// It doesn't matter which repo we use since the query will specify the owner/repo.
154+
const githubRepository = folderManager.gitHubRepositories[0];
155+
if (!githubRepository) {
156+
continue;
157+
}
158+
initialized++;
159+
const prs = await folderManager.getPullRequestsForCategory(githubRepository, await variableSubstitution(query, undefined, await folderManager.getPullRequestDefaults(), await this._getCurrentUser(folderManager)));
160+
for (const pr of prs?.items ?? []) {
161+
unseenKeys.delete(this._model.makeKey(pr.remote.owner, pr.remote.repositoryName, pr.number));
162+
const copilotEvents = await pr.getCopilotTimelineEvents();
163+
if (copilotEvents.length === 0) {
164+
continue;
165+
}
166+
const lastStatus = this._model.get(pr.remote.owner, pr.remote.repositoryName, pr.number) ?? CopilotPRStatus.None;
167+
const latestEvent = copilotEventToStatus(copilotEvents[copilotEvents.length - 1]);
168+
if (latestEvent !== lastStatus) {
169+
stateChanges.push({
170+
owner: pr.remote.owner,
171+
repo: pr.remote.repositoryName,
172+
prNumber: pr.number,
173+
status: latestEvent
174+
});
175+
this._model.set(pr.remote.owner, pr.remote.repositoryName, pr.number, latestEvent);
176+
}
177+
}
178+
179+
for (const key of unseenKeys) {
180+
this._model.deleteKey(key);
181+
}
182+
}
183+
if (!this._model.isInitialized) {
184+
if ((initialized === this._reposManager.folderManagers.length) && (this._reposManager.folderManagers.length > 0)) {
185+
this._model.setInitialized();
186+
}
187+
return [];
188+
} else {
189+
return stateChanges;
190+
}
191+
}
192+
}

src/github/copilotRemoteAgent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Disposable } from '../common/lifecycle';
1010
import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH, CODING_AGENT_ENABLED } from '../common/settingKeys';
1111
import { toOpenPullRequestWebviewUri } from '../common/uri';
1212
import { CopilotApi, RemoteAgentJobPayload } from './copilotApi';
13+
import { CopilotPRWatcher, CopilotStateModel } from './copilotPrWatcher';
1314
import { CredentialStore } from './credentials';
1415
import { PullRequestModel } from './pullRequestModel';
1516
import { RepositoriesManager } from './repositoriesManager';
@@ -26,7 +27,7 @@ export class CopilotRemoteAgentManager extends Disposable {
2627
public readonly onDidChangeEnabled: vscode.Event<boolean> = this._onDidChangeEnabled.event;
2728
public static ID = 'CopilotRemoteAgentManager';
2829

29-
constructor(private credentialStore: CredentialStore, public repositoriesManager: RepositoriesManager) {
30+
constructor(private credentialStore: CredentialStore, public repositoriesManager: RepositoriesManager, stateModel: CopilotStateModel) {
3031
super();
3132
this._register(this.credentialStore.onDidChangeSessions((e: vscode.AuthenticationSessionsChangeEvent) => {
3233
if (e.provider.id === 'github') {
@@ -38,6 +39,8 @@ export class CopilotRemoteAgentManager extends Disposable {
3839
this._onDidChangeEnabled.fire(this.enabled());
3940
}
4041
}));
42+
this._register(new CopilotPRWatcher(this.repositoriesManager, stateModel));
43+
4144
}
4245

4346
private _copilotApiPromise: Promise<CopilotApi | undefined> | undefined;

src/github/folderRepositoryManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1728,7 +1728,7 @@ export class FolderRepositoryManager extends Disposable {
17281728
*/
17291729
this.telemetry.sendTelemetryEvent('pr.merge.success');
17301730
this._onDidMergePullRequest.fire();
1731-
return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await pullRequest.getRestOnlyTimelineEvents(), pullRequest.githubRepository) };
1731+
return { merged: true, message: '', timeline: await parseCombinedTimelineEvents(result.data?.mergePullRequest.pullRequest.timelineItems.nodes ?? [], await pullRequest.getCopilotTimelineEvents(), pullRequest.githubRepository) };
17321732
})
17331733
.catch(e => {
17341734
/* __GDPR__

src/github/issueModel.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ export class IssueModel<TItem extends Issue = Issue> {
329329
/**
330330
* TODO: @alexr00 we should delete this https://github.com/microsoft/vscode-pull-request-github/issues/6965
331331
*/
332-
async getRestOnlyTimelineEvents(): Promise<TimelineEvent[]> {
332+
async getCopilotTimelineEvents(): Promise<TimelineEvent[]> {
333333
if (!COPILOT_ACCOUNTS[this.author.login]) {
334334
return [];
335335
}
@@ -372,7 +372,7 @@ export class IssueModel<TItem extends Issue = Issue> {
372372
return [];
373373
}
374374
const ret = data.repository.pullRequest.timelineItems.nodes;
375-
const events = await parseCombinedTimelineEvents(ret, await this.getRestOnlyTimelineEvents(), githubRepository);
375+
const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(), githubRepository);
376376

377377
return events;
378378
} catch (e) {

src/github/pullRequestModel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,7 @@ export class PullRequestModel extends IssueModel<PullRequest> implements IPullRe
11951195

11961196

11971197
const ret = data?.repository?.pullRequest.timelineItems.nodes ?? [];
1198-
const events = await parseCombinedTimelineEvents(ret, await this.getRestOnlyTimelineEvents(), this.githubRepository);
1198+
const events = await parseCombinedTimelineEvents(ret, await this.getCopilotTimelineEvents(), this.githubRepository);
11991199

12001200
this.addReviewTimelineEventComments(events, reviewThreads);
12011201
insertNewCommitsSinceReview(events, latestReviewCommitInfo?.sha, currentUser, this.head);

src/test/view/prsTree.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { DataUri } from '../../common/uri';
3131
import { IAccount, ITeam } from '../../github/interface';
3232
import { asPromise } from '../../common/utils';
3333
import { CreatePullRequestHelper } from '../../view/createPullRequestHelper';
34+
import { CopilotStateModel } from '../../github/copilotPrWatcher';
3435

3536
describe('GitHub Pull Requests view', function () {
3637
let sinon: SinonSandbox;
@@ -40,7 +41,7 @@ describe('GitHub Pull Requests view', function () {
4041
let credentialStore: CredentialStore;
4142
let reposManager: RepositoriesManager;
4243
let createPrHelper: CreatePullRequestHelper;
43-
44+
let copilotStateModel: CopilotStateModel;
4445

4546
beforeEach(function () {
4647
sinon = createSandbox();
@@ -53,7 +54,8 @@ describe('GitHub Pull Requests view', function () {
5354
credentialStore,
5455
telemetry,
5556
);
56-
provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager);
57+
copilotStateModel = new CopilotStateModel();
58+
provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotStateModel);
5759
credentialStore = new CredentialStore(telemetry, context);
5860
createPrHelper = new CreatePullRequestHelper();
5961

@@ -124,7 +126,7 @@ describe('GitHub Pull Requests view', function () {
124126
assert(treeItems[treeItems.length - 1].collapsibleState === vscode.TreeItemCollapsibleState.Expanded);
125127
assert.deepStrictEqual(
126128
treeItems.map(n => n.label),
127-
['Local Pull Request Branches', 'Waiting For My Review', 'Created By Me', 'All Open'],
129+
['Copilot on My Behalf', 'Local Pull Request Branches', 'Waiting For My Review', 'Created By Me', 'All Open'],
128130
);
129131
});
130132

src/test/view/reviewCommentController.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { CreatePullRequestHelper } from '../../view/createPullRequestHelper';
3939
import { mergeQuerySchemaWithShared } from '../../github/common';
4040
import { GitHubRef } from '../../common/githubRef';
4141
import { AccountType } from '../../github/interface';
42+
import { CopilotStateModel } from '../../github/copilotPrWatcher';
4243
const schema = mergeQuerySchemaWithShared(require('../../github/queries.gql'), require('../../github/queriesShared.gql')) as any;
4344

4445
const protocol = new Protocol('https://github.com/github/test.git');
@@ -62,6 +63,7 @@ describe('ReviewCommentController', function () {
6263
let reviewManager: ReviewManager;
6364
let reposManager: RepositoriesManager;
6465
let gitApiImpl: GitApiImpl;
66+
let copilotStateModel: CopilotStateModel;
6567

6668
beforeEach(async function () {
6769
sinon = createSandbox();
@@ -74,7 +76,8 @@ describe('ReviewCommentController', function () {
7476
repository = new MockRepository();
7577
repository.addRemote('origin', 'git@github.com:aaa/bbb');
7678
reposManager = new RepositoriesManager(credentialStore, telemetry);
77-
provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager);
79+
copilotStateModel = new CopilotStateModel();
80+
provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager, copilotStateModel);
7881
const activePrViewCoordinator = new WebviewViewCoordinator(context);
7982
const createPrHelper = new CreatePullRequestHelper();
8083
Resource.initialize(context);

0 commit comments

Comments
 (0)