From ea2459897b7d30165fe8962d7cc3caa271a0d818 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Wed, 12 Jun 2024 23:02:46 +0300 Subject: [PATCH 1/8] Fix: RangeError on Number.padLeft on large numbers --- spec/models/time.spec.js | 12 ++++++++++++ src/models/time.js | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 spec/models/time.spec.js diff --git a/spec/models/time.spec.js b/spec/models/time.spec.js new file mode 100644 index 0000000..a96f034 --- /dev/null +++ b/spec/models/time.spec.js @@ -0,0 +1,12 @@ +const config = require('./../../src/models/time'); +const expect = require('chai').expect; + +describe('time class', () => { + it('Formats numbers', () => { + expect((new Number(1)).padLeft(2, "0")).to.equal('01'); + expect((new Number(12)).padLeft(2, "0")).to.equal('12'); + expect((new Number(123)).padLeft(2, "0")).to.equal('123'); + expect((new Number(1234)).padLeft(2, "0")).to.equal('1234'); + }); +}); + diff --git a/src/models/time.js b/src/models/time.js index abb0ae2..60af5a7 100755 --- a/src/models/time.js +++ b/src/models/time.js @@ -9,8 +9,15 @@ const roundedRegex = /(\[\%([^\>\]]*)\:([^\]]*)\])/ig; const conditionalSimpleRegex = /([0-9]*)\>(.*)/ig; const defaultRegex = /(\[\%([^\]]*)\])/ig; +/** + * Adds leading zeroes to the number + * + * @param {Number} n + * @param {String} str + * @returns {String} + */ Number.prototype.padLeft = function (n, str) { - return Array(n - String(this).length + 1).join(str || '0') + this; + return Array(Math.max(0, n - String(this).length + 1)).join(str || '0') + this; }; /** From 449f595b70d99df8c26cf5b6934768fe2dffeef5 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Sun, 16 Jun 2024 00:12:17 +0300 Subject: [PATCH 2/8] Fix: Markdown and CSV output do not show projects --- spec/output/csv.spec.js | 89 ++++++++++++++++++++++++++++++++++++ spec/output/markdown.spec.js | 75 ++++++++++++++++++++++++++++++ src/output/csv.js | 4 +- src/output/markdown.js | 6 +-- 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 spec/output/csv.spec.js create mode 100644 spec/output/markdown.spec.js diff --git a/spec/output/csv.spec.js b/spec/output/csv.spec.js new file mode 100644 index 0000000..c940b81 --- /dev/null +++ b/spec/output/csv.spec.js @@ -0,0 +1,89 @@ +const moment = require('moment'); +const config = require('../../src/include/config'); +const Report = require('../../src/models/report'); +const Sinon = require('sinon'); +const { describe } = require('mocha'); +const Csv = require('csv-string'); +const csv = require('../../src/output/csv'); +const { assert } = require('chai'); + +describe('csv output', () => { + /** + * @type {Sinon.SinonSpyStatic} + */ + let csvSpy; + + beforeEach(() => { + csvSpy = Sinon.spy(Csv, 'stringify') + }) + + afterEach(() => { + csvSpy.restore(); + }) + + it('renders projects if more than one', () => { + const issues = [ + { + times: [ + { + user: "user1", + project_namespace: "project1", + seconds: 360, + date: moment('2024-06-06 00:00:00') + }, + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match((value) => { + return Array.isArray(value) && Array.isArray(value[0]) && value[0].includes('project1') && value[0].includes('project2') + }, 'Both projects exist') + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new csv(reportConfig, report); + out.make(); + assert(csvSpy.calledWith(projectsMatcher), 'Not called properly') + }) + + it('does not render single project', () => { + const issues = [ + { + times: [ + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match((value) => { + return Array.isArray(value) && Array.isArray(value[0]) && !value[0].includes('project2') + }, 'Project does not exist') + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new csv(reportConfig, report); + out.make(); + assert(csvSpy.calledWith(projectsMatcher), 'Not called properly') + }) +}) diff --git a/spec/output/markdown.spec.js b/spec/output/markdown.spec.js new file mode 100644 index 0000000..044bafd --- /dev/null +++ b/spec/output/markdown.spec.js @@ -0,0 +1,75 @@ +const moment = require('moment'); +const config = require('../../src/include/config'); +const Report = require('../../src/models/report'); +const markdown = require('../../src/output/markdown'); +const Sinon = require('sinon'); + +describe('markdown class', () => { + it('Renders projects if more than one', () => { + const issues = [ + { + times: [ + { + user: "user1", + project_namespace: "project1", + seconds: 360, + date: moment('2024-06-06 00:00:00') + }, + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match('project1').and(Sinon.match('project2')) + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new markdown(reportConfig, report); + let mdMock = Sinon.mock(out); + mdMock.expects('headline').once(); + mdMock.expects('write').once().withArgs(projectsMatcher); + out.make(); + }); + + it('Does not render projects if one project only', () => { + const issues = [ + { + times: [ + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match(function(value) { + return ((typeof(value) === 'string') && (/project2/.test(value) === false)); + }) + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new markdown(reportConfig, report); + let mdMock = Sinon.mock(out); + mdMock.expects('headline').once(); + mdMock.expects('write').once().withArgs(projectsMatcher); + out.make(); + }); +}); + diff --git a/src/output/csv.js b/src/output/csv.js index 58c188e..da856b0 100755 --- a/src/output/csv.js +++ b/src/output/csv.js @@ -17,7 +17,7 @@ class csv extends Base { stats[1].push(time); }); - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { _.each(this.projects, (time, name) => { stats[0].push(name); stats[1].push(time); @@ -101,4 +101,4 @@ class csv extends Base { } } -module.exports = csv; \ No newline at end of file +module.exports = csv; diff --git a/src/output/markdown.js b/src/output/markdown.js index 5116abd..52447c4 100755 --- a/src/output/markdown.js +++ b/src/output/markdown.js @@ -25,8 +25,8 @@ class markdown extends Base { _.each(this.stats, (time, name) => stats += `\n* **${name}**: ${time}`); stats += `\n`; - if (this.projects.length > 1) { - _.each(this.projects, (time, name) => stats += `\n* **${name.red}**: ${time}`); + if (Object.keys(this.projects).length > 1) { + _.each(this.projects, (time, name) => stats += `\n* **${name}**: ${time}`); stats += `\n`; } @@ -69,4 +69,4 @@ class markdown extends Base { } } -module.exports = markdown; \ No newline at end of file +module.exports = markdown; From 7a5c68bf8b317d478849ad634c2c066a763c5586 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Mon, 17 Jun 2024 20:38:54 +0300 Subject: [PATCH 3/8] Fix: Table output does not show projects --- spec/output/table.spec.js | 75 +++++++++++++++++++++++++++++++++++++++ src/output/table.js | 4 +-- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 spec/output/table.spec.js diff --git a/spec/output/table.spec.js b/spec/output/table.spec.js new file mode 100644 index 0000000..d0e3767 --- /dev/null +++ b/spec/output/table.spec.js @@ -0,0 +1,75 @@ +const moment = require('moment'); +const config = require('../../src/include/config'); +const Report = require('../../src/models/report'); +const table = require('../../src/output/table'); +const Sinon = require('sinon'); + +describe('Table output', () => { + it('Renders projects if more than one', () => { + const issues = [ + { + times: [ + { + user: "user1", + project_namespace: "project1", + seconds: 360, + date: moment('2024-06-06 00:00:00') + }, + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match('project1').and(Sinon.match('project2')) + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new table(reportConfig, report); + let mdMock = Sinon.mock(out); + mdMock.expects('headline').once(); + mdMock.expects('write').once().withArgs(projectsMatcher); + out.make(); + }); + + it('Does not render single project', () => { + const issues = [ + { + times: [ + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match(function(value) { + return ((typeof(value) === 'string') && (/project2/.test(value) === false)); + }) + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new table(reportConfig, report); + let mdMock = Sinon.mock(out); + mdMock.expects('headline').once(); + mdMock.expects('write').once().withArgs(projectsMatcher); + out.make(); + }); +}); + diff --git a/src/output/table.js b/src/output/table.js index 157e5dd..875f336 100755 --- a/src/output/table.js +++ b/src/output/table.js @@ -25,7 +25,7 @@ class table extends Base { _.each(this.stats, (time, name) => stats += `\n* ${name.red}: ${time}`); stats += `\n`; - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { _.each(this.projects, (time, name) => stats += `\n* ${name.red}: ${time}`); stats += `\n`; } @@ -65,4 +65,4 @@ class table extends Base { } } -module.exports = table; \ No newline at end of file +module.exports = table; From 06239c6f21478aae2a19ca7723372d62d62b8e77 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Mon, 17 Jun 2024 20:53:44 +0300 Subject: [PATCH 4/8] Fix: XLSX output does not show projects --- spec/output/xlsx.spec.js | 89 ++++++++++++++++++++++++++++++++++++++++ src/output/xlsx.js | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 spec/output/xlsx.spec.js diff --git a/spec/output/xlsx.spec.js b/spec/output/xlsx.spec.js new file mode 100644 index 0000000..13336fa --- /dev/null +++ b/spec/output/xlsx.spec.js @@ -0,0 +1,89 @@ +const moment = require('moment'); +const config = require('../../src/include/config'); +const Report = require('../../src/models/report'); +const Sinon = require('sinon'); +const { describe } = require('mocha'); +const output = require('../../src/output/xlsx'); +const XLSX = require('xlsx'); +const { assert } = require('chai'); + +describe('XLSX output', () => { + /** + * @type {Sinon.SinonSpyStatic} + */ + let xlsxSpy; + + beforeEach(() => { + xlsxSpy = Sinon.spy(XLSX.utils, 'aoa_to_sheet') + }) + + afterEach(() => { + xlsxSpy.restore(); + }) + + it('renders projects if more than one', () => { + const issues = [ + { + times: [ + { + user: "user1", + project_namespace: "project1", + seconds: 360, + date: moment('2024-06-06 00:00:00') + }, + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match((value) => { + return Array.isArray(value) && Array.isArray(value[0]) && value[0].includes('project1') && value[0].includes('project2') + }, 'Both projects exist') + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new output(reportConfig, report); + out.make(); + assert(xlsxSpy.calledWith(projectsMatcher), 'Not called properly') + }) + + it('does not render single project', () => { + const issues = [ + { + times: [ + { + user: "user2", + project_namespace: "project2", + seconds: 720, + date: moment('2024-06-06 00:00:00') + } + ], + stats: {time_estimate: 0, total_time_spent: 0} + } + ]; + + const projectsMatcher = Sinon.match((value) => { + return Array.isArray(value) && Array.isArray(value[0]) && !value[0].includes('project2') + }, 'Project does not exist') + + let reportConfig = new config(); + reportConfig.set('report', ['stats']) + let report = new Report(reportConfig); + report.issues = issues; + report.mergeRequests = []; + + let out = new output(reportConfig, report); + out.make(); + assert(xlsxSpy.calledWith(projectsMatcher), 'Not called properly') + }) +}) diff --git a/src/output/xlsx.js b/src/output/xlsx.js index 9581ad1..6e8ab45 100644 --- a/src/output/xlsx.js +++ b/src/output/xlsx.js @@ -19,7 +19,7 @@ class xlsx extends Base { stats[1].push(time); }); - if (this.projects.length > 1) { + if (Object.keys(this.projects).length > 1) { _.each(this.projects, (time, name) => { stats[0].push(name); stats[1].push(time); From 6a3988a385b9578c158610aed5cb9394d252a906 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Mon, 17 Jun 2024 22:22:44 +0300 Subject: [PATCH 5/8] Fix: Report: Allow to select output file if report from dump selected --- src/gtt-report.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gtt-report.js b/src/gtt-report.js index fac46da..97d380c 100755 --- a/src/gtt-report.js +++ b/src/gtt-report.js @@ -75,6 +75,10 @@ if (program.from_dump && fs.existsSync(program.from_dump)) { let data = JSON.parse(fs.readFileSync(program.from_dump)); if (data.data) _.each(data.data, (v, i) => { + // Unsetting option "file", as it does not allow to select different output file + if (i === 'file') { + return; + } config.set(i, v); }); From 868185afe267226a10eb4bf1ab6e86494e2f41c7 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Mon, 1 Jul 2024 21:36:40 +0300 Subject: [PATCH 6/8] Feat: Add noteable title to record --- documentation.md | 1 + spec/models/time.title.spec.js | 36 ++++++++++++++++++++++++++++++++++ src/models/time.js | 11 ++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 spec/models/time.title.spec.js diff --git a/documentation.md b/documentation.md index 41fe224..aac9616 100644 --- a/documentation.md +++ b/documentation.md @@ -558,6 +558,7 @@ mergeRequestColumns: recordColumns: - user - iid +- title - time # Add columns for each project member to issue and diff --git a/spec/models/time.title.spec.js b/spec/models/time.title.spec.js new file mode 100644 index 0000000..1e8b726 --- /dev/null +++ b/spec/models/time.title.spec.js @@ -0,0 +1,36 @@ +const moment = require('moment'); +const Config = require('../../src/include/config'); +const Time = require('./../../src/models/time'); +const issue = require('../../src/models/issue'); +const mergeRequest = require('../../src/models/mergeRequest'); +const expect = require('chai').expect; + +describe('time class', () => { + it('Returns title of parent Issue', () => { + const config = new Config(); + const parent = new issue(config, {title: "Test title"}) + const time = new Time('1h', moment(), {}, parent, config); + + expect(time.title).to.be.equal("Test title"); + }); + + it('Returns title of parent MergeRequest', () => { + const config = new Config(); + const parent = new mergeRequest(config, {title: "Test title"}) + const time = new Time('1h', moment(), {}, parent, config); + + expect(time.title).to.be.equal("Test title"); + }); + + it('Returns Null for missed title or parent', () => { + const config = new Config(); + const parent = new mergeRequest(config, {}); + let time; + time = new Time('1h', moment(), {}, parent, config); + expect(time.title).to.be.equal(null); + + time = new Time('1h', moment(), {}, null, config); + expect(time.title).to.be.equal(null); + }); +}); + diff --git a/src/models/time.js b/src/models/time.js index 60af5a7..b9bfbc0 100755 --- a/src/models/time.js +++ b/src/models/time.js @@ -28,7 +28,7 @@ class time { * construct * @param timeString * @param note - * @param parent + * @param {hasTimes} parent * @param config */ constructor(timeString, date = null, note, parent, config) { @@ -75,6 +75,15 @@ class time { return time.toHumanReadable(this.seconds, this._hoursPerDay, this._timeFormat); } + /** + * Title of the linked Noteable object (Issue/MergeRequest) + * + * @returns {String|null} + */ + get title() { + return this.parent && this.parent.title || null; + } + get _timeFormat() { return this.config && this.config.get('timeFormat', 'records') ? this.config.get('timeFormat', 'records') : ''; } From 078d4243e7a5a8225fdaa37a9fb447be891427ed Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Mon, 17 Jun 2024 22:29:49 +0300 Subject: [PATCH 7/8] Feat: Report: Add date shortcut last_month --- spec/commands/report.date.shortcuts.spec.js | 62 +++++++++++++++++++++ src/gtt-report.js | 5 ++ 2 files changed, 67 insertions(+) create mode 100644 spec/commands/report.date.shortcuts.spec.js diff --git a/spec/commands/report.date.shortcuts.spec.js b/spec/commands/report.date.shortcuts.spec.js new file mode 100644 index 0000000..94be06b --- /dev/null +++ b/spec/commands/report.date.shortcuts.spec.js @@ -0,0 +1,62 @@ +const Sinon = require('sinon'); +const program = require('commander'); +const { assert } = require('chai'); +const Config = require('../../src/include/file-config'); +const moment = require('moment'); +const report = require('../../src/models/report'); + +describe('Command Report. Date shortcuts', () => { + it('Shortcut last_month registered', () => { + /** @type {Sinon.SinonMock} */ + const stub = Sinon.mock(program); + + /** @type {Sinon.SinonSpy} */ + const spy = Sinon.spy(program, 'option'); + + stub.expects('parse').throws('Test exception to prevent further execution of gtt-report') + try { + require('../../src/gtt-report'); + } catch (e) {} + + try { + assert(spy.calledWith('--last_month'), 'last_month not registered'); + } finally { + spy.restore(); + stub.restore(); + } + }); + + it('Shortcut last_month. Sets From and To to Config', () => { + program.last_month = true; + program.args = []; + let configSpy = Sinon.spy(Config.prototype, 'set'); + + const reportOriginal = Object.getPrototypeOf(report); + let reportStub = Sinon.stub().callsFake(() => { + throw new Error('Test exception to prevent further execution of gtt-report') + }); + Object.setPrototypeOf(report, reportStub); + + stub = Sinon.mock(program); + stub.expects('parse').returnsThis(); + + try { + require('../../src/gtt-report'); + } catch (e) {} + + try { + assert( + configSpy.calledWith('from', moment().subtract(1, 'months').startOf('month')), + 'From is not set to Config' + ); + assert( + configSpy.calledWith('to', moment().subtract(1, 'months').endOf('month')), + 'To is not set to Config' + ); + } finally { + configSpy.restore(); + Object.setPrototypeOf(report, reportOriginal); + delete require.cache[require.resolve('../../src/models/report')]; + } + }) +}) diff --git a/src/gtt-report.js b/src/gtt-report.js index 97d380c..d16b92c 100755 --- a/src/gtt-report.js +++ b/src/gtt-report.js @@ -40,6 +40,7 @@ program .option('--today', 'ignores --from and --to and queries entries for today') .option('--this_week', 'ignores --from and --to and queries entries for this week') .option('--this_month', 'ignores --from and --to and queries entries for this month') + .option('--last_month', 'ignores --from and --to and queries entries for last month') .option('-c --closed', 'include closed issues') .option('-m --milestone ', 'include issues from the given milestone') .option('--hours_per_day ', 'hours per day for human readable time formats') @@ -142,6 +143,10 @@ if (program.this_month) config .set('from', moment().startOf('month')) .set('to', moment().endOf('month')); +if (program.last_month) + config + .set('from', moment().subtract(1, 'months').startOf('month')) + .set('to', moment().subtract(1, 'months').endOf('month')); Cli.quiet = config.get('quiet'); Cli.verbose = config.get('_verbose'); From df46145b25c8925a73386d7f7d0592511c5ca1a4 Mon Sep 17 00:00:00 2001 From: Boris Rogachov Date: Wed, 3 Jul 2024 21:48:49 +0300 Subject: [PATCH 8/8] Add github workflows --- .github/workflows/test.yml | 22 ++++++++++++++++++++++ .istanbul.yml | 4 ++++ 2 files changed, 26 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .istanbul.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..998f7fc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Tests +on: [ push, pull_request ] + +env: + COVERALLS_SERVICE_NUMBER: ${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: lts/* + cache: yarn + - run: yarn install + - run: yarn run coveralls + - name: Coveralls + id: coveralls + uses: coverallsapp/github-action@v2 + diff --git a/.istanbul.yml b/.istanbul.yml new file mode 100644 index 0000000..e761138 --- /dev/null +++ b/.istanbul.yml @@ -0,0 +1,4 @@ +instrumentation: + root: ./src + include-all-sources: true +