wukong-gitlog-cli 1.0.36 → 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/bin/wukong-gitlog-cli +1 -1
- package/package.json +3 -1
- package/src/cli/parseOptions.mjs +9 -0
- package/src/git.mjs +23 -17
- package/src/handlers/handleServe.mjs +203 -0
- package/src/{cli.mjs → index.mjs} +60 -276
- package/src/lib/configStore.mjs +11 -0
- package/src/lib/memoize.mjs +14 -0
- package/src/overtime/createOvertimeStats.mjs +11 -0
- package/src/utils/analyzeOvertimeCached.mjs +7 -0
- package/src/utils/emoji.mjs +83 -0
- package/src/utils/exitWithTime.mjs +17 -0
- package/src/utils/getWeekRange.mjs +16 -0
- package/src/utils/handleSuccess.mjs +12 -0
- package/src/utils/logDev.mjs +19 -0
- package/src/utils/logger.mjs +145 -0
- package/src/utils/timer.mjs +35 -0
- /package/src/{overtime.mjs → overtime/overtime.mjs} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
### [1.0.37](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.36...v1.0.37) (2025-12-12)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 🎸 add logDev ([a85905b](https://github.com/tomatobybike/wukong-gitlog-cli/commit/a85905b038014fb62c7fd21713f63e2405e3cfa7))
|
|
11
|
+
* 🎸 createOvertimeStats ([abb0051](https://github.com/tomatobybike/wukong-gitlog-cli/commit/abb0051efc91cceb9845a1c5c9a00f9d6747baf4))
|
|
12
|
+
* 🎸 logDev ([e122042](https://github.com/tomatobybike/wukong-gitlog-cli/commit/e12204203a077630784caa16b01c566fdd72b84a))
|
|
13
|
+
* 🎸 logger debug ([32a55fe](https://github.com/tomatobybike/wukong-gitlog-cli/commit/32a55fe8d191969ca887cb46789d14b65b446f6d))
|
|
14
|
+
|
|
5
15
|
### [1.0.36](https://github.com/tomatobybike/wukong-gitlog-cli/compare/v1.0.35...v1.0.36) (2025-12-12)
|
|
6
16
|
|
|
7
17
|
|
package/bin/wukong-gitlog-cli
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import('../src/
|
|
2
|
+
import('../src/index.mjs')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wukong-gitlog-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.37",
|
|
4
4
|
"description": "Advanced Git commit log exporter with Excel/JSON/TXT output, grouping, stats and CLI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
"commander": "12.1.0",
|
|
97
97
|
"date-holidays": "2.1.1",
|
|
98
98
|
"dayjs": "1.11.19",
|
|
99
|
+
"date-fns": "4.1.0",
|
|
100
|
+
"dotenv": "17.2.2",
|
|
99
101
|
"exceljs": "4.4.0",
|
|
100
102
|
"is-online": "12.0.2",
|
|
101
103
|
"ora": "9.0.0",
|
package/src/git.mjs
CHANGED
|
@@ -76,21 +76,20 @@ export async function getGitLogsSlow(opts) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
return commits
|
|
79
|
+
return { commits, authorMap: {} }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export async function getGitLogsQuick(opts) {
|
|
83
83
|
const { author, email, since, until, limit, merges } = opts
|
|
84
84
|
|
|
85
|
-
const pretty =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
].join('%x1f') }%x1e`
|
|
85
|
+
const pretty = `${[
|
|
86
|
+
'%H', // hash
|
|
87
|
+
'%an', // author name
|
|
88
|
+
'%ae', // email
|
|
89
|
+
'%ad', // date
|
|
90
|
+
'%s', // subject
|
|
91
|
+
'%B' // body
|
|
92
|
+
].join('%x1f')}%x1e`
|
|
94
93
|
|
|
95
94
|
const args = [
|
|
96
95
|
'log',
|
|
@@ -177,11 +176,16 @@ export async function getGitLogsFast(opts = {}) {
|
|
|
177
176
|
'%an', // author name
|
|
178
177
|
'%ae', // email
|
|
179
178
|
'%ad', // date
|
|
180
|
-
'%s',
|
|
181
|
-
'%B'
|
|
182
|
-
].join('%x1f')
|
|
179
|
+
'%s', // subject
|
|
180
|
+
'%B' // body
|
|
181
|
+
].join('%x1f')}%x1e`
|
|
183
182
|
|
|
184
|
-
const args = [
|
|
183
|
+
const args = [
|
|
184
|
+
'log',
|
|
185
|
+
`--pretty=format:${pretty}`,
|
|
186
|
+
'--date=iso-local',
|
|
187
|
+
'--numstat'
|
|
188
|
+
]
|
|
185
189
|
|
|
186
190
|
if (author) args.push(`--author=${author}`)
|
|
187
191
|
if (email) args.push(`--author=${email}`)
|
|
@@ -196,13 +200,15 @@ export async function getGitLogsFast(opts = {}) {
|
|
|
196
200
|
const commits = []
|
|
197
201
|
|
|
198
202
|
// 匹配每个 commit header + numstat
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
const commitRegex =
|
|
204
|
+
// eslint-disable-next-line no-control-regex
|
|
205
|
+
/([0-9a-f]+)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([\s\S]*?)(?=(?:[0-9a-f]{7,40}\x1f)|\x1e$)/g
|
|
201
206
|
let match
|
|
202
207
|
|
|
203
208
|
for (const ns of raw.matchAll(commitRegex)) {
|
|
204
209
|
const [_, hash, authorName, emailAddr, date, subject, bodyAndNumstat] = ns
|
|
205
|
-
const [, changeId] =
|
|
210
|
+
const [, changeId] =
|
|
211
|
+
bodyAndNumstat.match(/Change-Id:\s*(I[0-9a-fA-F]+)/) || []
|
|
206
212
|
|
|
207
213
|
const c = {
|
|
208
214
|
hash,
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line no-unused-vars
|
|
4
|
+
import { renderAuthorChangesJson } from '../json.mjs'
|
|
5
|
+
import { startServer } from '../server.mjs'
|
|
6
|
+
import { getWeekRange } from '../utils/getWeekRange.mjs'
|
|
7
|
+
import { groupRecords, outputFilePath, writeTextFile } from '../utils/index.mjs'
|
|
8
|
+
import { logDev } from '../utils/logDev.mjs'
|
|
9
|
+
|
|
10
|
+
export const handleServe = async ({
|
|
11
|
+
opts,
|
|
12
|
+
outDir,
|
|
13
|
+
records,
|
|
14
|
+
getOvertimeStats
|
|
15
|
+
}) => {
|
|
16
|
+
try {
|
|
17
|
+
const stats = getOvertimeStats(records)
|
|
18
|
+
|
|
19
|
+
const dataCommitsFile = outputFilePath('data/commits.mjs', outDir)
|
|
20
|
+
const commitsModule = `export default ${JSON.stringify(records, null, 2)};\n`
|
|
21
|
+
writeTextFile(dataCommitsFile, commitsModule)
|
|
22
|
+
|
|
23
|
+
const dataCommitsChangedFile = outputFilePath(
|
|
24
|
+
'data/author-changes.mjs',
|
|
25
|
+
outDir
|
|
26
|
+
)
|
|
27
|
+
const jsonText = renderAuthorChangesJson(records)
|
|
28
|
+
|
|
29
|
+
const commitsChangedModule = `export default ${JSON.stringify(jsonText, null, 2)};\n`
|
|
30
|
+
writeTextFile(dataCommitsChangedFile, commitsChangedModule)
|
|
31
|
+
|
|
32
|
+
const dataStatsFile = outputFilePath('data/overtime-stats.mjs', outDir)
|
|
33
|
+
const statsModule = `export default ${JSON.stringify(stats, null, 2)};\n`
|
|
34
|
+
writeTextFile(dataStatsFile, statsModule)
|
|
35
|
+
|
|
36
|
+
// 新增:每周趋势数据(用于前端图表)
|
|
37
|
+
const weekGroups = groupRecords(records, 'week')
|
|
38
|
+
const weekKeys = Object.keys(weekGroups).sort()
|
|
39
|
+
const weeklySeries = weekKeys.map((k) => {
|
|
40
|
+
const s = getOvertimeStats(weekGroups[k])
|
|
41
|
+
return {
|
|
42
|
+
period: k,
|
|
43
|
+
range: getWeekRange(k),
|
|
44
|
+
total: s.total,
|
|
45
|
+
outsideWorkCount: s.outsideWorkCount,
|
|
46
|
+
outsideWorkRate: s.outsideWorkRate,
|
|
47
|
+
nonWorkdayCount: s.nonWorkdayCount,
|
|
48
|
+
nonWorkdayRate: s.nonWorkdayRate
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
const dataWeeklyFile = outputFilePath('data/overtime-weekly.mjs', outDir)
|
|
52
|
+
const weeklyModule = `export default ${JSON.stringify(weeklySeries, null, 2)};\n`
|
|
53
|
+
writeTextFile(dataWeeklyFile, weeklyModule)
|
|
54
|
+
logDev(`Weekly series 已导出: ${dataWeeklyFile}`)
|
|
55
|
+
|
|
56
|
+
// 新增:每月趋势数据(用于前端图表)
|
|
57
|
+
const monthGroups2 = groupRecords(records, 'month')
|
|
58
|
+
const monthKeys2 = Object.keys(monthGroups2).sort()
|
|
59
|
+
const monthlySeries = monthKeys2.map((k) => {
|
|
60
|
+
const s = getOvertimeStats(monthGroups2[k])
|
|
61
|
+
return {
|
|
62
|
+
period: k,
|
|
63
|
+
total: s.total,
|
|
64
|
+
outsideWorkCount: s.outsideWorkCount,
|
|
65
|
+
outsideWorkRate: s.outsideWorkRate,
|
|
66
|
+
nonWorkdayCount: s.nonWorkdayCount,
|
|
67
|
+
nonWorkdayRate: s.nonWorkdayRate
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
const dataMonthlyFile = outputFilePath('data/overtime-monthly.mjs', outDir)
|
|
71
|
+
const monthlyModule = `export default ${JSON.stringify(monthlySeries, null, 2)};\n`
|
|
72
|
+
writeTextFile(dataMonthlyFile, monthlyModule)
|
|
73
|
+
logDev(`Monthly series 已导出: ${dataMonthlyFile}`)
|
|
74
|
+
|
|
75
|
+
// 新增:每日最晚提交小时(用于显著展示加班严重程度)
|
|
76
|
+
const dayGroups2 = groupRecords(records, 'day')
|
|
77
|
+
const dayKeys2 = Object.keys(dayGroups2).sort()
|
|
78
|
+
|
|
79
|
+
// 次日凌晨归并窗口(默认 6 点前仍算前一天的加班)
|
|
80
|
+
const overnightCutoff = Number.isFinite(opts.overnightCutoff)
|
|
81
|
+
? opts.overnightCutoff
|
|
82
|
+
: 6
|
|
83
|
+
// 次日上班时间(默认按 workStart,若未指定则 9 点)
|
|
84
|
+
const workStartHour =
|
|
85
|
+
opts.workStart || opts.workStart === 0 ? opts.workStart : 9
|
|
86
|
+
const workEndHour = opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18
|
|
87
|
+
|
|
88
|
+
// 有些日期「本身没有 commit」,但第二天凌晨有提交要归并到这一天,
|
|
89
|
+
// 需要补出这些“虚拟日期”,否则 latestByDay 会漏掉这天。
|
|
90
|
+
const virtualPrevDays = new Set()
|
|
91
|
+
records.forEach((r) => {
|
|
92
|
+
const d = new Date(r.date)
|
|
93
|
+
if (Number.isNaN(d.valueOf())) return
|
|
94
|
+
const h = d.getHours()
|
|
95
|
+
if (h < 0 || h >= overnightCutoff || h >= workStartHour) return
|
|
96
|
+
const curDay = dayjs(d).format('YYYY-MM-DD')
|
|
97
|
+
const prevDay = dayjs(curDay).subtract(1, 'day').format('YYYY-MM-DD')
|
|
98
|
+
if (!dayGroups2[prevDay]) {
|
|
99
|
+
virtualPrevDays.add(prevDay)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const allDayKeys = Array.from(
|
|
104
|
+
new Set([...dayKeys2, ...virtualPrevDays])
|
|
105
|
+
).sort()
|
|
106
|
+
|
|
107
|
+
const latestByDay = allDayKeys.map((k) => {
|
|
108
|
+
const list = dayGroups2[k] || []
|
|
109
|
+
|
|
110
|
+
// 1) 当天「下班后」的提交:只统计 >= workEndHour 的小时
|
|
111
|
+
const sameDayHours = list
|
|
112
|
+
.map((r) => new Date(r.date))
|
|
113
|
+
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
114
|
+
.map((d) => d.getHours())
|
|
115
|
+
.filter((h) => h >= workEndHour && h < 24)
|
|
116
|
+
|
|
117
|
+
// 2) 次日凌晨、但仍算前一日加班的提交
|
|
118
|
+
const nextKey = dayjs(k).add(1, 'day').format('YYYY-MM-DD')
|
|
119
|
+
const early = dayGroups2[nextKey] || []
|
|
120
|
+
const earlyHours = early
|
|
121
|
+
.map((r) => new Date(r.date))
|
|
122
|
+
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
123
|
+
.map((d) => d.getHours())
|
|
124
|
+
// 只看 [0, overnightCutoff) 之间的小时,
|
|
125
|
+
// 并且默认认为 < workStartHour 属于「次日上班前」
|
|
126
|
+
.filter(
|
|
127
|
+
(h) =>
|
|
128
|
+
h >= 0 &&
|
|
129
|
+
h < overnightCutoff &&
|
|
130
|
+
// 保护性判断:若有人把 overnightCutoff 设得大于上班时间,
|
|
131
|
+
// 我们仍然只统计到上班时间为止
|
|
132
|
+
h < workStartHour
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// 3) 计算「逻辑上的最晚加班时间」
|
|
136
|
+
// - 当天晚上的用原始小时(如 22 点)
|
|
137
|
+
// - 次日凌晨的用 24 + 小时(如 1 点 → 25)
|
|
138
|
+
const overtimeValues = [
|
|
139
|
+
...sameDayHours.map((h) => h),
|
|
140
|
+
...earlyHours.map((h) => 24 + h)
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
if (overtimeValues.length === 0) {
|
|
144
|
+
// 这一天没有任何「下班后到次日上班前」的提交
|
|
145
|
+
return {
|
|
146
|
+
date: k,
|
|
147
|
+
latestHour: null,
|
|
148
|
+
latestHourNormalized: null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const latestHourNormalized = Math.max(...overtimeValues)
|
|
153
|
+
|
|
154
|
+
// latestHour 保留「当天自然日内」的最晚提交通常小时数,供前端需要时参考
|
|
155
|
+
const sameDayMax =
|
|
156
|
+
sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
date: k,
|
|
160
|
+
latestHour: sameDayMax,
|
|
161
|
+
latestHourNormalized
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
const dataLatestByDayFile = outputFilePath(
|
|
165
|
+
'data/overtime-latest-by-day.mjs',
|
|
166
|
+
outDir
|
|
167
|
+
)
|
|
168
|
+
const latestByDayModule = `export default ${JSON.stringify(latestByDay, null, 2)};\n`
|
|
169
|
+
writeTextFile(dataLatestByDayFile, latestByDayModule)
|
|
170
|
+
logDev(`Latest-by-day series 已导出: ${dataLatestByDayFile}`)
|
|
171
|
+
|
|
172
|
+
// 导出配置(供前端显示)
|
|
173
|
+
try {
|
|
174
|
+
const configFile = outputFilePath('data/config.mjs', outDir)
|
|
175
|
+
const cfg = {
|
|
176
|
+
startHour: opts.workStart || 9,
|
|
177
|
+
endHour: opts.workEnd || 18,
|
|
178
|
+
lunchStart: opts.lunchStart || 12,
|
|
179
|
+
lunchEnd: opts.lunchEnd || 14,
|
|
180
|
+
overnightCutoff
|
|
181
|
+
}
|
|
182
|
+
writeTextFile(
|
|
183
|
+
configFile,
|
|
184
|
+
`export default ${JSON.stringify(cfg, null, 2)};\n`
|
|
185
|
+
)
|
|
186
|
+
logDev(`Config 已导出: ${configFile}`)
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.warn('Export config failed:', e && e.message ? e.message : e)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
startServer(opts.port || 3000, outDir).catch((err) => {
|
|
192
|
+
console.warn(
|
|
193
|
+
'Start server failed.',
|
|
194
|
+
err && err.message ? err.message : err
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.warn(
|
|
199
|
+
'Export data modules failed:',
|
|
200
|
+
err && err.message ? err.message : err
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -7,6 +7,7 @@ import ora from 'ora'
|
|
|
7
7
|
import path from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
|
|
10
|
+
import { parseOptions } from './cli/parseOptions.mjs'
|
|
10
11
|
// eslint-disable-next-line no-unused-vars
|
|
11
12
|
import { CLI_NAME } from './constants/index.mjs'
|
|
12
13
|
import {
|
|
@@ -15,23 +16,27 @@ import {
|
|
|
15
16
|
exportExcelPerPeriodSheets
|
|
16
17
|
} from './excel.mjs'
|
|
17
18
|
import { getGitLogsFast } from './git.mjs'
|
|
19
|
+
import { handleServe } from './handlers/handleServe.mjs'
|
|
18
20
|
import { renderAuthorChangesJson } from './json.mjs'
|
|
21
|
+
import { setConfig } from './lib/configStore.mjs'
|
|
22
|
+
import { createOvertimeStats } from './overtime/createOvertimeStats.mjs'
|
|
19
23
|
import {
|
|
20
|
-
analyzeOvertime,
|
|
21
24
|
renderOvertimeCsv,
|
|
22
25
|
renderOvertimeTab,
|
|
23
26
|
renderOvertimeText
|
|
24
|
-
} from './overtime.mjs'
|
|
27
|
+
} from './overtime/overtime.mjs'
|
|
25
28
|
import { renderAuthorMapText } from './renderAuthorMapText.mjs'
|
|
26
29
|
import { startServer } from './server.mjs'
|
|
27
30
|
import { renderChangedLinesText, renderText } from './text.mjs'
|
|
28
31
|
import { checkUpdateWithPatch } from './utils/checkUpdate.mjs'
|
|
32
|
+
import { handleSuccess } from './utils/handleSuccess.mjs'
|
|
29
33
|
import {
|
|
30
34
|
groupRecords,
|
|
31
35
|
outputFilePath,
|
|
32
36
|
writeJSON,
|
|
33
37
|
writeTextFile
|
|
34
38
|
} from './utils/index.mjs'
|
|
39
|
+
import { logDev } from './utils/logDev.mjs'
|
|
35
40
|
import { showVersionInfo } from './utils/showVersionInfo.mjs'
|
|
36
41
|
|
|
37
42
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -78,6 +83,8 @@ export function getWeekRange(periodStr) {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
const main = async () => {
|
|
86
|
+
const startTime = performance.now()
|
|
87
|
+
|
|
81
88
|
const program = new Command()
|
|
82
89
|
|
|
83
90
|
program
|
|
@@ -90,6 +97,7 @@ const main = async () => {
|
|
|
90
97
|
.option('--until <date>', '结束日期')
|
|
91
98
|
.option('--limit <n>', '限制数量', parseInt)
|
|
92
99
|
.option('--no-merges', '不包含 merge commit')
|
|
100
|
+
.option('--export', '导出统计数据')
|
|
93
101
|
.option('--json', '输出 JSON')
|
|
94
102
|
.option('--format <type>', '输出格式: text | excel | json', 'text')
|
|
95
103
|
.option('--group-by <type>', '按日期分组: day | month | week')
|
|
@@ -171,6 +179,7 @@ const main = async () => {
|
|
|
171
179
|
(v) => parseInt(v, 10),
|
|
172
180
|
3000
|
|
173
181
|
)
|
|
182
|
+
.option('--debug', 'enable debug logs')
|
|
174
183
|
.option(
|
|
175
184
|
'--serve-only',
|
|
176
185
|
'仅启动 web 服务,不导出或分析数据(使用 output-wukong/data 中已有的数据)'
|
|
@@ -179,6 +188,14 @@ const main = async () => {
|
|
|
179
188
|
.parse()
|
|
180
189
|
|
|
181
190
|
const opts = program.opts()
|
|
191
|
+
|
|
192
|
+
const config = parseOptions(opts)
|
|
193
|
+
|
|
194
|
+
// ❗只创建一次缓存实例
|
|
195
|
+
const getOvertimeStats = createOvertimeStats(config)
|
|
196
|
+
|
|
197
|
+
setConfig('debug', opts.debug === true)
|
|
198
|
+
|
|
182
199
|
// compute output directory root early (so serve-only can use it)
|
|
183
200
|
const outDir = opts.outParent
|
|
184
201
|
? path.resolve(process.cwd(), '..', 'output-wukong')
|
|
@@ -308,16 +325,14 @@ const main = async () => {
|
|
|
308
325
|
// --- 分组 ---
|
|
309
326
|
const groups = opts.groupBy ? groupRecords(records, opts.groupBy) : null
|
|
310
327
|
|
|
328
|
+
// If serve mode is enabled, write data modules and launch the web server
|
|
329
|
+
if (opts.serve) {
|
|
330
|
+
handleServe({ opts, outDir, records, getOvertimeStats })
|
|
331
|
+
}
|
|
332
|
+
|
|
311
333
|
// --- Overtime analysis ---
|
|
312
334
|
if (opts.overtime) {
|
|
313
|
-
const stats =
|
|
314
|
-
startHour: opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
315
|
-
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
316
|
-
lunchStart:
|
|
317
|
-
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
318
|
-
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
319
|
-
country: opts.country || 'CN'
|
|
320
|
-
})
|
|
335
|
+
const stats = getOvertimeStats(records)
|
|
321
336
|
// Output to console
|
|
322
337
|
console.log('\n--- Overtime analysis ---\n')
|
|
323
338
|
console.log(renderOvertimeText(stats))
|
|
@@ -325,13 +340,13 @@ const main = async () => {
|
|
|
325
340
|
const authorMapText = renderAuthorMapText(authorMap)
|
|
326
341
|
console.log('\n Developers:\n', authorMapText, '\n')
|
|
327
342
|
writeTextFile(outputFilePath('authors.text', outDir), authorMapText)
|
|
328
|
-
|
|
343
|
+
|
|
329
344
|
// if user requested json format, write stats to file
|
|
330
345
|
if (opts.json || opts.format === 'json') {
|
|
331
346
|
const file = opts.out || 'overtime.json'
|
|
332
347
|
const filepath = outputFilePath(file, outDir)
|
|
333
348
|
writeJSON(filepath, stats)
|
|
334
|
-
|
|
349
|
+
logDev(`overtime JSON 已导出: ${filepath}`)
|
|
335
350
|
}
|
|
336
351
|
// Always write human readable overtime text to file (default: overtime.txt)
|
|
337
352
|
const outBase = opts.out
|
|
@@ -348,218 +363,9 @@ const main = async () => {
|
|
|
348
363
|
const overtimeCsvFileName = `overtime_${outBase}.csv`
|
|
349
364
|
const overtimeCsvFile = outputFilePath(overtimeCsvFileName, outDir)
|
|
350
365
|
writeTextFile(overtimeCsvFile, renderOvertimeCsv(stats))
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// If serve mode is enabled, write data modules and launch the web server
|
|
356
|
-
if (opts.serve) {
|
|
357
|
-
try {
|
|
358
|
-
const dataCommitsFile = outputFilePath('data/commits.mjs', outDir)
|
|
359
|
-
const commitsModule = `export default ${JSON.stringify(records, null, 2)};\n`
|
|
360
|
-
writeTextFile(dataCommitsFile, commitsModule)
|
|
361
|
-
|
|
362
|
-
const dataCommitsChangedFile = outputFilePath(
|
|
363
|
-
'data/author-changes.mjs',
|
|
364
|
-
outDir
|
|
365
|
-
)
|
|
366
|
-
const jsonText = renderAuthorChangesJson(records)
|
|
367
|
-
|
|
368
|
-
const commitsChangedModule = `export default ${JSON.stringify(jsonText, null, 2)};\n`
|
|
369
|
-
writeTextFile(dataCommitsChangedFile, commitsChangedModule)
|
|
370
|
-
|
|
371
|
-
const dataStatsFile = outputFilePath('data/overtime-stats.mjs', outDir)
|
|
372
|
-
const statsModule = `export default ${JSON.stringify(stats, null, 2)};\n`
|
|
373
|
-
writeTextFile(dataStatsFile, statsModule)
|
|
374
|
-
|
|
375
|
-
// 新增:每周趋势数据(用于前端图表)
|
|
376
|
-
const weekGroups = groupRecords(records, 'week')
|
|
377
|
-
const weekKeys = Object.keys(weekGroups).sort()
|
|
378
|
-
const weeklySeries = weekKeys.map((k) => {
|
|
379
|
-
const s = analyzeOvertime(weekGroups[k], {
|
|
380
|
-
startHour:
|
|
381
|
-
opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
382
|
-
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
383
|
-
lunchStart:
|
|
384
|
-
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
385
|
-
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
386
|
-
country: opts.country || 'CN'
|
|
387
|
-
})
|
|
388
|
-
return {
|
|
389
|
-
period: k,
|
|
390
|
-
range: getWeekRange(k),
|
|
391
|
-
total: s.total,
|
|
392
|
-
outsideWorkCount: s.outsideWorkCount,
|
|
393
|
-
outsideWorkRate: s.outsideWorkRate,
|
|
394
|
-
nonWorkdayCount: s.nonWorkdayCount,
|
|
395
|
-
nonWorkdayRate: s.nonWorkdayRate
|
|
396
|
-
}
|
|
397
|
-
})
|
|
398
|
-
const dataWeeklyFile = outputFilePath(
|
|
399
|
-
'data/overtime-weekly.mjs',
|
|
400
|
-
outDir
|
|
401
|
-
)
|
|
402
|
-
const weeklyModule = `export default ${JSON.stringify(weeklySeries, null, 2)};\n`
|
|
403
|
-
writeTextFile(dataWeeklyFile, weeklyModule)
|
|
404
|
-
console.log(chalk.green(`Weekly series 已导出: ${dataWeeklyFile}`))
|
|
405
|
-
|
|
406
|
-
// 新增:每月趋势数据(用于前端图表)
|
|
407
|
-
const monthGroups2 = groupRecords(records, 'month')
|
|
408
|
-
const monthKeys2 = Object.keys(monthGroups2).sort()
|
|
409
|
-
const monthlySeries = monthKeys2.map((k) => {
|
|
410
|
-
const s = analyzeOvertime(monthGroups2[k], {
|
|
411
|
-
startHour:
|
|
412
|
-
opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
413
|
-
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
414
|
-
lunchStart:
|
|
415
|
-
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
416
|
-
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
417
|
-
country: opts.country || 'CN'
|
|
418
|
-
})
|
|
419
|
-
return {
|
|
420
|
-
period: k,
|
|
421
|
-
total: s.total,
|
|
422
|
-
outsideWorkCount: s.outsideWorkCount,
|
|
423
|
-
outsideWorkRate: s.outsideWorkRate,
|
|
424
|
-
nonWorkdayCount: s.nonWorkdayCount,
|
|
425
|
-
nonWorkdayRate: s.nonWorkdayRate
|
|
426
|
-
}
|
|
427
|
-
})
|
|
428
|
-
const dataMonthlyFile = outputFilePath(
|
|
429
|
-
'data/overtime-monthly.mjs',
|
|
430
|
-
outDir
|
|
431
|
-
)
|
|
432
|
-
const monthlyModule = `export default ${JSON.stringify(monthlySeries, null, 2)};\n`
|
|
433
|
-
writeTextFile(dataMonthlyFile, monthlyModule)
|
|
434
|
-
console.log(chalk.green(`Monthly series 已导出: ${dataMonthlyFile}`))
|
|
435
|
-
|
|
436
|
-
// 新增:每日最晚提交小时(用于显著展示加班严重程度)
|
|
437
|
-
const dayGroups2 = groupRecords(records, 'day')
|
|
438
|
-
const dayKeys2 = Object.keys(dayGroups2).sort()
|
|
439
|
-
|
|
440
|
-
// 次日凌晨归并窗口(默认 6 点前仍算前一天的加班)
|
|
441
|
-
const overnightCutoff = Number.isFinite(opts.overnightCutoff)
|
|
442
|
-
? opts.overnightCutoff
|
|
443
|
-
: 6
|
|
444
|
-
// 次日上班时间(默认按 workStart,若未指定则 9 点)
|
|
445
|
-
const workStartHour =
|
|
446
|
-
opts.workStart || opts.workStart === 0 ? opts.workStart : 9
|
|
447
|
-
const workEndHour =
|
|
448
|
-
opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18
|
|
449
|
-
|
|
450
|
-
// 有些日期「本身没有 commit」,但第二天凌晨有提交要归并到这一天,
|
|
451
|
-
// 需要补出这些“虚拟日期”,否则 latestByDay 会漏掉这天。
|
|
452
|
-
const virtualPrevDays = new Set()
|
|
453
|
-
records.forEach((r) => {
|
|
454
|
-
const d = new Date(r.date)
|
|
455
|
-
if (Number.isNaN(d.valueOf())) return
|
|
456
|
-
const h = d.getHours()
|
|
457
|
-
if (h < 0 || h >= overnightCutoff || h >= workStartHour) return
|
|
458
|
-
const curDay = dayjs(d).format('YYYY-MM-DD')
|
|
459
|
-
const prevDay = dayjs(curDay).subtract(1, 'day').format('YYYY-MM-DD')
|
|
460
|
-
if (!dayGroups2[prevDay]) {
|
|
461
|
-
virtualPrevDays.add(prevDay)
|
|
462
|
-
}
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
const allDayKeys = Array.from(
|
|
466
|
-
new Set([...dayKeys2, ...virtualPrevDays])
|
|
467
|
-
).sort()
|
|
468
|
-
|
|
469
|
-
const latestByDay = allDayKeys.map((k) => {
|
|
470
|
-
const list = dayGroups2[k] || []
|
|
471
|
-
|
|
472
|
-
// 1) 当天「下班后」的提交:只统计 >= workEndHour 的小时
|
|
473
|
-
const sameDayHours = list
|
|
474
|
-
.map((r) => new Date(r.date))
|
|
475
|
-
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
476
|
-
.map((d) => d.getHours())
|
|
477
|
-
.filter((h) => h >= workEndHour && h < 24)
|
|
478
|
-
|
|
479
|
-
// 2) 次日凌晨、但仍算前一日加班的提交
|
|
480
|
-
const nextKey = dayjs(k).add(1, 'day').format('YYYY-MM-DD')
|
|
481
|
-
const early = dayGroups2[nextKey] || []
|
|
482
|
-
const earlyHours = early
|
|
483
|
-
.map((r) => new Date(r.date))
|
|
484
|
-
.filter((d) => !Number.isNaN(d.valueOf()))
|
|
485
|
-
.map((d) => d.getHours())
|
|
486
|
-
// 只看 [0, overnightCutoff) 之间的小时,
|
|
487
|
-
// 并且默认认为 < workStartHour 属于「次日上班前」
|
|
488
|
-
.filter(
|
|
489
|
-
(h) =>
|
|
490
|
-
h >= 0 &&
|
|
491
|
-
h < overnightCutoff &&
|
|
492
|
-
// 保护性判断:若有人把 overnightCutoff 设得大于上班时间,
|
|
493
|
-
// 我们仍然只统计到上班时间为止
|
|
494
|
-
h < workStartHour
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
// 3) 计算「逻辑上的最晚加班时间」
|
|
498
|
-
// - 当天晚上的用原始小时(如 22 点)
|
|
499
|
-
// - 次日凌晨的用 24 + 小时(如 1 点 → 25)
|
|
500
|
-
const overtimeValues = [
|
|
501
|
-
...sameDayHours.map((h) => h),
|
|
502
|
-
...earlyHours.map((h) => 24 + h)
|
|
503
|
-
]
|
|
504
|
-
|
|
505
|
-
if (overtimeValues.length === 0) {
|
|
506
|
-
// 这一天没有任何「下班后到次日上班前」的提交
|
|
507
|
-
return {
|
|
508
|
-
date: k,
|
|
509
|
-
latestHour: null,
|
|
510
|
-
latestHourNormalized: null
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const latestHourNormalized = Math.max(...overtimeValues)
|
|
515
|
-
|
|
516
|
-
// latestHour 保留「当天自然日内」的最晚提交通常小时数,供前端需要时参考
|
|
517
|
-
const sameDayMax =
|
|
518
|
-
sameDayHours.length > 0 ? Math.max(...sameDayHours) : null
|
|
519
|
-
|
|
520
|
-
return {
|
|
521
|
-
date: k,
|
|
522
|
-
latestHour: sameDayMax,
|
|
523
|
-
latestHourNormalized
|
|
524
|
-
}
|
|
525
|
-
})
|
|
526
|
-
const dataLatestByDayFile = outputFilePath(
|
|
527
|
-
'data/overtime-latest-by-day.mjs',
|
|
528
|
-
outDir
|
|
529
|
-
)
|
|
530
|
-
const latestByDayModule = `export default ${JSON.stringify(latestByDay, null, 2)};\n`
|
|
531
|
-
writeTextFile(dataLatestByDayFile, latestByDayModule)
|
|
532
|
-
console.log(
|
|
533
|
-
chalk.green(`Latest-by-day series 已导出: ${dataLatestByDayFile}`)
|
|
534
|
-
)
|
|
535
|
-
|
|
536
|
-
// 导出配置(供前端显示)
|
|
537
|
-
try {
|
|
538
|
-
const configFile = outputFilePath('data/config.mjs', outDir)
|
|
539
|
-
const cfg = {
|
|
540
|
-
startHour: opts.workStart || 9,
|
|
541
|
-
endHour: opts.workEnd || 18,
|
|
542
|
-
lunchStart: opts.lunchStart || 12,
|
|
543
|
-
lunchEnd: opts.lunchEnd || 14,
|
|
544
|
-
overnightCutoff
|
|
545
|
-
}
|
|
546
|
-
writeTextFile(
|
|
547
|
-
configFile,
|
|
548
|
-
`export default ${JSON.stringify(cfg, null, 2)};\n`
|
|
549
|
-
)
|
|
550
|
-
console.log(chalk.green(`Config 已导出: ${configFile}`))
|
|
551
|
-
} catch (e) {
|
|
552
|
-
console.warn('Export config failed:', e && e.message ? e.message : e)
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
startServer(opts.port || 3000, outDir).catch(() => {})
|
|
556
|
-
} catch (err) {
|
|
557
|
-
console.warn(
|
|
558
|
-
'Export data modules failed:',
|
|
559
|
-
err && err.message ? err.message : err
|
|
560
|
-
)
|
|
561
|
-
}
|
|
562
|
-
}
|
|
366
|
+
logDev(`Overtime text 已导出: ${overtimeFile}`)
|
|
367
|
+
logDev(`Overtime table (tabs) 已导出: ${overtimeTabFile}`)
|
|
368
|
+
logDev(`Overtime CSV 已导出: ${overtimeCsvFile}`)
|
|
563
369
|
|
|
564
370
|
// 按月输出 ... 保持原逻辑
|
|
565
371
|
const perPeriodFormats = String(opts.perPeriodFormats || '')
|
|
@@ -578,15 +384,7 @@ const main = async () => {
|
|
|
578
384
|
const monthKeys = Object.keys(monthGroups).sort()
|
|
579
385
|
monthKeys.forEach((k) => {
|
|
580
386
|
const groupRecs = monthGroups[k]
|
|
581
|
-
const s =
|
|
582
|
-
startHour:
|
|
583
|
-
opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
584
|
-
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
585
|
-
lunchStart:
|
|
586
|
-
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
587
|
-
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
588
|
-
country: opts.country || 'CN'
|
|
589
|
-
})
|
|
387
|
+
const s = getOvertimeStats(groupRecs)
|
|
590
388
|
monthlyContent += `===== ${k} =====\n`
|
|
591
389
|
monthlyContent += `${renderOvertimeText(s)}\n\n`
|
|
592
390
|
// Also write a single file per month under 'month/' folder
|
|
@@ -594,9 +392,7 @@ const main = async () => {
|
|
|
594
392
|
const perMonthFileName = `month/overtime_${outBase}_${k}.txt`
|
|
595
393
|
const perMonthFile = outputFilePath(perMonthFileName, outDir)
|
|
596
394
|
writeTextFile(perMonthFile, renderOvertimeText(s))
|
|
597
|
-
|
|
598
|
-
chalk.green(`Overtime 月度(${k}) 已导出: ${perMonthFile}`)
|
|
599
|
-
)
|
|
395
|
+
logDev(`Overtime 月度(${k}) 已导出: ${perMonthFile}`)
|
|
600
396
|
// per-period CSV / Tab format (按需生成)
|
|
601
397
|
if (perPeriodFormats.includes('csv')) {
|
|
602
398
|
try {
|
|
@@ -605,10 +401,8 @@ const main = async () => {
|
|
|
605
401
|
outputFilePath(perMonthCsvName, outDir),
|
|
606
402
|
renderOvertimeCsv(s)
|
|
607
403
|
)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
`Overtime 月度(CSV)(${k}) 已导出: ${outputFilePath(perMonthCsvName, outDir)}`
|
|
611
|
-
)
|
|
404
|
+
logDev(
|
|
405
|
+
`Overtime 月度(CSV)(${k}) 已导出: ${outputFilePath(perMonthCsvName, outDir)}`
|
|
612
406
|
)
|
|
613
407
|
} catch (err) {
|
|
614
408
|
console.warn(
|
|
@@ -624,10 +418,8 @@ const main = async () => {
|
|
|
624
418
|
outputFilePath(perMonthTabName, outDir),
|
|
625
419
|
renderOvertimeTab(s)
|
|
626
420
|
)
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
`Overtime 月度(Tab)(${k}) 已导出: ${outputFilePath(perMonthTabName, outDir)}`
|
|
630
|
-
)
|
|
421
|
+
logDev(
|
|
422
|
+
`Overtime 月度(Tab)(${k}) 已导出: ${outputFilePath(perMonthTabName, outDir)}`
|
|
631
423
|
)
|
|
632
424
|
} catch (err) {
|
|
633
425
|
console.warn(
|
|
@@ -645,7 +437,7 @@ const main = async () => {
|
|
|
645
437
|
})
|
|
646
438
|
if (!opts.perPeriodOnly) {
|
|
647
439
|
writeTextFile(monthlyFile, monthlyContent)
|
|
648
|
-
|
|
440
|
+
logDev(`Overtime 月度汇总 已导出: ${monthlyFile}`)
|
|
649
441
|
}
|
|
650
442
|
// per-period Excel (sheets or files)
|
|
651
443
|
if (perPeriodFormats.includes('xlsx')) {
|
|
@@ -658,9 +450,7 @@ const main = async () => {
|
|
|
658
450
|
stats: opts.stats,
|
|
659
451
|
gerrit: opts.gerrit
|
|
660
452
|
})
|
|
661
|
-
|
|
662
|
-
chalk.green(`Overtime 月度(XLSX) 已导出: ${monthXlsxFile}`)
|
|
663
|
-
)
|
|
453
|
+
logDev(`Overtime 月度(XLSX) 已导出: ${monthXlsxFile}`)
|
|
664
454
|
} catch (err) {
|
|
665
455
|
console.warn(
|
|
666
456
|
'Export month XLSX (sheets) failed:',
|
|
@@ -710,22 +500,16 @@ const main = async () => {
|
|
|
710
500
|
const weekKeys = Object.keys(weekGroups).sort()
|
|
711
501
|
weekKeys.forEach((k) => {
|
|
712
502
|
const groupRecs = weekGroups[k]
|
|
713
|
-
const s =
|
|
714
|
-
startHour:
|
|
715
|
-
opts.workStart || opts.workStart === 0 ? opts.workStart : 9,
|
|
716
|
-
endHour: opts.workEnd || opts.workEnd === 0 ? opts.workEnd : 18,
|
|
717
|
-
lunchStart:
|
|
718
|
-
opts.lunchStart || opts.lunchStart === 0 ? opts.lunchStart : 12,
|
|
719
|
-
lunchEnd: opts.lunchEnd || opts.lunchEnd === 0 ? opts.lunchEnd : 14,
|
|
720
|
-
country: opts.country || 'CN'
|
|
721
|
-
})
|
|
503
|
+
const s = getOvertimeStats(groupRecs)
|
|
722
504
|
weeklyContent += `===== ${k} =====\n`
|
|
723
505
|
weeklyContent += `${renderOvertimeText(s)}\n\n`
|
|
724
506
|
try {
|
|
725
507
|
const perWeekFileName = `week/overtime_${outBase}_${k}.txt`
|
|
726
508
|
const perWeekFile = outputFilePath(perWeekFileName, outDir)
|
|
727
509
|
writeTextFile(perWeekFile, renderOvertimeText(s))
|
|
728
|
-
console.log(chalk.green(`Overtime 周度(${k}) 已导出: ${perWeekFile}`))
|
|
510
|
+
// console.log(chalk.green(`Overtime 周度(${k}) 已导出: ${perWeekFile}`))
|
|
511
|
+
logDev(`Overtime 周度(${k}) 已导出: ${perWeekFile}`)
|
|
512
|
+
|
|
729
513
|
// eslint-disable-next-line no-shadow
|
|
730
514
|
const perPeriodFormats = String(opts.perPeriodFormats || '')
|
|
731
515
|
.split(',')
|
|
@@ -743,10 +527,8 @@ const main = async () => {
|
|
|
743
527
|
outputFilePath(perWeekCsvName, outDir),
|
|
744
528
|
renderOvertimeCsv(s)
|
|
745
529
|
)
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
`Overtime 周度(CSV)(${k}) 已导出: ${outputFilePath(perWeekCsvName, outDir)}`
|
|
749
|
-
)
|
|
530
|
+
logDev(
|
|
531
|
+
`Overtime 周度(CSV)(${k}) 已导出: ${outputFilePath(perWeekCsvName, outDir)}`
|
|
750
532
|
)
|
|
751
533
|
} catch (err) {
|
|
752
534
|
console.warn(
|
|
@@ -762,10 +544,8 @@ const main = async () => {
|
|
|
762
544
|
outputFilePath(perWeekTabName, outDir),
|
|
763
545
|
renderOvertimeTab(s)
|
|
764
546
|
)
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
`Overtime 周度(Tab)(${k}) 已导出: ${outputFilePath(perWeekTabName, outDir)}`
|
|
768
|
-
)
|
|
547
|
+
logDev(
|
|
548
|
+
`Overtime 周度(Tab)(${k}) 已导出: ${outputFilePath(perWeekTabName, outDir)}`
|
|
769
549
|
)
|
|
770
550
|
} catch (err) {
|
|
771
551
|
console.warn(
|
|
@@ -782,7 +562,7 @@ const main = async () => {
|
|
|
782
562
|
}
|
|
783
563
|
})
|
|
784
564
|
writeTextFile(weeklyFile, weeklyContent)
|
|
785
|
-
|
|
565
|
+
logDev(`Overtime 周度汇总 已导出: ${weeklyFile}`)
|
|
786
566
|
} catch (err) {
|
|
787
567
|
console.warn(
|
|
788
568
|
'Generate weekly overtime failed:',
|
|
@@ -798,8 +578,8 @@ const main = async () => {
|
|
|
798
578
|
writeJSON(filepath, groups || records)
|
|
799
579
|
const jsonText = renderAuthorChangesJson(records)
|
|
800
580
|
writeJSON(outputFilePath('author-changes.json', outDir), jsonText)
|
|
801
|
-
|
|
802
|
-
spinner
|
|
581
|
+
logDev(`JSON 已导出: ${filepath}`)
|
|
582
|
+
handleSuccess({ startTime, spinner })
|
|
803
583
|
return
|
|
804
584
|
}
|
|
805
585
|
|
|
@@ -809,13 +589,16 @@ const main = async () => {
|
|
|
809
589
|
const text = renderText(records, groups, { showGerrit: !!opts.gerrit })
|
|
810
590
|
writeTextFile(filepath, text)
|
|
811
591
|
writeTextFile(
|
|
812
|
-
outputFilePath('author-changes.
|
|
592
|
+
outputFilePath('author-changes.txt', outDir),
|
|
813
593
|
renderChangedLinesText(records)
|
|
814
594
|
)
|
|
815
595
|
|
|
816
|
-
console.log(text)
|
|
817
|
-
|
|
818
|
-
|
|
596
|
+
console.log('\n Commits List:\n', text, '\n')
|
|
597
|
+
|
|
598
|
+
logDev(`文本已导出: ${filepath}`)
|
|
599
|
+
|
|
600
|
+
handleSuccess({ startTime, spinner })
|
|
601
|
+
|
|
819
602
|
return
|
|
820
603
|
}
|
|
821
604
|
|
|
@@ -835,9 +618,10 @@ const main = async () => {
|
|
|
835
618
|
)
|
|
836
619
|
const text = renderText(records, groups)
|
|
837
620
|
writeTextFile(txtPath, text)
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
621
|
+
logDev(`Excel 已导出: ${excelPath}`)
|
|
622
|
+
logDev(`文本已自动导出: ${txtPath}`)
|
|
623
|
+
|
|
624
|
+
handleSuccess({ startTime, spinner })
|
|
841
625
|
}
|
|
842
626
|
|
|
843
627
|
await autoCheckUpdate()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// memoize.mjs
|
|
2
|
+
export function memoize(fn) {
|
|
3
|
+
const cache = new Map()
|
|
4
|
+
|
|
5
|
+
return function (...args) {
|
|
6
|
+
const key = JSON.stringify(args)
|
|
7
|
+
if (cache.has(key)) {
|
|
8
|
+
return cache.get(key)
|
|
9
|
+
}
|
|
10
|
+
const result = fn(...args)
|
|
11
|
+
cache.set(key, result)
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { analyzeOvertime } from './overtime.mjs';
|
|
2
|
+
|
|
3
|
+
export function createOvertimeStats(defaultConfig = {}) {
|
|
4
|
+
return function getOvertimeStats(records, overrides) {
|
|
5
|
+
const config = overrides
|
|
6
|
+
? { ...defaultConfig, ...overrides }
|
|
7
|
+
: defaultConfig;
|
|
8
|
+
|
|
9
|
+
return analyzeOvertime(records, config);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file: emoji.mjs
|
|
3
|
+
* @description:
|
|
4
|
+
* ✅ 智能判断终端是否支持 Emoji,并提供统一包装函数和开关变量。
|
|
5
|
+
*
|
|
6
|
+
* 📌 支持判断逻辑说明:
|
|
7
|
+
* 判断点 是否禁用 Emoji
|
|
8
|
+
* WUKONG_NO_EMOJI=1 ✅ 是(强制关闭)
|
|
9
|
+
* WUKONG_NO_EMOJI=0 ❌ 否(强制启用)
|
|
10
|
+
* TERM=dumb(能力极差) ✅ 是
|
|
11
|
+
* Windows + 非现代终端(无 WT_SESSION/TERM_PROGRAM) ✅ 是
|
|
12
|
+
* Git Bash / Windows Terminal / VSCode Terminal / macOS / Linux ❌ 不禁用
|
|
13
|
+
*
|
|
14
|
+
* @author:
|
|
15
|
+
* @created: 2025-08-05
|
|
16
|
+
*/
|
|
17
|
+
import dotenv from 'dotenv'
|
|
18
|
+
import process from 'node:process'
|
|
19
|
+
|
|
20
|
+
dotenv.config({ quiet: true })
|
|
21
|
+
|
|
22
|
+
// ========== 用户设置 ==========
|
|
23
|
+
|
|
24
|
+
// 用户通过环境变量控制 Emoji
|
|
25
|
+
const userForceSetting = process.env.WUKONG_NO_EMOJI
|
|
26
|
+
|
|
27
|
+
// ========== 终端和平台判断 ==========
|
|
28
|
+
|
|
29
|
+
// 判断终端是否为 dumb(极简终端)
|
|
30
|
+
const isDumb = () => process.env.TERM === 'dumb'
|
|
31
|
+
|
|
32
|
+
// 检测是否 Git Bash(支持 Emoji)
|
|
33
|
+
const isTrueGitBash = () =>
|
|
34
|
+
process.platform === 'win32' &&
|
|
35
|
+
process.env.SHELL?.toLowerCase().includes('bash')
|
|
36
|
+
|
|
37
|
+
// 判断是否为现代 Windows 终端(如 Windows Terminal 或 VSCode Terminal)
|
|
38
|
+
const isModernWindowsTerminal = () =>
|
|
39
|
+
process.platform === 'win32' &&
|
|
40
|
+
(process.env.WT_SESSION ||
|
|
41
|
+
process.env.TERM_PROGRAM?.toLowerCase().includes('vscode'))
|
|
42
|
+
|
|
43
|
+
// ========== Emoji 渲染能力检测 ==========
|
|
44
|
+
|
|
45
|
+
// 尝试写入一个 emoji 判断 stdout 是否支持
|
|
46
|
+
const canRenderEmoji = () => {
|
|
47
|
+
try {
|
|
48
|
+
return process.stdout.isTTY && Buffer.from('✅', 'utf8').length > 1
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ========== Emoji 启用判断 ==========
|
|
55
|
+
|
|
56
|
+
export const emojiEnabled =
|
|
57
|
+
// eslint-disable-next-line no-nested-ternary
|
|
58
|
+
userForceSetting === '0'
|
|
59
|
+
? true // 强制启用
|
|
60
|
+
: userForceSetting === '1'
|
|
61
|
+
? false // 强制禁用
|
|
62
|
+
: !isDumb() &&
|
|
63
|
+
canRenderEmoji() &&
|
|
64
|
+
(process.platform !== 'win32' ||
|
|
65
|
+
isModernWindowsTerminal() ||
|
|
66
|
+
isTrueGitBash())
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 包装 Emoji(根据环境决定是否返回 emoji 或备用字符)
|
|
70
|
+
* @param {string} emoji - emoji 表情字符
|
|
71
|
+
* @param {string} fallback - 替代字符(默认空字符串)
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function e(emoji, fallback = '') {
|
|
75
|
+
return emojiEnabled ? emoji : fallback
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ========== 可选调试输出 ==========
|
|
79
|
+
/*
|
|
80
|
+
if (!emojiEnabled) {
|
|
81
|
+
console.log('⚠️ 当前终端未启用 Emoji,已自动禁用(如需强制启用请设置 WUKONG_NO_EMOJI=0)')
|
|
82
|
+
}
|
|
83
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Chalk } from 'chalk'
|
|
2
|
+
import { performance } from 'perf_hooks'
|
|
3
|
+
|
|
4
|
+
// 强制开启 truecolor chalk v5
|
|
5
|
+
import { costTimer } from './timer.mjs'
|
|
6
|
+
|
|
7
|
+
const chalk = new Chalk({ level: 3 }) // 强制开启 truecolor chalk v5
|
|
8
|
+
|
|
9
|
+
// 退出前打印耗时并退出
|
|
10
|
+
export const exitWithTime = (start, exitCode = 0, exitNow = false) => {
|
|
11
|
+
const end = performance.now()
|
|
12
|
+
const timeMsg = costTimer(start, end)
|
|
13
|
+
console.log(chalk.green(`\n${timeMsg}\n`))
|
|
14
|
+
if (exitNow) {
|
|
15
|
+
process.exit(exitCode)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import dayjs from 'dayjs'
|
|
2
|
+
|
|
3
|
+
/** 将 "2025-W48" → { start: '2025-11-24', end: '2025-11-30' } */
|
|
4
|
+
export function getWeekRange(periodStr) {
|
|
5
|
+
// periodStr = "2025-W48"
|
|
6
|
+
const [year, w] = periodStr.split('-W')
|
|
7
|
+
const week = parseInt(w, 10)
|
|
8
|
+
|
|
9
|
+
const start = dayjs().year(year).isoWeek(week).startOf('week') // Monday
|
|
10
|
+
const end = dayjs().year(year).isoWeek(week).endOf('week') // Sunday
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
start: start.format('YYYY-MM-DD'),
|
|
14
|
+
end: end.format('YYYY-MM-DD')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Chalk } from 'chalk'
|
|
2
|
+
import { performance } from 'perf_hooks'
|
|
3
|
+
|
|
4
|
+
import { exitWithTime } from './exitWithTime.mjs'
|
|
5
|
+
import { costTimer } from './timer.mjs'
|
|
6
|
+
|
|
7
|
+
export const handleSuccess = ({ message = 'Done', startTime, spinner }) => {
|
|
8
|
+
spinner.succeed(message)
|
|
9
|
+
|
|
10
|
+
// 如果需要退出,可以调用 exitWithTime
|
|
11
|
+
exitWithTime(startTime, 0)
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file: devLog.mjs
|
|
3
|
+
* @description:
|
|
4
|
+
* @author: King Monkey
|
|
5
|
+
* @created: 2025-08-04 16:25
|
|
6
|
+
*/
|
|
7
|
+
import { getConfig } from '../lib/configStore.mjs'
|
|
8
|
+
import { logger } from './logger.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 开发环境下输出调试信息
|
|
12
|
+
* 推荐用于局部调试
|
|
13
|
+
*/
|
|
14
|
+
export function logDev(...args) {
|
|
15
|
+
if (getConfig('debug')) {
|
|
16
|
+
const msg = args.join(' ')
|
|
17
|
+
logger.debug(msg)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file: logger.mjs
|
|
3
|
+
* @description:
|
|
4
|
+
* 避免了 Windows 上查看日志文件中文乱码
|
|
5
|
+
* 使用 stripAnsi(msg) 过滤终端颜色代码
|
|
6
|
+
* prefix 使用 emoji,也兼容终端彩色输出
|
|
7
|
+
*
|
|
8
|
+
* 在日志文件首次创建时:
|
|
9
|
+
写入了 UTF-8 的 BOM(Byte Order Mark)
|
|
10
|
+
让 Windows 系统(尤其是旧版记事本)自动识别为 UTF-8 编码
|
|
11
|
+
* @author: King Monkey
|
|
12
|
+
* @created: 2025-08-02 11:59
|
|
13
|
+
*/
|
|
14
|
+
// scripts/logger.mjs
|
|
15
|
+
import { Chalk } from 'chalk'
|
|
16
|
+
import { format } from 'date-fns'
|
|
17
|
+
import fs from 'fs-extra'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import process from 'node:process'
|
|
20
|
+
import stripAnsi from 'strip-ansi'
|
|
21
|
+
|
|
22
|
+
import { e } from './emoji.mjs'
|
|
23
|
+
|
|
24
|
+
const chalk = new Chalk({ level: 3 }) // 强制开启 truecolor chalk v5
|
|
25
|
+
|
|
26
|
+
// 💡 自动启用颜色,即使 isTTY 无效(Git Bash、CI 等) chalk v4
|
|
27
|
+
/*
|
|
28
|
+
if (!process.stdout.isTTY || chalk.level === 0) {
|
|
29
|
+
chalk.level = 3
|
|
30
|
+
}
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// 缓存起来文件的日志路径
|
|
34
|
+
let cachedDay = ''
|
|
35
|
+
let cachedPath = ''
|
|
36
|
+
|
|
37
|
+
// // 彩色前缀
|
|
38
|
+
// const prefix = {
|
|
39
|
+
// info: chalk.cyan('ℹ'),
|
|
40
|
+
// success: chalk.green('✔'),
|
|
41
|
+
// error: chalk.red('✖'),
|
|
42
|
+
// warn: chalk.yellow('⚠'),
|
|
43
|
+
// debug: chalk.gray('➤')
|
|
44
|
+
// }
|
|
45
|
+
|
|
46
|
+
// 🔧 1. 修改 prefix:只保留 emoji,不带颜色
|
|
47
|
+
const prefix = {
|
|
48
|
+
info: e('➤', '[i]'),
|
|
49
|
+
success: e('✔', '[✓]'),
|
|
50
|
+
error: e('✖', '[x]'),
|
|
51
|
+
warn: e('⚠', '[!]'),
|
|
52
|
+
debug: e('➤', '->')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 时间戳 [HH:mm:ss]
|
|
56
|
+
// 短时间戳用于终端输出
|
|
57
|
+
const shortTimestamp = () =>
|
|
58
|
+
chalk.dim(`[${new Date().toTimeString().slice(0, 8)}]`)
|
|
59
|
+
|
|
60
|
+
// 日志文件中用本地完整时间
|
|
61
|
+
const fullTimestamp = () => format(new Date(), 'yyyy-MM-dd HH:mm:ss')
|
|
62
|
+
|
|
63
|
+
// 默认日志路径:项目根目录 logs/yyyy-mm-dd.log
|
|
64
|
+
const getLogFilePath = () => {
|
|
65
|
+
const day = format(new Date(), 'yyyy-MM-dd')
|
|
66
|
+
// 缓存起来文件的日志路径
|
|
67
|
+
const shouldRecreate =
|
|
68
|
+
cachedDay !== day || !cachedPath || !fs.existsSync(cachedPath)
|
|
69
|
+
if (shouldRecreate) {
|
|
70
|
+
cachedDay = day
|
|
71
|
+
const logDir = path.resolve(process.cwd(), 'logs', day)
|
|
72
|
+
const logPath = path.join(logDir, 'wukong.log')
|
|
73
|
+
fs.ensureDirSync(logDir)
|
|
74
|
+
/*
|
|
75
|
+
在日志文件首次创建时:
|
|
76
|
+
|
|
77
|
+
写入了 UTF-8 的 BOM(Byte Order Mark)
|
|
78
|
+
|
|
79
|
+
让 Windows 系统(尤其是旧版记事本)自动识别为 UTF-8 编码
|
|
80
|
+
*/
|
|
81
|
+
if (!fs.existsSync(logPath)) {
|
|
82
|
+
fs.ensureFileSync(logPath)
|
|
83
|
+
fs.writeFileSync(logPath, '\uFEFF', { encoding: 'utf-8' }) // 添加 BOM
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
cachedPath = logPath
|
|
87
|
+
return logPath
|
|
88
|
+
}
|
|
89
|
+
return cachedPath
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 写入日志(同步 + 追加)
|
|
93
|
+
const writeToFile = (level, msg, newline) => {
|
|
94
|
+
const logPath = getLogFilePath()
|
|
95
|
+
if (!logPath) {
|
|
96
|
+
console.error('❌ 日志路径未生成,终止写入')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const newLineString = newline ? '\n' : ''
|
|
100
|
+
const line = `${newLineString}[${fullTimestamp()}] [${level.toUpperCase()}] ${stripAnsi(msg)}\n`
|
|
101
|
+
fs.appendFileSync(logPath, line, 'utf-8')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 主函数工厂,支持 { write: true } 控制是否写文件
|
|
105
|
+
function createLogger(level, colorFn, outFn = console.log) {
|
|
106
|
+
return (...args) => {
|
|
107
|
+
let options = {}
|
|
108
|
+
if (
|
|
109
|
+
args.length &&
|
|
110
|
+
typeof args[args.length - 1] === 'object' &&
|
|
111
|
+
args[args.length - 1] !== null &&
|
|
112
|
+
('write' in args[args.length - 1] || 'newline' in args[args.length - 1])
|
|
113
|
+
) {
|
|
114
|
+
options = args.pop()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const msg = args
|
|
118
|
+
.map((arg) =>
|
|
119
|
+
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
|
|
120
|
+
)
|
|
121
|
+
.join(' ')
|
|
122
|
+
|
|
123
|
+
// 🔧 2. 修改拼接方式,colorFn 统一处理 prefix + msg
|
|
124
|
+
const line = `${shortTimestamp()} ${colorFn(`${prefix[level]} ${msg}`)}`
|
|
125
|
+
|
|
126
|
+
if (options.newline) {
|
|
127
|
+
outFn('')
|
|
128
|
+
}
|
|
129
|
+
outFn(line)
|
|
130
|
+
|
|
131
|
+
if (options.write) {
|
|
132
|
+
writeToFile(level, msg, options.newline)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const logger = {
|
|
138
|
+
info: createLogger('info', chalk.cyan),
|
|
139
|
+
success: createLogger('success', chalk.green),
|
|
140
|
+
error: createLogger('error', chalk.red, console.error),
|
|
141
|
+
warn: createLogger('warn', chalk.yellow, console.warn),
|
|
142
|
+
debug: createLogger('debug', chalk.white)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default logger
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
export const costTimer = (startTime, endTime) => {
|
|
4
|
+
if (typeof startTime !== 'number' || typeof endTime !== 'number') {
|
|
5
|
+
console.error(
|
|
6
|
+
chalk.red.bold('Error: startTime and endTime must be numbers.')
|
|
7
|
+
)
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const durationMs = endTime - startTime // 时间差(以毫秒为单位)
|
|
12
|
+
if (durationMs < 0) {
|
|
13
|
+
console.error(
|
|
14
|
+
chalk.red.bold('Error: endTime must be greater than startTime.')
|
|
15
|
+
)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const costSeconds = durationMs / 1000 // 转换为秒
|
|
20
|
+
const costMin = costSeconds / 60 // 转换为分钟
|
|
21
|
+
const costHour = costMin / 60 // 转换为小时
|
|
22
|
+
|
|
23
|
+
let displayTime = ''
|
|
24
|
+
if (costSeconds < 60) {
|
|
25
|
+
displayTime = `${costSeconds.toFixed(2)} seconds`
|
|
26
|
+
} else if (costMin < 60) {
|
|
27
|
+
displayTime = `${costMin.toFixed(3)} minutes`
|
|
28
|
+
} else {
|
|
29
|
+
displayTime = `${costHour.toFixed(3)} hours`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
return `⏱ Execution time: ${displayTime}`
|
|
35
|
+
}
|
|
File without changes
|