diff --git a/documentation.md b/documentation.md index f84142d..856a5f1 100644 --- a/documentation.md +++ b/documentation.md @@ -28,7 +28,7 @@ ### limitations - Time tracking for other work items (e.g. tasks, epics) is currently not supported. See also https://github.com/ndu2/gitlab-time-tracker/issues/33 +Time tracking and reporting for epics is currently not supported. ## requirements @@ -58,7 +58,7 @@ token: 01234567891011 You will need node and npm to build the project -* [node.js](https://nodejs.org/en/download) version >= 22 +* [node.js](https://nodejs.org/en/download) version 24 * [npm](https://github.com/npm/npm) @@ -197,6 +197,15 @@ gtt edit You can omit the id to edit to bring up a list of the latest records to choose from. + +**In-Console edit records** + +```shell +gtt edit -i +gtt edit -i --this_week +gtt edit -i --week 2026-02-04 +``` + **Delete a local time record by the given id:** ```shell @@ -389,7 +398,7 @@ gtt report --no_warnings gtt report --date_format="DD.MM.YYYY HH:mm:ss" ``` -*Note: [Click here](http://momentjs.com/docs/#/displaying/format/) for a further documentation on the date format.* +*Note: [Click here](https://day.js.org/docs/en/display/format) for a further documentation on the date format.* #### Set time format for the report @@ -561,12 +570,16 @@ mergeRequestColumns: # Include the given columns in the time record table # See --record_columns option for more information -# defaults to user, date, type, iid, time +# defaults to user, date, project, type, iid, title, time, note recordColumns: - user +- date +- project +- type - iid - title - time +- note # Add columns for each project member to issue and # merge request table, including their total time spent @@ -574,7 +587,7 @@ recordColumns: userColumns: true # Date format -# Click here for format options: http://momentjs.com/docs/#/displaying/format/ +# Click here for format options: https://day.js.org/docs/en/display/format # defaults to DD.MM.YYYY HH:mm:ss dateFormat: DD.MM.YYYY HH:mm:ss diff --git a/package-lock.json b/package-lock.json index 48b94d7..3b32ee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.9.0-snapshot", "license": "GPL-2.0", "dependencies": { - "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", "camelcase": "^9.0.0", @@ -621,29 +620,6 @@ "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", - "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/confirm": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", diff --git a/package.json b/package.json index 26607e2..88145f3 100755 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "author": "kriskbx", "license": "GPL-2.0", "dependencies": { - "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/select": "^5.2.1", "camelcase": "^9.0.0", diff --git a/readme.md b/readme.md index 8b5a4c9..60a84db 100755 --- a/readme.md +++ b/readme.md @@ -9,11 +9,11 @@ gtt is a fully featured command line interface for GitLab's time tracking featur ## About this fork (ndu2) -Shout-out to kris for creating this handy tool. As we use gtt regularly and maintenance on the original repo stopped, I maintain the code base in the foreseeable future on this fork (versions 1.8.x). +Shout-out to kris for creating this handy tool. As we use gtt regularly and maintenance on the original repo stopped, I maintain the code base in the foreseeable future on this fork (versions 1.8.x and later). Feel free to contact me or create a Pull Request if you have some patches or proposals. -### Where to get gtt v1.8 +### Where to get gtt There are various options: @@ -33,7 +33,7 @@ How to install and use gtt? You can find the documentation [here](documentation. ### limitations -Time tracking for other work items (e.g. tasks, epics) is currently not supported. +Time tracking and reporting for epics is currently not supported. ## license diff --git a/spec/output/base.spec.js b/spec/output/base.spec.js index 4e2ad29..6f562f6 100644 --- a/spec/output/base.spec.js +++ b/spec/output/base.spec.js @@ -4,17 +4,19 @@ import Config from '../../src/core/config.js'; import Output from '../../src/reporting/output/base.js'; import calculateStats from '../../src/reporting/stats.js'; -function makeTime({ user = 'alice', seconds = 0, date = '2026-01-05T10:00:00Z', iid = 1, project = 'group/project' } = {}) { - return { user, seconds, iid, project_namespace: project, date: dayjs(date) }; +function makeTime({ user = 'alice', seconds = 0, date = '2026-01-05T10:00:00Z', iid = 1, project = 'group/project', note = null, chargeRatio = 1.0 } = {}) { + return { user, seconds, iid, project_namespace: project, date: dayjs(date), note, chargeRatio }; } -function makeIssue({ iid = 1, labels = [], times = [], days = {}, estimate = 0, spent = 0 } = {}) { +function makeIssue({ iid = 1, labels = [], times = [], estimate = 0, spent = 0, project_id = 1, title = 'issue' } = {}) { return { iid, + project_id, + title, labels, times, - days, - stats: { time_estimate: estimate, total_time_spent: spent } + total_spent_s : spent, + total_estimate_s : estimate }; } @@ -104,15 +106,19 @@ describe('calculateStats', () => { }); it('consolidates per-day data of all issues', () => { + config.set('dateFormatGroupReport', 'YYYY-MM-DD'); + const output = calculate([ - makeIssue({ iid: 1, days: { '2026-01-01': 'a' } }), - makeIssue({ iid: 2, days: { '2026-01-01': 'b', '2026-01-02': 'c' } }) + makeIssue({ iid: 1, times: [makeTime({ date: '2026-01-01T10:00:00Z', note: 'a', seconds: 60 })] }), + makeIssue({ iid: 2, times: [ + makeTime({ date: '2026-01-01T10:00:00Z', note: 'b', seconds: 120 }), + makeTime({ date: '2026-01-02T10:00:00Z', note: 'c', seconds: 180 }) + ] }) ]); - expect(output.daysNew).to.deep.equal({ - '2026-01-01': ['a', 'b'], - '2026-01-02': ['c'] - }); + expect(Object.keys(output.daysNew)).to.deep.equal(['2026-01-01', '2026-01-02']); + expect(output.daysNew['2026-01-01'].map(d => d.getNotes())).to.deep.equal([['a'], ['b']]); + expect(output.daysNew['2026-01-02'].map(d => d.getNotes())).to.deep.equal([['c']]); }); }); diff --git a/src/core/config.js b/src/core/config.js index c53a460..dcd8e88 100755 --- a/src/core/config.js +++ b/src/core/config.js @@ -19,7 +19,7 @@ const defaults = { weeksPerMonth: 4, issueColumns: ['iid', 'title', 'spent', 'total_estimate'], mergeRequestColumns: ['iid', 'title', 'spent', 'total_estimate'], - recordColumns: ['user', 'date', 'type', 'iid', 'time'], + recordColumns: ['user', 'date', 'project', 'type', 'iid', 'title', 'time', 'note'], userColumns: false, dateFormat: 'DD.MM.YYYY HH:mm:ss', timeFormat: Time.defaultTimeFormat, diff --git a/src/core/issue.js b/src/core/issue.js new file mode 100644 index 0000000..fdcd227 --- /dev/null +++ b/src/core/issue.js @@ -0,0 +1,22 @@ +import CoreTask from './task.js'; +import GitlabClient from './gitlab-client.js'; + +class Issue extends CoreTask { + static resoureType = 'issues'; + + constructor(config, data, client) { + super(config, data, client, Issue.resoureType); + } + + static list(config, project, state, my, client = new GitlabClient(config)) { + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + const path = project + ? `projects/${encodeURIComponent(project)}/${Issue.resoureType}?${query}` + : `${Issue.resoureType}/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); + } +} + +export default Issue; diff --git a/src/core/mergeRequest.js b/src/core/mergeRequest.js new file mode 100644 index 0000000..34f158a --- /dev/null +++ b/src/core/mergeRequest.js @@ -0,0 +1,22 @@ +import CoreTask from './task.js'; +import GitlabClient from './gitlab-client.js'; + +class MergeRequest extends CoreTask { + static resoureType = 'merge_requests'; + + constructor(config, data, client) { + super(config, data, client, MergeRequest.resoureType); + } + + static list(config, project, state, my, client = new GitlabClient(config)) { + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + const path = project + ? `projects/${encodeURIComponent(project)}/${MergeRequest.resoureType}?${query}` + : `${MergeRequest.resoureType}}/?${query}`; + + return client.get(path) + .then(response => response.body.map(data => new this(config, data, client))); + } +} + +export default MergeRequest; diff --git a/src/core/task.js b/src/core/task.js index 4890433..03e6434 100644 --- a/src/core/task.js +++ b/src/core/task.js @@ -1,25 +1,16 @@ import dayjs from './dayjs.js'; import GitlabClient from './gitlab-client.js'; +import Time from './time.js'; -/** - * task model — shared data/getters for issues and merge requests, which differ - * only by their GitLab resource type. Write ops (make/createTime) and the - * list() query live in timekeeping/api/*; read/aggregation in reporting/api/*. - * @param type the GitLab resource type: 'issues' or 'merge_requests' - */ class Task { constructor(config, data = {}, client = new GitlabClient(config), type) { this.config = config; this.client = client; this.times = []; - this.days = {}; this.data = data; this.type = type; } - /* - * properties - */ get iid() { return this.data.iid; } @@ -86,11 +77,19 @@ class Task { } get total_spent() { - return this.stats ? this.config.toHumanReadable(this.stats.total_time_spent, this._type) : null; + return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.total_time_spent, this._type) : null; + } + + get total_spent_s() { + return this.data.time_stats ? this.data.time_stats.total_time_spent : 0; } get total_estimate() { - return this.stats ? this.config.toHumanReadable(this.stats.time_estimate, this._type) : null; + return this.data.time_stats ? this.config.toHumanReadable(this.data.time_stats.time_estimate, this._type) : null; + } + + get total_estimate_s() { + return this.data.time_stats ? this.data.time_stats.time_estimate : 0; } get _type() { @@ -100,6 +99,122 @@ class Task { get _typeSingular() { return this.type === 'merge_requests' ? 'Merge Request' : 'Issue'; } + + make(project, id, create = false) { + let promise = create + ? this.client.post(`projects/${encodeURIComponent(project)}/${this._type}`, {title: id}) + : this.client.get(`projects/${encodeURIComponent(project)}/${this._type}/${id}`); + + return promise.then(response => { + this.data = response.body; + return this; + }); + } + + getNotes() { + let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); + promise.then(notes => this.notes = notes); + + return promise; + } + + createTime(time, created_at, note) { + if(note === null || note === undefined) { + note = ''; + } + else { + note = '\n\n' + note; + } + var date = new Date(created_at); + var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); + const query = + `mutation($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + body + } + errors + } + }` + let noteablePath = (this._type == 'merge_requests') ? 'MergeRequest' : 'WorkItem'; + let request = { + "query": query, + "variables": { + "input": { + "noteableId": `gid://gitlab/${noteablePath}/${this.id}`, + "body": '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]' + ' ' + spentAt + note), + } + } + }; + return this.client.graphQL(request).then(response => { + let errors = response.body?.errors ?? response.body?.data?.createNote?.errors; + if(!response.body || (errors && errors.length)) { + throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); + } + return response.body.data.createNote.note; + }); + } + + recordTimelogs(timelogs){ + let spentFreeLabels = this.config.get('freeLabels'); + if(undefined === spentFreeLabels) { + spentFreeLabels = []; + } + let spentHalfPriceLabels = this.config.get('halfPriceLabels'); + if(undefined === spentHalfPriceLabels) { + spentHalfPriceLabels = []; + } + + let free = false; + let halfPrice = false; + this.labels.forEach(label => { + spentFreeLabels.forEach(freeLabel => { + free |= (freeLabel == label); + }); + }); + this.labels.forEach(label => { + spentHalfPriceLabels.forEach(halfPriceLabel => { + halfPrice |= (halfPriceLabel == label); + }); + }); + + let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); + + let times = [], + timeSpent = 0, + timeUsers = {}, + timeFormat = this.config.get('timeFormat', this._type); + + timelogs.forEach( + (timelog) => { + let spentAt = dayjs(timelog.spentAt); + + let time = new Time(null, spentAt, { + author: {username: timelog.user.username}, + created_at: timelog.spentAt, + noteable_type: this._typeSingular + }, this, this.config); + time.seconds = timelog.timeSpent; + time.project_namespace = this.project_namespace; + time.note = timelog.note && timelog.note.body ? timelog.note.body : null; + time.chargeRatio = chargeRatio; + + // only include times by the configured user + if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; + + if (!timeUsers[timelog.user.username]) timeUsers[timelog.user.username] = 0; + + timeSpent += time.seconds; + timeUsers[timelog.user.username] += time.seconds; + + times.push(time); + }); + + Object.entries(timeUsers).forEach(([name, time]) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); + this.timeSpent = timeSpent; + this.times = times; + } } export default Task; diff --git a/src/core/time.js b/src/core/time.js index 0648a7c..fc71f1b 100755 --- a/src/core/time.js +++ b/src/core/time.js @@ -28,6 +28,8 @@ class Time { this._date = date; this.parent = parent; this.config = config; + this.note = null; + this.chargeRatio = 1.0; if(!timeString) { return; diff --git a/src/reporting/api/dayReport.js b/src/reporting/api/dayReport.js index 6d8f71b..658a85d 100644 --- a/src/reporting/api/dayReport.js +++ b/src/reporting/api/dayReport.js @@ -3,7 +3,8 @@ * day model of one item */ class DayReport { - constructor(iid, title, spentAt, chargeRatio) { + constructor(project_id, iid, title, spentAt, chargeRatio) { + this.project_id = project_id; this.iid = iid; this.title = title; this.spentAt = spentAt; @@ -12,6 +13,10 @@ class DayReport { this.spent = 0; this.notes = []; } + + getProjectId() { + return this.project_id; + } getIid() { return this.iid; diff --git a/src/reporting/api/issue.js b/src/reporting/api/issue.js deleted file mode 100644 index a70f93b..0000000 --- a/src/reporting/api/issue.js +++ /dev/null @@ -1,13 +0,0 @@ -import CoreTask from '../../core/task.js'; -import reportable from './reportable.js'; - -/** - * issue with reporting read/aggregation (getStats, recordTimelogs). - */ -class Issue extends reportable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'issues'); - } -} - -export default Issue; diff --git a/src/reporting/api/mergeRequest.js b/src/reporting/api/mergeRequest.js deleted file mode 100644 index e4b7ad9..0000000 --- a/src/reporting/api/mergeRequest.js +++ /dev/null @@ -1,13 +0,0 @@ -import CoreTask from '../../core/task.js'; -import reportable from './reportable.js'; - -/** - * merge request with reporting read/aggregation (getStats, recordTimelogs). - */ -class MergeRequest extends reportable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'merge_requests'); - } -} - -export default MergeRequest; diff --git a/src/reporting/api/report.js b/src/reporting/api/report.js index 3554980..18f41aa 100755 --- a/src/reporting/api/report.js +++ b/src/reporting/api/report.js @@ -1,8 +1,8 @@ import dayjs from '../../core/dayjs.js'; import GitlabClient from '../../core/gitlab-client.js'; import parallel from '../../core/parallel.js'; -import Issue from './issue.js'; -import MergeRequest from './mergeRequest.js'; +import Issue from '../../core/issue.js'; +import MergeRequest from '../../core/mergeRequest.js'; import Project from './project.js'; /** @@ -139,6 +139,7 @@ class Report { endCursor } nodes { + id user { username } @@ -152,10 +153,16 @@ class Report { mergeRequests:mergeRequest { iid projectId + title } issues:issue { iid projectId + title + } + project { + id + name } } } @@ -243,8 +250,6 @@ class Report { timelog[input].iid == data.iid && timelog[input].projectId == data.project_id)); - await item.getStats(); - if (this.config.get('showWithoutTimes') || item.times.length > 0) { collect.push(item); } diff --git a/src/reporting/api/reportable.js b/src/reporting/api/reportable.js deleted file mode 100644 index d45d9c6..0000000 --- a/src/reporting/api/reportable.js +++ /dev/null @@ -1,89 +0,0 @@ -import dayjs from '../../core/dayjs.js'; -import Time from '../../core/time.js'; -import DayReport from './dayReport.js'; - -/** - * mixin: adds reporting read/aggregation to a core issue/mergeRequest class. - * @param Base a core issue/mergeRequest class - */ -export default Base => class extends Base { - /** - * set stats - * @returns {Promise} - */ - getStats() { - let promise = this.client.get(`projects/${this.data.project_id}/${this._type}/${this.iid}/time_stats`); - promise.then(response => this.stats = response.body); - - return promise; - } - - recordTimelogs(timelogs){ - - let spentFreeLabels = this.config.get('freeLabels'); - if(undefined === spentFreeLabels) { - spentFreeLabels = []; - } - let spentHalfPriceLabels = this.config.get('halfPriceLabels'); - if(undefined === spentHalfPriceLabels) { - spentHalfPriceLabels = []; - } - - let free = false; - let halfPrice = false; - this.labels.forEach(label => { - spentFreeLabels.forEach(freeLabel => { - free |= (freeLabel == label); - }); - }); - this.labels.forEach(label => { - spentHalfPriceLabels.forEach(halfPriceLabel => { - halfPrice |= (halfPriceLabel == label); - }); - }); - - - let chargeRatio = free? 0.0: (halfPrice? 0.5: 1.0); - - let times = [], - timeSpent = 0, - timeUsers = {}, - timeFormat = this.config.get('timeFormat', this._type); - - timelogs.forEach( - (timelog) => { - let spentAt = dayjs(timelog.spentAt); - let dateGrp = spentAt.format(this.config.get('dateFormatGroupReport')); - if(!this.days[dateGrp]) - { - this.days[dateGrp] = new DayReport(this.iid, this.title, spentAt, chargeRatio); - } - if(timelog.note && timelog.note.body) { - this.days[dateGrp].addNote(timelog.note.body); - } - this.days[dateGrp].addSpent(timelog.timeSpent); - - let time = new Time(null, spentAt, { - author: {username: timelog.user.username}, - created_at: timelog.spentAt, - noteable_type: this._typeSingular - }, this, this.config); - time.seconds = timelog.timeSpent; - time.project_namespace = this.project_namespace; - - // only include times by the configured user - if (this.config.get('user') && this.config.get('user') !== timelog.user.username) return; - - if (!timeUsers[timelog.user.username]) timeUsers[timelog.user.username] = 0; - - timeSpent += time.seconds; - timeUsers[timelog.user.username] += time.seconds; - - times.push(time); - }); - - Object.entries(timeUsers).forEach(([name, time]) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); - this.timeSpent = timeSpent; - this.times = times; - } -}; diff --git a/src/reporting/output/table.js b/src/reporting/output/table.js index 0bab106..6d24e1b 100755 --- a/src/reporting/output/table.js +++ b/src/reporting/output/table.js @@ -1,6 +1,7 @@ import Table from 'cli-table'; import Output from './base.js'; import pc from 'picocolors'; +import dayjs from 'dayjs'; const format = { headline: h => `\n${pc.bold(pc.underline(h))}\n`, @@ -58,29 +59,21 @@ class TableOutput extends Output { makeDailyStats() { this.headline('DAILY RECORDS'); - var tabledt = new Table({head: ['date', 'time']}); var tabledit = new Table({head: ['date', 'project', 'iid', 'time']}); - let days = Object.keys(this.days); - days.sort(); - days.forEach( - k => { - let day = this.days[k]; - let refD = this.daysMoment[k].format(this.config.get('dateFormat')); - let projects = Object.keys(day); - let time = 0; - projects.forEach( - p => { - let iids = Object.keys(day[p]); - iids.sort(); - iids.forEach( - iid => { - tabledit.push([refD, p, iid, this.config.toHumanReadable(day[p][iid], 'records')]); - time += day[p][iid]; - }); - }); - tabledt.push([refD, this.config.toHumanReadable(time)]); + let daysNew = Object.keys(this.daysNew); + daysNew.sort(); + daysNew.forEach(k => { + let dayReports = this.daysNew[k]; + // for each day + let refD = this.daysMoment[k].format(this.config.get('dateFormat')); + let time = 0; + dayReports.forEach(dayReport => { + tabledit.push([refD, this.report.projects[dayReport.getProjectId()], dayReport.getIid(), this.config.toHumanReadable(dayReport.getSpent(), 'records')]); + time += dayReport.getSpent(); }); + tabledt.push([refD, this.config.toHumanReadable(time)]); + }); this.write(tabledt.toString()); this.write(tabledit.toString()); } @@ -89,7 +82,19 @@ class TableOutput extends Output { this.makeDailyStats(); this.headline('TIME RECORDS'); let times = new Table({head: this.config.get('recordColumns').map(c => c.replace('_', ' '))}); - this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns')))); + this.report.timelogs.forEach(timelog => + { + let data = { + ...timelog.issues, + ...timelog.mergeRequests, + time: this.config.toHumanReadable(timelog.timeSpent, 'records'), + date: dayjs(timelog.spentAt), + type: timelog.mergeRequests ? 'Merge Request' : 'Issue', + project: timelog.project.name, + user: timelog.user.username, + note: timelog.note?.body ?? ''}; + times.push(this.prepare(data, this.config.get('recordColumns'))); + }); this.write(times.toString()); } } diff --git a/src/reporting/stats.js b/src/reporting/stats.js index c20ff77..6ae5756 100644 --- a/src/reporting/stats.js +++ b/src/reporting/stats.js @@ -1,3 +1,5 @@ +import DayReport from './api/dayReport.js'; + /** * Aggregate a merged report into the numbers the output formats render: * per-user/per-project/per-day spent time, estimate and spent totals, @@ -33,15 +35,23 @@ export default function calculateStats(config, report) { let halfPrice = issue.labels.some(label => spentHalfPriceLabels.includes(label)); // consolidate all issues back in one day - Object.keys(issue.days).forEach((key) => { - if (!daysNew[key]) { - daysNew[key] = []; - } - daysNew[key].push(issue.days[key]); - }); + let issueDays = {}; issue.times.forEach(time => { let dateGrp = time.date.format(config.get('dateFormatGroupReport')); + + if (!issueDays[dateGrp]) { + issueDays[dateGrp] = new DayReport(issue.project_id, issue.iid, issue.title, time.date, time.chargeRatio); + if (!daysNew[dateGrp]) { + daysNew[dateGrp] = []; + } + daysNew[dateGrp].push(issueDays[dateGrp]); + } + if (time.note) { + issueDays[dateGrp].addNote(time.note); + } + issueDays[dateGrp].addSpent(time.seconds); + if (!users[time.user]) users[time.user] = 0; if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; if (!days[dateGrp]) { @@ -69,8 +79,8 @@ export default function calculateStats(config, report) { } times.push(time); }); - totalEstimate += parseInt(issue.stats.time_estimate); - totalSpent += parseInt(issue.stats.total_time_spent); + totalEstimate += parseFloat(issue.total_estimate_s); + totalSpent += parseFloat(issue.total_spent_s); }); report[type].sort((a, b) => { diff --git a/src/timekeeping/api/issue.js b/src/timekeeping/api/issue.js deleted file mode 100644 index fcb79ad..0000000 --- a/src/timekeeping/api/issue.js +++ /dev/null @@ -1,30 +0,0 @@ -import CoreTask from '../../core/task.js'; -import GitlabClient from '../../core/gitlab-client.js'; -import writable from './writable.js'; - -/** - * issue with timekeeping write operations (make/createTime via the writable - * mixin). The collection-level list() query is a static — it needs no - * instance state. - */ -class Issue extends writable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'issues'); - } - - /** - * list issues, either of a single project or across all projects - * @returns {Promise} resolving to an array of issue instances - */ - static list(config, project, state, my, client = new GitlabClient(config)) { - const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - const path = project - ? `projects/${encodeURIComponent(project)}/issues?${query}` - : `issues/?${query}`; - - return client.get(path) - .then(response => response.body.map(data => new this(config, data, client))); - } -} - -export default Issue; diff --git a/src/timekeeping/api/mergeRequest.js b/src/timekeeping/api/mergeRequest.js deleted file mode 100644 index 839a69c..0000000 --- a/src/timekeeping/api/mergeRequest.js +++ /dev/null @@ -1,29 +0,0 @@ -import CoreTask from '../../core/task.js'; -import GitlabClient from '../../core/gitlab-client.js'; -import writable from './writable.js'; - -/** - * merge request with timekeeping write operations (make/createTime) provided - * by the writable mixin; make() targets merge_requests via the _type getter. - */ -class MergeRequest extends writable(CoreTask) { - constructor(config, data, client) { - super(config, data, client, 'merge_requests'); - } - - /** - * list merge requests, either of a single project or across all projects - * @returns {Promise} resolving to an array of merge request instances - */ - static list(config, project, state, my, client = new GitlabClient(config)) { - const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; - const path = project - ? `projects/${encodeURIComponent(project)}/merge_requests?${query}` - : `merge_requests/?${query}`; - - return client.get(path) - .then(response => response.body.map(data => new this(config, data, client))); - } -} - -export default MergeRequest; diff --git a/src/timekeeping/api/writable.js b/src/timekeeping/api/writable.js deleted file mode 100644 index 875b8cc..0000000 --- a/src/timekeeping/api/writable.js +++ /dev/null @@ -1,82 +0,0 @@ -import Time from '../../core/time.js'; - -/** - * mixin: adds timekeeping write operations to a core issue/mergeRequest class: - * make() (get-or-create the resource) and createTime() (post a "/spend" note). - * The resource path segment is taken from the core class' _type getter. - * @param Base a core issue/mergeRequest class - */ -export default Base => class extends Base { - /** - * get-or-create the resource on GitLab and populate this.data - * @param project - * @param id - * @param create create the resource instead of fetching it - * @returns {Promise} - */ - make(project, id, create = false) { - let promise = create - ? this.client.post(`projects/${encodeURIComponent(project)}/${this._type}`, {title: id}) - : this.client.get(`projects/${encodeURIComponent(project)}/${this._type}/${id}`); - - return promise.then(response => { - this.data = response.body; - return this; - }); - } - - /** - * set notes - * @returns {Promise} - */ - getNotes() { - let promise = this.client.all(`projects/${this.data.project_id}/${this._type}/${this.iid}/notes`); - promise.then(notes => this.notes = notes); - - return promise; - } - - /** - * create time with notes using graphql. - * - * works for issues, tasks, incidents and merge requests (on 19.1.1-ee) to add - * spent times with the correct date and a comment - */ - createTime(time, created_at, note) { - if(note === null || note === undefined) { - note = ''; - } - else { - note = '\n\n' + note; - } - var date = new Date(created_at); - var spentAt = date.getUTCFullYear()+"-"+(date.getUTCMonth()+1)+"-"+date.getUTCDate(); - const query = - `mutation($input: CreateNoteInput!) { - createNote(input: $input) { - note { - id - body - } - errors - } - }` - let noteablePath = (this._type == 'merge_requests') ? 'MergeRequest' : 'WorkItem'; - let request = { - "query": query, - "variables": { - "input": { - "noteableId": `gid://gitlab/${noteablePath}/${this.id}`, - "body": '/spend '+Time.toHumanReadable(time, this.config.get('hoursPerDay'), '[%sign][%days>d ][%hours>h ][%minutes>m ][%seconds>s]' + ' ' + spentAt + note), - } - } - }; - let promise = this.client.graphQL(request); - promise.then(response => { - if(!response.body || response.body.errors) { - throw new Error(`createTime failed: ${JSON.stringify(response.body)}`); - } - return response; - }); - } -}; diff --git a/src/timekeeping/commands/edit.js b/src/timekeeping/commands/edit.js index f71b17c..2bbe981 100755 --- a/src/timekeeping/commands/edit.js +++ b/src/timekeeping/commands/edit.js @@ -3,7 +3,7 @@ import Cli from '../../core/cli.js'; import Fs from '../../core/filesystem.js'; import Time from '../../core/time.js'; import Frame from '../storage/frame.js'; -import select from '@inquirer/checkbox'; +import select, { Separator } from '@inquirer/select'; import dayjs from '../../core/dayjs.js'; import readline from 'readline'; import Timekeeper from '../timekeeper.js'; @@ -74,14 +74,16 @@ function showInteractiveMenu(frames) { // adjustment modes, cycled with m const modes = [ - { amount: SHIFT_MINUTES, moveAdjacent: true, label: '±15 min' }, - { amount: SHIFT_MINUTE, moveAdjacent: true, label: '±1 min' }, - { amount: SHIFT_MINUTES, moveAdjacent: false, label: '±15 min, don\'t move adjacent frame' }, - { amount: SHIFT_MINUTE, moveAdjacent: false, label: '±1 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTES, moveAdjacent: true, checkAdjacent: true, label: '±15 min' }, + { amount: SHIFT_MINUTE, moveAdjacent: true, checkAdjacent: true, label: '±1 min' }, + { amount: SHIFT_MINUTES, moveAdjacent: false, checkAdjacent: true, label: '±15 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTE, moveAdjacent: false, checkAdjacent: true, label: '±1 min, don\'t move adjacent frame' }, + { amount: SHIFT_MINUTES, moveAdjacent: false, checkAdjacent: false, label: '±15 min, allow moving over adjacent frame' }, + { amount: SHIFT_MINUTE, moveAdjacent: false, checkAdjacent: false, label: '±1 min, allow moving over frame' }, ]; let mode = 0; - function render() { // w write does not really save all, does it? + function render() { Cli.out('\x1Bc'); if (noteEditIndex !== null) { Cli.out('Editing note. Enter: confirm Esc: cancel\n\n\n'); @@ -93,7 +95,7 @@ function showInteractiveMenu(frames) { const focus = fields[cursor][0] === i ? fields[cursor][1] : null; const edited = editedFields[i]; const noteOverride = noteEditIndex === i ? pc.inverse(noteBuffer + ' ') : null; - Cli.out(`${formatFrameRow(frame, focus, edited.has('start'), edited.has('end'), edited.has('note'), noteOverride)}\n`); + Cli.out(`${formatFrameRow(frame, focus, edited.has('start'), edited.has('stop'), edited.has('note'), noteOverride)}\n`); }); } @@ -161,7 +163,7 @@ function showInteractiveMenu(frames) { editedFields[frameIdx - 1].add('stop'); frame.start = newStart; } - } else if (!(prev && prev.stop && newStart.isBefore(prev.stop))) { + } else if (!modes[mode].checkAdjacent || !(prev && prev.stop && newStart.isBefore(prev.stop))) { frame.start = newStart; } } @@ -177,7 +179,7 @@ function showInteractiveMenu(frames) { editedFields[frameIdx + 1].add('start'); frame.stop = newStop; } - } else if (!(next && newStop.isAfter(next.start))) { + } else if (!modes[mode].checkAdjacent || !(next && newStop.isAfter(next.start))) { frame.stop = newStop; } } @@ -228,10 +230,6 @@ let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeF let timekeeper = new Timekeeper(config); const listSize = program.opts().listsize; -function toHumanReadable(input) { - return Time.toHumanReadable(Math.ceil(input), config.get('hoursPerDay'), timeFormat); -} - function getMenuFrames() { return timekeeper.all().then(({ frames }) => { if (id) { @@ -272,43 +270,55 @@ function getMenuFrames() { }); } -function showNonInteractiveMenu(frames) { - let lastFramesDetails = frames.map((frame) => ({ - name: ` ${formatFrameRow(frame)}`, - value: frame.id, - })); +function showNonInteractiveMenu() { + function prompt(lastFrameDefault) { + return getMenuFrames().then((frames) => { + let lastFramesDetails = frames.map((frame) => ({ + name: ` ${formatFrameRow(frame)}`, + value: frame.id, + })); + + if (lastFramesDetails.length == 0) { + Cli.error("No records found."); + return; + } + if(!lastFrameDefault || !lastFramesDetails.some(frame=>frame.value == lastFrameDefault)) { + lastFrameDefault = lastFramesDetails[lastFramesDetails.length - 1].value; + } + lastFramesDetails.push(new Separator()); + let exit = {name: "Exit", value: null}; + lastFramesDetails.push(exit); - if (lastFramesDetails.length == 0) { - Cli.error("No records found."); - return; + return select({ + message: "Frame?", + default: lastFrameDefault, + choices: lastFramesDetails, + pageSize: lastFramesDetails.length, + }).then((answer) => { + if (!answer) { + return; + } + if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { + Cli.error("record not found."); + } else { + Fs.open(Fs.join(config.frameDir, answer + ".json")); + } + return prompt(answer); + }); + }); } - select({ - message: "Frame?", - default: lastFramesDetails[lastFramesDetails.length - 1].value, - choices: lastFramesDetails, - pageSize: listSize, - }).then((answers) => { - for (const answer of answers) { - if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) { - Cli.error("record not found."); - } else { - Fs.open(Fs.join(config.frameDir, answer + ".json")); - } - } - }); + return prompt(null); } if (!id || program.opts().following) { - getMenuFrames() - .then((frames) => { - if (program.opts().interactive) { - return showInteractiveMenu(frames); - } else { - return showNonInteractiveMenu(frames); - } - }) - .catch((error) => Cli.error(error)); + if (program.opts().interactive) { + getMenuFrames() + .then((frames) => showInteractiveMenu(frames)) + .catch((error) => Cli.error(error)); + } else { + showNonInteractiveMenu().catch((error) => Cli.error(error)); + } } else { if (!Fs.exists(Fs.join(config.frameDir, id + ".json"))) Cli.error("No record found."); diff --git a/src/timekeeping/commands/log.js b/src/timekeeping/commands/log.js index e1ee396..06e5a29 100755 --- a/src/timekeeping/commands/log.js +++ b/src/timekeeping/commands/log.js @@ -4,7 +4,6 @@ import dayjs from '../../core/dayjs.js'; import Cli from '../../core/cli.js'; import Time from '../../core/time.js'; import Timekeeper from '../timekeeper.js'; -import mergeRequest from '../api/mergeRequest.js'; function log(configLoader) { const log = new Command('log', 'log recorded time records') diff --git a/src/timekeeping/timekeeper.js b/src/timekeeping/timekeeper.js index 422f72f..5bb5458 100755 --- a/src/timekeeping/timekeeper.js +++ b/src/timekeeping/timekeeper.js @@ -1,7 +1,7 @@ import Fs from '../core/filesystem.js'; import Frame from './storage/frame.js'; -import Issue from './api/issue.js'; -import MergeRequest from './api/mergeRequest.js'; +import Issue from '../core/issue.js'; +import MergeRequest from '../core/mergeRequest.js'; import FrameCollection from './storage/frameCollection.js'; const classes = { @@ -156,8 +156,15 @@ class Timekeeper { async _addTime(frame, time) { let resource = this.sync.resources[frame.project][frame.resource.type][frame.resource.id]; - await resource.createTime(Math.ceil(time), frame._stop, frame.note); - await resource.getNotes(); + let createdNote = await resource.createTime(Math.ceil(time), frame._stop, frame.note); + let noteid = createdNote ?.id?.split('/')?.pop(); + // fallback, if gitlab does not return the created note + if(!isNaN(noteid)) { + noteid = parseInt(noteid) + } else { + await resource.getNotes() + noteid = resource.notes[0].id; + } if (frame.resource.new) { delete frame.resource.new; @@ -166,7 +173,7 @@ class Timekeeper { } frame.notes.push({ - id: resource.notes[0].id, + id: noteid, time: Math.ceil(time) });