From c5af87c0207c10f3c13160bcc6978e0ebde7da12 Mon Sep 17 00:00:00 2001 From: TESTELIN Geoffrey Date: Wed, 10 Nov 2021 01:06:10 +0100 Subject: [PATCH] feat(stale): stale locally the issues older than 30 days --- package-lock.json | 27 ++++ package.json | 2 + src/core/issues/issue-processor.spec.ts | 120 +++++++++++++++++- src/core/issues/issue-processor.ts | 37 +++++- src/core/issues/issue-stale-processor.spec.ts | 44 +++++++ src/core/issues/issue-stale-processor.ts | 31 +++++ 6 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 src/core/issues/issue-stale-processor.spec.ts create mode 100644 src/core/issues/issue-stale-processor.ts diff --git a/package-lock.json b/package-lock.json index 202a8af1e..7d418a639 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@octokit/webhooks-definitions": "3.67.3", "ansi-styles": "6.1.0", "lodash": "4.17.21", + "luxon": "2.1.1", "terminal-link": "3.0.0", "tslib": "2.3.1" }, @@ -25,6 +26,7 @@ "@types/faker": "5.5.9", "@types/jest": "27.0.2", "@types/lodash": "4.14.176", + "@types/luxon": "2.0.7", "@types/node": "14.17.33", "@typescript-eslint/eslint-plugin": "4.33.0", "@typescript-eslint/parser": "4.33.0", @@ -1954,6 +1956,12 @@ "integrity": "sha512-xZmuPTa3rlZoIbtDUyJKZQimJV3bxCmzMIO2c9Pz9afyDro6kr7R79GwcB6mRhuoPmV2p1Vb66WOJH7F886WKQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.7.tgz", + "integrity": "sha512-AxiYycfO+/M4VIH0ribSr2iPFC+APewpJIaQSydwVnzorK3mjSFXkA3HmhQidGx44MpwaatFyEkbW/WD4zdDaQ==", + "dev": true + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -12032,6 +12040,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz", + "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -20693,6 +20709,12 @@ "integrity": "sha512-xZmuPTa3rlZoIbtDUyJKZQimJV3bxCmzMIO2c9Pz9afyDro6kr7R79GwcB6mRhuoPmV2p1Vb66WOJH7F886WKQ==", "dev": true }, + "@types/luxon": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.7.tgz", + "integrity": "sha512-AxiYycfO+/M4VIH0ribSr2iPFC+APewpJIaQSydwVnzorK3mjSFXkA3HmhQidGx44MpwaatFyEkbW/WD4zdDaQ==", + "dev": true + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -28521,6 +28543,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz", + "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/package.json b/package.json index cdc607cbf..1ec03e9e3 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@octokit/webhooks-definitions": "3.67.3", "ansi-styles": "6.1.0", "lodash": "4.17.21", + "luxon": "2.1.1", "terminal-link": "3.0.0", "tslib": "2.3.1" }, @@ -109,6 +110,7 @@ "@types/faker": "5.5.9", "@types/jest": "27.0.2", "@types/lodash": "4.14.176", + "@types/luxon": "2.0.7", "@types/node": "14.17.33", "@typescript-eslint/eslint-plugin": "4.33.0", "@typescript-eslint/parser": "4.33.0", diff --git a/src/core/issues/issue-processor.spec.ts b/src/core/issues/issue-processor.spec.ts index 9722d8b1e..ba9ddbda0 100644 --- a/src/core/issues/issue-processor.spec.ts +++ b/src/core/issues/issue-processor.spec.ts @@ -1,8 +1,10 @@ import { IssueIgnoreProcessor } from '@core/issues/issue-ignore-processor'; import { IssueLogger } from '@core/issues/issue-logger'; import { IssueProcessor } from '@core/issues/issue-processor'; +import { IssueStaleProcessor } from '@core/issues/issue-stale-processor'; import { IGithubApiIssue } from '@github/api/issues/github-api-issue.interface'; import * as CreateLinkModule from '@utils/links/create-link'; +import { DateTime } from 'luxon'; import { createHydratedMock } from 'ts-auto-mock'; import { MockedObjectDeep } from 'ts-jest/dist/utils/testing'; import { mocked } from 'ts-jest/utils'; @@ -11,8 +13,9 @@ jest.mock(`@utils/loggers/logger.service`); jest.mock(`@utils/loggers/logger-format.service`); jest.mock(`@core/issues/issue-logger`); jest.mock(`@core/issues/issue-ignore-processor`); +jest.mock(`@core/issues/issue-stale-processor`); -describe(`IssueProcessor`, (): void => { +describe(`issueProcessor`, (): void => { let gitHubApiIssue: IGithubApiIssue; beforeEach((): void => { @@ -58,6 +61,7 @@ describe(`IssueProcessor`, (): void => { let loggerStartGroupSpy: jest.SpyInstance; let stopProcessingSpy: jest.SpyInstance; let shouldIgnoreSpy: jest.SpyInstance; + let processForStaleSpy: jest.SpyInstance; let loggerInfoSpy: jest.SpyInstance; let createLinkSpy: jest.SpyInstance; @@ -65,6 +69,7 @@ describe(`IssueProcessor`, (): void => { loggerStartGroupSpy = jest.spyOn(issueProcessor.logger, `startGroup`).mockImplementation(); stopProcessingSpy = jest.spyOn(issueProcessor, `stopProcessing$$`).mockImplementation(); shouldIgnoreSpy = jest.spyOn(issueProcessor, `shouldIgnore$$`).mockImplementation(); + processForStaleSpy = jest.spyOn(issueProcessor, `processForStale$$`).mockImplementation(); loggerInfoSpy = jest.spyOn(issueProcessor.logger, `info`).mockImplementation(); createLinkSpy = jest.spyOn(CreateLinkModule, `createLink`).mockReturnValue(`dummy-link`); }); @@ -104,12 +109,13 @@ describe(`IssueProcessor`, (): void => { }); it(`should stop to process this issue`, async (): Promise => { - expect.assertions(2); + expect.assertions(3); await issueProcessor.process(); expect(stopProcessingSpy).toHaveBeenCalledTimes(1); expect(stopProcessingSpy).toHaveBeenCalledWith(); + expect(processForStaleSpy).not.toHaveBeenCalled(); }); }); @@ -118,13 +124,51 @@ describe(`IssueProcessor`, (): void => { shouldIgnoreSpy.mockReturnValue(false); }); - it(`should stop to process this issue`, async (): Promise => { - expect.assertions(2); + it(`should really process the issue for the stale checks`, async (): Promise => { + expect.assertions(3); await issueProcessor.process(); - expect(stopProcessingSpy).toHaveBeenCalledTimes(1); - expect(stopProcessingSpy).toHaveBeenCalledWith(); + expect(processForStaleSpy).toHaveBeenCalledTimes(1); + expect(processForStaleSpy).toHaveBeenCalledWith(); + expect(stopProcessingSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe(`getUpdatedAt()`, (): void => { + describe(`when the creation date of the issue is invalid`, (): void => { + beforeEach((): void => { + issueProcessor = new IssueProcessor( + createHydratedMock({ + updatedAt: `dummy-wrong-date`, + }) + ); + }); + + it(`should throw an error`, (): void => { + expect.assertions(1); + + expect((): DateTime => issueProcessor.getUpdatedAt()).toThrow(new Error(`unparsable`)); + }); + }); + + describe(`when the creation date of the issue is valid`, (): void => { + beforeEach((): void => { + issueProcessor = new IssueProcessor( + createHydratedMock({ + updatedAt: DateTime.now().toISO(), + }) + ); + }); + + it(`should return the creation date as a date time class`, (): void => { + expect.assertions(2); + + const result = issueProcessor.getUpdatedAt(); + + expect(result).toBeInstanceOf(DateTime); + expect(result.equals(DateTime.now())).toBeTrue(); }); }); }); @@ -165,6 +209,7 @@ describe(`IssueProcessor`, (): void => { beforeEach((): void => { mockIssueIgnoreProcessor.mockClear(); + mockIssueIgnoreProcessor.prototype.shouldIgnore.mockImplementation().mockReturnValue(false); }); @@ -207,5 +252,68 @@ describe(`IssueProcessor`, (): void => { }); }); }); + + describe(`processForStale$$()`, (): void => { + const mockIssueStaleProcessor: MockedObjectDeep = mocked(IssueStaleProcessor, true); + + let stopProcessingSpy: jest.SpyInstance; + + beforeEach((): void => { + mockIssueStaleProcessor.mockClear(); + + stopProcessingSpy = jest.spyOn(issueProcessor, `stopProcessing$$`).mockImplementation(); + }); + + it(`should check if the issue should be stale`, async (): Promise => { + expect.assertions(4); + + await issueProcessor.processForStale$$(); + + expect(mockIssueStaleProcessor).toHaveBeenCalledTimes(1); + expect(mockIssueStaleProcessor).toHaveBeenCalledWith(issueProcessor); + expect(mockIssueStaleProcessor.prototype.shouldBeStale.mock.calls).toHaveLength(1); + expect(mockIssueStaleProcessor.prototype.shouldBeStale.mock.calls[0]).toHaveLength(0); + }); + + describe(`when the issue should not be stale`, (): void => { + beforeEach((): void => { + mockIssueStaleProcessor.prototype.shouldBeStale.mockImplementation().mockReturnValue(false); + }); + + it(`should stop to process this issue`, async (): Promise => { + expect.assertions(3); + + await issueProcessor.processForStale$$(); + + expect(mockIssueStaleProcessor.prototype.stale).not.toHaveBeenCalled(); + expect(stopProcessingSpy).toHaveBeenCalledTimes(1); + expect(stopProcessingSpy).toHaveBeenCalledWith(); + }); + }); + + describe(`when the issue should be stale`, (): void => { + beforeEach((): void => { + mockIssueStaleProcessor.prototype.shouldBeStale.mockImplementation().mockReturnValue(true); + }); + + it(`should stale the issue`, async (): Promise => { + expect.assertions(2); + + await issueProcessor.processForStale$$(); + + expect(mockIssueStaleProcessor.prototype.stale.mock.calls).toHaveLength(1); + expect(mockIssueStaleProcessor.prototype.stale.mock.calls[0]).toHaveLength(0); + }); + + it(`should stop to process this issue`, async (): Promise => { + expect.assertions(2); + + await issueProcessor.processForStale$$(); + + expect(stopProcessingSpy).toHaveBeenCalledTimes(1); + expect(stopProcessingSpy).toHaveBeenCalledWith(); + }); + }); + }); }); }); diff --git a/src/core/issues/issue-processor.ts b/src/core/issues/issue-processor.ts index 90e513eb6..3b04810db 100644 --- a/src/core/issues/issue-processor.ts +++ b/src/core/issues/issue-processor.ts @@ -1,9 +1,11 @@ import { IssueIgnoreProcessor } from '@core/issues/issue-ignore-processor'; import { IssueLogger } from '@core/issues/issue-logger'; +import { IssueStaleProcessor } from '@core/issues/issue-stale-processor'; import { IGithubApiIssue } from '@github/api/issues/github-api-issue.interface'; import { createLink } from '@utils/links/create-link'; import { LoggerFormatService } from '@utils/loggers/logger-format.service'; import _ from 'lodash'; +import { DateTime } from 'luxon'; export class IssueProcessor { public readonly githubIssue: IGithubApiIssue; @@ -14,6 +16,11 @@ export class IssueProcessor { this.logger = new IssueLogger(this.githubIssue.number); } + /** + * @description + * First step to process an issue + * @returns {Promise} + */ public async process(): Promise { this.logger.startGroup( `Processing the issue`, @@ -29,9 +36,17 @@ export class IssueProcessor { return; } - this.stopProcessing$$(); + return this.processForStale$$(); + } - return Promise.resolve(); + public getUpdatedAt(): DateTime { + const dateTime: DateTime = DateTime.fromISO(this.githubIssue.updatedAt); + + if (_.isString(dateTime.invalidReason)) { + throw new Error(dateTime.invalidReason); + } + + return dateTime; } public stopProcessing$$(): void { @@ -42,4 +57,22 @@ export class IssueProcessor { public shouldIgnore$$(): boolean { return new IssueIgnoreProcessor(this).shouldIgnore(); } + + /** + * @description + * Second step to process an issue + * At this point, the issue can really be processed (not ignored) + * @returns {Promise} + */ + public processForStale$$(): Promise { + const issueStaleProcessor: IssueStaleProcessor = new IssueStaleProcessor(this); + + if (issueStaleProcessor.shouldBeStale()) { + issueStaleProcessor.stale(); + } + + this.stopProcessing$$(); + + return Promise.resolve(); + } } diff --git a/src/core/issues/issue-stale-processor.spec.ts b/src/core/issues/issue-stale-processor.spec.ts new file mode 100644 index 000000000..508d361bc --- /dev/null +++ b/src/core/issues/issue-stale-processor.spec.ts @@ -0,0 +1,44 @@ +import { IssueProcessor } from '@core/issues/issue-processor'; +import { IssueStaleProcessor } from '@core/issues/issue-stale-processor'; +import { createHydratedMock } from 'ts-auto-mock'; + +jest.mock(`@utils/loggers/logger.service`); +jest.mock(`@utils/loggers/logger-format.service`); + +describe(`issueStaleProcessor`, (): void => { + let issueProcessor: IssueProcessor; + + beforeEach((): void => { + issueProcessor = createHydratedMock(); + }); + + describe(`constructor()`, (): void => { + it(`should save the given issue processor`, (): void => { + expect.assertions(1); + + const result = new IssueStaleProcessor(issueProcessor); + + expect(result.issueProcessor).toStrictEqual(issueProcessor); + }); + }); + + describe(`after creation`, (): void => { + let issueStaleProcessor: IssueStaleProcessor; + + beforeEach((): void => { + issueProcessor = createHydratedMock(); + }); + + describe(`shouldBeStale()`, (): void => { + beforeEach((): void => {}); + + it.todo(``); + }); + + describe(`isStaleByUpdateDate$$()`, (): void => { + beforeEach((): void => {}); + + it.todo(``); + }); + }); +}); diff --git a/src/core/issues/issue-stale-processor.ts b/src/core/issues/issue-stale-processor.ts new file mode 100644 index 000000000..aa612af28 --- /dev/null +++ b/src/core/issues/issue-stale-processor.ts @@ -0,0 +1,31 @@ +import { IssueProcessor } from '@core/issues/issue-processor'; +import { DateTime } from 'luxon'; + +// Days +const STALE_AFTER = 30; + +export class IssueStaleProcessor { + public readonly issueProcessor: IssueProcessor; + + public constructor(issueProcessor: Readonly) { + this.issueProcessor = issueProcessor; + } + + public shouldBeStale(): boolean { + return this.isStaleByUpdateDate$$(); + } + + public stale(): void { + // @todo + } + + public isStaleByUpdateDate$$(): boolean { + const updatedAt: DateTime = this.issueProcessor.getUpdatedAt(); + + return ( + DateTime.now().diff(updatedAt, `days`, { + conversionAccuracy: `longterm`, + }).days >= STALE_AFTER + ); + } +}