diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index bd4e624430..772fc387b9 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -95,6 +95,11 @@ declare module 'vscode' { */ description?: string | MarkdownString; + /** + * An optional badge that provides additional context about the chat session. + */ + badge?: string | MarkdownString; + /** * An optional status indicating the current state of the session. */ diff --git a/src/issues/stateManager.ts b/src/issues/stateManager.ts index aead116ced..14316c0818 100644 --- a/src/issues/stateManager.ts +++ b/src/issues/stateManager.ts @@ -1,512 +1,512 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import LRUCache from 'lru-cache'; -import * as vscode from 'vscode'; -import { CurrentIssue } from './currentIssue'; -import { Repository } from '../api/api'; -import { GitApiImpl } from '../api/api1'; -import { AuthProvider } from '../common/authentication'; -import { parseRepositoryRemotes } from '../common/remote'; -import { - DEFAULT, - DEV_MODE, - IGNORE_MILESTONES, - ISSUES_SETTINGS_NAMESPACE, - PR_SETTINGS_NAMESPACE, - QUERIES, - USE_BRANCH_FOR_ISSUES, -} from '../common/settingKeys'; -import { - FolderRepositoryManager, - PullRequestDefaults, - ReposManagerState, -} from '../github/folderRepositoryManager'; -import { IAccount } from '../github/interface'; -import { IssueModel } from '../github/issueModel'; -import { RepositoriesManager } from '../github/repositoriesManager'; -import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; - -const CURRENT_ISSUE_KEY = 'currentIssue'; - -const ISSUES_KEY = 'issues'; - -export interface IssueState { - branch?: string; - hasDraftPR?: boolean; -} - -interface TimeStampedIssueState extends IssueState { - stateModifiedTime: number; -} - -interface IssuesState { - issues: Record; - branches: Record; -} - -// eslint-disable-next-line no-template-curly-in-string -const DEFAULT_QUERY_CONFIGURATION_VALUE: { label: string, query: string, groupBy: QueryGroup[] }[] = [{ label: vscode.l10n.t('My Issues'), query: 'is:open assignee:@me repo:${owner}/${repository}', groupBy: ['milestone'] }]; - -export class IssueItem extends IssueModel { - uri: vscode.Uri; -} - -interface SingleRepoState { - lastHead?: string; - lastBranch?: string; - currentIssue?: CurrentIssue; - issueCollection: Map>; - maxIssueNumber: number; - userMap?: Promise>; - folderManager: FolderRepositoryManager; -} - -export type QueryGroup = 'repository' | 'milestone'; - -export interface IssueQueryResult { - groupBy: QueryGroup[]; - issues: IssueItem[] | undefined; -} - -export class StateManager { - public readonly resolvedIssues: Map> = new Map(); - private _singleRepoStates: Map = new Map(); - private _onRefreshCacheNeeded: vscode.EventEmitter = new vscode.EventEmitter(); - public onRefreshCacheNeeded: vscode.Event = this._onRefreshCacheNeeded.event; - private _onDidChangeIssueData: vscode.EventEmitter = new vscode.EventEmitter(); - public onDidChangeIssueData: vscode.Event = this._onDidChangeIssueData.event; - private _queries: { label: string; query: string, groupBy?: QueryGroup[] }[] = []; - - private _onDidChangeCurrentIssue: vscode.EventEmitter = new vscode.EventEmitter(); - public readonly onDidChangeCurrentIssue: vscode.Event = this._onDidChangeCurrentIssue.event; - private initializePromise: Promise | undefined; - private statusBarItem?: vscode.StatusBarItem; - - getIssueCollection(uri: vscode.Uri): Map> { - let collection = this._singleRepoStates.get(uri.path)?.issueCollection; - if (collection) { - return collection; - } else { - collection = new Map(); - return collection; - } - } - - constructor( - readonly gitAPI: GitApiImpl, - private manager: RepositoriesManager, - private context: vscode.ExtensionContext, - ) { } - - private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState | undefined { - let state = this._singleRepoStates.get(uri.path); - if (state) { - return state; - } - if (!folderManager) { - folderManager = this.manager.getManagerForFile(uri); - } - if (!folderManager) { - return undefined; - } - state = { - issueCollection: new Map(), - maxIssueNumber: 0, - folderManager, - }; - this._singleRepoStates.set(uri.path, state); - return state; - } - - async tryInitializeAndWait() { - if (!this.initializePromise) { - this.initializePromise = new Promise(resolve => { - if (!this.manager.credentialStore.isAnyAuthenticated()) { - // We don't wait for sign in to finish initializing. - const disposable = this.manager.credentialStore.onDidGetSession(() => { - disposable.dispose(); - this.doInitialize(); - }); - resolve(); - } else if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize().then(() => resolve()); - } else { - const disposable = this.manager.onDidChangeState(() => { - if (this.manager.state === ReposManagerState.RepositoriesLoaded) { - this.doInitialize().then(() => { - disposable.dispose(); - resolve(); - }); - } - }); - this.context.subscriptions.push(disposable); - } - }); - } - return this.initializePromise; - } - - private registerRepositoryChangeEvent() { - async function updateRepository(that: StateManager, repository: Repository) { - const state = that.getOrCreateSingleRepoState(repository.rootUri); - if (!state) { - return; - } - // setIssueData can cause the last head and branch state to change. Capture them before that can happen. - const oldHead = state.lastHead; - const oldBranch = state.lastBranch; - const newHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; - if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== oldHead) { - await that.setIssueData(state.folderManager); - } - - const newBranch = repository.state.HEAD?.name; - if ( - (oldHead !== newHead || oldBranch !== newBranch) && - (!state.currentIssue || newBranch !== state.currentIssue.branchName) - ) { - if (newBranch) { - if (state.folderManager) { - await that.setCurrentIssueFromBranch(state, newBranch, true); - } - } else { - await that.setCurrentIssue(state, undefined, !!newBranch); - } - } - state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; - state.lastBranch = repository.state.HEAD ? repository.state.HEAD.name : undefined; - } - - function addChangeEvent(that: StateManager, repository: Repository) { - that.context.subscriptions.push( - repository.state.onDidChange(async () => { - updateRepository(that, repository); - }), - ); - } - - this.context.subscriptions.push(this.gitAPI.onDidOpenRepository(repository => { - updateRepository(this, repository); - addChangeEvent(this, repository); - })); - this.gitAPI.repositories.forEach(repository => { - addChangeEvent(this, repository); - }); - } - - refreshCacheNeeded() { - this._onRefreshCacheNeeded.fire(); - } - - async refresh(folderManager?: FolderRepositoryManager) { - if (folderManager) { - return this.setIssueData(folderManager); - } else { - return this.setAllIssueData(); - } - } - - private async doInitialize() { - this.cleanIssueState(); - this._queries = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) - .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); - if (this._queries.length === 0) { - this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; - } - this.context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(change => { - if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { - this._queries = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) - .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); - this._onRefreshCacheNeeded.fire(); - } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { - this._onRefreshCacheNeeded.fire(); - } - }), - ); - this.registerRepositoryChangeEvent(); - // Skip fetching issues if dev mode is enabled - const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEV_MODE, false); - if (!devMode) { - await this.setAllIssueData(); - } - this.context.subscriptions.push( - this.onRefreshCacheNeeded(async () => { - await this.refresh(); - }), - ); - - for (const folderManager of this.manager.folderManagers) { - this.context.subscriptions.push(folderManager.onDidChangeRepositories(async (e) => { - if (e.added) { - const state = this.getOrCreateSingleRepoState(folderManager.repository.rootUri); - - if (state && ((state.issueCollection.size === 0) || (await Promise.all(state.issueCollection.values())).some(collection => collection.issues === undefined))) { - this.refresh(folderManager); - } - } - })); - - const singleRepoState: SingleRepoState | undefined = this.getOrCreateSingleRepoState( - folderManager.repository.rootUri, - folderManager, - ); - if (!singleRepoState) { - continue; - } - singleRepoState.lastHead = folderManager.repository.state.HEAD - ? folderManager.repository.state.HEAD.commit - : undefined; - this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); - const branch = folderManager.repository.state.HEAD?.name; - if (!singleRepoState.currentIssue && branch) { - await this.setCurrentIssueFromBranch(singleRepoState, branch, true); - } - } - } - - private cleanIssueState() { - const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); - const state: IssuesState = stateString ? JSON.parse(stateString) : { issues: [], branches: [] }; - const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; - for (const issueState in state.issues) { - if (state.issues[issueState].stateModifiedTime < deleteDate) { - if (state.branches && state.branches[issueState]) { - delete state.branches[issueState]; - } - delete state.issues[issueState]; - } - } - } - - private async getUsers(uri: vscode.Uri): Promise> { - await this.initializePromise; - const assignableUsers = await this.manager.getManagerForFile(uri)?.getAssignableUsers(); - const userMap: Map = new Map(); - for (const remote in assignableUsers) { - assignableUsers[remote].forEach(account => { - userMap.set(account.login, account); - }); - } - return userMap; - } - - async getUserMap(uri: vscode.Uri): Promise> { - const state = this.getOrCreateSingleRepoState(uri); - if (!this.initializePromise || !state) { - return Promise.resolve(new Map()); - } - if (!state.userMap || (await state.userMap).size === 0) { - state.userMap = this.getUsers(uri); - } - return state.userMap; - } - - private async getCurrentUser(authProviderId: AuthProvider): Promise { - return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; - } - - private async setAllIssueData() { - return Promise.all(this.manager.folderManagers.map(folderManager => this.setIssueData(folderManager))); - } - - private async setIssueData(folderManager: FolderRepositoryManager) { - const singleRepoState = this.getOrCreateSingleRepoState(folderManager.repository.rootUri, folderManager); - if (!singleRepoState) { - return; - } - singleRepoState.issueCollection.clear(); - const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( - remote => remote.isEnterprise - ); - const user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); - - for (let query of this._queries) { - let items: Promise | undefined; - if (query.query === DEFAULT) { - query = DEFAULT_QUERY_CONFIGURATION_VALUE[0]; - } - - items = this.setIssues( - folderManager, - // Do not resolve pull request defaults as they will get resolved in the query later per repository - variableSubstitution(query.query, undefined, undefined, user), - ).then(issues => ({ groupBy: query.groupBy ?? [], issues })); - - if (items) { - singleRepoState.issueCollection.set(query.label, items); - } - } - singleRepoState.maxIssueNumber = await folderManager.getMaxIssue(); - singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; - singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; - } - - private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { - return new Promise(async resolve => { - const issues = await folderManager.getIssues(query, { fetchNextPage: false, fetchOnePagePerRepo: true }); - this._onDidChangeIssueData.fire(); - resolve( - issues?.items.map(item => { - const issueItem: IssueItem = item as IssueItem; - issueItem.uri = folderManager.repository.rootUri; - return issueItem; - }), - ); - }); - } - - private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { - const createBranchConfig = vscode.workspace - .getConfiguration(ISSUES_SETTINGS_NAMESPACE) - .get(USE_BRANCH_FOR_ISSUES); - if (createBranchConfig === 'off') { - return; - } - - let defaults: PullRequestDefaults | undefined; - try { - defaults = await singleRepoState.folderManager.getPullRequestDefaults(); - } catch (e) { - // No remote, don't try to set the current issue - return; - } - if (branchName === defaults.base) { - await this.setCurrentIssue(singleRepoState, undefined, false); - return; - } - - if (singleRepoState.currentIssue && singleRepoState.currentIssue.branchName === branchName) { - return; - } - - const state: IssuesState = this.getSavedState(); - for (const branch in state.branches) { - if (branch === branchName) { - const issueModel = await singleRepoState.folderManager.resolveIssue( - state.branches[branch].owner, - state.branches[branch].repositoryName, - state.branches[branch].number, - ); - if (issueModel) { - await this.setCurrentIssue( - singleRepoState, - new CurrentIssue(issueModel, singleRepoState.folderManager, this), - false, - silent - ); - } - return; - } - } - } - - currentIssue(uri: vscode.Uri): CurrentIssue | undefined { - return this._singleRepoStates.get(uri.path)?.currentIssue; - } - - currentIssues(): CurrentIssue[] { - return Array.from(this._singleRepoStates.values()) - .filter(state => state?.currentIssue) - .map(state => state!.currentIssue!); - } - - maxIssueNumber(uri: vscode.Uri): number { - return this._singleRepoStates.get(uri.path)?.maxIssueNumber ?? 0; - } - - private isSettingIssue: boolean = false; - async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { - if (this.isSettingIssue && issue === undefined) { - return; - } - this.isSettingIssue = true; - if (repoState instanceof FolderRepositoryManager) { - const state = this._singleRepoStates.get(repoState.repository.rootUri.path); - if (!state) { - return; - } - repoState = state; - } - try { - if (repoState.currentIssue && issue?.issue.number === repoState.currentIssue.issue.number) { - return; - } - // Check if branch management is disabled - const createBranchConfig = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); - const shouldCheckoutDefaultBranch = createBranchConfig === 'off' ? false : checkoutDefaultBranch; - - if (repoState.currentIssue) { - await repoState.currentIssue.stopWorking(shouldCheckoutDefaultBranch); - } - if (issue) { - this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); - } - this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); - if (!issue || (await issue.startWorking(silent))) { - repoState.currentIssue = issue; - this.updateStatusBar(); - } - this._onDidChangeCurrentIssue.fire(); - } catch (e) { - // Error has already been surfaced - } finally { - this.isSettingIssue = false; - } - } - - private updateStatusBar() { - const currentIssues = this.currentIssues(); - const shouldShowStatusBarItem = currentIssues.length > 0; - if (!shouldShowStatusBarItem) { - if (this.statusBarItem) { - this.statusBarItem.hide(); - this.statusBarItem.dispose(); - this.statusBarItem = undefined; - } - return; - } - if (shouldShowStatusBarItem && !this.statusBarItem) { - this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); - this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); - } - const statusBarItem = this.statusBarItem!; - statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues - .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) - .join(', ')); - statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); - statusBarItem.command = 'issue.statusBar'; - statusBarItem.show(); - } - - private getSavedState(): IssuesState { - const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); - return stateString ? JSON.parse(stateString) : { issues: Object.create(null), branches: Object.create(null) }; - } - - getSavedIssueState(issueNumber: number): IssueState { - const state: IssuesState = this.getSavedState(); - return state.issues[`${issueNumber}`] ?? {}; - } - - async setSavedIssueState(issue: IssueModel, issueState: IssueState) { - const state: IssuesState = this.getSavedState(); - state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: new Date().valueOf() }; - if (issueState.branch) { - if (!state.branches) { - state.branches = Object.create(null); - } - state.branches[issueState.branch] = { - number: issue.number, - owner: issue.remote.owner, - repositoryName: issue.remote.repositoryName, - }; - } - return this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import LRUCache from 'lru-cache'; +import * as vscode from 'vscode'; +import { CurrentIssue } from './currentIssue'; +import { Repository } from '../api/api'; +import { GitApiImpl } from '../api/api1'; +import { AuthProvider } from '../common/authentication'; +import { parseRepositoryRemotes } from '../common/remote'; +import { + DEFAULT, + DEV_MODE, + IGNORE_MILESTONES, + ISSUES_SETTINGS_NAMESPACE, + PR_SETTINGS_NAMESPACE, + QUERIES, + USE_BRANCH_FOR_ISSUES, +} from '../common/settingKeys'; +import { + FolderRepositoryManager, + PullRequestDefaults, + ReposManagerState, +} from '../github/folderRepositoryManager'; +import { IAccount } from '../github/interface'; +import { IssueModel } from '../github/issueModel'; +import { RepositoriesManager } from '../github/repositoriesManager'; +import { getIssueNumberLabel, variableSubstitution } from '../github/utils'; + +const CURRENT_ISSUE_KEY = 'currentIssue'; + +const ISSUES_KEY = 'issues'; + +export interface IssueState { + branch?: string; + hasDraftPR?: boolean; +} + +interface TimeStampedIssueState extends IssueState { + stateModifiedTime: number; +} + +interface IssuesState { + issues: Record; + branches: Record; +} + +// eslint-disable-next-line no-template-curly-in-string +const DEFAULT_QUERY_CONFIGURATION_VALUE: { label: string, query: string, groupBy: QueryGroup[] }[] = [{ label: vscode.l10n.t('My Issues'), query: 'is:open assignee:@me repo:${owner}/${repository}', groupBy: ['milestone'] }]; + +export class IssueItem extends IssueModel { + uri: vscode.Uri; +} + +interface SingleRepoState { + lastHead?: string; + lastBranch?: string; + currentIssue?: CurrentIssue; + issueCollection: Map>; + maxIssueNumber: number; + userMap?: Promise>; + folderManager: FolderRepositoryManager; +} + +export type QueryGroup = 'repository' | 'milestone'; + +export interface IssueQueryResult { + groupBy: QueryGroup[]; + issues: IssueItem[] | undefined; +} + +export class StateManager { + public readonly resolvedIssues: Map> = new Map(); + private _singleRepoStates: Map = new Map(); + private _onRefreshCacheNeeded: vscode.EventEmitter = new vscode.EventEmitter(); + public onRefreshCacheNeeded: vscode.Event = this._onRefreshCacheNeeded.event; + private _onDidChangeIssueData: vscode.EventEmitter = new vscode.EventEmitter(); + public onDidChangeIssueData: vscode.Event = this._onDidChangeIssueData.event; + private _queries: { label: string; query: string, groupBy?: QueryGroup[] }[] = []; + + private _onDidChangeCurrentIssue: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDidChangeCurrentIssue: vscode.Event = this._onDidChangeCurrentIssue.event; + private initializePromise: Promise | undefined; + private statusBarItem?: vscode.StatusBarItem; + + getIssueCollection(uri: vscode.Uri): Map> { + let collection = this._singleRepoStates.get(uri.path)?.issueCollection; + if (collection) { + return collection; + } else { + collection = new Map(); + return collection; + } + } + + constructor( + readonly gitAPI: GitApiImpl, + private manager: RepositoriesManager, + private context: vscode.ExtensionContext, + ) { } + + private getOrCreateSingleRepoState(uri: vscode.Uri, folderManager?: FolderRepositoryManager): SingleRepoState | undefined { + let state = this._singleRepoStates.get(uri.path); + if (state) { + return state; + } + if (!folderManager) { + folderManager = this.manager.getManagerForFile(uri); + } + if (!folderManager) { + return undefined; + } + state = { + issueCollection: new Map(), + maxIssueNumber: 0, + folderManager, + }; + this._singleRepoStates.set(uri.path, state); + return state; + } + + async tryInitializeAndWait() { + if (!this.initializePromise) { + this.initializePromise = new Promise(resolve => { + if (!this.manager.credentialStore.isAnyAuthenticated()) { + // We don't wait for sign in to finish initializing. + const disposable = this.manager.credentialStore.onDidGetSession(() => { + disposable.dispose(); + this.doInitialize(); + }); + resolve(); + } else if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + this.doInitialize().then(() => resolve()); + } else { + const disposable = this.manager.onDidChangeState(() => { + if (this.manager.state === ReposManagerState.RepositoriesLoaded) { + this.doInitialize().then(() => { + disposable.dispose(); + resolve(); + }); + } + }); + this.context.subscriptions.push(disposable); + } + }); + } + return this.initializePromise; + } + + private registerRepositoryChangeEvent() { + async function updateRepository(that: StateManager, repository: Repository) { + const state = that.getOrCreateSingleRepoState(repository.rootUri); + if (!state) { + return; + } + // setIssueData can cause the last head and branch state to change. Capture them before that can happen. + const oldHead = state.lastHead; + const oldBranch = state.lastBranch; + const newHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + if ((repository.state.HEAD ? repository.state.HEAD.commit : undefined) !== oldHead) { + await that.setIssueData(state.folderManager); + } + + const newBranch = repository.state.HEAD?.name; + if ( + (oldHead !== newHead || oldBranch !== newBranch) && + (!state.currentIssue || newBranch !== state.currentIssue.branchName) + ) { + if (newBranch) { + if (state.folderManager) { + await that.setCurrentIssueFromBranch(state, newBranch, true); + } + } else { + await that.setCurrentIssue(state, undefined, !!newBranch); + } + } + state.lastHead = repository.state.HEAD ? repository.state.HEAD.commit : undefined; + state.lastBranch = repository.state.HEAD ? repository.state.HEAD.name : undefined; + } + + function addChangeEvent(that: StateManager, repository: Repository) { + that.context.subscriptions.push( + repository.state.onDidChange(async () => { + updateRepository(that, repository); + }), + ); + } + + this.context.subscriptions.push(this.gitAPI.onDidOpenRepository(repository => { + updateRepository(this, repository); + addChangeEvent(this, repository); + })); + this.gitAPI.repositories.forEach(repository => { + addChangeEvent(this, repository); + }); + } + + refreshCacheNeeded() { + this._onRefreshCacheNeeded.fire(); + } + + async refresh(folderManager?: FolderRepositoryManager) { + if (folderManager) { + return this.setIssueData(folderManager); + } else { + return this.setAllIssueData(); + } + } + + private async doInitialize() { + this.cleanIssueState(); + this._queries = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); + if (this._queries.length === 0) { + this._queries = DEFAULT_QUERY_CONFIGURATION_VALUE; + } + this.context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(change => { + if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${QUERIES}`)) { + this._queries = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE, null) + .get(QUERIES, DEFAULT_QUERY_CONFIGURATION_VALUE); + this._onRefreshCacheNeeded.fire(); + } else if (change.affectsConfiguration(`${ISSUES_SETTINGS_NAMESPACE}.${IGNORE_MILESTONES}`)) { + this._onRefreshCacheNeeded.fire(); + } + }), + ); + this.registerRepositoryChangeEvent(); + // Skip fetching issues if dev mode is enabled + const devMode = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(DEV_MODE, false); + if (!devMode) { + await this.setAllIssueData(); + } + this.context.subscriptions.push( + this.onRefreshCacheNeeded(async () => { + await this.refresh(); + }), + ); + + for (const folderManager of this.manager.folderManagers) { + this.context.subscriptions.push(folderManager.onDidChangeRepositories(async (e) => { + if (e.added) { + const state = this.getOrCreateSingleRepoState(folderManager.repository.rootUri); + + if (state && ((state.issueCollection.size === 0) || (await Promise.all(state.issueCollection.values())).some(collection => collection.issues === undefined))) { + this.refresh(folderManager); + } + } + })); + + const singleRepoState: SingleRepoState | undefined = this.getOrCreateSingleRepoState( + folderManager.repository.rootUri, + folderManager, + ); + if (!singleRepoState) { + continue; + } + singleRepoState.lastHead = folderManager.repository.state.HEAD + ? folderManager.repository.state.HEAD.commit + : undefined; + this._singleRepoStates.set(folderManager.repository.rootUri.path, singleRepoState); + const branch = folderManager.repository.state.HEAD?.name; + if (!singleRepoState.currentIssue && branch) { + await this.setCurrentIssueFromBranch(singleRepoState, branch, true); + } + } + } + + private cleanIssueState() { + const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); + const state: IssuesState = stateString ? JSON.parse(stateString) : { issues: [], branches: [] }; + const deleteDate: number = new Date().valueOf() - 30 /*days*/ * 86400000 /*milliseconds in a day*/; + for (const issueState in state.issues) { + if (state.issues[issueState].stateModifiedTime < deleteDate) { + if (state.branches && state.branches[issueState]) { + delete state.branches[issueState]; + } + delete state.issues[issueState]; + } + } + } + + private async getUsers(uri: vscode.Uri): Promise> { + await this.initializePromise; + const assignableUsers = await this.manager.getManagerForFile(uri)?.getAssignableUsers(); + const userMap: Map = new Map(); + for (const remote in assignableUsers) { + assignableUsers[remote].forEach(account => { + userMap.set(account.login, account); + }); + } + return userMap; + } + + async getUserMap(uri: vscode.Uri): Promise> { + const state = this.getOrCreateSingleRepoState(uri); + if (!this.initializePromise || !state) { + return Promise.resolve(new Map()); + } + if (!state.userMap || (await state.userMap).size === 0) { + state.userMap = this.getUsers(uri); + } + return state.userMap; + } + + private async getCurrentUser(authProviderId: AuthProvider): Promise { + return (await this.manager.credentialStore.getCurrentUser(authProviderId))?.login; + } + + private async setAllIssueData() { + return Promise.all(this.manager.folderManagers.map(folderManager => this.setIssueData(folderManager))); + } + + private async setIssueData(folderManager: FolderRepositoryManager) { + const singleRepoState = this.getOrCreateSingleRepoState(folderManager.repository.rootUri, folderManager); + if (!singleRepoState) { + return; + } + singleRepoState.issueCollection.clear(); + const enterpriseRemotes = parseRepositoryRemotes(folderManager.repository).filter( + remote => remote.isEnterprise + ); + const user = await this.getCurrentUser(enterpriseRemotes.length ? AuthProvider.githubEnterprise : AuthProvider.github); + + for (let query of this._queries) { + let items: Promise | undefined; + if (query.query === DEFAULT) { + query = DEFAULT_QUERY_CONFIGURATION_VALUE[0]; + } + + items = this.setIssues( + folderManager, + // Do not resolve pull request defaults as they will get resolved in the query later per repository + variableSubstitution(query.query, undefined, undefined, user).trim(), + ).then(issues => ({ groupBy: query.groupBy ?? [], issues })); + + if (items) { + singleRepoState.issueCollection.set(query.label, items); + } + } + singleRepoState.maxIssueNumber = await folderManager.getMaxIssue(); + singleRepoState.lastHead = folderManager.repository.state.HEAD?.commit; + singleRepoState.lastBranch = folderManager.repository.state.HEAD?.name; + } + + private setIssues(folderManager: FolderRepositoryManager, query: string): Promise { + return new Promise(async resolve => { + const issues = await folderManager.getIssues(query, { fetchNextPage: false, fetchOnePagePerRepo: true }); + this._onDidChangeIssueData.fire(); + resolve( + issues?.items.map(item => { + const issueItem: IssueItem = item as IssueItem; + issueItem.uri = folderManager.repository.rootUri; + return issueItem; + }), + ); + }); + } + + private async setCurrentIssueFromBranch(singleRepoState: SingleRepoState, branchName: string, silent: boolean = false) { + const createBranchConfig = vscode.workspace + .getConfiguration(ISSUES_SETTINGS_NAMESPACE) + .get(USE_BRANCH_FOR_ISSUES); + if (createBranchConfig === 'off') { + return; + } + + let defaults: PullRequestDefaults | undefined; + try { + defaults = await singleRepoState.folderManager.getPullRequestDefaults(); + } catch (e) { + // No remote, don't try to set the current issue + return; + } + if (branchName === defaults.base) { + await this.setCurrentIssue(singleRepoState, undefined, false); + return; + } + + if (singleRepoState.currentIssue && singleRepoState.currentIssue.branchName === branchName) { + return; + } + + const state: IssuesState = this.getSavedState(); + for (const branch in state.branches) { + if (branch === branchName) { + const issueModel = await singleRepoState.folderManager.resolveIssue( + state.branches[branch].owner, + state.branches[branch].repositoryName, + state.branches[branch].number, + ); + if (issueModel) { + await this.setCurrentIssue( + singleRepoState, + new CurrentIssue(issueModel, singleRepoState.folderManager, this), + false, + silent + ); + } + return; + } + } + } + + currentIssue(uri: vscode.Uri): CurrentIssue | undefined { + return this._singleRepoStates.get(uri.path)?.currentIssue; + } + + currentIssues(): CurrentIssue[] { + return Array.from(this._singleRepoStates.values()) + .filter(state => state?.currentIssue) + .map(state => state!.currentIssue!); + } + + maxIssueNumber(uri: vscode.Uri): number { + return this._singleRepoStates.get(uri.path)?.maxIssueNumber ?? 0; + } + + private isSettingIssue: boolean = false; + async setCurrentIssue(repoState: SingleRepoState | FolderRepositoryManager, issue: CurrentIssue | undefined, checkoutDefaultBranch: boolean, silent: boolean = false) { + if (this.isSettingIssue && issue === undefined) { + return; + } + this.isSettingIssue = true; + if (repoState instanceof FolderRepositoryManager) { + const state = this._singleRepoStates.get(repoState.repository.rootUri.path); + if (!state) { + return; + } + repoState = state; + } + try { + if (repoState.currentIssue && issue?.issue.number === repoState.currentIssue.issue.number) { + return; + } + // Check if branch management is disabled + const createBranchConfig = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(USE_BRANCH_FOR_ISSUES); + const shouldCheckoutDefaultBranch = createBranchConfig === 'off' ? false : checkoutDefaultBranch; + + if (repoState.currentIssue) { + await repoState.currentIssue.stopWorking(shouldCheckoutDefaultBranch); + } + if (issue) { + this.context.subscriptions.push(issue.onDidChangeCurrentIssueState(() => this.updateStatusBar())); + } + this.context.workspaceState.update(CURRENT_ISSUE_KEY, issue?.issue.number); + if (!issue || (await issue.startWorking(silent))) { + repoState.currentIssue = issue; + this.updateStatusBar(); + } + this._onDidChangeCurrentIssue.fire(); + } catch (e) { + // Error has already been surfaced + } finally { + this.isSettingIssue = false; + } + } + + private updateStatusBar() { + const currentIssues = this.currentIssues(); + const shouldShowStatusBarItem = currentIssues.length > 0; + if (!shouldShowStatusBarItem) { + if (this.statusBarItem) { + this.statusBarItem.hide(); + this.statusBarItem.dispose(); + this.statusBarItem = undefined; + } + return; + } + if (shouldShowStatusBarItem && !this.statusBarItem) { + this.statusBarItem = vscode.window.createStatusBarItem('github.issues.status', vscode.StatusBarAlignment.Left, 0); + this.statusBarItem.name = vscode.l10n.t('GitHub Active Issue'); + } + const statusBarItem = this.statusBarItem!; + statusBarItem.text = vscode.l10n.t('{0} Issue {1}', '$(issues)', currentIssues + .map(issue => getIssueNumberLabel(issue.issue, issue.repoDefaults)) + .join(', ')); + statusBarItem.tooltip = currentIssues.map(issue => issue.issue.title).join(', '); + statusBarItem.command = 'issue.statusBar'; + statusBarItem.show(); + } + + private getSavedState(): IssuesState { + const stateString: string | undefined = this.context.workspaceState.get(ISSUES_KEY); + return stateString ? JSON.parse(stateString) : { issues: Object.create(null), branches: Object.create(null) }; + } + + getSavedIssueState(issueNumber: number): IssueState { + const state: IssuesState = this.getSavedState(); + return state.issues[`${issueNumber}`] ?? {}; + } + + async setSavedIssueState(issue: IssueModel, issueState: IssueState) { + const state: IssuesState = this.getSavedState(); + state.issues[`${issue.number}`] = { ...issueState, stateModifiedTime: new Date().valueOf() }; + if (issueState.branch) { + if (!state.branches) { + state.branches = Object.create(null); + } + state.branches[issueState.branch] = { + number: issue.number, + owner: issue.remote.owner, + repositoryName: issue.remote.repositoryName, + }; + } + return this.context.workspaceState.update(ISSUES_KEY, JSON.stringify(state)); + } +} diff --git a/src/test/issues/stateManager.test.ts b/src/test/issues/stateManager.test.ts index 481f2687e8..4f2549ac0f 100644 --- a/src/test/issues/stateManager.test.ts +++ b/src/test/issues/stateManager.test.ts @@ -124,4 +124,49 @@ describe('StateManager branch behavior with useBranchForIssues setting', functio vscode.workspace.getConfiguration = originalGetConfiguration; } }); + + it('should trim whitespace from query strings', async function () { + const mockUri = vscode.Uri.parse('file:///test'); + const mockFolderManager = { + repository: { rootUri: mockUri, state: { HEAD: { commit: 'abc123' }, remotes: [] } }, + getIssues: async (query: string) => { + // Verify that the query doesn't have trailing whitespace + assert.strictEqual(query, query.trim(), 'Query should be trimmed'); + assert.strictEqual(query.endsWith(' '), false, 'Query should not end with whitespace'); + return { items: [], hasMorePages: false, hasUnsearchedRepositories: false, totalCount: 0 }; + }, + getMaxIssue: async () => 0, + }; + + // Mock workspace configuration with query that has trailing space + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === ISSUES_SETTINGS_NAMESPACE) { + return { + get: (key: string, defaultValue?: any) => { + if (key === 'queries') { + return [{ label: 'Test', query: 'is:open assignee:@me repo:owner/repo ', groupBy: [] }]; + } + return defaultValue; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + // Initialize the state manager with a query that has trailing space + const stateManager = new StateManager(undefined as any, { + folderManagers: [mockFolderManager], + credentialStore: { isAnyAuthenticated: () => true, getCurrentUser: async () => ({ login: 'testuser' }) }, + } as any, mockContext); + + // Manually trigger the setIssueData flow + await (stateManager as any).setIssueData(mockFolderManager); + + // If we get here without assertion failures in getIssues, the test passed + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); }); \ No newline at end of file