@codecovevienna/gittt-cli
Version:
Tracking time with CLI into a git repository
333 lines (280 loc) • 10.8 kB
text/typescript
import shelljs, { ExecOutputReturnValue } from "shelljs";
import { IProject, IProjectMeta, IRecord, IGitttFile } from "../interfaces";
import { GitNoOriginError, GitNoUrlError, GitRemoteError, GitNoRepoError, GitttFileError, RECORD_TYPES } from "../types";
import {
FileHelper,
GitHelper,
LogHelper,
parseProjectNameFromGitUrl,
QuestionHelper,
RecordHelper,
} from "./";
export class ProjectHelper {
/**
* Extracts the domain from a IProjectMetaData object
*
* @param projectMeta - MetaData object
* @returns Domain of the meta data as formatted string
*/
public static projectMetaToDomain = (projectMeta: IProjectMeta): string => {
const { host, port } = projectMeta;
return `${host.replace(/\./gi, "_")}${port ? "_" + port : ""}`;
}
/**
* Constructs a meta data object from a formatted domain string
*
* @param domain - formatted domain string
* @returns Meta data object based on the formatted string
*/
public static domainToProjectMeta = (domain: string): IProjectMeta => {
const split: string[] = domain.split("_");
const potentialPort: number = parseInt(split[split.length - 1], 10);
let port = 0;
let splitClean: string[] = [];
if (!isNaN(potentialPort)) {
port = potentialPort;
splitClean = split.slice(0, split.length - 1);
} else {
splitClean = split;
}
return {
host: splitClean.join("."),
port,
};
}
/**
* @param project - IProject object
* @returns Filename of the project file
*/
public static projectToProjectFilename = (project: IProject): string => {
return `${project.name}.json`;
}
/**
* @param project - IProject object
* @returns Path of the project file
*/
public static getProjectPath = (project: IProject): string => {
if (!project.meta) {
return `${ProjectHelper.projectToProjectFilename(project)}`;
} else {
return `${ProjectHelper.projectMetaToDomain(project.meta)}/${ProjectHelper.projectToProjectFilename(project)}`;
}
}
private fileHelper: FileHelper;
private gitHelper: GitHelper;
constructor(gitHelper: GitHelper, fileHelper: FileHelper) {
this.gitHelper = gitHelper;
this.fileHelper = fileHelper;
}
public getGitttProject = async (): Promise<IProject> => {
let project: IProject | undefined = undefined;
try {
const gitttFile: IGitttFile = await this.fileHelper.getGitttFile();
project = {
name: gitttFile.name,
records: []
}
if (gitttFile.requiresRoles) {
project.requiresRoles = gitttFile.requiresRoles;
}
LogHelper.debug("Got project from yaml file");
} catch (err: any) {
LogHelper.debug("Error getting project from .gittt.yml file, trying git config");
try {
project = this.getProjectFromGit();
} catch (err: any) {
LogHelper.debug("Unable to get project from git config", err);
throw err;
}
}
return project;
}
public initProject = async (): Promise<IProject> => {
try {
const project = await this.getGitttProject();
await this.fileHelper.initProject(project);
await this.gitHelper.commitChanges(`Initialized project`);
return project;
} catch (err: any) {
LogHelper.debug("Error initializing project", err);
throw new Error("Error initializing project");
}
}
public addRecordsToProject = async (
records: IRecord[],
project?: IProject,
uniqueOnly?: boolean,
nonOverlappingOnly?: boolean,
): Promise<void> => {
const selectedProject: IProject | undefined = project ?
project :
await this.getGitttProject();
if (!selectedProject) {
return;
}
records = records.filter((record: IRecord) => {
let shouldAddRecord = true;
// Checks uniqueness only against existing records, the provided records have to be unique in the first place!
if (uniqueOnly === true) {
shouldAddRecord = RecordHelper.isRecordUnique(record, selectedProject.records);
}
if (nonOverlappingOnly === true) {
shouldAddRecord = RecordHelper.isRecordOverlapping(record, selectedProject.records);
}
if (shouldAddRecord) {
return record;
}
LogHelper.warn(
`Could not add record (amount: ${record.amount}, end: ${record.end}, type: ${record.type}) to ${selectedProject.name}`,
);
});
if (records.length === 1) {
let record: IRecord = records[0];
LogHelper.info(`Adding record (amount: ${record.amount}, type: ${record.type}, role: ${record.role}) to ${selectedProject.name}`);
record = RecordHelper.setRecordDefaults(record);
selectedProject.records.push(record);
await this.fileHelper.saveProjectObject(selectedProject);
// TODO differ between types
const hourString: string = record.amount === 1 ? "hour" : "hours";
if (record.message) {
await this.gitHelper.commitChanges(
`Added ${record.amount} ${hourString} to ${selectedProject.name}: "${record.message}"`,
);
} else {
await this.gitHelper.commitChanges(`Added ${record.amount} ${hourString} to ${selectedProject.name}`);
}
} else if (records.length > 1) {
if (records.length > 1) {
records.forEach((record: IRecord) => {
record = RecordHelper.setRecordDefaults(record);
selectedProject.records.push(record);
});
LogHelper.info(`Adding (${records.length}) records to ${selectedProject.name}`);
await this.fileHelper.saveProjectObject(selectedProject);
await this.gitHelper.commitChanges(`Added ${records.length} records to ${selectedProject.name}`);
}
}
}
public addRecordToProject = async (
record: IRecord,
project?: IProject,
uniqueOnly?: boolean,
nonOverlappingOnly?: boolean,
): Promise<void> => this.addRecordsToProject([record], project, uniqueOnly, nonOverlappingOnly)
// TODO projectName optional? find it by .git folder
public getTotalHours = async (projectName: string): Promise<number> => {
const project: IProject | undefined = await this.fileHelper.findProjectByName(projectName);
if (!project) {
throw new Error(`Project "${projectName}" not found`);
}
return project.records.reduce((prev: number, curr: IRecord) => {
if (curr.type === RECORD_TYPES.Time) {
return prev + curr.amount;
} else {
return prev;
}
}, 0);
}
public getProjectByName = async (name: string): Promise<IProject> => {
const projects: IProject[] = await this.fileHelper.findAllProjects();
let foundProject: IProject | undefined = projects.find((p: IProject) => p.name === name);
if (!foundProject || !name) {
try {
foundProject = await this.getGitttProject();
if (foundProject) {
// Loads the records from the filesystem to avoid empty record array
foundProject = await this.fileHelper.findProjectByName(
foundProject.name,
);
} else {
throw new Error("Unable to get gittt project")
}
} catch (err: any) {
LogHelper.debug(`Unable to get project from git directory: ${err.message}`);
throw err;
}
}
if (!foundProject) {
throw new Error("Unable to get gittt project")
}
return foundProject;
}
public getAllProjects = async (): Promise<IProject[]> => {
return this.fileHelper.findAllProjects();
}
public getProjectFromGit = (): IProject => {
LogHelper.debug("Checking number of remote urls");
const gitRemoteExec: ExecOutputReturnValue = shelljs.exec("git remote", {
silent: true,
}) as ExecOutputReturnValue;
if (gitRemoteExec.code !== 0) {
if (gitRemoteExec.code === 128) {
LogHelper.debug(`"git remote" returned with exit code 128`);
throw new GitNoRepoError("Current directory does not appear to be a valid git repository");
}
LogHelper.debug("Error executing git remote", new Error(gitRemoteExec.stdout));
throw new GitRemoteError("Unable to get remotes from git config");
}
const remotes: string[] = gitRemoteExec.stdout.trim().split("\n");
if (remotes.length > 1) {
LogHelper.debug("Found more than one remotes, trying to find origin");
const hasOrigin: boolean = remotes.indexOf("origin") !== -1;
if (!hasOrigin) {
LogHelper.error(`Unable to find any remote called "origin"`);
throw new GitNoOriginError(`Unable to find any remote called "origin"`);
}
}
LogHelper.debug("Trying to find project name from .git folder");
const gitConfigExec: ExecOutputReturnValue = shelljs.exec("git config remote.origin.url", {
silent: true,
}) as ExecOutputReturnValue;
if (gitConfigExec.code !== 0 || gitConfigExec.stdout.length < 4) {
LogHelper.debug("Error executing git config remote.origin.url", new Error(gitConfigExec.stdout));
throw new GitNoUrlError("Unable to get URL from git config");
}
const originUrl: string = gitConfigExec.stdout.trim();
return parseProjectNameFromGitUrl(originUrl);
}
public getOrAskForProjectFromGit = async (): Promise<IProject> => {
let projectName: string;
let projectMeta: IProjectMeta | undefined;
let projectRequiresRoles: boolean | undefined;
try {
const gitttProject: IProject = await this.getGitttProject();
projectName = gitttProject.name;
projectRequiresRoles = gitttProject.requiresRoles;
} catch (e) {
if (e instanceof GitRemoteError ||
e instanceof GitNoRepoError ||
e instanceof GitttFileError) {
const selectedProjectName: string = await QuestionHelper.
chooseProjectFile(await this.fileHelper.findAllProjects());
const split = selectedProjectName.split("/");
// TODO find a better way?
if (split.length == 2) {
const [domain, name] = split;
projectName = name.replace(".json", "");
projectMeta = ProjectHelper.domainToProjectMeta(domain);
} else {
// Handles projects with no domain information
projectName = split[0].replace(".json", "");
projectMeta = undefined;
}
} else {
throw e;
}
}
const project: IProject | undefined = await this.fileHelper.findProjectByName(
projectName,
projectMeta,
);
if (!project) {
throw new Error(`Unable to find project "${projectName}" on disk`);
}
// requiresRoles from .gittt.yml file overwrites the config if it is set.
if (undefined !== projectRequiresRoles) {
project.requiresRoles = projectRequiresRoles;
}
return project;
}
}