Skip to content

Bitbucket cloud: add Start Work support #4119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 5, 2025
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 36 additions & 7 deletions src/plus/integrations/providers/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Issue | undefined> {
return Promise.resolve(undefined);
return (await this.container.bitbucket)?.getIssue(
this,
accessToken,
repo.owner,
repo.name,
id,
this.apiBaseUrl,
);
}

protected override async getProviderPullRequestForBranch(
Expand Down Expand Up @@ -240,10 +247,32 @@ export class BitbucketIntegration extends HostingIntegration<
}

protected override async searchProviderMyIssues(
_session: AuthenticationSession,
_repos?: BitbucketRepositoryDescriptor[],
session: AuthenticationSession,
repos?: BitbucketRepositoryDescriptor[],
): Promise<IssueShape[] | undefined> {
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<void> {
Expand Down
70 changes: 69 additions & 1 deletion src/plus/integrations/providers/bitbucket/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -92,6 +93,73 @@ export class BitbucketApi implements Disposable {
return fromBitbucketPullRequest(response.values[0], provider);
}

@debug<BitbucketApi['getUsersIssuesForRepo']>({ args: { 0: p => p.name, 1: '<token>' } })
async getUsersIssuesForRepo(
provider: Provider,
token: string,
userUuid: string,
owner: string,
repo: string,
baseUrl: string,
): Promise<Issue[] | undefined> {
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<BitbucketApi['getIssue']>({ args: { 0: p => p.name, 1: '<token>' } })
async getIssue(
provider: Provider,
token: string,
owner: string,
repo: string,
id: string,
baseUrl: string,
): Promise<Issue | undefined> {
const scope = getLogScope();

try {
const response = await this.request<BitbucketIssue>(
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<BitbucketApi['getIssueOrPullRequest']>({ args: { 0: p => p.name, 1: '<token>' } })
public async getIssueOrPullRequest(
provider: Provider,
Expand Down
61 changes: 53 additions & 8 deletions src/plus/integrations/providers/bitbucket/models.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions src/plus/integrations/providers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/plus/startWork/startWork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
ConnectIntegrationButton,
OpenOnAzureDevOpsQuickInputButton,
OpenOnBitbucketQuickInputButton,
OpenOnGitHubQuickInputButton,
OpenOnGitLabQuickInputButton,
OpenOnJiraQuickInputButton,
Expand Down Expand Up @@ -96,6 +97,7 @@ export const supportedStartWorkIntegrations = [
HostingIntegrationId.GitLab,
SelfHostedIntegrationId.CloudGitLabSelfHosted,
HostingIntegrationId.AzureDevOps,
HostingIntegrationId.Bitbucket,
IssueIntegrationId.Jira,
];
export type SupportedStartWorkIntegrationIds = (typeof supportedStartWorkIntegrations)[number];
Expand Down Expand Up @@ -483,6 +485,7 @@ export abstract class StartWorkBaseCommand extends QuickCommand<State> {
onDidClickItemButton: (_quickpick, button, { item }) => {
switch (button) {
case OpenOnAzureDevOpsQuickInputButton:
case OpenOnBitbucketQuickInputButton:
case OpenOnGitHubQuickInputButton:
case OpenOnGitLabQuickInputButton:
case OpenOnJiraQuickInputButton:
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/webviews/apps/plus/graph/GraphWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const createIconElements = (): Record<string, ReactElement> => {
'issue-gitlab',
'issue-jiraCloud',
'issue-azureDevops',
'issue-bitbucket',
];

const miniIconList = ['upstream-ahead', 'upstream-behind'];
Expand Down
7 changes: 7 additions & 0 deletions src/webviews/apps/plus/graph/graph.scss
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,13 @@ button:not([disabled]),
@include iconUtils.codicon('issues');
}
}

&--issue-bitbucket {
&::before {
font-family: codicon;
@include iconUtils.codicon('issues');
}
}
}

.titlebar {
Expand Down
3 changes: 3 additions & 0 deletions src/webviews/plus/graph/graphWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down