diff --git a/cli/.eslintrc.js b/cli/.eslintrc.js index aeaef6a..9ae186c 100644 --- a/cli/.eslintrc.js +++ b/cli/.eslintrc.js @@ -1,72 +1,77 @@ -module.exports = { - parser: "@typescript-eslint/parser", - parserOptions: { - project: "tsconfig.json", - tsconfigRootDir: __dirname, - ecmaVersion: 2018, - sourceType: "module", - }, - extends: ["typed-fp", "agile-digital"], - env: { - "jest/globals": true, - es6: true, - }, - plugins: [ - "jest", - "sonarjs", - "functional", - "@typescript-eslint", - "prettier", - "total-functions", - ], - rules: { - // https://github.com/aotaduy/eslint-plugin-spellcheck - "spellcheck/spell-checker": [ - "warn", - { - skipWords: [ - "Argv", - "Authorised", - "assignee", - "changelog", - "changelogs", - "clc", - "codec", - "Codec", - "globals", - "io", - "issuetype", - "jira", - "Jira", - "jiralint", - "jiralintrc", - "jql", - "Kanban", - "Nullable", - "oauth", - "proxied", - "Readonly", - "readonly", - "servlet", - "sonarjs", - "subtask", - "subtasks", - "timetracking", - "unicode", - "utf8", - "Urls", - "versioned", - "worklog", - "Worklog", - "worklogs", - "yargs", - ], - }, - ], - }, - settings: { - jest: { - version: 28, - }, - }, -}; +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + ecmaVersion: 2018, + sourceType: "module", + }, + extends: ["typed-fp", "agile-digital"], + env: { + "jest/globals": true, + es6: true, + }, + plugins: [ + "jest", + "sonarjs", + "functional", + "@typescript-eslint", + "prettier", + "total-functions", + ], + rules: { + // https://github.com/aotaduy/eslint-plugin-spellcheck + "spellcheck/spell-checker": [ + "warn", + { + skipWords: [ + "Argv", + "Authorised", + "assignee", + "changelog", + "changelogs", + "clc", + "codec", + "Codec", + "globals", + "io", + "issuetype", + "jira", + "Jira", + "jiralint", + "jiralintrc", + "jql", + "Kanban", + "Nullable", + "oauth", + "proxied", + "Readonly", + "readonly", + "servlet", + "sonarjs", + "subtask", + "subtasks", + "timetracking", + "unicode", + "utf8", + "Urls", + "versioned", + "worklog", + "Worklog", + "worklogs", + "yargs", + "monday", + "tuesday", + "sunday", + "aggregatetimeoriginalestimate", + "aggregatetimespent", + ], + }, + ], + }, + settings: { + jest: { + version: 28, + }, + }, +}; diff --git a/cli/src/scripts/search.ts b/cli/src/scripts/search.ts index eca2083..79b425a 100644 --- a/cli/src/scripts/search.ts +++ b/cli/src/scripts/search.ts @@ -20,6 +20,7 @@ import * as clc from "cli-color"; // eslint-disable-next-line functional/no-expression-statements require("cli-color"); +// eslint-disable-next-line functional/type-declaration-immutability type CheckedIssue = EnhancedIssue & { readonly action: IssueAction; readonly reasons: readonly string[]; @@ -27,7 +28,9 @@ type CheckedIssue = EnhancedIssue & { }; const checkedIssues = ( + // eslint-disable-next-line functional/prefer-immutable-types issues: readonly EnhancedIssue[] + // eslint-disable-next-line functional/prefer-immutable-types ): readonly CheckedIssue[] => { const now = readonlyDate(readonlyNow()); @@ -51,9 +54,9 @@ const checkedIssues = ( }); }; -// eslint-disable-next-line functional/no-return-void +// eslint-disable-next-line functional/no-return-void, functional/prefer-immutable-types const renderJson = (issues: readonly EnhancedIssue[]): void => { - // eslint-disable-next-line functional/no-return-void + // eslint-disable-next-line functional/no-return-void, functional/prefer-immutable-types checkedIssues(issues).forEach((issue) => // eslint-disable-next-line no-console console.log(JSON.stringify(issue, null, 2)) @@ -61,6 +64,7 @@ const renderJson = (issues: readonly EnhancedIssue[]): void => { }; const renderTable = ( + // eslint-disable-next-line functional/prefer-immutable-types issues: readonly EnhancedIssue[], qualityFieldName: string // eslint-disable-next-line functional/no-return-void @@ -205,6 +209,7 @@ const search = async ( // eslint-disable-next-line functional/no-expression-statements countdown.start(); + // eslint-disable-next-line functional/prefer-immutable-types const issues = await jira.searchIssues( jql, boardNamesToIgnore, diff --git a/lib/.eslintrc.js b/lib/.eslintrc.js index 1253887..cd2ce93 100644 --- a/lib/.eslintrc.js +++ b/lib/.eslintrc.js @@ -66,6 +66,11 @@ module.exports = { "Worklog", "worklogs", "yargs", + "monday", + "tuesday", + "sunday", + "aggregatetimeoriginalestimate", + "aggregatetimespent", ], }, ], diff --git a/lib/src/services/issue_checks.ts b/lib/src/services/issue_checks.ts index 19ab3c2..eefc650 100644 --- a/lib/src/services/issue_checks.ts +++ b/lib/src/services/issue_checks.ts @@ -24,6 +24,7 @@ export type CheckResult = { readonly reasons: ReadonlyNonEmptyArray; }; +// eslint-disable-next-line functional/prefer-immutable-types export type Check = (issue: EnhancedIssue) => CheckResult; export type Action = "none" | "inspect"; @@ -84,6 +85,7 @@ const notInProgressReason = "not in progress"; */ export const validateInProgressHasWorklog = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => { const check = checker("In progress tickets have been worked"); @@ -111,6 +113,7 @@ export const validateInProgressHasWorklog = * @returns result of checking the issue. */ export const validateDependenciesHaveDueDate = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue ): CheckResult => { const check = checker("Dependencies have a due date"); @@ -132,6 +135,7 @@ export const validateDependenciesHaveDueDate = ( */ export const validateNotClosedDependenciesNotPassedDueDate = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => { const check = checker("Dependencies not passed due date"); @@ -160,6 +164,7 @@ export const validateNotClosedDependenciesNotPassedDueDate = }; export const validateInProgressHasEstimate = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue ): CheckResult => { const check = checker("In Progress issues have estimates"); @@ -182,6 +187,7 @@ export const validateInProgressHasEstimate = ( }; // TODO check whether the description is a template and fail if it is. +// eslint-disable-next-line functional/prefer-immutable-types export const validateDescription = (issue: EnhancedIssue): CheckResult => { const check = checker("Tickets have a description"); @@ -193,6 +199,7 @@ export const validateDescription = (issue: EnhancedIssue): CheckResult => { const validateNotStalledFor = (at: ReadonlyDate) => ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue, duration: number, durationDescription: string @@ -222,16 +229,19 @@ const validateNotStalledFor = const validateNotStalledForMoreThanOneDay = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => validateNotStalledFor(at)(issue, 0, "one day"); const validateNotStalledForMoreThanOneWeek = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => validateNotStalledFor(at)(issue, 5, "one week"); const vaildateNotWaitingForReviewForMoreThanHalfADay = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => { const check = checker("issues not waiting for review for too long"); @@ -275,6 +285,7 @@ const vaildateNotWaitingForReviewForMoreThanHalfADay = }; const validateInProgressNotCloseToEstimate = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue ): CheckResult => { const check = checker( @@ -299,6 +310,7 @@ const validateInProgressNotCloseToEstimate = ( */ export const validateTooLongInBacklog = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => { const check = checker("issues don't stay in the backlog for too long"); const ageInMonths = differenceInCalendarMonths( @@ -323,6 +335,7 @@ export const validateTooLongInBacklog = // TODO check sub-tasks for comments? export const validateComment = (at: ReadonlyDate) => + // eslint-disable-next-line functional/prefer-immutable-types (issue: EnhancedIssue): CheckResult => { const check = checker("issues that have been worked have comments"); @@ -366,7 +379,9 @@ export const validateComment = }; const check = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue, + // eslint-disable-next-line functional/prefer-immutable-types checks: readonly ((t: EnhancedIssue) => CheckResult)[] ): IssueAction => { const noAction: IssueAction = { @@ -400,6 +415,7 @@ const check = ( * @returns true if the issue deserves some grace, otherwise false. */ export const issueDeservesGrace = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue, now: ReadonlyDate ): boolean => { @@ -420,6 +436,7 @@ export const issueDeservesGrace = ( * @returns whether action is required, and the checks that were run to form that recommendation. */ export const issueActionRequired = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue, now: ReadonlyDate, customChecks: readonly Check[] diff --git a/lib/src/services/jira.test.ts b/lib/src/services/jira.test.ts index 4eb87a6..c6fec26 100644 --- a/lib/src/services/jira.test.ts +++ b/lib/src/services/jira.test.ts @@ -88,6 +88,7 @@ describe("decoding well-formed tickets", () => { // Given a well-formed bit of data. // When it is decoded. + // eslint-disable-next-line functional/prefer-immutable-types const actual = CloudIssue.decode(data); // Then no errors should be reported. @@ -181,7 +182,7 @@ describe("finding the most recent work date", () => { // Given an issue. // When the time it was last worked is found. - const lastWorked = issueLastWorked(issue); + const lastWorked = issueLastWorked(issue, []); // Then it should match the expected value. expect(lastWorked).toEqual(expected); @@ -310,8 +311,10 @@ describe("enhancing issues", () => { }; // When it is enhanced + // eslint-disable-next-line functional/prefer-immutable-types const enhanced = enhancedIssue( issue, + [], "viewlink", fieldName, "not_reason", @@ -341,8 +344,10 @@ describe("enhancing issues", () => { }; // When it is enhanced + // eslint-disable-next-line functional/prefer-immutable-types const enhanced = enhancedIssue( issue, + [], "viewlink", "not_quality", fieldName, diff --git a/lib/src/services/jira.ts b/lib/src/services/jira.ts index 5d06da8..c956524 100644 --- a/lib/src/services/jira.ts +++ b/lib/src/services/jira.ts @@ -1,4 +1,4 @@ -/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "ReadonlyDeep" }] */ +/* eslint-disable functional/prefer-immutable-types */ /* eslint-disable spellcheck/spell-checker */ import * as T from "io-ts"; import * as ITT from "io-ts-types"; @@ -152,6 +152,12 @@ export const BaseIssue = T.readonly( ) ), duedate: nullOrMissingToUndefined(readonlyDateFromDate), + subtasks: T.readonlyArray( + T.type({ + id: T.string, + key: T.string, + }) + ), }) ), T.readonly( @@ -294,7 +300,6 @@ export const Board = T.readonly( }) ); -// eslint-disable-next-line functional/type-declaration-immutability export type Board = Readonly>; export const BoardSummary = T.readonly( @@ -442,12 +447,26 @@ export const mostRecentIssueComment = ( * @returns the most recent worklog, or undefined if no work has been logged. */ export const mostRecentIssueWorklog = ( - issue: Issue + issue: Issue, + issues: readonly Issue[] ): IssueWorklog | undefined => { - const worklogs = - issue.fields.worklog === undefined ? [] : issue.fields.worklog.worklogs; + const subtaskWorklogs = issue.fields.subtasks.reduce( + (acc: IssueWorklog[], subtask) => { + const subtaskIssue = issues.find( + (subtaskIssue) => subtaskIssue.key === subtask.key + ); + const work = subtaskIssue?.fields.worklog?.worklogs; + + return work === undefined ? acc : acc.concat(work); + }, + [] + ); - return [...worklogs].sort((a, b) => + const allWorklogs: IssueWorklog[] = ( + issue.fields.worklog?.worklogs ?? [] + ).concat(subtaskWorklogs); + + return [...allWorklogs].sort((a, b) => compareDesc(a.started.valueOf(), b.started.valueOf()) )[0]; }; @@ -458,12 +477,15 @@ export const mostRecentIssueWorklog = ( * @param issue the issue. * @returns the time that the issue was last worked, or undefined if it has never been worked. */ -export const issueLastWorked = (issue: Issue): ReadonlyDate | undefined => { +export const issueLastWorked = ( + issue: Issue, + issues: readonly Issue[] +): ReadonlyDate | undefined => { const mostRecentTransition = mostRecentIssueTransition(issue); const mostRecentComment = mostRecentIssueComment(issue); - const mostRecentWorklog = mostRecentIssueWorklog(issue); + const mostRecentWorklog = mostRecentIssueWorklog(issue, issues); return [ mostRecentTransition?.created, @@ -476,6 +498,7 @@ export const issueLastWorked = (issue: Issue): ReadonlyDate | undefined => { export const enhancedIssue = ( issue: Issue, + issues: readonly Issue[], viewLink: string, qualityFieldName: string, qualityReasonFieldName: string, @@ -488,7 +511,7 @@ export const enhancedIssue = ( const released = issue.fields.fixVersions.some((version) => version.released); - const lastWorked = issueLastWorked(issue); + const lastWorked = issueLastWorked(issue, issues); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const quality = issue.fields[qualityFieldName] as string | undefined; diff --git a/lib/src/services/jira_api.ts b/lib/src/services/jira_api.ts index 9178497..5a3d381 100644 --- a/lib/src/services/jira_api.ts +++ b/lib/src/services/jira_api.ts @@ -1,4 +1,3 @@ -/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "ReadonlyDeep" }] */ /* eslint-disable spellcheck/spell-checker */ import { Either } from "fp-ts/lib/Either"; import * as E from "fp-ts/lib/Either"; @@ -59,6 +58,7 @@ export type JiraClient = { qualityReasonField: string, customFieldNames: readonly string[], descriptionFields: ReadonlyRecord + // eslint-disable-next-line functional/prefer-immutable-types ) => Promise>; readonly currentUser: () => Promise>; }; @@ -263,6 +263,7 @@ const jiraClient = ( * @returns the mapped validation. */ const mapValidationError = ( + // eslint-disable-next-line functional/prefer-immutable-types validation: T.Validation ): Either => E.isLeft(validation) @@ -280,6 +281,7 @@ const jiraClient = ( const decode = ( name: string, input: I, + // eslint-disable-next-line functional/prefer-immutable-types decoder: (i: I) => T.Validation ): Either => pipe( @@ -301,6 +303,7 @@ const jiraClient = ( * @returns Issue */ const onPremJiraToGeneric = ( + // eslint-disable-next-line functional/prefer-immutable-types onPremIssue: OnPremIssue ): TE.TaskEither => { // eslint-disable-next-line functional/prefer-immutable-types @@ -332,6 +335,7 @@ const jiraClient = ( * @returns Issue */ const cloudJiraToGeneric = ( + // eslint-disable-next-line functional/prefer-immutable-types cloudIssue: CloudIssue ): TE.TaskEither => { // eslint-disable-next-line functional/prefer-immutable-types @@ -440,10 +444,12 @@ const jiraClient = ( }; const boardsByProject = ( + // eslint-disable-next-line functional/prefer-immutable-types issues: readonly Issue[], boardNamesToIgnore: readonly string[] ): TE.TaskEither> => { const projectKeys: readonly string[] = issues + // eslint-disable-next-line functional/prefer-immutable-types .map((issue) => issue.fields.project.key) .filter( (value, index, self: readonly string[]) => self.indexOf(value) === index @@ -530,6 +536,7 @@ const jiraClient = ( return pipe(fetch, TE.chain(parsed)); }; + // eslint-disable-next-line functional/prefer-immutable-types const issueLink = (issue: Issue): string => `${jiraProtocol}://${jiraHost}/browse/${encodeURIComponent(issue.key)}`; @@ -565,6 +572,7 @@ const jiraClient = ( }, }), (error: unknown) => { + // eslint-disable-next-line functional/prefer-immutable-types const jiraError = JiraError.decode(error); return isLeft(jiraError) ? `Unexpected error from Jira when updating quality of [${key}] to [${quality}] - [${JSON.stringify( @@ -628,6 +636,7 @@ const jiraClient = ( qualityReasonField: string, customFieldNames: readonly string[], descriptionFields: ReadonlyRecord + // eslint-disable-next-line sonarjs/cognitive-complexity, functional/prefer-immutable-types ): Promise> => { const fetchIssues = TE.tryCatch( // eslint-disable-next-line functional/functional-parameters, functional/prefer-immutable-types @@ -673,6 +682,7 @@ const jiraClient = ( ? pipe( parseCloudJira(response), //response to cloudIssueType TE.chain( + // eslint-disable-next-line functional/prefer-immutable-types TE.traverseSeqArray((cloudIssue) => cloudJiraToGeneric(cloudIssue) ) @@ -681,6 +691,7 @@ const jiraClient = ( : pipe( parseOnPremJira(response), TE.chain( + // eslint-disable-next-line functional/prefer-immutable-types TE.traverseSeqArray((onPremIssue) => onPremJiraToGeneric(onPremIssue) ) @@ -713,10 +724,12 @@ const jiraClient = ( ); const enhancedIssues = ( + // eslint-disable-next-line functional/prefer-immutable-types issues: readonly Issue[] ): TE.TaskEither => { // eslint-disable-next-line functional/prefer-immutable-types return TE.map((boards: ReadonlyRecord) => { + // eslint-disable-next-line functional/prefer-immutable-types return issues.map((issue) => { const issueBoards = boards[issue.fields.project.key] ?? []; const boardByStatus = issueBoards.find((board) => @@ -728,6 +741,7 @@ const jiraClient = ( ); return enhancedIssue( issue, + issues, issueLink(issue), qualityField, qualityReasonField, @@ -739,6 +753,7 @@ const jiraClient = ( }; const issueWithComment = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue ): TE.TaskEither => { const mostRecentCommentLoaded = @@ -747,10 +762,12 @@ const jiraClient = ( const recentComment = ( worklogs: readonly IssueComment[] | undefined + // eslint-disable-next-line functional/prefer-immutable-types ): IssueComment | undefined => worklogs === undefined ? undefined - : [...worklogs].sort((w1, w2) => + : // eslint-disable-next-line functional/prefer-immutable-types + [...worklogs].sort((w1, w2) => compareDesc(w1.created.valueOf(), w2.created.valueOf()) )[0]; @@ -767,6 +784,7 @@ const jiraClient = ( }; const issueWithWorklog = ( + // eslint-disable-next-line functional/prefer-immutable-types issue: EnhancedIssue ): TE.TaskEither => { const mostRecentWorklogLoaded = @@ -775,17 +793,37 @@ const jiraClient = ( const recentWorklog = ( worklogs: readonly IssueWorklog[] | undefined + // eslint-disable-next-line functional/prefer-immutable-types ): IssueWorklog | undefined => worklogs === undefined ? undefined - : [...worklogs].sort((w1, w2) => + : // eslint-disable-next-line functional/prefer-immutable-types + [...worklogs].sort((w1, w2) => compareDesc(w1.started.valueOf(), w2.started.valueOf()) )[0]; + // eslint-disable-next-line functional/prefer-immutable-types + const relevantKeys: string[] = mostRecentWorklogLoaded + ? [] + : issue.fields.subtasks + // eslint-disable-next-line functional/prefer-immutable-types + .reduce((acc: string[], cur) => acc.concat([cur.key]), []) + .concat([issue.key]); + + // eslint-disable-next-line functional/prefer-immutable-types + const worklogs: TE.TaskEither[] = + relevantKeys.map((key) => fetchMostRecentWorklogs(key)); + + const worklog: TE.TaskEither = pipe( + worklogs, + TE.sequenceArray, + TE.map((x): readonly IssueWorklog[] => x.flat()) + ); + return pipe( mostRecentWorklogLoaded ? TE.right(issue.fields.worklog.worklogs) - : fetchMostRecentWorklogs(issue.key), + : worklog, // eslint-disable-next-line functional/prefer-immutable-types TE.map((worklogs) => ({ ...issue, @@ -798,7 +836,9 @@ const jiraClient = ( fetchIssues, TE.chain(convertIssueType), TE.chain(enhancedIssues), + // eslint-disable-next-line functional/prefer-immutable-types TE.chain(TE.traverseSeqArray((issue) => issueWithComment(issue))), + // eslint-disable-next-line functional/prefer-immutable-types TE.chain(TE.traverseSeqArray((issue) => issueWithWorklog(issue))) )(); }, diff --git a/lib/src/services/test_data/issue_data.ts b/lib/src/services/test_data/issue_data.ts index 664a1a1..622a849 100644 --- a/lib/src/services/test_data/issue_data.ts +++ b/lib/src/services/test_data/issue_data.ts @@ -1,4 +1,4 @@ -/* eslint functional/prefer-immutable-types: ["error", { "enforcement": "ReadonlyDeep" }] */ +/* eslint-disable functional/prefer-immutable-types */ /* eslint-disable spellcheck/spell-checker */ import type { EnhancedIssue, Issue, IssueWorklog } from "../jira"; import { readonlyDate } from "readonly-types"; @@ -42,6 +42,7 @@ export const issue: Issue = { total: 0, startAt: 0, }, + subtasks: [], duedate: undefined, worklog: { worklogs: [],