diff --git a/src/gitlab_service.ts b/src/gitlab_service.ts index d75c3612e347b003f051ed47f8ad854a8be37abd..ae42a780b1842912d581af70f2c2bb03b2e35ac3 100644 --- a/src/gitlab_service.ts +++ b/src/gitlab_service.ts @@ -21,10 +21,11 @@ interface GitLabPipeline { id: number; } -interface GitLabJob { +export interface RestJob { name: string; // eslint-disable-next-line camelcase created_at: string; + status: string; } const normalizeAvatarUrl = (instanceUrl: string) => (issuable: RestIssuable): RestIssuable => { @@ -102,6 +103,7 @@ async function fetch(path: string, method = 'GET', data?: Record vscode.commands.executeCommand(VS_COMMANDS.OPEN, vscode.Uri.parse(url)); +export const openUrl = async (url: string): Promise => + vscode.commands.executeCommand(VS_COMMANDS.OPEN, vscode.Uri.parse(url)); /** * Fetches user and project before opening a link. @@ -24,24 +26,29 @@ const openUrl = url => vscode.commands.executeCommand(VS_COMMANDS.OPEN, vscode.U * * @param {string} linkTemplate */ -async function getLink(linkTemplate, workspaceFolder) { +async function getLink(linkTemplate: string, workspaceFolder: string) { const user = await gitLabService.fetchCurrentUser(); const project = await gitLabService.fetchCurrentProject(workspaceFolder); + assert(project, 'Failed to fetch project'); return linkTemplate.replace('$userId', user.id).replace('$projectUrl', project.webUrl); } -async function openLink(linkTemplate, workspaceFolder) { +async function openLink(linkTemplate: string, workspaceFolder: string) { await openUrl(await getLink(linkTemplate, workspaceFolder)); } -async function showIssues() { +export async function showIssues(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; + await openLink('$projectUrl/issues?assignee_id=$userId', workspaceFolder); } -async function showMergeRequests() { +export async function showMergeRequests(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; + await openLink('$projectUrl/merge_requests?assignee_id=$userId', workspaceFolder); } @@ -73,7 +80,7 @@ async function getActiveFile() { await gitService.getRepositoryRootFolder(), editor.document.uri.fsPath, ); - const fileUrl = `${currentProject.webUrl}/blob/${branchName}/${filePath}`; + const fileUrl = `${currentProject!.webUrl}/blob/${branchName}/${filePath}`; let anchor = ''; if (editor.selection) { @@ -88,11 +95,11 @@ async function getActiveFile() { return `${fileUrl}${anchor}`; } -async function openActiveFile() { - await openUrl(await getActiveFile()); +export async function openActiveFile(): Promise { + await openUrl((await getActiveFile())!); } -async function copyLinkToActiveFile() { +export async function copyLinkToActiveFile(): Promise { const fileUrl = await getActiveFile(); if (fileUrl) { @@ -100,8 +107,10 @@ async function copyLinkToActiveFile() { } } -async function openCurrentMergeRequest() { +export async function openCurrentMergeRequest(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; + const mr = await gitLabService.fetchOpenMergeRequestForCurrentBranch(workspaceFolder); if (mr) { @@ -109,25 +118,29 @@ async function openCurrentMergeRequest() { } } -async function openCreateNewIssue() { +export async function openCreateNewIssue(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; + openLink('$projectUrl/issues/new', workspaceFolder); } -async function openCreateNewMr() { +export async function openCreateNewMr(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; const project = await gitLabService.fetchCurrentProject(workspaceFolder); const branchName = await createGitService(workspaceFolder).fetchTrackingBranchName(); - openUrl(`${project.webUrl}/merge_requests/new?merge_request%5Bsource_branch%5D=${branchName}`); + openUrl(`${project!.webUrl}/merge_requests/new?merge_request%5Bsource_branch%5D=${branchName}`); } -async function openProjectPage() { +export async function openProjectPage(): Promise { const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; openLink('$projectUrl', workspaceFolder); } -async function openCurrentPipeline(workspaceFolder) { +export async function openCurrentPipeline(workspaceFolder: string): Promise { const { pipeline } = await gitLabService.fetchPipelineAndMrForCurrentBranch(workspaceFolder); if (pipeline) { @@ -135,10 +148,11 @@ async function openCurrentPipeline(workspaceFolder) { } } -async function compareCurrentBranch() { +export async function compareCurrentBranch(): Promise { let project = null; let lastCommitId = null; const workspaceFolder = await getCurrentWorkspaceFolderOrSelectOne(); + if (!workspaceFolder) return; project = await gitLabService.fetchCurrentProject(workspaceFolder); lastCommitId = await createGitService(workspaceFolder).fetchLastCommitId(); @@ -147,15 +161,3 @@ async function compareCurrentBranch() { openUrl(`${project.webUrl}/compare/master...${lastCommitId}`); } } - -exports.openUrl = openUrl; -exports.showIssues = showIssues; -exports.showMergeRequests = showMergeRequests; -exports.openActiveFile = openActiveFile; -exports.copyLinkToActiveFile = copyLinkToActiveFile; -exports.openCurrentMergeRequest = openCurrentMergeRequest; -exports.openCreateNewIssue = openCreateNewIssue; -exports.openCreateNewMr = openCreateNewMr; -exports.openProjectPage = openProjectPage; -exports.openCurrentPipeline = openCurrentPipeline; -exports.compareCurrentBranch = compareCurrentBranch; diff --git a/src/status_bar.test.js b/src/status_bar.test.ts similarity index 65% rename from src/status_bar.test.js rename to src/status_bar.test.ts index b02efb2e944e01e296b207accd62bb7e2a0ab065..9f880675ec45a09bb9df4e54236c04e33db9e899 100644 --- a/src/status_bar.test.js +++ b/src/status_bar.test.ts @@ -1,27 +1,35 @@ -const vscode = require('vscode'); +import * as vscode from 'vscode'; +import * as gitLabService from './gitlab_service'; +import { pipeline, mr, issue } from './test_utils/entities'; jest.mock('./gitlab_service'); -vscode.workspace.getConfiguration.mockReturnValue({ + +const asMock = (mockFn: unknown) => mockFn as jest.Mock; + +asMock(vscode.workspace.getConfiguration).mockReturnValue({ showStatusBarLinks: true, showIssueLinkOnStatusBar: true, showMrStatusOnStatusBar: true, }); -const { StatusBar } = require('./status_bar'); -const gitLabService = require('./gitlab_service'); -const { pipeline, mr, issue } = require('./test_utils/entities'); - -const createFakeItem = () => ({ - show: jest.fn(), - hide: jest.fn(), - dispose: jest.fn(), -}); + +// StatusBar needs to be imported after we mock the configuration because it uses the configuration +// during module initialization +// eslint-disable-next-line import/first +import { StatusBar } from './status_bar'; + +const createFakeItem = (): vscode.StatusBarItem => + (({ + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + } as unknown) as vscode.StatusBarItem); // StatusBar is only interested in whether the project exists or not const mockedGitLabProject = {}; describe('status_bar', () => { - let fakeItems; - let statusBar; + let fakeItems: vscode.StatusBarItem[]; + let statusBar: StatusBar; const getPipelineItem = () => fakeItems[0]; const getClosingIssueItem = () => fakeItems[1]; const getMrItem = () => fakeItems[2]; @@ -29,7 +37,7 @@ describe('status_bar', () => { beforeEach(() => { fakeItems = []; statusBar = new StatusBar(); - vscode.window.createStatusBarItem.mockImplementation(() => { + asMock(vscode.window.createStatusBarItem).mockImplementation(() => { const fakeItem = createFakeItem(); fakeItems.push(fakeItem); return fakeItem; @@ -37,7 +45,7 @@ describe('status_bar', () => { }); describe('pipeline item', () => { beforeEach(() => { - gitLabService.fetchLastJobsForCurrentBranch.mockReset(); + asMock(gitLabService.fetchLastJobsForCurrentBranch).mockReset(); }); afterEach(() => { @@ -45,7 +53,7 @@ describe('status_bar', () => { }); it('initializes the pipeline item with success', async () => { - gitLabService.fetchPipelineAndMrForCurrentBranch.mockResolvedValue({ pipeline }); + asMock(gitLabService.fetchPipelineAndMrForCurrentBranch).mockResolvedValue({ pipeline }); await statusBar.init(); expect(getPipelineItem().show).toHaveBeenCalled(); expect(getPipelineItem().hide).not.toHaveBeenCalled(); @@ -53,13 +61,13 @@ describe('status_bar', () => { }); it('prints jobs for running pipeline', async () => { - gitLabService.fetchPipelineAndMrForCurrentBranch.mockResolvedValue({ + asMock(gitLabService.fetchPipelineAndMrForCurrentBranch).mockResolvedValue({ pipeline: { ...pipeline, status: 'running', }, }); - gitLabService.fetchLastJobsForCurrentBranch.mockReturnValue([ + asMock(gitLabService.fetchLastJobsForCurrentBranch).mockReturnValue([ { status: 'running', name: 'Unit Tests', @@ -80,13 +88,15 @@ describe('status_bar', () => { }); it('shows no pipeline text when there is no pipeline', async () => { - gitLabService.fetchPipelineAndMrForCurrentBranch.mockResolvedValue({ pipeline: null }); + asMock(gitLabService.fetchPipelineAndMrForCurrentBranch).mockResolvedValue({ + pipeline: null, + }); await statusBar.init(); expect(getPipelineItem().text).toBe('GitLab: No pipeline.'); }); it('hides the item when there is no project', async () => { - gitLabService.fetchPipelineAndMrForCurrentBranch.mockRejectedValue(new Error()); + asMock(gitLabService.fetchPipelineAndMrForCurrentBranch).mockRejectedValue(new Error()); await statusBar.init(); expect(getPipelineItem().hide).toHaveBeenCalled(); }); @@ -100,7 +110,7 @@ describe('status_bar', () => { ${'canceled'} | ${'$(circle-slash) GitLab: Pipeline canceled'} ${'skipped'} | ${'$(diff-renamed) GitLab: Pipeline skipped'} `('shows $itemText for pipeline with status $status', async ({ status, itemText }) => { - gitLabService.fetchPipelineAndMrForCurrentBranch.mockResolvedValue({ + asMock(gitLabService.fetchPipelineAndMrForCurrentBranch).mockResolvedValue({ pipeline: { ...pipeline, status, @@ -113,9 +123,9 @@ describe('status_bar', () => { describe('MR closing issue item', () => { beforeEach(() => { - gitLabService.fetchCurrentPipelineProject.mockReturnValue(mockedGitLabProject); + asMock(gitLabService.fetchCurrentPipelineProject).mockReturnValue(mockedGitLabProject); // FIXME: why is closing issue fetched from normal remote and pipeline result from pipeline remote? - gitLabService.fetchCurrentProject.mockReturnValue(mockedGitLabProject); + asMock(gitLabService.fetchCurrentProject).mockReturnValue(mockedGitLabProject); }); afterEach(() => { @@ -123,8 +133,8 @@ describe('status_bar', () => { }); it('shows closing issue for an MR', async () => { - gitLabService.fetchOpenMergeRequestForCurrentBranch.mockReturnValue(mr); - gitLabService.fetchMRIssues.mockReturnValue([issue]); + asMock(gitLabService.fetchOpenMergeRequestForCurrentBranch).mockReturnValue(mr); + asMock(gitLabService.fetchMRIssues).mockReturnValue([issue]); await statusBar.init(); expect(getClosingIssueItem().show).toHaveBeenCalled(); expect(getClosingIssueItem().hide).not.toHaveBeenCalled(); @@ -132,20 +142,20 @@ describe('status_bar', () => { }); it('shows no issue when there is not a closing issue', async () => { - gitLabService.fetchOpenMergeRequestForCurrentBranch.mockReturnValue(mr); - gitLabService.fetchMRIssues.mockReturnValue([]); + asMock(gitLabService.fetchOpenMergeRequestForCurrentBranch).mockReturnValue(mr); + asMock(gitLabService.fetchMRIssues).mockReturnValue([]); await statusBar.init(); expect(getClosingIssueItem().text).toBe('$(code) GitLab: No issue.'); }); it('shows no issue when there is no MR', async () => { - gitLabService.fetchOpenMergeRequestForCurrentBranch.mockReturnValue(null); + asMock(gitLabService.fetchOpenMergeRequestForCurrentBranch).mockReturnValue(null); await statusBar.init(); expect(getClosingIssueItem().text).toBe('$(code) GitLab: No issue.'); }); it('hides the item when there is no project', async () => { - gitLabService.fetchCurrentProject.mockReturnValue(null); + asMock(gitLabService.fetchCurrentProject).mockReturnValue(null); await statusBar.init(); expect(getClosingIssueItem().hide).toHaveBeenCalled(); }); @@ -153,9 +163,9 @@ describe('status_bar', () => { describe('MR item', () => { beforeEach(() => { - gitLabService.fetchCurrentPipelineProject.mockReturnValue(mockedGitLabProject); + asMock(gitLabService.fetchCurrentPipelineProject).mockReturnValue(mockedGitLabProject); // FIXME: why is closing issue fetched from normal remote and pipeline result from pipeline remote? - gitLabService.fetchCurrentProject.mockReturnValue(mockedGitLabProject); + asMock(gitLabService.fetchCurrentProject).mockReturnValue(mockedGitLabProject); }); afterEach(() => { @@ -163,7 +173,7 @@ describe('status_bar', () => { }); it('shows MR item', async () => { - gitLabService.fetchOpenMergeRequestForCurrentBranch.mockReturnValue(mr); + asMock(gitLabService.fetchOpenMergeRequestForCurrentBranch).mockReturnValue(mr); await statusBar.init(); expect(getMrItem().show).toHaveBeenCalled(); expect(getMrItem().hide).not.toHaveBeenCalled(); @@ -171,13 +181,13 @@ describe('status_bar', () => { }); it('shows create MR text when there is no MR', async () => { - gitLabService.fetchOpenMergeRequestForCurrentBranch.mockReturnValue(null); + asMock(gitLabService.fetchOpenMergeRequestForCurrentBranch).mockReturnValue(null); await statusBar.init(); expect(getMrItem().text).toBe('$(git-pull-request) GitLab: Create MR.'); }); it('hides the MR item when there is no project', async () => { - gitLabService.fetchCurrentProject.mockReturnValue(null); + asMock(gitLabService.fetchCurrentProject).mockReturnValue(null); await statusBar.init(); expect(getMrItem().hide).toHaveBeenCalled(); }); diff --git a/src/status_bar.js b/src/status_bar.ts similarity index 74% rename from src/status_bar.js rename to src/status_bar.ts index 9ba2dae628802280d6fed54dfa57a1568dfda1b1..4b0a01a3f285f2eba25ce51be205ba40d35d91fc 100644 --- a/src/status_bar.js +++ b/src/status_bar.ts @@ -1,10 +1,11 @@ -const vscode = require('vscode'); -const openers = require('./openers'); -const gitLabService = require('./gitlab_service'); -const { getCurrentWorkspaceFolder } = require('./services/workspace_service'); -const { UserFriendlyError } = require('./errors/user_friendly_error'); -const { handleError, logError } = require('./log'); -const { USER_COMMANDS } = require('./command_names'); +/* eslint-disable no-unused-expressions */ +import * as vscode from 'vscode'; +import * as openers from './openers'; +import * as gitLabService from './gitlab_service'; +import { getCurrentWorkspaceFolder } from './services/workspace_service'; +import { UserFriendlyError } from './errors/user_friendly_error'; +import { handleError, logError } from './log'; +import { USER_COMMANDS } from './command_names'; const MAXIMUM_DISPLAYED_JOBS = 4; @@ -16,7 +17,7 @@ const { showPipelineUpdateNotifications, } = vscode.workspace.getConfiguration('gitlab'); -const iconForStatus = { +const iconForStatus: Record = { running: { icon: 'pulse' }, pending: { icon: 'clock' }, success: { icon: 'check', text: 'passed' }, @@ -25,9 +26,9 @@ const iconForStatus = { skipped: { icon: 'diff-renamed' }, }; -const getStatusText = status => iconForStatus[status].text || status; +const getStatusText = (status: string) => iconForStatus[status]?.text || status; -const createStatusTextFromJobs = (jobs, status) => { +const createStatusTextFromJobs = (jobs: gitLabService.RestJob[], status: string) => { let statusText = getStatusText(status); const jobNames = jobs.filter(job => job.status === status).map(job => job.name); if (jobNames.length > MAXIMUM_DISPLAYED_JOBS) { @@ -41,7 +42,7 @@ const createStatusTextFromJobs = (jobs, status) => { return statusText; }; -const createStatusBarItem = (text, command) => { +const createStatusBarItem = (text: string, command: string) => { const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); statusBarItem.text = text; statusBarItem.show(); @@ -53,29 +54,35 @@ const createStatusBarItem = (text, command) => { return statusBarItem; }; -const commandRegisterHelper = (cmdName, callback) => { +const commandRegisterHelper = (cmdName: string, callback: (...args: any[]) => any) => { vscode.commands.registerCommand(cmdName, callback); }; -class StatusBar { - constructor() { - this.pipelineStatusBarItem = null; - this.pipelinesStatusTimer = null; - this.mrStatusBarItem = null; - this.mrIssueStatusBarItem = null; - this.mrStatusTimer = null; - this.issue = null; - this.mr = null; - this.firstRun = true; - } +export class StatusBar { + pipelineStatusBarItem?: vscode.StatusBarItem; + + pipelinesStatusTimer?: NodeJS.Timeout; + + mrStatusBarItem?: vscode.StatusBarItem; + + mrIssueStatusBarItem?: vscode.StatusBarItem; + + mrStatusTimer?: NodeJS.Timeout; + + issue?: RestIssuable; + + mr?: RestIssuable; + + firstRun = true; async refreshPipeline() { - let workspaceFolder = null; + if (!this.pipelineStatusBarItem) return; + let workspaceFolder: string | undefined; let pipeline = null; try { workspaceFolder = await getCurrentWorkspaceFolder(); - const result = await gitLabService.fetchPipelineAndMrForCurrentBranch(workspaceFolder); + const result = await gitLabService.fetchPipelineAndMrForCurrentBranch(workspaceFolder!); // TODO: the result contains the MR for this branch as well, we can refactor status_bar // to use this response instead of making a separate request. pipeline = result.pipeline; @@ -95,7 +102,7 @@ class StatusBar { if (status === 'running' || status === 'failed') { try { - const jobs = await gitLabService.fetchLastJobsForCurrentBranch(pipeline, workspaceFolder); + const jobs = await gitLabService.fetchLastJobsForCurrentBranch(pipeline, workspaceFolder!); if (jobs) { statusText = createStatusTextFromJobs(jobs, status); } @@ -104,7 +111,7 @@ class StatusBar { } } - const msg = `$(${iconForStatus[status].icon}) GitLab: Pipeline ${statusText}`; + const msg = `$(${iconForStatus[status]?.icon}) GitLab: Pipeline ${statusText}`; if ( showPipelineUpdateNotifications && @@ -117,7 +124,7 @@ class StatusBar { .showInformationMessage(message, { modal: false }, 'View in Gitlab') .then(selection => { if (selection === 'View in Gitlab') { - openers.openCurrentPipeline(workspaceFolder); + openers.openCurrentPipeline(workspaceFolder!); } }); } @@ -140,7 +147,8 @@ class StatusBar { await this.refreshPipeline(); } - async fetchMRIssues(workspaceFolder) { + async fetchMRIssues(workspaceFolder: string) { + if (!this.mrIssueStatusBarItem || !this.mr) return; const issues = await gitLabService.fetchMRIssues(this.mr.iid, workspaceFolder); let text = `$(code) GitLab: No issue.`; @@ -153,15 +161,18 @@ class StatusBar { } async fetchBranchMR() { + if (!this.mrIssueStatusBarItem || !this.mrStatusBarItem) return; let text = '$(git-pull-request) GitLab: Create MR.'; let workspaceFolder = null; let project = null; try { workspaceFolder = await getCurrentWorkspaceFolder(); - project = await gitLabService.fetchCurrentProject(workspaceFolder); + project = await gitLabService.fetchCurrentProject(workspaceFolder!); if (project != null) { - this.mr = await gitLabService.fetchOpenMergeRequestForCurrentBranch(workspaceFolder); + this.mr = + (await gitLabService.fetchOpenMergeRequestForCurrentBranch(workspaceFolder!)) ?? + undefined; this.mrStatusBarItem.show(); } else { this.mrStatusBarItem.hide(); @@ -173,7 +184,7 @@ class StatusBar { if (project && this.mr) { text = `$(git-pull-request) GitLab: MR !${this.mr.iid}`; - await this.fetchMRIssues(workspaceFolder); + await this.fetchMRIssues(workspaceFolder!); this.mrIssueStatusBarItem.show(); } else if (project) { this.mrIssueStatusBarItem.text = `$(code) GitLab: No issue.`; @@ -239,29 +250,26 @@ class StatusBar { dispose() { if (showStatusBarLinks) { - this.pipelineStatusBarItem.dispose(); + this.pipelineStatusBarItem?.dispose(); if (showIssueLinkOnStatusBar) { - this.mrIssueStatusBarItem.dispose(); + this.mrIssueStatusBarItem?.dispose(); } if (showMrStatusOnStatusBar) { - this.mrStatusBarItem.dispose(); + this.mrStatusBarItem?.dispose(); } } if (this.pipelinesStatusTimer) { clearInterval(this.pipelinesStatusTimer); - this.pipelinesStatusTimer = null; + this.pipelinesStatusTimer = undefined; } if (this.mrStatusTimer) { clearInterval(this.mrStatusTimer); - this.mrStatusTimer = null; + this.mrStatusTimer = undefined; } } } -module.exports = { - StatusBar, - instance: new StatusBar(), -}; +export const instance = new StatusBar();