diff --git a/CHANGELOG.md b/CHANGELOG.md index 031f15f13edef..81e9d78d64c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds integration with Bitbucket Cloud ([#3916](https://github.com/gitkraken/vscode-gitlens/issues/3916)) - shows enriched links to PRs and issues [#4045](https://github.com/gitkraken/vscode-gitlens/issues/4045) - shows Bitbucket PRs in Launchpad [#4046](https://github.com/gitkraken/vscode-gitlens/issues/4046) + - supports Bitbucket issues in Start Work and lets associate issues with branches [#4047](https://github.com/gitkraken/vscode-gitlens/issues/4047) - Adds ability to control how worktrees are displayed in the views - Adds a `gitlens.views.worktrees.worktrees.viewAs` setting to specify whether to show worktrees by name, path, or relative path - Adds a `gitlens.views.worktrees.branches.layout` setting to specify whether to show branch worktrees as a list or tree, similar to branches diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 5490dbb583479..724c79127a766 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -97,11 +97,18 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderIssue( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _id: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + id: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getIssue( + this, + accessToken, + repo.owner, + repo.name, + id, + this.apiBaseUrl, + ); } protected override async getProviderPullRequestForBranch( @@ -240,10 +247,32 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async searchProviderMyIssues( - _session: AuthenticationSession, - _repos?: BitbucketRepositoryDescriptor[], + session: AuthenticationSession, + repos?: BitbucketRepositoryDescriptor[], ): Promise { - return Promise.resolve(undefined); + if (repos == null || repos.length === 0) return undefined; + + const user = await this.getProviderCurrentAccount(session); + if (user?.username == null) return undefined; + + const workspaces = await this.getProviderResourcesForUser(session); + if (workspaces == null || workspaces.length === 0) return undefined; + + const api = await this.container.bitbucket; + if (!api) return undefined; + const issueResult = await flatSettled( + repos.map(repo => { + return api.getUsersIssuesForRepo( + this, + session.accessToken, + user.id, + repo.owner, + repo.name, + this.apiBaseUrl, + ); + }), + ); + return issueResult; } protected override async providerOnConnect(): Promise { diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index 776bc7d6507a3..ee48a1d26975c 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -13,6 +13,7 @@ import { RequestClientError, RequestNotFoundError, } from '../../../../errors'; +import type { Issue } from '../../../../git/models/issue'; import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; @@ -25,7 +26,7 @@ import type { LogScope } from '../../../../system/logger.scope'; import { getLogScope } from '../../../../system/logger.scope'; import { maybeStopWatch } from '../../../../system/stopwatch'; import type { BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models'; -import { bitbucketIssueStateToState, fromBitbucketPullRequest } from './models'; +import { bitbucketIssueStateToState, fromBitbucketIssue, fromBitbucketPullRequest } from './models'; export class BitbucketApi implements Disposable { private readonly _disposable: Disposable; @@ -92,6 +93,73 @@ export class BitbucketApi implements Disposable { return fromBitbucketPullRequest(response.values[0], provider); } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getUsersIssuesForRepo( + provider: Provider, + token: string, + userUuid: string, + owner: string, + repo: string, + baseUrl: string, + ): Promise { + const scope = getLogScope(); + const query = encodeURIComponent(`assignee.uuid="${userUuid}" OR reporter.uuid="${userUuid}"`); + + const response = await this.request<{ + values: BitbucketIssue[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/issues?q=${query}`, + { + method: 'GET', + }, + scope, + ); + + if (!response?.values?.length) { + return undefined; + } + return response.values.map(issue => fromBitbucketIssue(issue, provider)); + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getIssue( + provider: Provider, + token: string, + owner: string, + repo: string, + id: string, + baseUrl: string, + ): Promise { + const scope = getLogScope(); + + try { + const response = await this.request( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/issues/${id}`, + { + method: 'GET', + }, + scope, + ); + + if (response) { + return fromBitbucketIssue(response, provider); + } + return undefined; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + @debug({ args: { 0: p => p.name, 1: '' } }) public async getIssueOrPullRequest( provider: Provider, diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts index 061dfcb6a1abf..63637f849c789 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -1,4 +1,5 @@ -import { RepositoryAccessLevel } from '../../../../git/models/issue'; +import type { IssueRepository } from '../../../../git/models/issue'; +import { Issue, RepositoryAccessLevel } from '../../../../git/models/issue'; import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPullRequest'; import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest'; import { @@ -196,6 +197,12 @@ export interface BitbucketIssue { created_on: string; updated_on: string; repository: BitbucketRepository; + votes?: number; + content: { + raw: string; + markup: string; + html: string; + }; links: { self: BitbucketLink; html: BitbucketLink; @@ -240,6 +247,10 @@ export function isClosedBitbucketPullRequestState(state: BitbucketPullRequestSta return bitbucketPullRequestStateToState(state) !== 'opened'; } +export function isClosedBitbucketIssueState(state: BitbucketIssueState): boolean { + return bitbucketIssueStateToState(state) !== 'opened'; +} + export function fromBitbucketUser(user: BitbucketUser): PullRequestMember { return { avatarUrl: user.links.avatar.href, @@ -295,6 +306,46 @@ function getBitbucketReviewDecision(pr: BitbucketPullRequest): PullRequestReview return PullRequestReviewDecision.ReviewRequired; // nobody has reviewed yet. } +function fromBitbucketRepository(repo: BitbucketRepository): IssueRepository { + return { + owner: repo.full_name.split('/')[0], + repo: repo.name, + id: repo.uuid, + // TODO: Remove this assumption once actual access level is available + accessLevel: RepositoryAccessLevel.Write, + }; +} + +export function fromBitbucketIssue(issue: BitbucketIssue, provider: Provider): Issue { + return new Issue( + provider, + issue.id.toString(), + issue.id.toString(), + issue.title, + issue.links.html.href, + new Date(issue.created_on), + new Date(issue.updated_on), + isClosedBitbucketIssueState(issue.state), + bitbucketIssueStateToState(issue.state), + fromBitbucketUser(issue.reporter), + issue.assignee ? [fromBitbucketUser(issue.assignee)] : [], + fromBitbucketRepository(issue.repository), + undefined, // closedDate + undefined, // labels + undefined, // commentsCount + issue.votes, // thumbsUpCount + issue.content.html, // body + !issue.repository?.project + ? undefined + : { + id: issue.repository.project.uuid, + name: issue.repository.project.name, + resourceId: issue.repository.project.uuid, + resourceName: issue.repository.project.name, + }, + ); +} + export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Provider): PullRequest { return new PullRequest( provider, @@ -303,13 +354,7 @@ export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Pro pr.id.toString(), pr.title, pr.links.html.href, - { - owner: pr.destination.repository.full_name.split('/')[0], - repo: pr.destination.repository.name, - id: pr.destination.repository.uuid, - // TODO: Remove this assumption once actual access level is available - accessLevel: RepositoryAccessLevel.Write, - }, + fromBitbucketRepository(pr.destination.repository), bitbucketPullRequestStateToState(pr.state), new Date(pr.created_on), new Date(pr.updated_on), diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index e756267598cdd..168d61ba2bddf 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -105,6 +105,8 @@ export function getProviderIdFromEntityIdentifier( return IssueIntegrationId.Jira; case EntityIdentifierProviderType.Azure: return HostingIntegrationId.AzureDevOps; + case EntityIdentifierProviderType.Bitbucket: + return HostingIntegrationId.Bitbucket; default: return undefined; } @@ -228,6 +230,7 @@ export async function getIssueFromGitConfigEntityIdentifier( identifier.provider !== EntityIdentifierProviderType.Gitlab && identifier.provider !== EntityIdentifierProviderType.GithubEnterprise && identifier.provider !== EntityIdentifierProviderType.GitlabSelfHosted && + identifier.provider !== EntityIdentifierProviderType.Bitbucket && identifier.provider !== EntityIdentifierProviderType.Azure ) { return undefined; diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index 92d9e8fb3038a..7f0fc9b3f033a 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -21,6 +21,7 @@ import { import { ConnectIntegrationButton, OpenOnAzureDevOpsQuickInputButton, + OpenOnBitbucketQuickInputButton, OpenOnGitHubQuickInputButton, OpenOnGitLabQuickInputButton, OpenOnJiraQuickInputButton, @@ -96,6 +97,7 @@ export const supportedStartWorkIntegrations = [ HostingIntegrationId.GitLab, SelfHostedIntegrationId.CloudGitLabSelfHosted, HostingIntegrationId.AzureDevOps, + HostingIntegrationId.Bitbucket, IssueIntegrationId.Jira, ]; export type SupportedStartWorkIntegrationIds = (typeof supportedStartWorkIntegrations)[number]; @@ -483,6 +485,7 @@ export abstract class StartWorkBaseCommand extends QuickCommand { onDidClickItemButton: (_quickpick, button, { item }) => { switch (button) { case OpenOnAzureDevOpsQuickInputButton: + case OpenOnBitbucketQuickInputButton: case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: case OpenOnJiraQuickInputButton: @@ -716,6 +719,8 @@ function getOpenOnWebQuickInputButton(integrationId: string): QuickInputButton | switch (integrationId) { case HostingIntegrationId.AzureDevOps: return OpenOnAzureDevOpsQuickInputButton; + case HostingIntegrationId.Bitbucket: + return OpenOnBitbucketQuickInputButton; case HostingIntegrationId.GitHub: case SelfHostedIntegrationId.CloudGitHubEnterprise: return OpenOnGitHubQuickInputButton; diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index ca8e69c1e2b1c..0d9b9ae5818bc 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -184,6 +184,7 @@ const createIconElements = (): Record => { 'issue-gitlab', 'issue-jiraCloud', 'issue-azureDevops', + 'issue-bitbucket', ]; const miniIconList = ['upstream-ahead', 'upstream-behind']; diff --git a/src/webviews/apps/plus/graph/graph.scss b/src/webviews/apps/plus/graph/graph.scss index f06f430f6a99b..3a02ccbb2b4e1 100644 --- a/src/webviews/apps/plus/graph/graph.scss +++ b/src/webviews/apps/plus/graph/graph.scss @@ -871,6 +871,13 @@ button:not([disabled]), @include iconUtils.codicon('issues'); } } + + &--issue-bitbucket { + &::before { + font-family: codicon; + @include iconUtils.codicon('issues'); + } + } } .titlebar { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 5ac52b1727a16..47dc8b06a6b39 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -4225,6 +4225,9 @@ function toGraphIssueTrackerType(id: string): GraphIssueTrackerType | undefined case 'azure-devops': // TODO: Remove the casting once this is officially recognized by the component return 'azureDevops' as GraphIssueTrackerType; + case 'bitbucket': + // TODO: Remove the casting once this is officially recognized by the component + return HostingIntegrationId.Bitbucket as GraphIssueTrackerType; default: return undefined; }