UNPKG

@codecovevienna/gittt-cli

Version:

Tracking time with CLI into a git repository

1,412 lines (1,189 loc) 50.4 kB
import axios, { AxiosResponse } from "axios"; import chalk from "chalk"; import commander, { Command } from "commander"; import _, { isString } from "lodash"; import moment, { Moment } from "moment"; import path from "path"; import { AuthHelper, ChartHelper, FileHelper, ExportHelper, GitHelper, ImportHelper, LogHelper, ProjectHelper, QuestionHelper, TimerHelper, ValidationHelper, RecordHelper, ConfigHelper, MultipieHelper, appendTicketNumber, toFixedLength, } from "./helper"; import { IIntegrationLink, IJiraLink, IJiraPublishResult, IProject, IRecord, IMultipieInputLink, IPublishSummaryItem, IMultipieStoreLink, ISelectChoice, IMultipiePublishResult, } from "./interfaces"; import { ORDER_DIRECTION, ORDER_TYPE, RECORD_TYPES } from "./types"; import { DefaultLogFields } from "simple-git/src/lib/tasks/log"; import { Token } from "client-oauth2"; // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-explicit-any const packageJson: any = require("./package.json"); const APP_NAME: string = packageJson.name; const APP_VERSION: string = packageJson.version; const APP_CONFIG_DIR = ".gittt-cli"; const JIRA_ENDPOINT_VERSION = "v2"; export class App { private configDir: string; private configHelper: ConfigHelper; private fileHelper: FileHelper; private timerHelper: TimerHelper; private gitHelper: GitHelper; private projectHelper: ProjectHelper; private importHelper: ImportHelper; private authHelper: AuthHelper; private commander: commander.Command; public start(): void { if (process.argv.length === 2) { this.commander.help(); } else { this.commander.parse(process.argv); } } public exit(msg: string, code: number): void { if (code === 0) { LogHelper.warn(msg); } else { LogHelper.error(msg); } process.exit(code); } public async setup(): Promise<void> { this.configDir = path.join(FileHelper.getHomeDir(), `${APP_CONFIG_DIR}`); this.fileHelper = new FileHelper(this.configDir, "config.json", "timer.json", "projects"); this.configHelper = ConfigHelper.getInstance(this.fileHelper); if (!(await this.configHelper.isInitialized())) { if (await QuestionHelper.confirmSetup()) { await this.initConfigDir(); LogHelper.info("Initialized git-time-tracker (GITTT) you are good to go now ;)\n\n"); } else { this.exit(`${APP_NAME} does not work without setup, bye!`, 0); } } this.gitHelper = new GitHelper(this.configDir, this.fileHelper); this.projectHelper = new ProjectHelper(this.gitHelper, this.fileHelper); this.timerHelper = new TimerHelper(this.fileHelper, this.projectHelper); this.importHelper = new ImportHelper(); this.authHelper = new AuthHelper(); this.initCommander(); } // TODO should be moved to config helper, but gitHelper needs a valid config dir public async initConfigDir(): Promise<void> { if (!(await this.fileHelper.configDirExists())) { await this.fileHelper.createConfigDir(); this.gitHelper = new GitHelper(this.configDir, this.fileHelper); if (!(await this.fileHelper.isConfigFileValid())) { const gitUrl: string = await QuestionHelper.askGitUrl(); LogHelper.info("Initializing local repo"); await this.gitHelper.initRepo(gitUrl); // TODO remove reset=true? LogHelper.info("Pulling repo..."); await this.gitHelper.pullRepo(); // Check if a valid config file is already in the repo if (!(await this.fileHelper.isConfigFileValid())) { LogHelper.info("Initializing gittt config file"); await this.fileHelper.initConfigFile(gitUrl); LogHelper.info("Committing created config file"); await this.gitHelper.commitChanges("Initialized config file"); LogHelper.info("Pushing changes to remote repo"); await this.gitHelper.pushChanges(); } } else { await this.gitHelper.pullRepo(); } } else { if (await this.fileHelper.isConfigFileValid()) { this.gitHelper = new GitHelper(this.configDir, this.fileHelper); await this.gitHelper.pullRepo(); LogHelper.info(`Config directory ${this.configDir} already initialized`); } else { LogHelper.warn(`Config file exists, but is invalid`); this.exit("Invalid config file", 1); // TODO reinitialize? } } } private async handleRole(cmd: Command, interactive: boolean, project: IProject, record: IRecord, inputRole?: string): Promise<IRecord> { const multipieHelper = new MultipieHelper(); const updatedRecord = record; // Project does not require a role, just return the original record if (!project.requiresRoles) { return updatedRecord; } if (!interactive) { if (!inputRole) { LogHelper.error("No role option found"); return cmd.help(); } // get roles if (project.requiresRoles) { const availableRoles = await multipieHelper.getValidRoles(project, record); const role = availableRoles.find((role_: ISelectChoice) => role_.name == inputRole)?.value; if (role === undefined) { LogHelper.info(`Available roles for ${project.name} are: \n${availableRoles .map(availableRole => ` - ${availableRole.name}`) .sort() .join(",\n")}`); this.exit(`Role "${inputRole}" is not available in this project`, 1); } updatedRecord.role = role; } } else { updatedRecord.role = await QuestionHelper.chooseRole(project, record); } return updatedRecord; } public async exportAction(options: any): Promise<void> { LogHelper.print(`Gathering projects...`) let projectsToExport: IProject[] = []; const { project, directory, filename, type } = options; if (project) { const projectToExport: IProject | undefined = await this.fileHelper.findProjectByName(project); if (!projectToExport) { this.exit(`✗ Project "${project}" not found`, 1) } else { projectsToExport.push(projectToExport); } } else { projectsToExport = await this.fileHelper.findAllProjects(); } LogHelper.info(`✓ Got all ${projectsToExport.length} projects`); ExportHelper.export(directory, filename, type, projectsToExport); LogHelper.info(`✓ Export done`) } public async linkAction(options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let project: IProject | undefined; try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } const integration: string = await QuestionHelper.chooseIntegration(); LogHelper.debug(`Trying to find links for "${project.name}"`) // Check for previous data const prevIntegrationLink: IIntegrationLink | undefined = (await this.configHelper .findLinksByProject(project, integration))[0]; switch (integration) { case "Jira": let prevJiraLink: IJiraLink | undefined; if (prevIntegrationLink) { LogHelper.info(`Found link for "${project.name}", enriching dialog with previous data`) prevJiraLink = prevIntegrationLink as IJiraLink; } const jiraLink: IJiraLink = await QuestionHelper.askJiraLink(project, prevJiraLink, JIRA_ENDPOINT_VERSION); try { await this.configHelper.addOrUpdateLink(jiraLink); } catch (err: any) { LogHelper.debug(`Unable to add link to config file`, err); return this.exit(`Unable to add link to config file`, 1); } break; case "Multipie": let prevMultipieLink: IMultipieInputLink | undefined; if (prevIntegrationLink) { LogHelper.info(`Found link for "${project.name}", enriching dialog with previous data`) prevMultipieLink = prevIntegrationLink as IMultipieInputLink; } const multiPieInputLink: IMultipieInputLink = await QuestionHelper.askMultipieLink(project, prevMultipieLink); const multipieAuth = this.authHelper.getAuthClient(multiPieInputLink); try { const authResponse: Token = await multipieAuth.owner.getToken(multiPieInputLink.username, multiPieInputLink.password); LogHelper.debug(`Got offline access refresh token`); const offlineToken: IMultipieStoreLink = { projectName: multiPieInputLink.projectName, linkType: multiPieInputLink.linkType, endpoint: multiPieInputLink.endpoint, clientSecret: multiPieInputLink.clientSecret, refreshToken: authResponse.refreshToken, } try { await this.configHelper.addOrUpdateLink(offlineToken); } catch (err: any) { LogHelper.debug(`Unable to add link to config file`, err); return this.exit(`Unable to add link to config file`, 1); } } catch (err: any) { LogHelper.debug(`Unable to authenticate user`, err); return this.exit(`Unable to authenticate user`, 1); } break; default: this.exit(`Integration "${integration}" not implemented`, 1); break; } } public async publishAction(options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let projects: Array<IProject> = []; try { if (!interactiveMode) { if (options.all) { projects = await this.projectHelper.getAllProjects(); } else { const project: IProject | undefined = await this.projectHelper.getProjectByName(options.project); if (project) { projects = [project]; } } } else { const project: IProject | undefined = await this.projectHelper.getOrAskForProjectFromGit(); if (project) { projects = [project]; } } } catch (err: any) { return this.exit(err.message, 1); } if (projects.length < 1 || !projects) { return this.exit("No valid git project", 1); } const projectIntegrationLinks = await Promise.all( projects.map(async (project) => { const links = await this.configHelper.findLinksByProject(project); return { name: project.name, meta: project.meta, records: project.records, integrationLinks: links }; }) ); if (projectIntegrationLinks.length === 1 && projectIntegrationLinks[0].integrationLinks.length === 0) { LogHelper.warn(`Unable to find a link for "${projectIntegrationLinks[0].name}"`); if (await QuestionHelper.confirmLinkCreation()) { await this.linkAction(options); return await this.publishAction(options); } else { return this.exit(`Unable to publish without link`, 1); } } else { for (const project of projectIntegrationLinks) { if (project.integrationLinks.length === 0) { LogHelper.warn(`Unable to find a link for "${project.name}"`); } } } const logs: ReadonlyArray<DefaultLogFields> = await this.gitHelper.logChanges(); if (logs.length > 0) { if (await QuestionHelper.confirmPushLocalChanges()) { await this.gitHelper.pushChanges(); } else { return this.exit("Unable to publish with local changes", 1); } } const publishSummary: IPublishSummaryItem[] = []; for (const project of projectIntegrationLinks) { for (const link of project.integrationLinks) { switch (link.linkType) { case "Jira": const jiraLink: IJiraLink = link as IJiraLink; // Map local project to jira key if (jiraLink.issue) { LogHelper.info(`Mapping "${project.name}" to Jira issue "${jiraLink.issue}" within project "${jiraLink.key}"`); } else { LogHelper.info(`Mapping "${project.name}" to Jira project "${jiraLink.key}"`); } if (!jiraLink.host) { // Handle deprecated config return this.exit('The configuration of this jira link is deprecated, please consider updating the link with "gittt link"', 1) } const jiraUrl = `${jiraLink.host}${jiraLink.endpoint}`; LogHelper.debug(`Publishing to ${jiraUrl}`); try { const publishResult: AxiosResponse = await axios .post(jiraUrl, { projectKey: jiraLink.key, issueKey: jiraLink.issue, project, }, { headers: { "Authorization": `Basic ${jiraLink.hash}`, "Cache-Control": "no-cache", "Content-Type": "application/json", }, }, ); const data: IJiraPublishResult = publishResult.data; if (data.success) { publishSummary.push({ success: true, type: link.linkType, }) } else { publishSummary.push({ success: false, type: link.linkType, reason: `Publishing failed [${publishResult.status}]` }) } } catch (err: any) { delete err.config; delete err.request; delete err.response; LogHelper.debug("Publish request failed", err); publishSummary.push({ success: false, type: link.linkType, reason: `Publish request failed, please consider updating the link` }) } break; case "Multipie": const multipieLink: IMultipieStoreLink = link as IMultipieStoreLink; try { let authorizationHeader = ""; if (multipieLink.username) { // Legacy flow LogHelper.debug("Found username parameter in link configuration, using legacy auth method") authorizationHeader = this.authHelper.getLegacyAuth(multipieLink); } else { const multipieAuth = this.authHelper.getAuthClient(multipieLink); const { refreshToken } = multipieLink; if (!refreshToken) { this.exit(`Unable to find refresh token for this project, please login via 'gittt link'`, 1); return; } const offlineToken: Token = await multipieAuth.createToken("", refreshToken, {}); LogHelper.debug(`Refreshing token to get access token`); const refreshedToken: Token = await offlineToken.refresh(); LogHelper.debug(`Got access token`); authorizationHeader = `Bearer ${refreshedToken.accessToken}` } const multipieUrl = `${multipieLink.endpoint}`; LogHelper.debug(`Publishing to ${multipieUrl}`); const publishResult: AxiosResponse<IMultipiePublishResult> = await axios .post(multipieUrl, project, { headers: { "Authorization": authorizationHeader, "Cache-Control": "no-cache", "Content-Type": "application/json", }, }, ); const data: IMultipiePublishResult = publishResult.data; if (data && (publishResult.status === 200 || publishResult.status === 201)) { publishSummary.push({ success: true, type: link.linkType, }) } else { publishSummary.push({ success: false, type: link.linkType, reason: `Publishing failed [${publishResult.status}]` }) } } catch (err: any) { delete err.config; delete err.request; delete err.response; LogHelper.debug("Publish request failed", err); publishSummary.push({ success: false, type: link.linkType, reason: `Publish request failed, please consider updating the link` }) } break; default: publishSummary.push({ success: false, type: "unknown", reason: `Link type "${link.linkType}" not implemented` }) break; } } } for (const item of publishSummary) { if (item.success) { LogHelper.info(`✓ Successfully published to ${item.type}`) } else { LogHelper.warn(`✗ Unable to publish to ${item.type}: ${item.reason}`) } } if (publishSummary.filter(item => item.success === false).length > 0) { this.exit(`One or more errors occurred while publishing data`, 1); } } public async editAction(options: any, cmd: Command): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let project: IProject | undefined; // TODO move to own function, is used multiple times try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } const projectWithRecords: IProject | undefined = await this.fileHelper.findProjectByName(project.name); if (!projectWithRecords) { return this.exit(`Unable to find project "${project.name}"`, 1); } if (projectWithRecords.records.length === 0) { return this.exit(`No records found for "${project.name}"`, 1); } const { records } = projectWithRecords; let recordsToEdit: IRecord[]; let chosenRecord: IRecord; if (!interactiveMode) { const recordGuid: string = options.guid; const chosenRecords: IRecord[] = records.filter((rc: IRecord) => { return rc.guid === recordGuid; }); chosenRecord = chosenRecords[0]; if (!chosenRecord) { return this.exit(`No records found for guid "${recordGuid}"`, 1); } } else { recordsToEdit = await RecordHelper.filterRecordsByYear(records); recordsToEdit = await RecordHelper.filterRecordsByMonth(recordsToEdit); recordsToEdit = await RecordHelper.filterRecordsByDay(recordsToEdit); chosenRecord = await QuestionHelper.chooseRecord(recordsToEdit); } let updatedRecord: IRecord = Object.assign({}, chosenRecord); let year: number; let month: number; let day: number; let hour: number; let minute: number; let amount: number; let message: string | undefined; if (!interactiveMode) { if (options.type) { updatedRecord.type = options.type; } else { LogHelper.error("No type option found"); return cmd.help(); } if (!ValidationHelper.validateNumber(options.amount)) { LogHelper.error("No amount option found"); return cmd.help(); } if (!options.role && project.requiresRoles) { LogHelper.error("No role option found"); return cmd.help(); } amount = parseFloat(options.amount); year = ValidationHelper.validateNumber(options.year) ? parseInt(options.year, 10) : moment().year(); month = ValidationHelper.validateNumber(options.month, 1, 12) ? parseInt(options.month, 10) : moment().month() + 1; day = ValidationHelper.validateNumber(options.day, 1, 31) ? parseInt(options.day, 10) : moment().date(); hour = ValidationHelper.validateNumber(options.hour, 0, 23) ? parseInt(options.hour, 10) : moment().hour(); minute = ValidationHelper.validateNumber(options.minute, 0, 59) ? parseInt(options.minute, 10) : moment().minute(); message = (options.message && options.message.length > 0) ? options.message : undefined; } else { updatedRecord.type = await QuestionHelper.chooseType(chosenRecord.type); year = await QuestionHelper.askYear(moment(chosenRecord.end).year()); month = await QuestionHelper.askMonth(moment(chosenRecord.end).month() + 1); day = await QuestionHelper.askDay(moment(chosenRecord.end).date()); hour = await QuestionHelper.askHour(moment(chosenRecord.end).hour()); minute = await QuestionHelper.askMinute(moment(chosenRecord.end).minute()); amount = await QuestionHelper.askAmount(chosenRecord.amount); message = await QuestionHelper.askMessage(chosenRecord.message); } updatedRecord.updated = Date.now(); updatedRecord.message = message; updatedRecord.amount = amount; updatedRecord = await this.handleRole(cmd, interactiveMode, project, updatedRecord, options.role) const modifiedMoment: Moment = moment().set({ date: day, hour, millisecond: 0, minute, month: month - 1, second: 0, year, }); updatedRecord.end = modifiedMoment.unix() * 1000; const updatedRecords: IRecord[] = records.map((rc: IRecord) => { return rc.guid === updatedRecord.guid ? updatedRecord : rc; }); const updatedProject: IProject = projectWithRecords; updatedProject.records = updatedRecords; await this.fileHelper.saveProjectObject(updatedProject); let changes = ""; if (updatedRecord.amount !== chosenRecord.amount) { changes += `amount: ${updatedRecord.amount}, `; } if (updatedRecord.end !== chosenRecord.end) { changes += `end: ${updatedRecord.end}, `; } if (updatedRecord.message !== chosenRecord.message) { changes += `message: ${updatedRecord.message}, `; } if (updatedRecord.type !== chosenRecord.type) { changes += `type: ${updatedRecord.type}, `; } if (updatedRecord.role !== chosenRecord.role) { changes += `role: ${updatedRecord.role}, `; } if (changes.length > 0) { changes = changes.slice(0, -2); } const commitMessage: string = changes.length > 0 ? `Updated record(${changes}) at ${updatedProject.name} ` : `Updated record at ${updatedProject.name} `; await this.gitHelper.commitChanges(commitMessage); LogHelper.info(commitMessage); } // TODO pretty much the same as editAction, refactor? public async removeAction(options: any, cmd: Command): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let project: IProject | undefined; try { try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } } catch (err: any) { LogHelper.debug("Unable to get project name from git folder", err); return this.exit("Unable to get project name from git folder", 1); } if (!project) { return this.exit("No valid git project", 1); } const projectWithRecords: IProject | undefined = await this.fileHelper.findProjectByName(project.name); if (!projectWithRecords) { return this.exit(`Unable to find project "${project.name}"`, 1); } if (projectWithRecords.records.length === 0) { return this.exit(`No records found for "${project.name}"`, 1); } const { records } = projectWithRecords; let recordsToDelete: IRecord[]; let chosenRecord: IRecord; if (!interactiveMode) { if (!options.guid) { LogHelper.error("No guid option found"); return cmd.help(); } const recordGuid: string = options.guid; const chosenRecords: IRecord[] = records.filter((rc: IRecord) => { return rc.guid === recordGuid; }); chosenRecord = chosenRecords[0]; if (!chosenRecord) { return this.exit(`No records found for guid "${recordGuid}"`, 1); } } else { recordsToDelete = await RecordHelper.filterRecordsByYear(records); recordsToDelete = await RecordHelper.filterRecordsByMonth(recordsToDelete); recordsToDelete = await RecordHelper.filterRecordsByDay(recordsToDelete); chosenRecord = await QuestionHelper.chooseRecord(recordsToDelete); } // TODO confirm deletion? const updatedRecords: IRecord[] = records.filter((rc: IRecord) => { return rc.guid !== chosenRecord.guid; }); const updatedProject: IProject = projectWithRecords; updatedProject.records = updatedRecords; await this.fileHelper.saveProjectObject(updatedProject); const commitMessage = `Removed record ${chosenRecord.guid} from project ${updatedProject.name}`; await this.gitHelper.commitChanges(commitMessage); LogHelper.info(`Removed record (${moment(chosenRecord.end).format("DD.MM.YYYY, HH:mm:ss") }: ${chosenRecord.amount} ${chosenRecord.type} - "${_.truncate(chosenRecord.message)}") from project ${updatedProject.name}`); } public async commitAction(options: any, cmd: Command): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let amount: number; let message: string | undefined; let commitMessage: string; let project: IProject | undefined; try { if (!interactiveMode) { amount = parseFloat(options.amount); message = options.message; project = await this.projectHelper.getProjectByName(options.project); } else { amount = await QuestionHelper.askAmount(1); project = await this.projectHelper.getOrAskForProjectFromGit(); message = await QuestionHelper.askMessage(); } } catch (err: any) { return this.exit(err.message, 1); } if (isNaN(amount)) { return this.exit("No valid amount", 1); } if (!project) { return this.exit("No valid git project", 1); } if (message && message.length > 0) { commitMessage = message; } else { commitMessage = `Committed ${amount} hour${amount > 1 ? "s" : ""} to ${project.name}` } commitMessage = await appendTicketNumber(commitMessage, await this.gitHelper.getCurrentBranch()) try { const data: IRecord = { amount, end: Date.now(), message: commitMessage, type: RECORD_TYPES.Time, }; const updatedData = await this.handleRole(cmd, interactiveMode, project, data, options.role) await this.projectHelper.addRecordToProject(updatedData, project); } catch (err: any) { LogHelper.debug("Unable to add record to project", err); this.exit("Unable to add record to project", 1); } } public async addAction(options: any, cmd: Command): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let year: number; let month: number; let day: number; let hour: number; let minute: number; let amount: number; let message: string | undefined; let type: RECORD_TYPES; let project: IProject; try { if (!interactiveMode) { // TODO move to option of commander if (!ValidationHelper.validateNumber(options.amount)) { LogHelper.error("No amount option found"); return cmd.help(); } if (!options.type) { LogHelper.error("No type option found"); return cmd.help(); } amount = parseFloat(options.amount); type = options.type; year = ValidationHelper.validateNumber(options.year) ? parseInt(options.year, 10) : moment().year(); month = ValidationHelper.validateNumber(options.month, 1, 12) ? parseInt(options.month, 10) : moment().month() + 1; day = ValidationHelper.validateNumber(options.day, 1, 31) ? parseInt(options.day, 10) : moment().date(); hour = ValidationHelper.validateNumber(options.hour, 0, 23) ? parseInt(options.hour, 10) : moment().hour(); minute = ValidationHelper.validateNumber(options.minute, 0, 59) ? parseInt(options.minute, 10) : moment().minute(); message = (options.message && options.message.length > 0) ? options.message : undefined; project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); year = await QuestionHelper.askYear(); month = await QuestionHelper.askMonth(); day = await QuestionHelper.askDay(); hour = await QuestionHelper.askHour(); minute = await QuestionHelper.askMinute(); amount = await QuestionHelper.askAmount(1); message = await QuestionHelper.askMessage(); type = await QuestionHelper.chooseType(); } } catch (err: any) { return this.exit(err.message, 1); } const modifiedMoment: Moment = moment().set({ date: day, hour, millisecond: 0, minute, month: month - 1, second: 0, year, }); const end: number = modifiedMoment.unix() * 1000; let newRecord: IRecord = { amount, end, message: message ? message : undefined, type, }; newRecord = await this.handleRole(cmd, interactiveMode, project, newRecord, options.role) if (newRecord.message) { newRecord.message = await appendTicketNumber(newRecord.message, await this.gitHelper.getCurrentBranch()); } try { await this.projectHelper.addRecordToProject(newRecord, project); } catch (err: any) { LogHelper.debug("Unable to add record to project", err); this.exit("Unable to add record to project", 1); } } public async importCsv(cmd: string, options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 4; const filePath: string = cmd; if (!isString(filePath) || !ValidationHelper.validateFile(filePath)) { return this.exit("Unable to get csv file path", 1); } let project: IProject | undefined; try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } try { const records: IRecord[] = await this.importHelper.importCsv(filePath); LogHelper.debug(`Parsed ${records.length} records from ${filePath}`); const uniqueRecords = _.uniqWith(records, _.isEqual); LogHelper.debug(`Filtered out ${records.length - uniqueRecords.length} duplicates`); await this.projectHelper.addRecordsToProject(uniqueRecords, project, true, false); } catch (err: any) { LogHelper.debug("Error importing records from csv", err); this.exit(err.message, 1); } } public async infoAction(options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; const order: string = ORDER_TYPE.indexOf(options.order) === -1 ? ORDER_TYPE[0] : options.order; const direction: string = ORDER_DIRECTION.indexOf(options.direction) === -1 ? ORDER_DIRECTION[0] : options.direction; let project: IProject | undefined; try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } const projects: IProject[] = await this.fileHelper.findAllProjects(); // get current Gittt project if (!project) { return this.exit("No valid git project", 1); } else { // check if the project is a gittt project const foundProject: IProject = projects.filter((p: IProject) => project && p.name === project.name)[0]; if (foundProject) { LogHelper.info(""); LogHelper.info(`Current project:`); const hours: number = await this.projectHelper.getTotalHours(foundProject.name); LogHelper.log(`Name:\t${foundProject.name}`); LogHelper.log(`Hours:\t${hours}h`); const links: IIntegrationLink[] = await this.configHelper.findLinksByProject(project); for (const link of links) { switch (link.linkType) { case "Jira": const jiraLink: IJiraLink = link as IJiraLink; LogHelper.log(""); LogHelper.log("Jira link:"); LogHelper.log(`> Host:\t\t${jiraLink.host}`); LogHelper.log(`> Project:\t${jiraLink.key}`); if (jiraLink.issue) { LogHelper.log(`> Issue:\t${jiraLink.issue}`); } break; case "Multipie": const multipieLink: IMultipieInputLink = link as IMultipieInputLink; LogHelper.log(""); LogHelper.log("Multipie link:"); LogHelper.log(`> Host:\t\t${multipieLink.endpoint}`); LogHelper.log(`> Project:\t${multipieLink.projectName}`); break; } } } else { LogHelper.error("No gittt project in current git project."); } } LogHelper.info(""); LogHelper.info(`Projects:`); // add hours to projects const projectsWithHours: { hours: number; project: IProject }[] = await Promise.all(projects.map(async prj => { return { hours: await this.projectHelper.getTotalHours(prj.name), project: prj, } })) // order projects // sort mutates the array so we cannot save it to orderedProjects directly projectsWithHours.sort((a: { hours: number; project: IProject }, b: { hours: number; project: IProject }) => { if (order === "hours") { if (direction === "desc") { return (a.hours - b.hours) * -1; } return (a.hours - b.hours); } if (a.project.name < b.project.name) { return (direction === "desc") ? 1 : -1; } if (a.project.name > b.project.name) { return (direction === "desc") ? -1 : 1; } return 0; }); const orderedProjects = projectsWithHours; // print projects for (const prj of orderedProjects) { LogHelper.log(`- ${prj.project.name}: ${prj.hours}h`); } } public async listAction(options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let project: IProject | undefined; try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } if (project.records.length === 0) { return this.exit(`No records found for "${project.name}"`, 1); } // sorting newest to latest // because sort mutates the array we cannot assign it to records directly project.records.sort((a: IRecord, b: IRecord) => { const aStartTime: moment.Moment = moment(a.end).subtract(a.amount, "hours"); const bStartTime: moment.Moment = moment(b.end).subtract(b.amount, "hours"); return aStartTime.diff(bStartTime); }); const records: IRecord[] = project.records; LogHelper.info(`${project.name}`); LogHelper.print(`--------------------------------------------------------------------------------`); LogHelper.info(`TYPE\tAMOUNT\tTIME\t\t\tCOMMENT\t\t\t\tROLE`); LogHelper.print(`--------------------------------------------------------------------------------`); let sumOfTime = 0; for (const record of records) { let line = ""; line += `${record.type}\t`; line += chalk.yellow.bold(`${record.amount.toFixed(2)}h\t`); line += `${moment(record.end).format("DD.MM.YYYY HH:mm:ss")}\t`; line += chalk.yellow.bold(`${toFixedLength(record.message, 24)}\t`); line += chalk.yellow.bold(`${record.role}`); sumOfTime += record.amount; LogHelper.print(line); } LogHelper.print(`--------------------------------------------------------------------------------`); LogHelper.info(`SUM:\t${sumOfTime}h`); } public async todayAction(): Promise<void> { const projects: IProject[] = await this.fileHelper.findAllProjects(); const todaysRecords: { project: IProject; record: IRecord; }[] = projects.flatMap(project => { return project.records.filter(record => { const momentEnd = moment(record.end); const momentDayStart = moment().startOf('day'); const momentDayEnd = moment().endOf('day'); return momentEnd.isBetween(momentDayStart, momentDayEnd); }).map(record => { return { record, project } }); }); // because sort mutates the array we cannot assign it to sortedTodaysRecords directly todaysRecords.sort((a: { project: IProject; record: IRecord; }, b: { project: IProject; record: IRecord; }) => { return moment(a.record.end).diff(moment(b.record.end)); }); const sortedTodaysRecords = todaysRecords; LogHelper.info(`${moment().format("dddd, MMMM D, YYYY")}`); LogHelper.print(`-------------------------------------------------------------------------------------------------------`); LogHelper.info(`TYPE\tAMOUNT\tTIME\t\tPROJECT\t\t\tCOMMENT\t\t\t\tROLE`); LogHelper.print(`-------------------------------------------------------------------------------------------------------`); let sumOfTime = 0; for (const todayRecord of sortedTodaysRecords) { const { record, project } = todayRecord; let line = ""; line += `${record.type}\t`; line += chalk.yellow.bold(`${record.amount.toFixed(2)}h\t`); line += `${moment(record.end).format("HH:mm:ss")}\t`; line += chalk.yellow.bold(`${toFixedLength(project.name, 16)}\t`); line += chalk.yellow.bold(`${toFixedLength(record.message, 24)}\t`); line += chalk.yellow.bold(`${record.role}`); sumOfTime += record.amount; LogHelper.print(line); } LogHelper.print(`-------------------------------------------------------------------------------------------------------`); LogHelper.info(`SUM:\t${sumOfTime}h`); } public async reportAction(options: any): Promise<void> { const interactiveMode: boolean = process.argv.length === 3; let project: IProject | undefined; try { if (!interactiveMode) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } const days: number = parseInt(options.days, 10) || 14; // default is 14 days (2 weeks sprint) const daysData: any = {}; const weekdayData: any = { Monday: 0, Tuesday: 0, Wednesday: 0, Thursday: 0, Friday: 0, Saturday: 0, Sunday: 0 }; // get tomorrow 00:00 const now: moment.Moment = moment(); now.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); now.add(1, "days"); // get all records in timeframe for (const record of project.records) { const startTime: moment.Moment = moment(record.end).subtract(record.amount, "hours"); // the difference will be positive for every day into the past const difference: moment.Duration = moment.duration(now.diff(startTime)); // if difference is to great we skip the record if (difference.asDays() > days && days !== -1) { continue; } // add to daysData const dayString: string = startTime.format("MMM DD, YYYY (ddd)"); daysData[dayString] = daysData[dayString] ? daysData[dayString] + record.amount : record.amount; // add to weeklyData const weekdayString: string = startTime.format("dddd"); weekdayData[weekdayString] += record.amount; } LogHelper.info("----------------------------------------------------------------------"); LogHelper.info(`Project: ${project.name}`); LogHelper.info(`for the last ${days} days`); LogHelper.info("----------------------------------------------------------------------"); // separator LogHelper.log(""); // print daysData if (Object.keys(daysData).length > 0) { LogHelper.info("Days report"); LogHelper.log("----------------------------------------------------------------------"); LogHelper.log(ChartHelper.chart(daysData, true, 50, false, "h")); } // separator LogHelper.log(""); // print weeklyData LogHelper.info("Weekday report"); LogHelper.log("---------------------------------------------------------------------"); LogHelper.log(ChartHelper.chart(weekdayData, true, 50, false, "h")); } public async stopAction(options: any): Promise<void> { let project: IProject | undefined; if (options.kill) { await this.timerHelper.killTimer(); } else { try { if (options.project) { project = await this.projectHelper.getProjectByName(options.project); } else { project = await this.projectHelper.getOrAskForProjectFromGit(); } } catch (err: any) { return this.exit(err.message, 1); } if (!project) { return this.exit("No valid git project", 1); } await this.timerHelper.stopTimer(options.message, project); } } public async initAction(): Promise<void> { if (await QuestionHelper.confirmInit()) { try { await this.projectHelper.initProject(); } catch (err: any) { LogHelper.debug("Error initializing project", err); this.exit("Error initializing project", 1); } } else { this.exit("Initialization canceled", 0) } } public initCommander(): void { // Only matters for tests to omit 'MaxListenersExceededWarning' // commander.removeAllListeners(); // commander.on("command:*", () => { // commander.help(); // }); this.commander = new Command(); // add version command this.commander .version(APP_VERSION); // Commit action this.commander .command("commit") .description("Committing certain hours to a project") .option("-a, --amount <amount>", "Amount of hours spent") .option("-m, --message [message]", "Description of the spent hours") .option("-p, --project [project]", "Specify a project to commit to") .option("-r, --role [role]", "Specify a role string") .action(async (options, cmd): Promise<void> => this.commitAction(options, cmd)) // add command this.commander .command("add") .description("Adding hours to the project in the past") .option("-a, --amount <amount>", "Specify the amount") .option("-y, --year [year]", "Specify the year, defaults to current year") .option("-m, --month [month]", "Specify the month, defaults to current month") .option("-d, --day [day]", "Specify the day, defaults to current day") .option("-h, --hour [hour]", "Specify the hour, defaults to current hour") .option("-M, --minute [minute]", "Specify the minute, defaults to current minute") .option("-w, --message [message]", "Specify the message of the record") .option("-t, --type [type]", "Specify the type of the record") .option("-r, --role [role]", "Specify the role of the record") .option("-p, --project [project]", "Specify the project to add the record") .action(async (options, cmd): Promise<void> => this.addAction(options, cmd)); // push command this.commander .command("push") .description("Pushing changes to repository") .action(async (): Promise<void> => { LogHelper.info("Pushing changes..."); await this.gitHelper.pushChanges(); LogHelper.info("Done"); }); // info command this.commander .command("info") .description("Lists info about gittt for this users (projects and hours)") .option("-o, --order <type>", "Specify the ordering (hours or name) default is " + ORDER_TYPE[0]) .option("-d, --direction <direction>", "Specify the ordering direction (asc, desc)" + ORDER_DIRECTION[0]) .option("-p, --project [project]", "Specify the project to get the information") .action((options): Promise<void> => this.infoAction(options)); // list command // will be changed in GITTT-85 this.commander .command("list") .description("List of time tracks in project") .option("-p, --project <project>", "Specify the project to get the time tracks") .action((options): Promise<void> => this.listAction(options)); this.commander .command("today") .description("List of time tracks of current day") .action((): Promise<void> => this.todayAction()); // report command // will be changed in GITTT-85 this.commander .command("report") .description("Prints a small report") .option("-d, --days [number]", "Specify for how many days the report should be printed. Default: 14") .option("-p, --project [project]", "Specify the project the report should be printed for") .action((options): Promise<void> => this.reportAction(options)); this.commander .command("setup") .description("Initializes config directory and setup of gittt git project") .action(async (): Promise<void> => this.initConfigDir()); // start command this.commander .command("start") .description("Start the timer") .action(async (): Promise<void> => this.timerHelper.startTimer()); // stop command this.commander .command("stop") .description("Stop the timer and commit to a project") .option("-k, --kill", "Kill the timer for a project") .option("-m, --message <message>", "Commit message for the project") .option("-p, --project [project]", "Specify the project to add your time to") .action(async (options): Promise<void> => this.stopAction(options)); // init command this.commander .command("init") .description("Initializes the project in current git directory") .action(async (): Promise<void> => this.initAction()); // link command this.commander .command("link") .description("Initialize or edit link to third party applications") .option("-p, --project [project]", "Specify the project to link") .action(async (options): Promise<void> => this.linkAction(options)); // publish command this.commander .command("publish") .description("Publishes stored records to external endpoint") .option("-p, --project [project]", "Specify the project to publish") .option("-a, --all", "Publish all projects") .action(async (options): Promise<void> => this.publishAction(options)); // edit command this.commander .command("edit") .description("Edit record of current project") .option("-g, --guid <guid>", "GUID of the record to edit") .option("-a, --amount <amount>", "Specify the amount") .option("-y, --year [year]", "Specify the year, defaults to current year") .option("-m, --month [month]", "Specify the month, defaults to current month") .option("-d, --day [day]", "Specify the day, defaults to current day") .option("-h, --hour [hour]", "Specify the hour, defaults to current hour") .option("-M, --minute [minute]", "Specify the minute, defaults to current minute") .option("-w, --message [message]", "Specify the message of the record") .option("-t, --type [type]", "Specify the type of the record") .option("-r, --role [role]", "Specify the role of the record") .option("-p, --project [project]", "Specify the project to edit") .action(async (options, cmd): Promise<void> => this.editAction(options, cmd)); // remove command this.commander .command("remove") .description("Remove record from a project") .option("-g, --guid [guid]", "GUID of the record to remove") .option("-p, --project [project]", "Specify the project to remove a record") .action(async (options, cmd): Promise<void> => this.removeAction(options, cmd)); // import command this.commander .command("import <file>") .description("Import records from csv file to current project") .option("-p, --project [project]", "Specify the project to import records to") .action(async (cmd: string, options: any): Promise<void> => this.importCsv(cmd, options)); // export command this.commander .command("export") .description("Exports projects to ods file") .option("-f, --filename [filename]", "Filename of the output file (default: gittt-report)") .option("-d, --directory [directory]", "Di