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 + 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/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/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/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/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/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/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/gtt-report.js b/src/gtt-report.js index fac46da..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') @@ -75,6 +76,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); }); @@ -138,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'); diff --git a/src/models/time.js b/src/models/time.js index abb0ae2..b9bfbc0 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; }; /** @@ -21,7 +28,7 @@ class time { * construct * @param timeString * @param note - * @param parent + * @param {hasTimes} parent * @param config */ constructor(timeString, date = null, note, parent, config) { @@ -68,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') : ''; } 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; 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; 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);