From 76adbcb8201eff86f7436c0dc7a8d5613934c18e Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 22 Aug 2025 13:20:31 +0200 Subject: [PATCH 01/10] Add linear provider and implement getting linear teams (#4543, #4579) --- docs/telemetry-events.md | 14 +-- package.json | 2 +- pnpm-lock.yaml | 53 ++++++++++-- src/config.ts | 1 + src/constants.integrations.ts | 1 + .../integrationAuthenticationService.ts | 5 ++ .../integrations/authentication/linear.ts | 54 ++++++++++++ .../integrations/authentication/models.ts | 3 + src/plus/integrations/integrationService.ts | 10 +++ src/plus/integrations/providers/linear.ts | 86 +++++++++++++++++++ src/plus/integrations/providers/models.ts | 11 ++- .../integrations/providers/providersApi.ts | 4 + src/plus/launchpad/enrichmentService.ts | 1 + src/plus/launchpad/models/enrichedItem.ts | 2 +- src/plus/startWork/startWork.ts | 1 + 15 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 src/plus/integrations/authentication/linear.ts create mode 100644 src/plus/integrations/providers/linear.ts diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index f267fd4528316..5eaeb1a95f388 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -668,7 +668,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello' } ``` @@ -679,7 +679,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello' } ``` @@ -690,7 +690,7 @@ void ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello' } ``` @@ -701,7 +701,7 @@ void ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello' } ``` @@ -735,7 +735,7 @@ or when connection refresh is skipped due to being a non-cloud session ```typescript { - 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' + 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello' } ``` @@ -2892,7 +2892,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello', // @deprecated: true 'remoteProviders.key': string } @@ -2905,7 +2905,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'linear' | 'trello', // @deprecated: true 'remoteProviders.key': string } diff --git a/package.json b/package.json index 738f06c263d77..b7ded63810c8a 100644 --- a/package.json +++ b/package.json @@ -25043,7 +25043,7 @@ }, "dependencies": { "@gitkraken/gitkraken-components": "13.0.0-vnext.8", - "@gitkraken/provider-apis": "0.29.6", + "@gitkraken/provider-apis": "0.29.7", "@gitkraken/shared-web-components": "0.1.1-rc.15", "@gk-nzaytsev/fast-string-truncated-width": "1.1.0", "@lit-labs/signals": "0.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36d40308b3e1a..7c7caf2eef1eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: 13.0.0-vnext.8 version: 13.0.0-vnext.8(@types/react@19.0.12)(react@19.0.0) '@gitkraken/provider-apis': - specifier: 0.29.6 - version: 0.29.6(encoding@0.1.13) + specifier: 0.29.7 + version: 0.29.7(encoding@0.1.13) '@gitkraken/shared-web-components': specifier: 0.1.1-rc.15 version: 0.1.1-rc.15 @@ -657,8 +657,8 @@ packages: peerDependencies: react: 19.0.0 - '@gitkraken/provider-apis@0.29.6': - resolution: {integrity: sha512-aRgR7lL6MxnCFtbbNB5AJuQVRGBy6nJlZE+Yn0FZ/G8QrqgxhBkexVtG0ji/wiq2HmQ4DxNk6eo3xMfYqoTq6w==} + '@gitkraken/provider-apis@0.29.7': + resolution: {integrity: sha512-i1StvQ0L5UPnVNnnud6vN+E80SAIXp/iX1UxlCIXXibYiw088x+cGBsfvzopx6rtkacuDd0rJTN75CMbEKsGwA==} engines: {node: '>= 14'} '@gitkraken/shared-web-components@0.1.1-rc.15': @@ -667,6 +667,11 @@ packages: '@gk-nzaytsev/fast-string-truncated-width@1.1.0': resolution: {integrity: sha512-NPKNmdjRFUNpMRzQU3m+AmKzbiQ3WGFXxacMyfmRgm1N+vRhuCzAD3t2dRD29aX1n6a+PNBK2a6hwPwFTfx1rw==} + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -857,6 +862,10 @@ packages: resolution: {integrity: sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==} engines: {node: '>= 20'} + '@linear/sdk@58.1.0': + resolution: {integrity: sha512-sqzo1j+uZsxeJlMTV2mrBH3yukB/liev7IySmkZil0ka7ic6b4RE9Jk3x+ohw8YgYB52IRR3SPWzhWu96E6W9g==} + engines: {node: '>=12.x', yarn: 1.x} + '@lit-labs/signals@0.1.3': resolution: {integrity: sha512-P0yWgH5blwVyEwBg+WFspLzeu1i0ypJP1QB0l1Omr9qZLIPsUu0p4Fy2jshOg7oQyha5n163K3GJGeUhQQ682Q==} @@ -3422,6 +3431,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@15.10.1: + resolution: {integrity: sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==} + engines: {node: '>= 10.x'} + gunzip-maybe@1.4.2: resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} hasBin: true @@ -3881,6 +3894,9 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-unfetch@3.1.0: + resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5971,6 +5987,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + unfetch@4.2.0: + resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -6537,8 +6556,9 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@gitkraken/provider-apis@0.29.6(encoding@0.1.13)': + '@gitkraken/provider-apis@0.29.7(encoding@0.1.13)': dependencies: + '@linear/sdk': 58.1.0(encoding@0.1.13) js-base64: 3.7.5 node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: @@ -6551,6 +6571,10 @@ snapshots: '@gk-nzaytsev/fast-string-truncated-width@1.1.0': {} + '@graphql-typed-document-node/core@3.2.0(graphql@15.10.1)': + dependencies: + graphql: 15.10.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -6712,6 +6736,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@linear/sdk@58.1.0(encoding@0.1.13)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@15.10.1) + graphql: 15.10.1 + isomorphic-unfetch: 3.1.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + '@lit-labs/signals@0.1.3': dependencies: lit: 3.3.1 @@ -9540,6 +9572,8 @@ snapshots: graphemer@1.4.0: {} + graphql@15.10.1: {} + gunzip-maybe@1.4.2: dependencies: browserify-zlib: 0.1.4 @@ -10006,6 +10040,13 @@ snapshots: isobject@3.0.1: {} + isomorphic-unfetch@3.1.0(encoding@0.1.13): + dependencies: + node-fetch: 2.7.0(encoding@0.1.13) + unfetch: 4.2.0 + transitivePeerDependencies: + - encoding + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -12309,6 +12350,8 @@ snapshots: undici-types@5.26.5: {} + unfetch@4.2.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} diff --git a/src/config.ts b/src/config.ts index f28d595d89703..c9f07ec6eb96e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ import type { DateTimeFormat } from './system/date'; import type { LogLevel } from './system/logger.constants'; export interface Config { + readonly 'temporary-configured-linear-config': string | null; readonly advanced: AdvancedConfig; readonly ai: AIConfig; readonly autolinks: AutolinkConfig[] | null; diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index a8f828fb78fa2..90337aa74c0dc 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -16,6 +16,7 @@ export enum GitSelfManagedHostIntegrationId { export enum IssuesCloudHostIntegrationId { Jira = 'jira', + Linear = 'linear', Trello = 'trello', } diff --git a/src/plus/integrations/authentication/integrationAuthenticationService.ts b/src/plus/integrations/authentication/integrationAuthenticationService.ts index 50fe93f97483b..97ed9dd55b436 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationService.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationService.ts @@ -148,6 +148,11 @@ export class IntegrationAuthenticationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './jira') ).JiraAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; + case IssuesCloudHostIntegrationId.Linear: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './linear') + ).LinearAuthenticationProvider(); + break; default: provider = new BuiltInAuthenticationProvider( this.container, diff --git a/src/plus/integrations/authentication/linear.ts b/src/plus/integrations/authentication/linear.ts new file mode 100644 index 0000000000000..628ba6109e260 --- /dev/null +++ b/src/plus/integrations/authentication/linear.ts @@ -0,0 +1,54 @@ +import type { Disposable, Event } from 'vscode'; +import type { Sources } from '../../../constants.telemetry'; +import { configuration } from '../../../system/-webview/configuration'; +import type { + IntegrationAuthenticationProvider, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthenticationProvider'; +import type { ProviderAuthenticationSession } from './models'; + +export class LinearAuthenticationProvider implements IntegrationAuthenticationProvider { + // I want to read the token from the config "temporary-configured-linear-config": + private currentToken: string | undefined = + (configuration.get('temporary-configured-linear-config') as string) ?? undefined; + + deleteSession(_descriptor: IntegrationAuthenticationSessionDescriptor): Promise { + //throw new Error('Method not implemented.'); + this.currentToken = undefined; + return Promise.resolve(); + } + deleteAllSessions(): Promise { + //throw new Error('Method not implemented.'); + this.currentToken = undefined; + return Promise.resolve(); + } + getSession( + _descriptor: IntegrationAuthenticationSessionDescriptor, + _options?: + | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources } + | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources }, + ): Promise { + return Promise.resolve( + this.currentToken + ? { + accessToken: this.currentToken, + id: 'linear', + account: { + id: 'linear', + label: 'Linear', + }, + scopes: ['read'], + cloud: true, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + domain: 'linear.app', + } + : undefined, + ); + } + get onDidChange(): Event { + return (_listener: (e: void) => any, _thisArgs?: any, _disposables?: Disposable[]): Disposable => { + return { dispose: () => {} }; + }; + } + dispose(): void {} +} diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index d7f10b099d9de..10bbfe8ee68f8 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -44,6 +44,7 @@ export interface CloudIntegrationConnection { export type CloudIntegrationType = | 'jira' + | 'linear' | 'trello' | 'gitlab' | 'github' @@ -70,6 +71,7 @@ export function isSupportedCloudIntegrationId(id: string): id is SupportedCloudI export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationIds } = { jira: IssuesCloudHostIntegrationId.Jira, + linear: IssuesCloudHostIntegrationId.Linear, trello: IssuesCloudHostIntegrationId.Trello, gitlab: GitCloudHostIntegrationId.GitLab, github: GitCloudHostIntegrationId.GitHub, @@ -83,6 +85,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationIds } export const toCloudIntegrationType: { [key in IntegrationIds]: CloudIntegrationType | undefined } = { [IssuesCloudHostIntegrationId.Jira]: 'jira', + [IssuesCloudHostIntegrationId.Linear]: 'linear', [IssuesCloudHostIntegrationId.Trello]: 'trello', [GitCloudHostIntegrationId.GitLab]: 'gitlab', [GitCloudHostIntegrationId.GitHub]: 'github', diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index af66d72128ede..e6f5c1c79d7ca 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -540,6 +540,16 @@ export class IntegrationService implements Disposable { ) as IssuesIntegration as IntegrationById; break; + case IssuesCloudHostIntegrationId.Linear: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/linear') + ).LinearIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + this._onDidChangeIntegrationConnection, + ) as IssuesIntegration as IntegrationById; + break; default: throw new Error(`Integration with '${id}' is not supported`); } diff --git a/src/plus/integrations/providers/linear.ts b/src/plus/integrations/providers/linear.ts new file mode 100644 index 0000000000000..52df443c2ad50 --- /dev/null +++ b/src/plus/integrations/providers/linear.ts @@ -0,0 +1,86 @@ +import type { CancellationToken } from 'vscode'; +import { IssuesCloudHostIntegrationId } from '../../../constants.integrations'; +import type { Account } from '../../../git/models/author'; +import type { Issue, IssueShape } from '../../../git/models/issue'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; +import type { IssueResourceDescriptor, ResourceDescriptor } from '../../../git/models/resourceDescriptor'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; +import type { ProviderAuthenticationSession } from '../authentication/models'; +import { IssuesIntegration } from '../models/issuesIntegration'; +import type { IssueFilter } from './models'; +import { providersMetadata, toIssueShape } from './models'; + +const metadata = providersMetadata[IssuesCloudHostIntegrationId.Linear]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); +const maxPagesPerRequest = 10; + +export interface LinearTeamDescriptor extends IssueResourceDescriptor { + url: string; +} + +export interface LinearProjectDescriptor extends IssueResourceDescriptor {} + +export class LinearIntegration extends IssuesIntegration { + protected override getProviderResourcesForUser( + _session: ProviderAuthenticationSession, + ): Promise { + throw new Error('Method not implemented.'); + } + protected override getProviderProjectsForResources( + _session: ProviderAuthenticationSession, + _resources: ResourceDescriptor[], + ): Promise { + throw new Error('Method not implemented.'); + } + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + + protected override getProviderAccountForResource( + _session: ProviderAuthenticationSession, + _resource: ResourceDescriptor, + ): Promise { + throw new Error('Method not implemented.'); + } + + protected override getProviderIssuesForProject( + _session: ProviderAuthenticationSession, + _project: ResourceDescriptor, + _options?: { user?: string; filters?: IssueFilter[] }, + ): Promise { + throw new Error('Method not implemented.'); + } + + override get id(): IssuesCloudHostIntegrationId.Linear { + return IssuesCloudHostIntegrationId.Linear; + } + protected override get key(): 'linear' { + return 'linear'; + } + override get name(): string { + return metadata.name; + } + override get domain(): string { + return metadata.domain; + } + protected override async searchProviderMyIssues( + _session: ProviderAuthenticationSession, + _resources?: ResourceDescriptor[], + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(undefined); + } + protected override getProviderIssueOrPullRequest( + _session: ProviderAuthenticationSession, + _resource: ResourceDescriptor, + _id: string, + _type: undefined | IssueOrPullRequestType, + ): Promise { + throw new Error('Method not implemented.'); + } + protected override getProviderIssue( + _session: ProviderAuthenticationSession, + _resource: ResourceDescriptor, + _id: string, + ): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 973db318f6e78..7f7a8afe1e896 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -19,6 +19,7 @@ import type { Jira, JiraProject, JiraResource, + Linear, NumberedPageInput, Issue as ProviderApiIssue, PullRequestWithUniqueID, @@ -355,7 +356,7 @@ export type GetIssuesForResourceForCurrentUserFn = ( ) => Promise<{ data: ProviderIssue[] }>; export interface ProviderInfo extends ProviderMetadata { - provider: GitHub | GitLab | Bitbucket | BitbucketServer | Jira | Trello | AzureDevOps; + provider: GitHub | GitLab | Bitbucket | BitbucketServer | Jira | Linear | Trello | AzureDevOps; getRepoFn?: GetRepoFn; getRepoOfProjectFn?: GetRepoOfProjectFn; getPullRequestsForReposFn?: GetPullRequestsForReposFn; @@ -602,6 +603,14 @@ export const providersMetadata: ProvidersMetadata = { ], supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], }, + [IssuesCloudHostIntegrationId.Linear]: { + domain: 'linear.app', + id: IssuesCloudHostIntegrationId.Linear, + name: 'Linear', + type: 'issues', + iconKey: IssuesCloudHostIntegrationId.Linear, + scopes: [], + }, [IssuesCloudHostIntegrationId.Trello]: { domain: 'trello.com', id: IssuesCloudHostIntegrationId.Trello, diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index c8018510e4d78..6c2148da39f4f 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -322,6 +322,10 @@ export class ProvidersApi { providerApis.jira, ), }, + [IssuesCloudHostIntegrationId.Linear]: { + ...providersMetadata[IssuesCloudHostIntegrationId.Linear], + provider: providerApis.linear, + }, [IssuesCloudHostIntegrationId.Trello]: { ...providersMetadata[IssuesCloudHostIntegrationId.Trello], provider: providerApis.trello, diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts index 56dc6ca44f1cf..677aa441df9eb 100644 --- a/src/plus/launchpad/enrichmentService.ts +++ b/src/plus/launchpad/enrichmentService.ts @@ -204,6 +204,7 @@ const supportedIntegrationIdsToEnrich: Record Date: Tue, 26 Aug 2025 17:20:30 +0200 Subject: [PATCH 02/10] Adds support for fetching user issues from Linear (#4543, #4579) --- src/plus/integrations/providers/linear.ts | 42 +++++++++++++++++-- src/plus/integrations/providers/models.ts | 6 +++ .../integrations/providers/providersApi.ts | 22 ++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/plus/integrations/providers/linear.ts b/src/plus/integrations/providers/linear.ts index 52df443c2ad50..2cbc61f9bb872 100644 --- a/src/plus/integrations/providers/linear.ts +++ b/src/plus/integrations/providers/linear.ts @@ -4,6 +4,7 @@ import type { Account } from '../../../git/models/author'; import type { Issue, IssueShape } from '../../../git/models/issue'; import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; import type { IssueResourceDescriptor, ResourceDescriptor } from '../../../git/models/resourceDescriptor'; +import { Logger } from '../../../system/logger'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { ProviderAuthenticationSession } from '../authentication/models'; import { IssuesIntegration } from '../models/issuesIntegration'; @@ -62,11 +63,44 @@ export class LinearIntegration extends IssuesIntegration { - return Promise.resolve(undefined); + if (resources != null) { + return undefined; + } + const api = await this.getProvidersApi(); + let cursor = undefined; + let hasMore = false; + let requestCount = 0; + const issues = []; + try { + do { + if (cancellation?.isCancellationRequested) { + break; + } + const result = await api.getIssuesForCurrentUser(this.id, { + accessToken: session.accessToken, + cursor: cursor, + }); + requestCount += 1; + hasMore = result.paging?.more ?? false; + cursor = result.paging?.cursor; + const formattedIssues = result.values + .map(issue => toIssueShape(issue, this)) + .filter((result): result is IssueShape => result != null); + if (formattedIssues.length > 0) { + issues.push(...formattedIssues); + } + } while (requestCount < maxPagesPerRequest && hasMore); + } catch (ex) { + if (issues.length === 0) { + throw ex; + } + Logger.error(ex, 'searchProviderMyIssues'); + } + return issues; } protected override getProviderIssueOrPullRequest( _session: ProviderAuthenticationSession, diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 7f7a8afe1e896..c492631d7f742 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -281,6 +281,11 @@ export type GetIssuesForReposFn = ( options?: EnterpriseOptions, ) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; +export type GetIssuesForCurrentUserFn = ( + input: PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + export type GetIssuesForRepoFn = ( input: GetIssuesForRepoInput & PagingInput, options?: EnterpriseOptions, @@ -365,6 +370,7 @@ export interface ProviderInfo extends ProviderMetadata { getPullRequestsForAzureProjectsFn?: GetPullRequestsForAzureProjectsFn; getIssueFn?: GetIssueFn; getIssuesForReposFn?: GetIssuesForReposFn; + getIssuesForCurrentUserFn?: GetIssuesForCurrentUserFn; getIssuesForRepoFn?: GetIssuesForRepoFn; getIssuesForAzureProjectFn?: GetIssuesForAzureProjectFn; getCurrentUserFn?: GetCurrentUserFn; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 6c2148da39f4f..67229281d0582 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -44,6 +44,7 @@ import type { IssueFilter, MergePullRequestFn, PageInfo, + PagingInput, PagingMode, ProviderAccount, ProviderAzureProject, @@ -325,6 +326,7 @@ export class ProvidersApi { [IssuesCloudHostIntegrationId.Linear]: { ...providersMetadata[IssuesCloudHostIntegrationId.Linear], provider: providerApis.linear, + getIssuesForCurrentUserFn: providerApis.linear.getIssuesForCurrentUser.bind(providerApis.linear), }, [IssuesCloudHostIntegrationId.Trello]: { ...providersMetadata[IssuesCloudHostIntegrationId.Trello], @@ -1035,6 +1037,26 @@ export class ProvidersApi { ); } + async getIssuesForCurrentUser( + providerId: IntegrationIds, + options?: PagingInput & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForCurrentUserFn', + options?.accessToken, + ); + return this.getPagedResult( + provider, + options, + provider.getIssuesForCurrentUserFn, + token, + options?.cursor ?? undefined, + options?.isPAT, + options?.baseUrl, + ); + } + async getIssuesForAzureProject( providerId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, namespace: string, From 312d1ecb776161d9c4c0d7f60e6949a96df54adf Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 27 Aug 2025 17:53:35 +0200 Subject: [PATCH 03/10] Makes sure that user can associate an issue with a branch and it's saved (#4543, #4579) --- src/plus/integrations/providers/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index b89ce5de01acc..dc50eb161e265 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -120,6 +120,8 @@ export function getProviderIdFromEntityIdentifier( : GitSelfManagedHostIntegrationId.GitLabSelfHosted; case EntityIdentifierProviderType.Jira: return IssuesCloudHostIntegrationId.Jira; + case EntityIdentifierProviderType.Linear: + return IssuesCloudHostIntegrationId.Linear; case EntityIdentifierProviderType.Azure: return GitCloudHostIntegrationId.AzureDevOps; case EntityIdentifierProviderType.AzureDevOpsServer: @@ -147,6 +149,8 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier return EntityIdentifierProviderType.Gitlab; case 'jira': return EntityIdentifierProviderType.Jira; + case 'linear': + return EntityIdentifierProviderType.Linear; case 'azure': case 'azureDevOps': case 'azure-devops': @@ -252,6 +256,7 @@ export async function getIssueFromGitConfigEntityIdentifier( // TODO: Centralize where we represent all supported providers for issues if ( identifier.provider !== EntityIdentifierProviderType.Jira && + identifier.provider !== EntityIdentifierProviderType.Linear && identifier.provider !== EntityIdentifierProviderType.Github && identifier.provider !== EntityIdentifierProviderType.Gitlab && identifier.provider !== EntityIdentifierProviderType.GithubEnterprise && @@ -288,7 +293,7 @@ export async function getIssueFromGitConfigEntityIdentifier( export function getIssueOwner( issue: IssueShape, ): RepositoryDescriptor | IssueResourceDescriptor | AzureProjectInputDescriptor | undefined { - const isAzure = issue.provider.id === 'azure' || GitCloudHostIntegrationId.AzureDevOps || 'azure-devops'; + const isAzure = ['azure', GitCloudHostIntegrationId.AzureDevOps, 'azure-devops'].includes(issue.provider.id); return issue.repository ? { key: `${issue.repository.owner}/${issue.repository.repo}`, From ea7e613ebee69081f8727a5a941b755527866716 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 27 Aug 2025 19:32:52 +0200 Subject: [PATCH 04/10] Fixes showing associated branch Linear issues on Home View (#4543, #4579) --- src/git/models/issue.ts | 1 + src/plus/integrations/providers/linear.ts | 37 ++++++++++++++++--- src/plus/integrations/providers/models.ts | 1 + .../integrations/providers/providersApi.ts | 1 + .../apps/plus/home/components/branch-card.ts | 2 +- src/webviews/home/homeWebview.ts | 7 +++- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/git/models/issue.ts b/src/git/models/issue.ts index c9a30db5e51e3..d13a5b61875a7 100644 --- a/src/git/models/issue.ts +++ b/src/git/models/issue.ts @@ -37,6 +37,7 @@ export class Issue implements IssueShape { public readonly thumbsUpCount?: number, public readonly body?: string, public readonly project?: IssueProject, + public readonly number?: string, ) {} } diff --git a/src/plus/integrations/providers/linear.ts b/src/plus/integrations/providers/linear.ts index 2cbc61f9bb872..eb97537e97bb4 100644 --- a/src/plus/integrations/providers/linear.ts +++ b/src/plus/integrations/providers/linear.ts @@ -4,12 +4,13 @@ import type { Account } from '../../../git/models/author'; import type { Issue, IssueShape } from '../../../git/models/issue'; import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; import type { IssueResourceDescriptor, ResourceDescriptor } from '../../../git/models/resourceDescriptor'; +import { isIssueResourceDescriptor } from '../../../git/utils/resourceDescriptor.utils'; import { Logger } from '../../../system/logger'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { ProviderAuthenticationSession } from '../authentication/models'; import { IssuesIntegration } from '../models/issuesIntegration'; import type { IssueFilter } from './models'; -import { providersMetadata, toIssueShape } from './models'; +import { fromProviderIssue, providersMetadata, toIssueShape } from './models'; const metadata = providersMetadata[IssuesCloudHostIntegrationId.Linear]; const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); @@ -110,11 +111,35 @@ export class LinearIntegration extends IssuesIntegration { throw new Error('Method not implemented.'); } - protected override getProviderIssue( - _session: ProviderAuthenticationSession, - _resource: ResourceDescriptor, - _id: string, + protected override async getProviderIssue( + session: ProviderAuthenticationSession, + resource: ResourceDescriptor, + id: string, ): Promise { - throw new Error('Method not implemented.'); + const api = await this.getProvidersApi(); + try { + if (!isIssueResourceDescriptor(resource)) { + Logger.error(undefined, 'getProviderIssue: resource is not an IssueResourceDescriptor'); + return undefined; + } + + const result = await api.getIssue( + this.id, + { + resourceId: resource.id, + number: id, + }, + { + accessToken: session.accessToken, + }, + ); + + if (result == null) return undefined; + + return fromProviderIssue(result, this); + } catch (ex) { + Logger.error(ex, 'getProviderIssue'); + return undefined; + } } } diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index c492631d7f742..d1579615b1e1f 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -1055,6 +1055,7 @@ export function fromProviderIssue( resourceName: issue.project.namespace, } : undefined, + issue.number, ); } diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 67229281d0582..00d1386fee2ac 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -326,6 +326,7 @@ export class ProvidersApi { [IssuesCloudHostIntegrationId.Linear]: { ...providersMetadata[IssuesCloudHostIntegrationId.Linear], provider: providerApis.linear, + getIssueFn: providerApis.linear.getIssue.bind(providerApis.linear) as GetIssueFn, getIssuesForCurrentUserFn: providerApis.linear.getIssuesForCurrentUser.bind(providerApis.linear), }, [IssuesCloudHostIntegrationId.Trello]: { diff --git a/src/webviews/apps/plus/home/components/branch-card.ts b/src/webviews/apps/plus/home/components/branch-card.ts index 19f6e06a8cff7..f9ecf2b874837 100644 --- a/src/webviews/apps/plus/home/components/branch-card.ts +++ b/src/webviews/apps/plus/home/components/branch-card.ts @@ -567,7 +567,7 @@ export abstract class GlBranchCardBase extends GlElement { ${issue.title} - #${issue.id} + ${isNaN(parseInt(issue.id)) ? '' : '#'}${issue.id}

`; })} diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 377cd9cb78ef8..cf85fb4d16c02 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -1808,7 +1808,12 @@ function enrichOverviewBranchesCore( issues => issues?.map( i => - ({ id: i.id, title: i.title, state: i.state, url: i.url }) satisfies NonNullable[0], + ({ + id: i.number || i.id, + title: i.title, + state: i.state, + url: i.url, + }) satisfies NonNullable[0], ) ?? [], ); From a7492fae01779911edd184e419b5444f3015a126 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 28 Aug 2025 21:31:48 +0200 Subject: [PATCH 05/10] Adds Linear autolink matching. (#4543, #4579) --- .../utils/-webview/autolinks.utils.ts | 6 +- src/plus/integrations/providers/linear.ts | 109 +++++++++++++++++- src/plus/integrations/providers/models.ts | 8 ++ .../integrations/providers/providersApi.ts | 42 +++++++ 4 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/autolinks/utils/-webview/autolinks.utils.ts b/src/autolinks/utils/-webview/autolinks.utils.ts index ff75d9e4bf4e6..9653da5c55f21 100644 --- a/src/autolinks/utils/-webview/autolinks.utils.ts +++ b/src/autolinks/utils/-webview/autolinks.utils.ts @@ -32,7 +32,7 @@ export function serializeAutolink(value: Autolink): Autolink { return serialized; } -export const supportedAutolinkIntegrations = [IssuesCloudHostIntegrationId.Jira]; +export const supportedAutolinkIntegrations = [IssuesCloudHostIntegrationId.Jira, IssuesCloudHostIntegrationId.Linear]; export function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference { return !('prefix' in ref) && !('url' in ref); @@ -154,10 +154,10 @@ export function getBranchAutolinks(branchName: string, refsets: Readonly { - if (a[0]?.id === IssuesCloudHostIntegrationId.Jira || a[0]?.id === IssuesCloudHostIntegrationId.Trello) { + if (a[0]?.id && Object.values(IssuesCloudHostIntegrationId).includes(a[0].id)) { return -1; } - if (b[0]?.id === IssuesCloudHostIntegrationId.Jira || b[0]?.id === IssuesCloudHostIntegrationId.Trello) { + if (b[0]?.id && Object.values(IssuesCloudHostIntegrationId).includes(b[0].id)) { return 1; } return 0; diff --git a/src/plus/integrations/providers/linear.ts b/src/plus/integrations/providers/linear.ts index eb97537e97bb4..dfe7d2c50d22b 100644 --- a/src/plus/integrations/providers/linear.ts +++ b/src/plus/integrations/providers/linear.ts @@ -1,4 +1,5 @@ -import type { CancellationToken } from 'vscode'; +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { AutolinkReference, DynamicAutolinkReference } from '../../../autolinks/models/autolinks'; import { IssuesCloudHostIntegrationId } from '../../../constants.integrations'; import type { Account } from '../../../git/models/author'; import type { Issue, IssueShape } from '../../../git/models/issue'; @@ -17,12 +18,118 @@ const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }) const maxPagesPerRequest = 10; export interface LinearTeamDescriptor extends IssueResourceDescriptor { + avatarUrl: string | undefined; +} + +export interface LinearOrganizationDescriptor extends IssueResourceDescriptor { url: string; } export interface LinearProjectDescriptor extends IssueResourceDescriptor {} export class LinearIntegration extends IssuesIntegration { + private _autolinks: Map | undefined; + override async autolinks(): Promise<(AutolinkReference | DynamicAutolinkReference)[]> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected || this._session == null) { + return []; + } + const cachedAutolinks = this._autolinks?.get(this._session.accessToken); + if (cachedAutolinks != null) return cachedAutolinks; + + const organization = await this.getOrganization(this._session); + if (organization == null) return []; + + const autolinks: (AutolinkReference | DynamicAutolinkReference)[] = []; + + const teams = await this.getTeams(this._session); + for (const team of teams ?? []) { + const dashedPrefix = `${team.key}-`; + const underscoredPrefix = `${team.key}_`; + + autolinks.push({ + prefix: dashedPrefix, + url: `${organization.url}/issue/${dashedPrefix}`, + alphanumeric: false, + ignoreCase: false, + title: `Open Issue ${dashedPrefix} on ${organization.name}`, + + type: 'issue', + description: `${organization.name} Issue ${dashedPrefix}`, + descriptor: { ...organization }, + }); + autolinks.push({ + prefix: underscoredPrefix, + url: `${organization.url}/issue/${dashedPrefix}`, + alphanumeric: false, + ignoreCase: false, + referenceType: 'branch', + title: `Open Issue ${dashedPrefix} on ${organization.name}`, + + type: 'issue', + description: `${organization.name} Issue ${dashedPrefix}`, + descriptor: { ...organization }, + }); + } + + this._autolinks ??= new Map(); + this._autolinks.set(this._session.accessToken, autolinks); + + return autolinks; + } + + private _organizations: Map | undefined; + private async getOrganization( + { accessToken }: AuthenticationSession, + force: boolean = false, + ): Promise { + this._organizations ??= new Map(); + + const cachedResources = this._organizations.get(accessToken); + + if (cachedResources == null || force) { + const api = await this.getProvidersApi(); + const organization = await api.getLinearOrganization({ accessToken: accessToken }); + const descriptor: LinearOrganizationDescriptor | undefined = organization && { + id: organization.id, + key: organization.key, + name: organization.name, + url: organization.url, + }; + if (descriptor) { + this._organizations.set(accessToken, descriptor); + } + } + + return this._organizations.get(accessToken); + } + + private _teams: Map | undefined; + private async getTeams( + { accessToken }: AuthenticationSession, + force: boolean = false, + ): Promise { + this._teams ??= new Map(); + + const cachedResources = this._teams.get(accessToken); + + if (cachedResources == null || force) { + const api = await this.getProvidersApi(); + const teams = await api.getLinearTeamsForCurrentUser({ accessToken: accessToken }); + const descriptors: LinearTeamDescriptor[] | undefined = teams?.map(t => ({ + id: t.id, + key: t.key, + name: t.name, + avatarUrl: t.iconUrl, + })); + if (descriptors) { + this._teams.set(accessToken, descriptors); + } + } + + return this._teams.get(accessToken); + } + protected override getProviderResourcesForUser( _session: ProviderAuthenticationSession, ): Promise { diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index d1579615b1e1f..2e04a9622e7f7 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -20,6 +20,8 @@ import type { JiraProject, JiraResource, Linear, + LinearOrganization, + LinearTeam, NumberedPageInput, Issue as ProviderApiIssue, PullRequestWithUniqueID, @@ -75,6 +77,8 @@ export type ProviderIssue = ProviderApiIssue; export type ProviderEnterpriseOptions = EnterpriseOptions; export type ProviderJiraProject = JiraProject; export type ProviderJiraResource = JiraResource; +export type ProviderLinearTeam = LinearTeam; +export type ProviderLinearOrganization = LinearOrganization; export type ProviderAzureProject = AzureProject; export type ProviderAzureResource = AzureOrganization; export type ProviderBitbucketResource = BitbucketWorkspaceStub; @@ -315,6 +319,8 @@ export type GetCurrentUserForResourceFn = ( ) => Promise<{ data: ProviderAccount }>; export type GetJiraResourcesForCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: JiraResource[] }>; +export type GetLinearOrganizationFn = (options?: EnterpriseOptions) => Promise<{ data: LinearOrganization }>; +export type GetLinearTeamsForCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: LinearTeam[] }>; export type GetJiraProjectsForResourcesFn = ( input: { resourceIds: string[] }, options?: EnterpriseOptions, @@ -377,6 +383,8 @@ export interface ProviderInfo extends ProviderMetadata { getCurrentUserForInstanceFn?: GetCurrentUserForInstanceFn; getCurrentUserForResourceFn?: GetCurrentUserForResourceFn; getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn; + getLinearOrganizationFn?: GetLinearOrganizationFn; + getLinearTeamsForCurrentUserFn?: GetLinearTeamsForCurrentUserFn; getAzureResourcesForUserFn?: GetAzureResourcesForUserFn; getBitbucketResourcesForUserFn?: GetBitbucketResourcesForUserFn; getBitbucketPullRequestsAuthoredByUserForWorkspaceFn?: GetBitbucketPullRequestsAuthoredByUserForWorkspaceFn; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 00d1386fee2ac..95be946c5d3a4 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -54,6 +54,8 @@ import type { ProviderIssue, ProviderJiraProject, ProviderJiraResource, + ProviderLinearOrganization, + ProviderLinearTeam, ProviderPullRequest, ProviderRepoInput, ProviderReposInput, @@ -328,6 +330,8 @@ export class ProvidersApi { provider: providerApis.linear, getIssueFn: providerApis.linear.getIssue.bind(providerApis.linear) as GetIssueFn, getIssuesForCurrentUserFn: providerApis.linear.getIssuesForCurrentUser.bind(providerApis.linear), + getLinearOrganizationFn: providerApis.linear.getLinearOrganization.bind(providerApis.linear), + getLinearTeamsForCurrentUserFn: providerApis.linear.getTeamsForCurrentUser.bind(providerApis.linear), }, [IssuesCloudHostIntegrationId.Trello]: { ...providersMetadata[IssuesCloudHostIntegrationId.Trello], @@ -657,6 +661,44 @@ export class ProvidersApi { } } + async getLinearOrganization(options?: { accessToken?: string }): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssuesCloudHostIntegrationId.Linear, + 'getLinearOrganizationFn', + options?.accessToken, + ); + + try { + const x = await provider.getLinearOrganizationFn?.({ token: token }); + const y = x?.data; + return y; + } catch (e) { + return this.handleProviderError( + IssuesCloudHostIntegrationId.Linear, + token, + e, + ); + } + } + + async getLinearTeamsForCurrentUser(options?: { accessToken?: string }): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssuesCloudHostIntegrationId.Linear, + 'getLinearTeamsForCurrentUserFn', + options?.accessToken, + ); + + try { + return (await provider.getLinearTeamsForCurrentUserFn?.({ token: token }))?.data; + } catch (e) { + return this.handleProviderError( + IssuesCloudHostIntegrationId.Linear, + token, + e, + ); + } + } + async getAzureResourcesForUser( userId: string, integrationId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, From 2f82fe2ebdeb118c268a4de9fcaa0230b9afe4c5 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 28 Aug 2025 22:40:48 +0200 Subject: [PATCH 06/10] Lets Jira and Linear links appear in commit tooltips (#4543, #4579) --- src/git/models/commit.ts | 1 - src/hovers/hovers.ts | 1 - src/views/nodes/commitNode.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index cb443dfaa0bf5..3603991134d16 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -551,7 +551,6 @@ export class GitCommit implements GitRevisionReference { if (this.isUncommitted) return undefined; remote ??= await this.container.git.getRepositoryService(this.repoPath).remotes.getBestRemoteWithIntegration(); - if (remote?.provider == null) return undefined; // TODO@eamodio should we cache these? Seems like we would use more memory than it's worth // async function getCore(this: GitCommit): Promise | undefined> { diff --git a/src/hovers/hovers.ts b/src/hovers/hovers.ts index 4e35c59d38667..cfcda04a35e71 100644 --- a/src/hovers/hovers.ts +++ b/src/hovers/hovers.ts @@ -215,7 +215,6 @@ export async function detailsMessage( const cfg = configuration.get('hovers'); const enhancedAutolinks = - remote?.provider != null && options?.autolinks !== false && (options?.autolinks || cfg.autolinks.enabled) && cfg.autolinks.enhanced && diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 7122bd91e06f4..c0d45577618b8 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -269,7 +269,7 @@ export class CommitNode extends ViewRefNode<'commit', ViewsWithCommits | FileHis let enrichedAutolinks; let pr; - if (remote?.supportsIntegration()) { + if (!remote || remote?.supportsIntegration()) { const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ pauseOnCancelOrTimeoutMapTuplePromise(this.commit.getEnrichedAutolinks(remote), cancellation), this.getAssociatedPullRequest(this.commit, remote), From bab837c26ca23befad0756403ce5e54f672c1b95 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 28 Aug 2025 23:57:08 +0200 Subject: [PATCH 07/10] Fetches issue data for linear links in commit tooltips (#4543, #4579) --- src/autolinks/autolinksProvider.ts | 3 +++ src/plus/integrations/providers/linear.ts | 24 ++++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/autolinks/autolinksProvider.ts b/src/autolinks/autolinksProvider.ts index 0e8f4a9527b1d..455cf1523482c 100644 --- a/src/autolinks/autolinksProvider.ts +++ b/src/autolinks/autolinksProvider.ts @@ -178,8 +178,11 @@ export class AutolinksProvider implements Disposable { } getAutolinkEnrichableId(autolink: Autolink): string { + // TODO: this should return linking key for all types of providers: such as TST-123 or #89 or PR 89 (or a pair: key+id). + // Each provider should form whatever ID they need in their specific getIssueOrPullRequest() method. switch (autolink.provider?.id) { case IssuesCloudHostIntegrationId.Jira: + case IssuesCloudHostIntegrationId.Linear: return `${autolink.prefix}${autolink.id}`; default: return autolink.id; diff --git a/src/plus/integrations/providers/linear.ts b/src/plus/integrations/providers/linear.ts index dfe7d2c50d22b..65086da82b5e1 100644 --- a/src/plus/integrations/providers/linear.ts +++ b/src/plus/integrations/providers/linear.ts @@ -10,7 +10,7 @@ import { Logger } from '../../../system/logger'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { ProviderAuthenticationSession } from '../authentication/models'; import { IssuesIntegration } from '../models/issuesIntegration'; -import type { IssueFilter } from './models'; +import type { IssueFilter, ProviderIssue } from './models'; import { fromProviderIssue, providersMetadata, toIssueShape } from './models'; const metadata = providersMetadata[IssuesCloudHostIntegrationId.Linear]; @@ -210,19 +210,29 @@ export class LinearIntegration extends IssuesIntegration { - throw new Error('Method not implemented.'); + const issue = await this.getRawProviderIssue(session, resource, id); + return issue && toIssueShape(issue, this); } protected override async getProviderIssue( session: ProviderAuthenticationSession, resource: ResourceDescriptor, id: string, ): Promise { + const result = await this.getRawProviderIssue(session, resource, id); + return result && fromProviderIssue(result, this); + } + + private async getRawProviderIssue( + session: ProviderAuthenticationSession, + resource: ResourceDescriptor, + id: string, + ): Promise { const api = await this.getProvidersApi(); try { if (!isIssueResourceDescriptor(resource)) { @@ -243,7 +253,7 @@ export class LinearIntegration extends IssuesIntegration Date: Wed, 10 Sep 2025 18:23:43 +0200 Subject: [PATCH 08/10] Authenticates Linear trhough GKDev (#4543, #4579) --- src/config.ts | 1 - src/constants.integrations.ts | 14 ++++- .../integrationAuthenticationService.ts | 2 +- .../integrations/authentication/linear.ts | 56 ++----------------- 4 files changed, 19 insertions(+), 54 deletions(-) diff --git a/src/config.ts b/src/config.ts index c9f07ec6eb96e..f28d595d89703 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,6 @@ import type { DateTimeFormat } from './system/date'; import type { LogLevel } from './system/logger.constants'; export interface Config { - readonly 'temporary-configured-linear-config': string | null; readonly advanced: AdvancedConfig; readonly ai: AIConfig; readonly autolinks: AutolinkConfig[] | null; diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 90337aa74c0dc..22f989d2aeed3 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -31,7 +31,10 @@ export type IssuesHostIntegrationIds = IssuesCloudHostIntegrationId; export type IntegrationIds = GitHostIntegrationIds | IssuesHostIntegrationIds; -export const supportedOrderedCloudIssuesIntegrationIds = [IssuesCloudHostIntegrationId.Jira]; +export const supportedOrderedCloudIssuesIntegrationIds = [ + IssuesCloudHostIntegrationId.Jira, + IssuesCloudHostIntegrationId.Linear, +]; export const supportedOrderedCloudIntegrationIds = [ GitCloudHostIntegrationId.GitHub, GitSelfManagedHostIntegrationId.CloudGitHubEnterprise, @@ -42,6 +45,7 @@ export const supportedOrderedCloudIntegrationIds = [ GitCloudHostIntegrationId.Bitbucket, GitSelfManagedHostIntegrationId.BitbucketServer, IssuesCloudHostIntegrationId.Jira, + IssuesCloudHostIntegrationId.Linear, ]; export const integrationIds = [ @@ -56,6 +60,7 @@ export const integrationIds = [ GitSelfManagedHostIntegrationId.GitHubEnterprise, GitSelfManagedHostIntegrationId.GitLabSelfHosted, IssuesCloudHostIntegrationId.Jira, + IssuesCloudHostIntegrationId.Linear, IssuesCloudHostIntegrationId.Trello, ]; @@ -143,4 +148,11 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ supports: ['issues'], requiresPro: true, }, + { + id: IssuesCloudHostIntegrationId.Linear, + name: 'Linear', + icon: 'gl-provider-linear', + supports: ['issues'], + requiresPro: true, + }, ]; diff --git a/src/plus/integrations/authentication/integrationAuthenticationService.ts b/src/plus/integrations/authentication/integrationAuthenticationService.ts index 97ed9dd55b436..0862245dffeed 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationService.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationService.ts @@ -151,7 +151,7 @@ export class IntegrationAuthenticationService implements Disposable { case IssuesCloudHostIntegrationId.Linear: provider = new ( await import(/* webpackChunkName: "integrations" */ './linear') - ).LinearAuthenticationProvider(); + ).LinearAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; default: provider = new BuiltInAuthenticationProvider( diff --git a/src/plus/integrations/authentication/linear.ts b/src/plus/integrations/authentication/linear.ts index 628ba6109e260..11c78ebf510a8 100644 --- a/src/plus/integrations/authentication/linear.ts +++ b/src/plus/integrations/authentication/linear.ts @@ -1,54 +1,8 @@ -import type { Disposable, Event } from 'vscode'; -import type { Sources } from '../../../constants.telemetry'; -import { configuration } from '../../../system/-webview/configuration'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from './integrationAuthenticationProvider'; -import type { ProviderAuthenticationSession } from './models'; +import { IssuesCloudHostIntegrationId } from '../../../constants.integrations'; +import { CloudIntegrationAuthenticationProvider } from './integrationAuthenticationProvider'; -export class LinearAuthenticationProvider implements IntegrationAuthenticationProvider { - // I want to read the token from the config "temporary-configured-linear-config": - private currentToken: string | undefined = - (configuration.get('temporary-configured-linear-config') as string) ?? undefined; - - deleteSession(_descriptor: IntegrationAuthenticationSessionDescriptor): Promise { - //throw new Error('Method not implemented.'); - this.currentToken = undefined; - return Promise.resolve(); - } - deleteAllSessions(): Promise { - //throw new Error('Method not implemented.'); - this.currentToken = undefined; - return Promise.resolve(); - } - getSession( - _descriptor: IntegrationAuthenticationSessionDescriptor, - _options?: - | { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources } - | { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources }, - ): Promise { - return Promise.resolve( - this.currentToken - ? { - accessToken: this.currentToken, - id: 'linear', - account: { - id: 'linear', - label: 'Linear', - }, - scopes: ['read'], - cloud: true, - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), - domain: 'linear.app', - } - : undefined, - ); - } - get onDidChange(): Event { - return (_listener: (e: void) => any, _thisArgs?: any, _disposables?: Disposable[]): Disposable => { - return { dispose: () => {} }; - }; +export class LinearAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): IssuesCloudHostIntegrationId.Linear { + return IssuesCloudHostIntegrationId.Linear; } - dispose(): void {} } From eca4d29754df64def6f07afbb6f0cce48e19a5d4 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 10 Sep 2025 19:36:51 +0200 Subject: [PATCH 09/10] Adds Linear icon (#4543, #4579) --- images/icons/provider-bitbucket.svg | 2 +- images/icons/provider-linear.svg | 1 + images/icons/template/mapping.json | 3 ++- package.json | 7 +++++++ src/webviews/apps/commitDetails/commitDetails.html | 2 +- src/webviews/apps/home/home.html | 2 +- src/webviews/apps/plus/composer/composer.html | 2 +- src/webviews/apps/plus/graph/graph.html | 2 +- src/webviews/apps/plus/graph/graph.scss | 7 +++++++ src/webviews/apps/plus/patchDetails/patchDetails.html | 2 +- src/webviews/apps/plus/timeline/timeline.html | 2 +- src/webviews/apps/shared/components/icons/glicons-map.ts | 1 + src/webviews/apps/shared/glicons.scss | 5 ++++- src/webviews/apps/shared/styles/icons/glicons-map.scss | 1 + 14 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 images/icons/provider-linear.svg diff --git a/images/icons/provider-bitbucket.svg b/images/icons/provider-bitbucket.svg index 54ddd78d10b7f..6119ea4d5779f 100644 --- a/images/icons/provider-bitbucket.svg +++ b/images/icons/provider-bitbucket.svg @@ -1 +1 @@ - + diff --git a/images/icons/provider-linear.svg b/images/icons/provider-linear.svg new file mode 100644 index 0000000000000..79cff26e491b8 --- /dev/null +++ b/images/icons/provider-linear.svg @@ -0,0 +1 @@ + diff --git a/images/icons/template/mapping.json b/images/icons/template/mapping.json index e3997808159ea..3a6cc0eba8ee9 100644 --- a/images/icons/template/mapping.json +++ b/images/icons/template/mapping.json @@ -64,5 +64,6 @@ "repository": 61763, "worktree": 61764, "worktree-filled": 61765, - "repository-cloud": 61766 + "repository-cloud": 61766, + "provider-linear": 61767 } \ No newline at end of file diff --git a/package.json b/package.json index b7ded63810c8a..e53d6b1b080b9 100644 --- a/package.json +++ b/package.json @@ -10992,6 +10992,13 @@ "fontPath": "dist/glicons.woff2", "fontCharacter": "\\f146" } + }, + "gitlens-provider-linear": { + "description": "provider-linear icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f147" + } } }, "menus": { diff --git a/src/webviews/apps/commitDetails/commitDetails.html b/src/webviews/apps/commitDetails/commitDetails.html index 2e5623674bae5..a69627b889131 100644 --- a/src/webviews/apps/commitDetails/commitDetails.html +++ b/src/webviews/apps/commitDetails/commitDetails.html @@ -11,7 +11,7 @@ @font-face { font-family: 'glicons'; font-display: block; - src: url('#{root}/dist/glicons.woff2?b264f1ed05b6fced8e7bfbe5c426587e') format('woff2'); + src: url('#{root}/dist/glicons.woff2?d3b316716ee1329763a193a20834cd0a') format('woff2'); } diff --git a/src/webviews/apps/home/home.html b/src/webviews/apps/home/home.html index fe38169f1c1d4..ae2ca4cb107f4 100644 --- a/src/webviews/apps/home/home.html +++ b/src/webviews/apps/home/home.html @@ -11,7 +11,7 @@ @font-face { font-family: 'glicons'; font-display: block; - src: url('#{root}/dist/glicons.woff2?b264f1ed05b6fced8e7bfbe5c426587e') format('woff2'); + src: url('#{root}/dist/glicons.woff2?d3b316716ee1329763a193a20834cd0a') format('woff2'); } diff --git a/src/webviews/apps/plus/composer/composer.html b/src/webviews/apps/plus/composer/composer.html index df2d229eb8621..41bfa03e3a9e7 100644 --- a/src/webviews/apps/plus/composer/composer.html +++ b/src/webviews/apps/plus/composer/composer.html @@ -11,7 +11,7 @@ @font-face { font-family: 'glicons'; font-display: block; - src: url('#{root}/dist/glicons.woff2?3614d4fae1d1a9c07c67436aad657680') format('woff2'); + src: url('#{root}/dist/glicons.woff2?d3b316716ee1329763a193a20834cd0a') format('woff2'); }