diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts new file mode 100644 index 00000000..305ebfa3 --- /dev/null +++ b/__mocks__/vscode.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +module.exports = { + ...require('jest-mock-vscode').createVSCodeMock(jest), + env: { + uriScheme: 'vscode' + } +} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 24ac1fbb..f9bc91f8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ module.exports = { - roots: ['/src'], + roots: [''], testMatch: ['**/test/**/*.+(ts|tsx|js)', '**/?(*.)+(spec|test).+(ts|tsx|js)'], transform: { '^.+\\.(min.js|ts|tsx)$': [ diff --git a/package-lock.json b/package-lock.json index 3ce112d4..2e6d1cd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -180,6 +180,7 @@ "fork-ts-checker-webpack-plugin": "^9.0.2", "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", + "jest-mock-vscode": "^4.0.4", "license-checker": "^25.0.1", "mini-css-extract-plugin": "^2.9.1", "npm-run-all": "^4.1.5", @@ -18770,6 +18771,22 @@ "node": ">=8" } }, + "node_modules/jest-mock-vscode": { + "version": "4.0.4", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/jest-mock-vscode/-/jest-mock-vscode-4.0.4.tgz", + "integrity": "sha512-Mq9/sTcYjuYRIKJwYrPy29+oX5gkJcCbMoPl9m2lZbCAzJH7UasRJn42/oRE8XFwMSyLMJPmU4Uvx9vqjX0nRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "engines": { + "node": ">20.0.0" + }, + "peerDependencies": { + "@types/vscode": "^1.90.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://packages.atlassian.com/api/npm/npm-remote/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -43035,6 +43052,15 @@ } } }, + "jest-mock-vscode": { + "version": "4.0.4", + "resolved": "https://packages.atlassian.com/api/npm/npm-remote/jest-mock-vscode/-/jest-mock-vscode-4.0.4.tgz", + "integrity": "sha512-Mq9/sTcYjuYRIKJwYrPy29+oX5gkJcCbMoPl9m2lZbCAzJH7UasRJn42/oRE8XFwMSyLMJPmU4Uvx9vqjX0nRg==", + "dev": true, + "requires": { + "vscode-uri": "^3.0.8" + } + }, "jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://packages.atlassian.com/api/npm/npm-remote/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", diff --git a/package.json b/package.json index ae898d0c..a015f0ff 100644 --- a/package.json +++ b/package.json @@ -1521,6 +1521,7 @@ "fork-ts-checker-webpack-plugin": "^9.0.2", "html-webpack-plugin": "^5.6.0", "jest": "^29.7.0", + "jest-mock-vscode": "^4.0.4", "license-checker": "^25.0.1", "mini-css-extract-plugin": "^2.9.1", "npm-run-all": "^4.1.5", diff --git a/src/atlclients/authStore.ts b/src/atlclients/authStore.ts index 63aeb26f..3bdfe34c 100644 --- a/src/atlclients/authStore.ts +++ b/src/atlclients/authStore.ts @@ -26,7 +26,7 @@ import { Tokens } from './tokens'; import crypto from 'crypto'; import { keychain } from '../util/keychain'; import { loggedOutEvent } from '../analytics'; -import { Container } from 'src/container'; +import { Container } from '../container'; const keychainServiceNameV3 = version.endsWith('-insider') ? 'atlascode-insiders-authinfoV3' : 'atlascode-authinfoV3'; enum Priority { diff --git a/src/atlclients/oauthRefresher.ts b/src/atlclients/oauthRefresher.ts index 6df10dfa..864e9675 100644 --- a/src/atlclients/oauthRefresher.ts +++ b/src/atlclients/oauthRefresher.ts @@ -6,7 +6,7 @@ import { AxiosUserAgent } from '../constants'; import { ConnectionTimeout } from '../util/time'; import { Container } from '../container'; import { Disposable } from 'vscode'; -import { Logger } from 'src/logger'; +import { Logger } from '../logger'; import { addCurlLogging } from './interceptors'; import { getAgent } from '../jira/jira-client/providers'; import { strategyForProvider } from './strategy'; diff --git a/src/container.ts b/src/container.ts index 9b25ec87..d52fe3ce 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,6 +1,6 @@ -import { AtlascodeUriHandler, ONBOARDING_URL, SETTINGS_URL } from './uriHandler'; +import { LegacyAtlascodeUriHandler, ONBOARDING_URL, SETTINGS_URL } from './uriHandler/legacyUriHandler'; import { BitbucketIssue, BitbucketSite, PullRequest, WorkspaceRepo } from './bitbucket/model'; -import { Disposable, ExtensionContext, UriHandler, env, workspace, UIKind } from 'vscode'; +import { Disposable, ExtensionContext, env, workspace, UIKind } from 'vscode'; import { IConfig, configuration } from './config/configuration'; import { analyticsClient } from './analytics-node-client/src/client.min.js'; @@ -65,8 +65,9 @@ import { VSCWelcomeActionApi } from './webview/welcome/vscWelcomeActionApi'; import { VSCWelcomeWebviewControllerFactory } from './webview/welcome/vscWelcomeWebviewControllerFactory'; import { WelcomeAction } from './lib/ipc/fromUI/welcome'; import { WelcomeInitMessage } from './lib/ipc/toUI/welcome'; -import { FeatureFlagClient } from './util/featureFlags'; +import { FeatureFlagClient, Features } from './util/featureFlags'; import { EventBuilder } from './util/featureFlags/eventBuilder'; +import { AtlascodeUriHandler } from './uriHandler'; import { CheckoutHelper } from './bitbucket/interfaces'; const isDebuggingRegex = /^--(debug|inspect)\b(-brk\b|(?!-))=?/; @@ -86,14 +87,6 @@ export class Container { enable: this.getAnalyticsEnable(), }); - FeatureFlagClient.initialize({ - analyticsClient: this._analyticsClient, - identifiers: { - analyticsAnonymousId: env.machineId, - }, - eventBuilder: new EventBuilder(), - }); - this._cancellationManager = new Map(); this._analyticsApi = new VSCAnalyticsApi(this._analyticsClient, this.isRemote, this.isWebUI); this._commonMessageHandler = new VSCCommonMessageHandler(this._analyticsApi, this._cancellationManager); @@ -189,9 +182,6 @@ export class Container { this._loginManager = new LoginManager(this._credentialManager, this._siteManager, this._analyticsClient); this._bitbucketHelper = new BitbucketCheckoutHelper(context.globalState); - context.subscriptions.push( - (this._uriHandler = new AtlascodeUriHandler(this._analyticsApi, this._bitbucketHelper)), - ); if (config.jira.explorer.enabled) { context.subscriptions.push((this._jiraExplorer = new JiraContext())); @@ -206,6 +196,16 @@ export class Container { } context.subscriptions.push((this._helpExplorer = new HelpExplorer())); + + FeatureFlagClient.initialize({ + analyticsClient: this._analyticsClient, + identifiers: { + analyticsAnonymousId: env.machineId, + }, + eventBuilder: new EventBuilder(), + }).then(() => { + this.initializeUriHandler(context, this._analyticsApi, this._bitbucketHelper); + }); } static getAnalyticsEnable(): boolean { @@ -213,6 +213,27 @@ export class Container { return telemetryConfig.get('enableTelemetry', true); } + static initializeUriHandler( + context: ExtensionContext, + analyticsApi: VSCAnalyticsApi, + bitbucketHelper: CheckoutHelper, + ) { + FeatureFlagClient.checkGate(Features.EnableNewUriHandler) + .then((enabled) => { + if (enabled) { + console.log('Using new URI handler'); + context.subscriptions.push(AtlascodeUriHandler.create(analyticsApi, bitbucketHelper)); + } else { + context.subscriptions.push(new LegacyAtlascodeUriHandler(analyticsApi, bitbucketHelper)); + } + }) + .catch((err) => { + // Not likely that we'd land here - but if anything goes wrong, default to legacy handler + console.error(`Error checking feature flag ${Features.EnableNewUriHandler}: ${err}`); + context.subscriptions.push(new LegacyAtlascodeUriHandler(analyticsApi, bitbucketHelper)); + }); + } + static initializeBitbucket(bbCtx: BitbucketContext) { this._bitbucketContext = bbCtx; this._pipelinesExplorer = new PipelinesExplorer(bbCtx); @@ -288,11 +309,6 @@ export class Container { this._context.globalState.update(ConfigTargetKey, target); } - private static _uriHandler: UriHandler; - static get uriHandler() { - return this._uriHandler; - } - private static _version: string; static get version() { return this._version; diff --git a/src/jira/jira-client/providers.ts b/src/jira/jira-client/providers.ts index 74c067c7..034b6157 100644 --- a/src/jira/jira-client/providers.ts +++ b/src/jira/jira-client/providers.ts @@ -3,7 +3,7 @@ import { AgentProvider, getProxyHostAndPort, shouldTunnelHost } from '@atlassian import axios, { AxiosInstance } from 'axios'; import * as fs from 'fs'; import * as https from 'https'; -import { Logger } from 'src/logger'; +import { Logger } from '../../logger'; import * as sslRootCas from 'ssl-root-cas'; import { DetailedSiteInfo, SiteInfo } from '../../atlclients/authInfo'; import { BasicInterceptor } from '../../atlclients/basicInterceptor'; diff --git a/src/uriHandler/actions/checkoutBranch.test.ts b/src/uriHandler/actions/checkoutBranch.test.ts new file mode 100644 index 00000000..70863905 --- /dev/null +++ b/src/uriHandler/actions/checkoutBranch.test.ts @@ -0,0 +1,47 @@ +import { Uri, window } from 'vscode'; +import { CheckoutBranchUriHandlerAction } from './checkoutBranch'; + +describe('CheckoutBranchUriHandlerAction', () => { + const mockAnalyticsApi = { + fireDeepLinkEvent: jest.fn(), + }; + const mockCheckoutHelper = { + checkoutRef: jest.fn().mockResolvedValue(true), + }; + let action: CheckoutBranchUriHandlerAction; + + beforeEach(() => { + jest.clearAllMocks(); + action = new CheckoutBranchUriHandlerAction(mockCheckoutHelper as any, mockAnalyticsApi as any); + }); + + describe('handle', () => { + it('throws if required query params are missing', async () => { + await expect(action.handle(Uri.parse('https://some-uri/checkoutBranch'))).rejects.toThrow(); + await expect( + action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=...&ref=...')), + ).rejects.toThrow(); + await expect( + action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=...&refType=...')), + ).rejects.toThrow(); + await expect( + action.handle(Uri.parse('https://some-uri/checkoutBranch?ref=...&refType=...')), + ).rejects.toThrow(); + }); + + it('checks out the branch and fires an event on success', async () => { + mockCheckoutHelper.checkoutRef.mockResolvedValue(true); + await action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=one&ref=two&refType=three')); + + expect(mockCheckoutHelper.checkoutRef).toHaveBeenCalledWith('one', 'two', 'three', ''); + expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled(); + }); + + it('shows an error message on failure', async () => { + mockCheckoutHelper.checkoutRef.mockRejectedValue(new Error('oh no')); + await action.handle(Uri.parse('https://some-uri/checkoutBranch?cloneUrl=one&ref=two&refType=three')); + + expect(window.showErrorMessage).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/checkoutBranch.ts b/src/uriHandler/actions/checkoutBranch.ts new file mode 100644 index 00000000..bcc0c1ae --- /dev/null +++ b/src/uriHandler/actions/checkoutBranch.ts @@ -0,0 +1,53 @@ +import { Uri, window } from 'vscode'; +import { isAcceptedBySuffix, UriHandlerAction } from '../uriHandlerAction'; +import { CheckoutHelper } from '../../bitbucket/interfaces'; +import { AnalyticsApi } from '../../lib/analyticsApi'; +import { Logger } from '../../logger'; + +/** + * Use a deep link to checkout a branch + * + * Expected link: + * `vscode://atlassian.atlascode/checkoutBranch?cloneUrl=...&ref=...&refType=...` + * + * Query params: + * - `cloneUrl`: the clone URL of the repository + * - `ref`: the ref to check out + * - `refType`: the type of ref to check out + * - `sourceCloneUrl`: (optional) the clone URL of the source repository (for branches originating from a forked repo) + */ +export class CheckoutBranchUriHandlerAction implements UriHandlerAction { + constructor( + private bitbucketHelper: CheckoutHelper, + private analyticsApi: AnalyticsApi, + ) {} + + isAccepted(uri: Uri): boolean { + return isAcceptedBySuffix(uri, 'checkoutBranch'); + } + + async handle(uri: Uri) { + const query = new URLSearchParams(uri.query); + const cloneUrl = decodeURIComponent(query.get('cloneUrl') || ''); + const sourceCloneUrl = decodeURIComponent(query.get('sourceCloneUrl') || ''); //For branches originating from a forked repo + const ref = query.get('ref'); + const refType = query.get('refType'); + if (!ref || !cloneUrl || !refType) { + throw new Error(`Query params are missing data: ${query}`); + } + + try { + const success = await this.bitbucketHelper.checkoutRef(cloneUrl, ref, refType, sourceCloneUrl); + + if (success) { + this.analyticsApi.fireDeepLinkEvent( + decodeURIComponent(query.get('source') || 'unknown'), + 'checkoutBranch', + ); + } + } catch (e) { + Logger.debug('error checkout out branch:', e); + window.showErrorMessage('Error checkout out branch (check log for details)'); + } + } +} diff --git a/src/uriHandler/actions/cloneRepository.test.ts b/src/uriHandler/actions/cloneRepository.test.ts new file mode 100644 index 00000000..dd170c50 --- /dev/null +++ b/src/uriHandler/actions/cloneRepository.test.ts @@ -0,0 +1,45 @@ +import { Uri, window } from 'vscode'; +import { CloneRepositoryUriHandlerAction } from './cloneRepository'; + +describe('CloneRepositoryUriHandlerAction', () => { + const mockAnalyticsApi = { + fireDeepLinkEvent: jest.fn(), + }; + const mockCheckoutHelper = { + cloneRepository: jest.fn(), + }; + let action: CloneRepositoryUriHandlerAction; + + beforeEach(() => { + jest.clearAllMocks(); + action = new CloneRepositoryUriHandlerAction(mockCheckoutHelper as any, mockAnalyticsApi as any); + }); + + describe('isAccepted', () => { + it('only accepts URIs ending with cloneRepository', () => { + expect(action.isAccepted(Uri.parse('https://some-uri/cloneRepository'))).toBe(true); + expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false); + }); + }); + + describe('handle', () => { + it('throws if required query params are missing', async () => { + await expect(action.handle(Uri.parse('https://some-uri/cloneRepository'))).rejects.toThrow(); + }); + + it('clones the repo and fires an event on success', async () => { + mockCheckoutHelper.cloneRepository.mockResolvedValue(null); + await action.handle(Uri.parse('https://some-uri/cloneRepository?q=one')); + + expect(mockCheckoutHelper.cloneRepository).toHaveBeenCalledWith('one'); + expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled(); + }); + + it('shows an error message on failure', async () => { + mockCheckoutHelper.cloneRepository.mockRejectedValue(new Error('oh no')); + await action.handle(Uri.parse('https://some-uri/cloneRepository?q=one')); + + expect(window.showErrorMessage).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/cloneRepository.ts b/src/uriHandler/actions/cloneRepository.ts new file mode 100644 index 00000000..14b4a067 --- /dev/null +++ b/src/uriHandler/actions/cloneRepository.ts @@ -0,0 +1,44 @@ +import { Uri, window } from 'vscode'; +import { isAcceptedBySuffix, UriHandlerAction } from '../uriHandlerAction'; +import { CheckoutHelper } from '../../bitbucket/interfaces'; +import { AnalyticsApi } from '../../lib/analyticsApi'; +import { Logger } from '../../logger'; + +/** + * Use a deep link to clone a repository + * + * Expected link: + * `vscode://atlassian.atlascode/cloneRepository?q=...` + * + * Query params: + * - `q`: the clone URL of the repository + */ +export class CloneRepositoryUriHandlerAction implements UriHandlerAction { + constructor( + private bitbucketHelper: CheckoutHelper, + private analyticsApi: AnalyticsApi, + ) {} + + isAccepted(uri: Uri): boolean { + return isAcceptedBySuffix(uri, 'cloneRepository'); + } + + async handle(uri: Uri) { + const query = new URLSearchParams(uri.query); + const repoUrl = decodeURIComponent(query.get('q') || ''); + if (!repoUrl) { + throw new Error(`Cannot parse clone URL from: ${query}`); + } + + try { + await this.bitbucketHelper.cloneRepository(repoUrl); + this.analyticsApi.fireDeepLinkEvent( + decodeURIComponent(query.get('source') || 'unknown'), + 'cloneRepository', + ); + } catch (e) { + Logger.debug('error cloning repository:', e); + window.showErrorMessage('Error cloning repository (check log for details)'); + } + } +} diff --git a/src/uriHandler/actions/openPullRequest.test.ts b/src/uriHandler/actions/openPullRequest.test.ts new file mode 100644 index 00000000..ffb8a7ed --- /dev/null +++ b/src/uriHandler/actions/openPullRequest.test.ts @@ -0,0 +1,70 @@ +import { Uri, window } from 'vscode'; +import { OpenPullRequestUriHandlerAction } from './openPullRequest'; + +describe('OpenPullRequestUriHandlerAction', () => { + let action: OpenPullRequestUriHandlerAction; + const mockAnalyticsApi = { + fireDeepLinkEvent: jest.fn(), + }; + const mockCheckoutHelper = { + pullRequest: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + action = new OpenPullRequestUriHandlerAction(mockAnalyticsApi as any, mockCheckoutHelper as any); + }); + + describe('isAccepted', () => { + it('only accepts URIs ending with openPullRequest', () => { + expect(action.isAccepted(Uri.parse('https://some-uri/openPullRequest'))).toBe(true); + expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false); + }); + }); + + describe('parsePrUrl', () => { + it('extracts the repo url and pr number from a URL', () => { + expect(action.parsePrUrl('https://bitbucket.com/my-cool-repo/pull-requests/123')).toEqual({ + repoUrl: 'https://bitbucket.com/my-cool-repo', + prId: 123, + }); + + // This will actually fail with the current implementation: + // expect(action.parsePrUrl('https://bitbucket.com/my-cool-repo/pull-requests/123/overview')).toEqual({ + // repoUrl: 'https://bitbucket.com/my-cool-repo', + // prId: 123 + // }); + }); + }); + + describe('handle', () => { + it('throws if required query params are missing', async () => { + await expect(action.handle(Uri.parse('https://some-uri/openPullRequest'))).rejects.toThrow(); + }); + + it('throws if repo URL fails to parse', async () => { + action.parsePrUrl = jest.fn().mockImplementation(() => { + throw new Error('oh no'); + }); + await expect(action.handle(Uri.parse('https://some-uri/openPullRequest?q=...'))).rejects.toThrow(); + }); + + it('opens a pull request and fires an event on success', async () => { + mockCheckoutHelper.pullRequest.mockResolvedValue(true); + action.parsePrUrl = jest.fn().mockReturnValue({ repoUrl: 'one', prId: 2 }); + await action.handle(Uri.parse('https://some-uri/openPullRequest?q=one')); + + expect(mockCheckoutHelper.pullRequest).toHaveBeenCalledWith('one', 2); + expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled(); + }); + + it('shows an error message on failure', async () => { + mockCheckoutHelper.pullRequest.mockRejectedValue(new Error('oh no')); + await action.handle(Uri.parse('https://some-uri/openPullRequest?q=...')); + action.parsePrUrl = jest.fn().mockReturnValue({ repoUrl: 'one', prId: 2 }); + + // This will actually fail with the current implementation: + expect(window.showErrorMessage).toHaveBeenCalledWith('Error opening pull request (check log for details)'); + }); + }); +}); diff --git a/src/uriHandler/actions/openPullRequest.ts b/src/uriHandler/actions/openPullRequest.ts new file mode 100644 index 00000000..ca579767 --- /dev/null +++ b/src/uriHandler/actions/openPullRequest.ts @@ -0,0 +1,52 @@ +import { Uri, window } from 'vscode'; +import { Logger } from '../../logger'; +import { UriHandlerAction } from '../uriHandlerAction'; +import { AnalyticsApi } from '../../lib/analyticsApi'; +import { CheckoutHelper } from '../../bitbucket/interfaces'; + +/** + * Use a deep link to open pull request + * + * Expected link: + * `vscode://atlassian.atlascode/openPullRequest?q=...[&source=...]` + * + * Query params: + * - `q`: the pull request URL + * - `source`: (optional) the source of the deep link + */ +export class OpenPullRequestUriHandlerAction implements UriHandlerAction { + constructor( + private analyticsApi: AnalyticsApi, + private bitbucketHelper: CheckoutHelper, + ) {} + + isAccepted(uri: Uri): boolean { + return uri.path.endsWith('openPullRequest'); + } + + async handle(uri: Uri) { + const query = new URLSearchParams(uri.query); + const source = decodeURIComponent(query.get('source') || 'unknown'); + const prUrl = decodeURIComponent(query.get('q') || ''); + const { repoUrl, prId } = this.parsePrUrl(prUrl); + if (!prUrl) { + throw new Error(`Cannot parse pull request URL from: ${query}`); + } + + try { + await this.bitbucketHelper.pullRequest(repoUrl, prId); + this.analyticsApi.fireDeepLinkEvent(source, 'pullRequest'); + } catch (e) { + Logger.debug('error opening pull request:', e); + window.showErrorMessage('Error opening pull request (check log for details)'); + } + } + + parsePrUrl(url: string): { repoUrl: string; prId: number } { + // TODO: this feels very sketchy. Redo in a follow-up using a proper URL parser and some regex? + const repoUrl = url.slice(0, url.indexOf('/pull-requests')); + const prUrlPath = Uri.parse(url).path; + const prId = prUrlPath.slice(prUrlPath.lastIndexOf('/') + 1); + return { repoUrl, prId: parseInt(prId) }; + } +} diff --git a/src/uriHandler/actions/showJiraIssue.test.ts b/src/uriHandler/actions/showJiraIssue.test.ts new file mode 100644 index 00000000..5831801f --- /dev/null +++ b/src/uriHandler/actions/showJiraIssue.test.ts @@ -0,0 +1,54 @@ +import { Uri, window } from 'vscode'; + +jest.mock('../../commands/jira/showIssue', () => ({ + showIssue: jest.fn(), +})); +import { showIssue } from '../../commands/jira/showIssue'; + +import { ShowJiraIssueUriHandlerAction } from './showJiraIssue'; + +describe('ShowJiraIssueUriHandlerAction', () => { + const mockAnalyticsApi = { + fireDeepLinkEvent: jest.fn(), + }; + const mockFetcher = { + fetchIssue: jest.fn(), + }; + let action: ShowJiraIssueUriHandlerAction; + + beforeEach(() => { + jest.clearAllMocks(); + action = new ShowJiraIssueUriHandlerAction(mockAnalyticsApi as any, mockFetcher as any); + }); + + describe('isAccepted', () => { + it('only accepts URIs ending with showJiraIssue', () => { + expect(action.isAccepted(Uri.parse('https://some-uri/showJiraIssue'))).toBe(true); + expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false); + }); + }); + + describe('handle', () => { + it('throws if required query params are missing', async () => { + await expect(action.handle(Uri.parse('https://some-uri/showJiraIssue'))).rejects.toThrow(); + await expect( + action.handle(Uri.parse('https://some-uri/showJiraIssue?issueKey=AXON-123')), + ).rejects.toThrow(); + }); + + it('shows error if the issue could not be fetched', async () => { + mockFetcher.fetchIssue.mockRejectedValue(new Error('oh no')); + await expect( + action.handle(Uri.parse('https://some-uri/showJiraIssue?issueKey=AXON-123&site=jira')), + ).resolves.toBeUndefined(); + expect(window.showErrorMessage).toHaveBeenCalled(); + }); + + it('shows the Jira issue and fires an analytics event if everything is good', async () => { + mockFetcher.fetchIssue.mockResolvedValue({ some: 'issue' }); + await action.handle(Uri.parse('https://some-uri/showJiraIssue?issueKey=AXON-123&site=jira')); + expect(showIssue).toHaveBeenCalledWith({ some: 'issue' }); + expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/showJiraIssue.ts b/src/uriHandler/actions/showJiraIssue.ts new file mode 100644 index 00000000..e60fdde0 --- /dev/null +++ b/src/uriHandler/actions/showJiraIssue.ts @@ -0,0 +1,46 @@ +import { Uri, window } from 'vscode'; +import { UriHandlerAction } from '../uriHandlerAction'; +import { Logger } from '../../logger'; +import { AnalyticsApi } from '../../lib/analyticsApi'; +import { showIssue } from '../../commands/jira/showIssue'; +import { JiraIssueFetcher } from './util/jiraIssueFetcher'; + +/** + * Use a deep link to show a Jira issue + * + * Expected link: + * vscode://atlassian.atlascode/showJiraIssue?site=...&issueKey=... + * + * Query params: + * - `site`: the site base URL, like `https://site.atlassian.net` + * - `issueKey`: the issue key, like `PROJ-123` + */ +export class ShowJiraIssueUriHandlerAction implements UriHandlerAction { + constructor( + private analyticsApi: AnalyticsApi, + private issueFetcher: JiraIssueFetcher, + ) {} + + isAccepted(uri: Uri): boolean { + return uri.path.endsWith('showJiraIssue'); + } + + async handle(uri: Uri) { + const query = new URLSearchParams(uri.query); + const siteBaseURL = query.get('site'); + const issueKey = query.get('issueKey'); + + if (!siteBaseURL || !issueKey) { + throw new Error(`Cannot parse request URL from: ${query}`); + } + + try { + const issue = await this.issueFetcher.fetchIssue(issueKey, siteBaseURL); + showIssue(issue); + this.analyticsApi.fireDeepLinkEvent(decodeURIComponent(query.get('source') || 'unknown'), 'showJiraIssue'); + } catch (e) { + Logger.debug('error opening issue page:', e); + window.showErrorMessage('Error opening issue page (check log for details)'); + } + } +} diff --git a/src/uriHandler/actions/simpleCallback.test.ts b/src/uriHandler/actions/simpleCallback.test.ts new file mode 100644 index 00000000..6a9bc00c --- /dev/null +++ b/src/uriHandler/actions/simpleCallback.test.ts @@ -0,0 +1,26 @@ +import { Uri } from 'vscode'; +import { SimpleCallbackAction } from './simpleCallback'; + +describe('OpenSettingsUriHandlerAction', () => { + const suffix = 'cool-callback'; + const callback = jest.fn(); + const action = new SimpleCallbackAction(suffix, callback); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isAccepted', () => { + it('only accepts URIs ending with openSettings', () => { + expect(action.isAccepted(Uri.parse(`https://some-uri/${suffix}`))).toBe(true); + expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false); + }); + }); + + describe('handle', () => { + it('calls the callback', async () => { + await action.handle(Uri.parse('https://some-uri/openSettings')); + expect(callback).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/simpleCallback.ts b/src/uriHandler/actions/simpleCallback.ts new file mode 100644 index 00000000..618d9ce2 --- /dev/null +++ b/src/uriHandler/actions/simpleCallback.ts @@ -0,0 +1,26 @@ +import { Uri } from 'vscode'; +import { UriHandlerAction } from '../uriHandlerAction'; + +/** + * Use a deep link to trigger a callback + * + * Expected link: + * vscode://atlassian.atlascode/[uriSuffix] + * + * Query params: + * - none + */ +export class SimpleCallbackAction implements UriHandlerAction { + constructor( + private uriSuffix: string, + private callback: () => Promise, + ) {} + + isAccepted(uri: Uri): boolean { + return uri.path.endsWith(this.uriSuffix); + } + + async handle(uri: Uri): Promise { + return await this.callback(); + } +} diff --git a/src/uriHandler/actions/startWork.test.ts b/src/uriHandler/actions/startWork.test.ts new file mode 100644 index 00000000..4ad5ef30 --- /dev/null +++ b/src/uriHandler/actions/startWork.test.ts @@ -0,0 +1,52 @@ +import { Uri, window } from 'vscode'; + +const mockStartWork = jest.fn(); +jest.mock('../../commands/jira/startWorkOnIssue', () => ({ + startWorkOnIssue: mockStartWork, +})); +import { StartWorkUriHandlerAction } from './startWork'; + +describe('StartWorkAction', () => { + const mockAnalyticsApi = { + fireDeepLinkEvent: jest.fn(), + }; + const mockFetcher = { + fetchIssue: jest.fn(), + }; + let action: StartWorkUriHandlerAction; + + beforeEach(() => { + jest.clearAllMocks(); + action = new StartWorkUriHandlerAction(mockAnalyticsApi as any, mockFetcher as any); + }); + + describe('isAccepted', () => { + it('only accepts URIs ending with startWorkOnJiraIssue', () => { + expect(action.isAccepted(Uri.parse('https://some-uri/startWorkOnJiraIssue'))).toBe(true); + expect(action.isAccepted(Uri.parse('https://some-uri/otherThing'))).toBe(false); + }); + }); + + describe('handle', () => { + it('throws if required query params are missing', async () => { + await expect(action.handle(Uri.parse('https://some-uri/startWork'))).rejects.toThrow(); + await expect(action.handle(Uri.parse('https://some-uri/startWork?issueKey=AXON-123'))).rejects.toThrow(); + await expect(action.handle(Uri.parse('https://some-uri/startWork?site=localhost'))).rejects.toThrow(); + }); + + it('shows error if the issue could not be fetched', async () => { + mockFetcher.fetchIssue.mockRejectedValue(new Error('oh no')); + await expect( + action.handle(Uri.parse('https://some-uri/startWork?issueKey=AXON-123&site=localhost')), + ).resolves.toBeUndefined(); + expect(window.showErrorMessage).toHaveBeenCalled(); + }); + + it('shows the Start Work view, and fires an analytics event if everything is good', async () => { + mockFetcher.fetchIssue.mockResolvedValue({ some: 'issue' }); + await action.handle(Uri.parse('https://some-uri/startWork?issueKey=AXON-123&site=localhost')); + expect(mockStartWork).toHaveBeenCalledWith({ some: 'issue' }); + expect(mockAnalyticsApi.fireDeepLinkEvent).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/startWork.ts b/src/uriHandler/actions/startWork.ts new file mode 100644 index 00000000..87c8d323 --- /dev/null +++ b/src/uriHandler/actions/startWork.ts @@ -0,0 +1,51 @@ +import { Uri, window } from 'vscode'; +import { UriHandlerAction } from '../uriHandlerAction'; +import { startWorkOnIssue } from '../../commands/jira/startWorkOnIssue'; +import { AnalyticsApi } from '../../lib/analyticsApi'; +import { Logger } from '../../logger'; +import { JiraIssueFetcher } from './util/jiraIssueFetcher'; + +/** + * Use a deep link to start work on a Jira issue + * + * Expected link: + * vscode://atlassian.atlascode/startWorkOnJiraIssue?site=...&issueKey=... + * + * Query params: + * - `site`: the site base URL, like `https://site.atlassian.net` + * - `issueKey`: the issue key, like `PROJ-123` + */ +export class StartWorkUriHandlerAction implements UriHandlerAction { + constructor( + private analyticsApi: AnalyticsApi, + private issueFetcher: JiraIssueFetcher, + ) {} + + isAccepted(uri: Uri): boolean { + return uri.path.endsWith('startWorkOnJiraIssue'); + } + + async handle(uri: Uri) { + const query = new URLSearchParams(uri.query); + const siteBaseURL = query.get('site'); + const issueKey = query.get('issueKey'); + // const aaid = query.get('aaid'); aaid is not currently used for anything is included in the url and may be useful to have in the future + + if (!siteBaseURL || !issueKey) { + throw new Error(`Cannot parse request URL from: ${query}`); + } + + try { + const issue = await this.issueFetcher.fetchIssue(issueKey, siteBaseURL); + startWorkOnIssue(issue); + + this.analyticsApi.fireDeepLinkEvent( + decodeURIComponent(query.get('source') || 'unknown'), + 'startWorkOnJiraIssue', + ); + } catch (e) { + Logger.debug('error opening start work page:', e); + window.showErrorMessage('Error opening start work page (check log for details)'); + } + } +} diff --git a/src/uriHandler/actions/util/jiraIssueFetcher.test.ts b/src/uriHandler/actions/util/jiraIssueFetcher.test.ts new file mode 100644 index 00000000..ea277f51 --- /dev/null +++ b/src/uriHandler/actions/util/jiraIssueFetcher.test.ts @@ -0,0 +1,104 @@ +import { window } from 'vscode'; + +const mockSiteInfo = { + id: 'one', + baseLinkUrl: 'https://jira.com', + isCloud: true, +}; + +const mockFindIssue = jest.fn(); +const mockFetchIssue = jest.fn(); + +jest.mock('../../../jira/fetchIssue', () => ({ + fetchMinimalIssue: mockFetchIssue, +})); + +jest.mock('../../../container', () => ({ + Container: { + siteManager: { + getSitesAvailable: jest.fn().mockImplementation(() => [mockSiteInfo]), + }, + settingsWebviewFactory: { + createOrShow: jest.fn(), + }, + jiraExplorer: { + findIssue: mockFindIssue, + }, + }, +})); +import { Container } from '../../../container'; +import { JiraIssueFetcher } from './jiraIssueFetcher'; + +describe('JiraIssueFetcher', () => { + let fetcher: JiraIssueFetcher; + const mockShowInformationMessage: jest.Mock = window.showInformationMessage as any; + + beforeEach(() => { + jest.clearAllMocks(); + fetcher = new JiraIssueFetcher(); + }); + + describe('fetchIssue', () => { + it('fetches the issue if found', async () => { + fetcher.findMatchingSite = jest.fn().mockReturnValue(mockSiteInfo); + fetcher.findIssueOnSite = jest.fn().mockResolvedValue({ issue: 'found' }); + await expect(fetcher.fetchIssue('AXON-123', 'https://jira.com')).resolves.toEqual({ issue: 'found' }); + }); + + it('throws an error if the site is not found', async () => { + fetcher.findMatchingSite = jest.fn().mockReturnValue(undefined); + await expect(fetcher.fetchIssue('AXON-123', 'https://github.com')).rejects.toThrowError(); + }); + + it('throws an error if the issue is not found', async () => { + fetcher.findMatchingSite = jest.fn().mockReturnValue(mockSiteInfo); + fetcher.findIssueOnSite = jest.fn().mockResolvedValue(undefined); + await expect(fetcher.fetchIssue('AXON-123', 'https://jira.com')).rejects.toThrowError(); + }); + }); + + describe('findMatchingSite', () => { + it('returns the matching site if found', () => { + expect(fetcher.findMatchingSite('jira')).toBe(mockSiteInfo); + }); + + it('returns undefined if no matching site is found', () => { + expect(fetcher.findMatchingSite('github')).toBeUndefined(); + }); + }); + + describe('findIssueOnSite', () => { + it('returns the issue if found', async () => { + mockFindIssue.mockResolvedValue({ issue: 'found' }); + mockFetchIssue.mockResolvedValue({ issue: 'found' }); + await expect(fetcher.findIssueOnSite('AXON-123', mockSiteInfo as any)).resolves.toEqual({ issue: 'found' }); + }); + + it('returns undefined if the issue is not found', async () => { + mockFindIssue.mockResolvedValue({ issue: 'found' }); + mockFetchIssue.mockResolvedValue(undefined); + await expect(fetcher.findIssueOnSite('AXON-123', mockSiteInfo as any)).resolves.toBeUndefined(); + }); + + it('returns undefined if the issue failed to fetch', async () => { + mockFindIssue.mockResolvedValue(undefined); + await expect(fetcher.findIssueOnSite('AXON-123', mockSiteInfo as any)).resolves.toBeUndefined(); + }); + }); + + describe('handleSiteNotFound', () => { + it('shows settings if the user selects `Open oauth settings`', async () => { + mockShowInformationMessage.mockResolvedValue('Open auth settings'); + await expect(fetcher.handleSiteNotFound('AXON-123', 'https://jira.com')).resolves.toBeUndefined(); + expect(mockShowInformationMessage).toHaveBeenCalled(); + expect(Container.settingsWebviewFactory.createOrShow).toHaveBeenCalled(); + }); + + it('does nothing otherwise', async () => { + mockShowInformationMessage.mockResolvedValue('literally anything else'); + await expect(fetcher.handleSiteNotFound('AXON-123', 'https://jira.com')).resolves.toBeUndefined(); + expect(mockShowInformationMessage).toHaveBeenCalled(); + expect(Container.settingsWebviewFactory.createOrShow).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/actions/util/jiraIssueFetcher.ts b/src/uriHandler/actions/util/jiraIssueFetcher.ts new file mode 100644 index 00000000..7daea453 --- /dev/null +++ b/src/uriHandler/actions/util/jiraIssueFetcher.ts @@ -0,0 +1,58 @@ +import { DetailedSiteInfo, ProductJira } from '../../../atlclients/authInfo'; +import { Container } from '../../../container'; +import { fetchMinimalIssue } from '../../../jira/fetchIssue'; +import { ConfigSection, ConfigSubSection } from '../../../lib/ipc/models/config'; +import { window } from 'vscode'; + +// Common logic that's shared between multiple actions that deal with Jira issues +export class JiraIssueFetcher { + // Resolves the site that matches the given siteBaseURL, + // finds the issue on that site and returns it + // Throws an error if anything goes wrong + async fetchIssue(issueKey: string, siteBaseURL: string) { + const site = this.findMatchingSite(siteBaseURL); + if (!site) { + this.handleSiteNotFound(issueKey, siteBaseURL); + throw new Error(`Could not find auth details for ${siteBaseURL}`); + } + + const issue = await this.findIssueOnSite(issueKey, site); + if (!issue) { + throw new Error(`Could not fetch issue: ${issueKey}`); + } + + return issue; + } + + async findIssueOnSite(issueKey: string, site: DetailedSiteInfo) { + let foundIssue = await Container.jiraExplorer.findIssue(issueKey); + if (foundIssue) { + return await fetchMinimalIssue(issueKey, site); + } + + return undefined; + } + + async handleSiteNotFound(issueKey: string, siteBaseURL: string) { + await window + .showInformationMessage( + `Cannot open ${issueKey} because site '${siteBaseURL}' is not authenticated. Please authenticate and try again.`, + 'Open auth settings', + ) + .then((userChoice) => { + if (userChoice === 'Open auth settings') { + Container.settingsWebviewFactory.createOrShow({ + section: ConfigSection.Jira, + subSection: ConfigSubSection.Auth, + }); + } + }); + } + + findMatchingSite(siteBaseURL: string): DetailedSiteInfo | undefined { + const jiraSitesAvailable = Container.siteManager.getSitesAvailable(ProductJira); + return jiraSitesAvailable.find( + (availableSite) => availableSite.isCloud && availableSite.baseLinkUrl.includes(siteBaseURL), + ); + } +} diff --git a/src/uriHandler/atlascodeUriHandler.test.ts b/src/uriHandler/atlascodeUriHandler.test.ts new file mode 100644 index 00000000..e2e1036c --- /dev/null +++ b/src/uriHandler/atlascodeUriHandler.test.ts @@ -0,0 +1,61 @@ +import { AtlascodeUriHandler } from './atlascodeUriHandler'; +import { Uri, window } from 'vscode'; + +describe('AtlascodeUriHandler', () => { + const mockDispose = jest.fn(); + const mockShowErrorMessage = jest.fn(); + const mockRegisterUriHandler = jest.fn().mockImplementation(() => ({ dispose: mockDispose })); + + const uri = Uri.parse('https://some-uri'); + + beforeEach(() => { + jest.clearAllMocks(); + window.showErrorMessage = mockShowErrorMessage; + window.registerUriHandler = mockRegisterUriHandler; + }); + + describe('create', () => { + it('should create a new instance', () => { + const uriHandler = AtlascodeUriHandler.create({} as any, {} as any); + expect(uriHandler).not.toBeNull(); + expect(uriHandler).toBeInstanceOf(AtlascodeUriHandler); + }); + }); + + describe('handleUri', () => { + it('shows error if the right action is not found', async () => { + const uriHandler = new AtlascodeUriHandler([]); + await uriHandler.handleUri(uri); + expect(mockShowErrorMessage).toHaveBeenCalled(); + }); + + it('shows error if action is found, but throws', async () => { + const action = { + isAccepted: jest.fn().mockReturnValue(true), + handle: jest.fn().mockRejectedValue(new Error('oh no')), + }; + const uriHandler = new AtlascodeUriHandler([action]); + await uriHandler.handleUri(uri); + expect(action.handle).toHaveBeenCalled(); + expect(mockShowErrorMessage).toHaveBeenCalled(); + }); + + it('executes the action if found', async () => { + const action = { + isAccepted: jest.fn().mockReturnValue(true), + handle: jest.fn().mockResolvedValue(undefined), + }; + const uriHandler = new AtlascodeUriHandler([action]); + await uriHandler.handleUri(uri); + expect(action.handle).toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('should dispose the uri handler', () => { + const uriHandler = new AtlascodeUriHandler([]); + uriHandler.dispose(); + expect(mockDispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/uriHandler/atlascodeUriHandler.ts b/src/uriHandler/atlascodeUriHandler.ts new file mode 100644 index 00000000..962be630 --- /dev/null +++ b/src/uriHandler/atlascodeUriHandler.ts @@ -0,0 +1,62 @@ +import { CheckoutBranchUriHandlerAction } from './actions/checkoutBranch'; +import { CloneRepositoryUriHandlerAction } from './actions/cloneRepository'; +import { OpenPullRequestUriHandlerAction } from './actions/openPullRequest'; +import { SimpleCallbackAction } from './actions/simpleCallback'; +import { ShowJiraIssueUriHandlerAction } from './actions/showJiraIssue'; +import { StartWorkUriHandlerAction } from './actions/startWork'; +import { UriHandlerAction } from './uriHandlerAction'; +import { AnalyticsApi } from '../lib/analyticsApi'; +import { CheckoutHelper } from '../bitbucket/interfaces'; +import { Uri, UriHandler, window, Disposable } from 'vscode'; +import { Logger } from '../logger'; +import { JiraIssueFetcher } from './actions/util/jiraIssueFetcher'; +import { Container } from '../container'; + +export class AtlascodeUriHandler implements Disposable, UriHandler { + private disposables: Disposable; + + constructor(private actions: Array) { + this.disposables = window.registerUriHandler(this); + } + + async handleUri(uri: Uri) { + Logger.debug(`Handling URI (new router): ${uri.toString()}`); + + // TODO: keeping the exact logic of the original code for now, + // since changing this would need a lot of manual E2E testing. + // We could probably simplify this to a route map? + const action = this.actions.find((h) => h.isAccepted(uri)); + if (!action) { + Logger.debug(`Unsupported URI path: ${uri.path}`); + window.showErrorMessage(`Handler not found for URI: ${uri.toString()}`); + return; + } + + try { + await action.handle(uri); + } catch (e) { + Logger.debug('Error handling URI:', e); + window.showErrorMessage(`Error handling URI: ${uri.toString()}. Check log for details`); + } + } + + dispose(): void { + this.disposables.dispose(); + } + + static create( + analyticsApi: AnalyticsApi, + bitbucketHelper: CheckoutHelper, + jiraIssueFetcher: JiraIssueFetcher = new JiraIssueFetcher(), + ) { + return new AtlascodeUriHandler([ + new SimpleCallbackAction('openSettings', () => Container.settingsWebviewFactory.createOrShow()), + new SimpleCallbackAction('openOnboarding', () => Container.onboardingWebviewFactory.createOrShow()), + new CheckoutBranchUriHandlerAction(bitbucketHelper, analyticsApi), + new OpenPullRequestUriHandlerAction(analyticsApi, bitbucketHelper), + new CloneRepositoryUriHandlerAction(bitbucketHelper, analyticsApi), + new StartWorkUriHandlerAction(analyticsApi, jiraIssueFetcher), + new ShowJiraIssueUriHandlerAction(analyticsApi, jiraIssueFetcher), + ]); + } +} diff --git a/src/uriHandler/index.ts b/src/uriHandler/index.ts new file mode 100644 index 00000000..a84af034 --- /dev/null +++ b/src/uriHandler/index.ts @@ -0,0 +1,3 @@ +import { AtlascodeUriHandler } from './atlascodeUriHandler'; + +export { AtlascodeUriHandler }; diff --git a/src/uriHandler.ts b/src/uriHandler/legacyUriHandler.ts similarity index 94% rename from src/uriHandler.ts rename to src/uriHandler/legacyUriHandler.ts index 343f0e7d..98dc6a65 100644 --- a/src/uriHandler.ts +++ b/src/uriHandler/legacyUriHandler.ts @@ -1,14 +1,14 @@ -import { ConfigSection, ConfigSubSection } from './lib/ipc/models/config'; +import { ConfigSection, ConfigSubSection } from '../lib/ipc/models/config'; import { Disposable, Uri, UriHandler, env, window } from 'vscode'; -import { AnalyticsApi } from './lib/analyticsApi'; -import { CheckoutHelper } from './bitbucket/interfaces'; -import { Container } from './container'; -import { Logger } from './logger'; -import { ProductJira } from './atlclients/authInfo'; -import { fetchMinimalIssue } from './jira/fetchIssue'; -import { showIssue } from './commands/jira/showIssue'; -import { startWorkOnIssue } from './commands/jira/startWorkOnIssue'; +import { AnalyticsApi } from '../lib/analyticsApi'; +import { CheckoutHelper } from '../bitbucket/interfaces'; +import { Container } from '../container'; +import { Logger } from '../logger'; +import { ProductJira } from '../atlclients/authInfo'; +import { fetchMinimalIssue } from '../jira/fetchIssue'; +import { showIssue } from '../commands/jira/showIssue'; +import { startWorkOnIssue } from '../commands/jira/startWorkOnIssue'; const ExtensionId = 'atlassian.atlascode'; //const pullRequestUrl = `${env.uriScheme}://${ExtensionId}/openPullRequest`; @@ -46,7 +46,8 @@ export const ONBOARDING_URL = `${env.uriScheme}://${ExtensionId}/openOnboarding` * -- issueKey: issue key to show * e.g. vscode://atlassian.atlascode/showJiraIssue?site=https%3A%2F%2Fsome-test-site.atlassian.net&issueKey=VSCODE-1320 */ -export class AtlascodeUriHandler implements Disposable, UriHandler { + +export class LegacyAtlascodeUriHandler implements Disposable, UriHandler { private disposables: Disposable; constructor( diff --git a/src/uriHandler/uriHandlerAction.test.ts b/src/uriHandler/uriHandlerAction.test.ts new file mode 100644 index 00000000..89aa9b31 --- /dev/null +++ b/src/uriHandler/uriHandlerAction.test.ts @@ -0,0 +1,23 @@ +import { Uri } from 'vscode'; +import { isAcceptedBySuffix } from './uriHandlerAction'; + +describe('isAcceptedBySuffix', () => { + const suffix = 'myCoolPath'; + const check = (uriString: string) => isAcceptedBySuffix(Uri.parse(uriString), suffix); + + it('returns true if the URI path ends with the suffix', () => { + // Regular URI + expect(check(`https://some-uri/${suffix}`)).toBe(true); + // Query is OK + expect(check(`https://some-uri/${suffix}?cloneUrl=...&ref=...&refType=...`)).toBe(true); + // No check on the host + expect(check(`https://some-other-uri/${suffix}`)).toBe(true); + }); + + it('returns false if the URI path does not end with the suffix', () => { + // No extra path + expect(check(`https://some-uri/${suffix}/somethingExtra`)).toBe(false); + // No other suffix + expect(check(`https://some-uri/somethingElse`)).toBe(false); + }); +}); diff --git a/src/uriHandler/uriHandlerAction.ts b/src/uriHandler/uriHandlerAction.ts new file mode 100644 index 00000000..a668d80d --- /dev/null +++ b/src/uriHandler/uriHandlerAction.ts @@ -0,0 +1,16 @@ +import { Uri } from 'vscode'; + +export function isAcceptedBySuffix(uri: Uri, suffix: string): boolean { + return uri.path.endsWith(suffix); +} + +// Helper interface to route URIs to the correct action +export interface UriHandlerAction { + // Return true if the URI is accepted by this action + // In that case, handle() will be called, and the URI + // will be considered handled by this action + isAccepted(uri: Uri): boolean; + + // Handle the URI - put your logic here + handle(uri: Uri): Promise; +} diff --git a/src/util/featureFlags/features.ts b/src/util/featureFlags/features.ts index 79a270e2..30874eb4 100644 --- a/src/util/featureFlags/features.ts +++ b/src/util/featureFlags/features.ts @@ -1,3 +1,4 @@ export enum Features { EnableRemoteAuthentication = 'atlascode-enable-remote-authentication', + EnableNewUriHandler = 'atlascode-enable-new-uri-handler', } diff --git a/src/webview/singleViewFactory.ts b/src/webview/singleViewFactory.ts index 612ee79b..73919167 100644 --- a/src/webview/singleViewFactory.ts +++ b/src/webview/singleViewFactory.ts @@ -16,7 +16,7 @@ import { CommonMessageType } from '../lib/ipc/toUI/common'; import { WebviewController } from '../lib/webview/controller/webviewController'; import { UIWebsocket } from '../ws'; import { VSCWebviewControllerFactory } from './vscWebviewControllerFactory'; -import { FeatureFlagClient } from 'src/util/featureFlags'; +import { FeatureFlagClient } from '../util/featureFlags'; // ReactWebview is the interface for all basic webviews. // It takes FD as a generic type parameter that represents the type of "Factory Data" that will be diff --git a/src/webview/startwork/vscStartWorkActionApi.ts b/src/webview/startwork/vscStartWorkActionApi.ts index b85f0c81..f243c17e 100644 --- a/src/webview/startwork/vscStartWorkActionApi.ts +++ b/src/webview/startwork/vscStartWorkActionApi.ts @@ -1,5 +1,5 @@ import { MinimalIssue, Transition } from '@atlassianlabs/jira-pi-common-models'; -import { Logger } from 'src/logger'; +import { Logger } from '../../logger'; import { DetailedSiteInfo } from '../../atlclients/authInfo'; import { clientForSite } from '../../bitbucket/bbUtils'; import { Repo, WorkspaceRepo, emptyRepo } from '../../bitbucket/model';