svn-visualizer 0.1.0 → 0.1.1
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/dist/client/main.js +8251 -0
- package/dist/index.js +1 -1
- package/package.json +4 -1
- package/.github/workflows/ci.yml +0 -60
- package/.gitignore +0 -100
- package/.husky/commit-msg +0 -1
- package/.husky/pre-commit +0 -1
- package/.release-it.json +0 -23
- package/AGENTS.md +0 -90
- package/CHANGELOG.md +0 -1
- package/CONTRIBUTING.md +0 -65
- package/GENERATED.md +0 -129
- package/commitlint.config.js +0 -5
- package/oxc.config.ts +0 -198
- package/oxfmt.config.ts +0 -9
- package/oxlint.config.ts +0 -36
- package/src/aggregation.test.ts +0 -118
- package/src/aggregation.ts +0 -198
- package/src/cli.test.ts +0 -10
- package/src/cli.ts +0 -61
- package/src/client/main.ts +0 -117
- package/src/gather.ts +0 -93
- package/src/index.ts +0 -9
- package/src/model.ts +0 -32
- package/src/report.test.ts +0 -91
- package/src/report.ts +0 -88
- package/src/store.test.ts +0 -52
- package/src/store.ts +0 -48
- package/src/svn-parser.test.ts +0 -34
- package/src/svn-parser.ts +0 -78
- package/tsconfig.json +0 -43
- package/vite.client.config.ts +0 -15
- package/vite.config.ts +0 -26
package/oxc.config.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
-
* DO NOT EDIT THIS FILE — IT IS MANAGED AND WILL BE OVERWRITTEN ON UPDATES.
|
|
4
|
-
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
-
*
|
|
6
|
-
* This file is the source of truth for the default `oxlint` and `oxfmt`
|
|
7
|
-
* configuration. It is automatically regenerated when the template is
|
|
8
|
-
* updated — any manual changes will be lost.
|
|
9
|
-
*
|
|
10
|
-
* To customize linting rules, edit `oxlint.config.ts`.
|
|
11
|
-
* To customize formatting settings, edit `oxfmt.config.ts`.
|
|
12
|
-
*
|
|
13
|
-
* Those two files are preserved across template updates and are designed
|
|
14
|
-
* to receive user overrides.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import {defineConfig} from 'oxlint';
|
|
18
|
-
import {configs as regexpConfigs} from 'eslint-plugin-regexp';
|
|
19
|
-
|
|
20
|
-
/** Filter out core ESLint rules bundled into eslint-plugin-regexp recommended config */
|
|
21
|
-
const regexpPluginRules = Object.fromEntries(Object.entries(regexpConfigs.recommended.rules).filter(([key]) => key.startsWith('regexp/')));
|
|
22
|
-
|
|
23
|
-
const commonIgnore = ['**/.*', 'node_modules/**', 'dist/**', 'build/**', 'coverage/**', 'temp/**', 'public/**', '**/*.md'];
|
|
24
|
-
|
|
25
|
-
export const linter = defineConfig({
|
|
26
|
-
options: {
|
|
27
|
-
typeAware: true,
|
|
28
|
-
typeCheck: true,
|
|
29
|
-
},
|
|
30
|
-
plugins: ['unicorn', 'typescript', 'oxc', 'import', 'react', 'jsdoc', 'promise', 'vitest'],
|
|
31
|
-
jsPlugins: ['eslint-plugin-regexp'],
|
|
32
|
-
categories: {
|
|
33
|
-
correctness: 'error',
|
|
34
|
-
nursery: 'error',
|
|
35
|
-
pedantic: 'error',
|
|
36
|
-
perf: 'error',
|
|
37
|
-
restriction: 'error',
|
|
38
|
-
style: 'error',
|
|
39
|
-
suspicious: 'error',
|
|
40
|
-
},
|
|
41
|
-
rules: {
|
|
42
|
-
...regexpPluginRules,
|
|
43
|
-
'eslint/capitalized-comments': 'off', // TODO: consider enabling
|
|
44
|
-
'eslint/complexity': 'off', // TODO: consider enabling
|
|
45
|
-
'eslint/curly': ['error', 'all'],
|
|
46
|
-
'eslint/id-length': 'off',
|
|
47
|
-
'eslint/init-declarations': 'off', // TODO: consider enabling
|
|
48
|
-
'eslint/max-depth': 'off', // TODO: consider enabling
|
|
49
|
-
'eslint/max-lines': 'off', // TODO: consider enabling
|
|
50
|
-
'eslint/max-lines-per-function': 'off', // TODO: consider enabling
|
|
51
|
-
'eslint/max-params': 'off', // TODO: consider enabling
|
|
52
|
-
'eslint/max-statements': 'off', // TODO: consider enabling
|
|
53
|
-
'eslint/no-await-in-loop': 'warn',
|
|
54
|
-
'eslint/no-console': 'off',
|
|
55
|
-
'eslint/no-continue': 'off',
|
|
56
|
-
'eslint/no-inline-comments': 'off',
|
|
57
|
-
'eslint/no-magic-numbers': 'off',
|
|
58
|
-
'eslint/no-negated-condition': 'off', // TODO: consider enabling
|
|
59
|
-
'eslint/no-nested-ternary': 'off',
|
|
60
|
-
'eslint/no-warning-comments': 'off',
|
|
61
|
-
'eslint/no-undefined': 'off', // TODO: consider enabling
|
|
62
|
-
'eslint/no-plusplus': 'off',
|
|
63
|
-
'eslint/one-var': 'off',
|
|
64
|
-
'eslint/sort-imports': 'off',
|
|
65
|
-
'eslint/sort-keys': 'off',
|
|
66
|
-
'eslint/no-ternary': 'off',
|
|
67
|
-
'eslint/no-void': ['error', {allowAsStatement: true}],
|
|
68
|
-
'typescript/consistent-type-definitions': ['error', 'type'],
|
|
69
|
-
'typescript/dot-notation': ['error', {allowPattern: '^[a-zA-Z]+(_[a-zA-Z]+)+$'}],
|
|
70
|
-
'typescript/no-import-type-side-effects': 'off',
|
|
71
|
-
'typescript/no-unused-vars': [
|
|
72
|
-
'error',
|
|
73
|
-
{
|
|
74
|
-
caughtErrors: 'none',
|
|
75
|
-
argsIgnorePattern: '^_',
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
'typescript/prefer-readonly-parameter-types': 'off',
|
|
79
|
-
'import/consistent-type-specifier-style': ['error', 'prefer-inline'],
|
|
80
|
-
'import/exports-last': 'off',
|
|
81
|
-
'import/group-exports': 'off',
|
|
82
|
-
'import/max-dependencies': 'off',
|
|
83
|
-
'import/no-named-export': 'off',
|
|
84
|
-
'import/no-namespace': 'off', // TODO: consider enabling
|
|
85
|
-
'import/no-nodejs-modules': 'off',
|
|
86
|
-
'import/no-unassigned-import': ['error', {allow: ['**/*.css']}],
|
|
87
|
-
'import/prefer-default-export': 'off',
|
|
88
|
-
'import/no-default-export': 'off',
|
|
89
|
-
'jsdoc/require-param': 'error',
|
|
90
|
-
'jsdoc/require-param-type': 'off',
|
|
91
|
-
'jsdoc/require-returns': 'warn',
|
|
92
|
-
'jsdoc/require-returns-type': 'off',
|
|
93
|
-
'oxc/no-async-await': 'off',
|
|
94
|
-
'oxc/no-map-spread': 'off', // TODO: consider enabling
|
|
95
|
-
'oxc/no-optional-chaining': 'off',
|
|
96
|
-
'oxc/no-rest-spread-properties': 'off',
|
|
97
|
-
'unicorn/escape-case': 'off',
|
|
98
|
-
'unicorn/filename-case': 'off', // TODO: consider enabling
|
|
99
|
-
'unicorn/max-nested-calls': ['warn', {max: 5}],
|
|
100
|
-
'unicorn/no-array-reduce': 'off', // TODO: consider enabling
|
|
101
|
-
'unicorn/no-array-sort': 'off', // TODO: consider enabling
|
|
102
|
-
'unicorn/no-hex-escape': 'off',
|
|
103
|
-
'unicorn/no-immediate-mutation': 'off',
|
|
104
|
-
'unicorn/no-negated-condition': 'off',
|
|
105
|
-
'unicorn/no-nested-ternary': 'off',
|
|
106
|
-
'unicorn/no-null': 'off', // TODO: consider enabling
|
|
107
|
-
'unicorn/no-process-exit': 'off', // TODO: consider enabling
|
|
108
|
-
'unicorn/no-typeof-undefined': 'off', // TODO: consider enabling
|
|
109
|
-
'unicorn/prefer-module': 'off', // TODO: consider enabling
|
|
110
|
-
'unicorn/prefer-number-coercion': 'off', // TODO: consider enabling
|
|
111
|
-
'react/function-component-definition': 'off', // TODO: consider enabling
|
|
112
|
-
'react/jsx-filename-extension': ['error', {extensions: ['.tsx']}],
|
|
113
|
-
'react/jsx-max-depth': ['error', {max: 5}],
|
|
114
|
-
'react/jsx-no-literals': 'off',
|
|
115
|
-
'react/react-in-jsx-scope': 'off',
|
|
116
|
-
'vitest/max-expects': 'off',
|
|
117
|
-
'vitest/no-conditional-in-test': 'off',
|
|
118
|
-
'vitest/no-hooks': 'off',
|
|
119
|
-
'vitest/no-importing-vitest-globals': 'off',
|
|
120
|
-
'vitest/prefer-describe-function-title': 'off',
|
|
121
|
-
'vitest/prefer-expect-assertions': 'off',
|
|
122
|
-
'vitest/prefer-lowercase-title': 'off',
|
|
123
|
-
'vitest/prefer-to-be-falsy': 'off', // NOTE: Pick strictness: keep prefer-strict-boolean-matchers, disable truthy/falsy rules.
|
|
124
|
-
'vitest/prefer-to-be-truthy': 'off', // NOTE: Pick strictness: keep prefer-strict-boolean-matchers, disable truthy/falsy rules.
|
|
125
|
-
'vitest/require-hook': 'off',
|
|
126
|
-
'vitest/require-test-timeout': 'off',
|
|
127
|
-
},
|
|
128
|
-
overrides: [
|
|
129
|
-
{
|
|
130
|
-
// Relax strict type rules for unit tests to allow easier mocking and test scaffolding
|
|
131
|
-
files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'],
|
|
132
|
-
rules: {
|
|
133
|
-
'typescript/no-unsafe-type-assertion': 'off',
|
|
134
|
-
'typescript/no-explicit-any': 'off',
|
|
135
|
-
'typescript/no-unsafe-assignment': 'off',
|
|
136
|
-
'typescript/no-unsafe-member-access': 'off',
|
|
137
|
-
'no-useless-undefined': 'off',
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
files: ['tests/e2e/**/*.e2e-test.ts', '**/*.e2e-test.ts'],
|
|
142
|
-
rules: {
|
|
143
|
-
'vitest/prefer-importing-vitest-globals': 'off',
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
],
|
|
147
|
-
settings: {
|
|
148
|
-
'jsx-a11y': {
|
|
149
|
-
polymorphicPropName: undefined,
|
|
150
|
-
components: {},
|
|
151
|
-
attributes: {},
|
|
152
|
-
},
|
|
153
|
-
next: {
|
|
154
|
-
rootDir: [],
|
|
155
|
-
},
|
|
156
|
-
react: {
|
|
157
|
-
formComponents: [],
|
|
158
|
-
linkComponents: [],
|
|
159
|
-
version: undefined,
|
|
160
|
-
},
|
|
161
|
-
jsdoc: {
|
|
162
|
-
ignorePrivate: false,
|
|
163
|
-
ignoreInternal: false,
|
|
164
|
-
ignoreReplacesDocs: true,
|
|
165
|
-
overrideReplacesDocs: true,
|
|
166
|
-
augmentsExtendsReplacesDocs: false,
|
|
167
|
-
implementsReplacesDocs: false,
|
|
168
|
-
exemptDestructuredRootsFromChecks: false,
|
|
169
|
-
tagNamePreference: {},
|
|
170
|
-
},
|
|
171
|
-
vitest: {
|
|
172
|
-
typecheck: false,
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
env: {
|
|
176
|
-
builtin: true,
|
|
177
|
-
node: true,
|
|
178
|
-
},
|
|
179
|
-
globals: {},
|
|
180
|
-
ignorePatterns: commonIgnore,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
export const formatter = {
|
|
184
|
-
printWidth: 160,
|
|
185
|
-
embeddedLanguageFormatting: 'off',
|
|
186
|
-
useTabs: true,
|
|
187
|
-
singleQuote: true,
|
|
188
|
-
bracketSpacing: false,
|
|
189
|
-
ignorePatterns: commonIgnore,
|
|
190
|
-
overrides: [
|
|
191
|
-
{
|
|
192
|
-
files: ['src/**/*.{scss,css}'],
|
|
193
|
-
options: {
|
|
194
|
-
singleQuote: false,
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
],
|
|
198
|
-
};
|
package/oxfmt.config.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import {formatter as defaults} from './oxc.config.ts';
|
|
2
|
-
|
|
3
|
-
// Add custom oxfmt formatter overrides here.
|
|
4
|
-
// This file is preserved on template updates.
|
|
5
|
-
const formatter: Partial<typeof defaults> = {};
|
|
6
|
-
|
|
7
|
-
const config = {...defaults, ...formatter};
|
|
8
|
-
|
|
9
|
-
export default config;
|
package/oxlint.config.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import {linter as defaults} from './oxc.config.ts';
|
|
2
|
-
|
|
3
|
-
// Add custom oxlint rule overrides here.
|
|
4
|
-
// This file is preserved on template updates.
|
|
5
|
-
//
|
|
6
|
-
// Example:
|
|
7
|
-
// rules: { 'no-console': 'off' }
|
|
8
|
-
// overrides: [{ files: ['scripts/**'], rules: { 'no-console': 'off' } }]
|
|
9
|
-
const rules = {
|
|
10
|
-
'eslint/func-style': 'off',
|
|
11
|
-
'typescript/promise-function-async': 'off',
|
|
12
|
-
'unicorn/max-nested-calls': ['warn', {max: 20}],
|
|
13
|
-
};
|
|
14
|
-
const overrides = [
|
|
15
|
-
{
|
|
16
|
-
files: ['src/client/**'],
|
|
17
|
-
env: {browser: true},
|
|
18
|
-
rules: {
|
|
19
|
-
'typescript/no-unsafe-type-assertion': 'off',
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
files: ['src/gather.ts'],
|
|
24
|
-
rules: {
|
|
25
|
-
'promise/avoid-new': 'off',
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const config = {
|
|
31
|
-
...defaults,
|
|
32
|
-
rules: {...defaults.rules, ...rules},
|
|
33
|
-
overrides: [...defaults.overrides, ...overrides],
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export default config;
|
package/src/aggregation.test.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import {describe, expect, it} from 'vitest';
|
|
2
|
-
import {aggregate, resolveRange} from './aggregation.js';
|
|
3
|
-
import {type Commit} from './model.js';
|
|
4
|
-
|
|
5
|
-
const commits: Commit[] = [
|
|
6
|
-
{revision: 1, author: 'ada', date: '2025-03-01T23:30:00.000Z', message: 'a'},
|
|
7
|
-
{revision: 2, author: 'ada', date: '2026-02-28T00:00:00.000Z', message: 'b'},
|
|
8
|
-
{revision: 3, author: null, date: '2026-03-01T01:00:00.000Z', message: 'c'},
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
describe('UTC date ranges and aggregation', () => {
|
|
12
|
-
it('uses deterministic relative UTC dates', () => {
|
|
13
|
-
expect(resolveRange(commits, {relativeDays: 30}, new Date('2026-03-01T23:59:59Z'))).toStrictEqual({from: '2026-01-31', to: '2026-03-01'});
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('rejects conflicts, invalid dates, and reversed dates', () => {
|
|
17
|
-
expect(() => resolveRange(commits, {from: '2026-01-01', relativeDays: 2})).toThrow('cannot be used');
|
|
18
|
-
expect(() => resolveRange(commits, {from: '2026-02-30'})).toThrow('Invalid UTC date');
|
|
19
|
-
expect(() => resolveRange(commits, {from: '2026-03-02', to: '2026-03-01'})).toThrow('after end date');
|
|
20
|
-
expect(() => resolveRange(commits, {relativeDays: 0})).toThrow('positive integer');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('defaults to dataset bounds and today for empty data', () => {
|
|
24
|
-
expect(resolveRange(commits, {}, new Date('2030-01-01T00:00:00Z'))).toStrictEqual({from: '2025-03-01', to: '2026-03-01'});
|
|
25
|
-
expect(resolveRange([], {}, new Date('2026-04-05T22:00:00Z'))).toStrictEqual({from: '2026-04-05', to: '2026-04-05'});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('produces UTC charts with exactly 30 days and 12 anchored months', () => {
|
|
29
|
-
const result = aggregate(commits, {from: '2025-03-01', to: '2026-03-01'});
|
|
30
|
-
expect(result.days.labels).toHaveLength(30);
|
|
31
|
-
expect(result.days.labels[0]).toBe('2026-01-31');
|
|
32
|
-
expect(result.days.labels.at(-1)).toBe('2026-03-01');
|
|
33
|
-
expect(result.months.labels).toHaveLength(12);
|
|
34
|
-
expect(result.months.labels).toStrictEqual([
|
|
35
|
-
'2025-04',
|
|
36
|
-
'2025-05',
|
|
37
|
-
'2025-06',
|
|
38
|
-
'2025-07',
|
|
39
|
-
'2025-08',
|
|
40
|
-
'2025-09',
|
|
41
|
-
'2025-10',
|
|
42
|
-
'2025-11',
|
|
43
|
-
'2025-12',
|
|
44
|
-
'2026-01',
|
|
45
|
-
'2026-02',
|
|
46
|
-
'2026-03',
|
|
47
|
-
]);
|
|
48
|
-
expect(result.weekdays.values.reduce((sum, count) => sum + count, 0)).toBe(3);
|
|
49
|
-
expect(result.hours.values[23]).toBe(1);
|
|
50
|
-
expect(result.users).toStrictEqual({labels: ['ada', '(no author)'], values: [2, 1]});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('produces a stacked commits-per-day-and-user series over the same 30 days', () => {
|
|
54
|
-
const result = aggregate(commits, {from: '2025-03-01', to: '2026-03-01'});
|
|
55
|
-
expect(result.daysByUser.labels).toStrictEqual(result.days.labels);
|
|
56
|
-
expect(result.daysByUser.datasets).toHaveLength(2);
|
|
57
|
-
const ada = result.daysByUser.datasets.find((dataset) => dataset.label === 'ada');
|
|
58
|
-
const noAuthor = result.daysByUser.datasets.find((dataset) => dataset.label === '(no author)');
|
|
59
|
-
expect(ada?.values.reduce((sum, count) => sum + count, 0)).toBe(1);
|
|
60
|
-
expect(noAuthor?.values.reduce((sum, count) => sum + count, 0)).toBe(1);
|
|
61
|
-
expect(noAuthor?.values.at(-1)).toBe(1);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('stacks the top-10 buckets for commits per day and user', () => {
|
|
65
|
-
const many = Array.from({length: 12}, (_, index) => ({
|
|
66
|
-
revision: index + 1,
|
|
67
|
-
author: `user${String(index).padStart(2, '0')}`,
|
|
68
|
-
date: '2026-03-01T00:00:00.000Z',
|
|
69
|
-
message: 'm',
|
|
70
|
-
}));
|
|
71
|
-
const result = aggregate(many, {from: '2026-03-01', to: '2026-03-01'});
|
|
72
|
-
expect(result.daysByUser.datasets).toHaveLength(11);
|
|
73
|
-
expect(result.daysByUser.datasets.map((dataset) => dataset.label)).toStrictEqual([
|
|
74
|
-
'user00',
|
|
75
|
-
'user01',
|
|
76
|
-
'user02',
|
|
77
|
-
'user03',
|
|
78
|
-
'user04',
|
|
79
|
-
'user05',
|
|
80
|
-
'user06',
|
|
81
|
-
'user07',
|
|
82
|
-
'user08',
|
|
83
|
-
'user09',
|
|
84
|
-
'(others)',
|
|
85
|
-
]);
|
|
86
|
-
const others = result.daysByUser.datasets.at(-1);
|
|
87
|
-
expect(others?.values.at(-1)).toBe(2);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('caps contributors at 10 and buckets the remainder into "(others)"', () => {
|
|
91
|
-
const many = Array.from({length: 12}, (_, index) => ({
|
|
92
|
-
revision: index + 1,
|
|
93
|
-
author: `user${String(index).padStart(2, '0')}`,
|
|
94
|
-
date: '2026-03-01T00:00:00.000Z',
|
|
95
|
-
message: 'm',
|
|
96
|
-
}));
|
|
97
|
-
const result = aggregate(many, {from: '2026-03-01', to: '2026-03-01'});
|
|
98
|
-
expect(result.users.labels).toHaveLength(11);
|
|
99
|
-
expect(result.users.labels.slice(0, 10)).toStrictEqual(Array.from({length: 10}, (_, index) => `user${String(index).padStart(2, '0')}`));
|
|
100
|
-
expect(result.users.labels.at(-1)).toBe('(others)');
|
|
101
|
-
expect(result.users.values.at(-1)).toBe(2);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('returns the 20 most recent commits within the range, newest first', () => {
|
|
105
|
-
const many: Commit[] = Array.from({length: 25}, (_, index) => ({
|
|
106
|
-
revision: index + 1,
|
|
107
|
-
author: 'ada',
|
|
108
|
-
date: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
109
|
-
message: `m${index}`,
|
|
110
|
-
}));
|
|
111
|
-
many.push({revision: 26, author: 'lin', date: '2025-12-31T00:00:00.000Z', message: 'outside'});
|
|
112
|
-
const result = aggregate(many, {from: '2026-01-01', to: '2026-01-31'});
|
|
113
|
-
expect(result.recent).toHaveLength(20);
|
|
114
|
-
expect(result.recent[0]?.revision).toBe(25);
|
|
115
|
-
expect(result.recent.at(-1)?.revision).toBe(6);
|
|
116
|
-
expect(result.recent.some((commit) => commit.revision === 26)).toBe(false);
|
|
117
|
-
});
|
|
118
|
-
});
|
package/src/aggregation.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import {z} from 'zod';
|
|
2
|
-
import {type Commit} from './model.js';
|
|
3
|
-
|
|
4
|
-
const DAY_MS = 86_400_000;
|
|
5
|
-
const dateTextSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/u, 'Expected YYYY-MM-DD');
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_CONTRIBUTORS = 10;
|
|
8
|
-
export const DEFAULT_RECENT = 20;
|
|
9
|
-
export const OTHER_CONTRIBUTORS_LABEL = '(others)';
|
|
10
|
-
|
|
11
|
-
export type DateRange = {readonly from: string; readonly to: string};
|
|
12
|
-
export type Series = {readonly labels: string[]; readonly values: number[]};
|
|
13
|
-
export type StackedDataset = {readonly label: string; readonly values: number[]};
|
|
14
|
-
export type StackedSeries = {readonly labels: string[]; readonly datasets: StackedDataset[]};
|
|
15
|
-
export type ReportData = {
|
|
16
|
-
readonly range: DateRange;
|
|
17
|
-
readonly total: number;
|
|
18
|
-
readonly users: Series;
|
|
19
|
-
readonly weekdays: Series;
|
|
20
|
-
readonly hours: Series;
|
|
21
|
-
readonly days: Series;
|
|
22
|
-
readonly daysByUser: StackedSeries;
|
|
23
|
-
readonly months: Series;
|
|
24
|
-
readonly recent: Commit[];
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
function parseDateText(value: string): Date {
|
|
28
|
-
dateTextSchema.parse(value);
|
|
29
|
-
const date = new Date(`${value}T00:00:00.000Z`);
|
|
30
|
-
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
|
|
31
|
-
throw new Error(`Invalid UTC date: ${value}`);
|
|
32
|
-
}
|
|
33
|
-
return date;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function dateText(date: Date): string {
|
|
37
|
-
return date.toISOString().slice(0, 10);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function addDays(value: string, amount: number): string {
|
|
41
|
-
const date = parseDateText(value);
|
|
42
|
-
return dateText(new Date(date.getTime() + amount * DAY_MS));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function datasetBounds(commits: readonly Commit[], today: string): DateRange {
|
|
46
|
-
if (commits.length === 0) {
|
|
47
|
-
return {from: today, to: today};
|
|
48
|
-
}
|
|
49
|
-
const dates = commits.map((commit) => commit.date.slice(0, 10)).sort();
|
|
50
|
-
const [first] = dates;
|
|
51
|
-
const last = dates.at(-1);
|
|
52
|
-
if (first === undefined || last === undefined) {
|
|
53
|
-
return {from: today, to: today};
|
|
54
|
-
}
|
|
55
|
-
return {from: first, to: last};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function resolveRange(
|
|
59
|
-
commits: readonly Commit[],
|
|
60
|
-
options: {readonly from?: string | undefined; readonly to?: string | undefined; readonly relativeDays?: number | undefined},
|
|
61
|
-
now = new Date(),
|
|
62
|
-
): DateRange {
|
|
63
|
-
const today = dateText(now);
|
|
64
|
-
parseDateText(today);
|
|
65
|
-
if (options.relativeDays !== undefined) {
|
|
66
|
-
if (options.from !== undefined || options.to !== undefined) {
|
|
67
|
-
throw new Error('--from/--to cannot be used with --relative-days');
|
|
68
|
-
}
|
|
69
|
-
if (!Number.isInteger(options.relativeDays) || options.relativeDays <= 0) {
|
|
70
|
-
throw new Error('--relative-days must be a positive integer');
|
|
71
|
-
}
|
|
72
|
-
return {from: addDays(today, 1 - options.relativeDays), to: today};
|
|
73
|
-
}
|
|
74
|
-
const bounds = datasetBounds(commits, today);
|
|
75
|
-
const from = options.from ?? bounds.from;
|
|
76
|
-
const to = options.to ?? bounds.to;
|
|
77
|
-
parseDateText(from);
|
|
78
|
-
parseDateText(to);
|
|
79
|
-
if (from > to) {
|
|
80
|
-
throw new Error(`Report start date ${from} is after end date ${to}`);
|
|
81
|
-
}
|
|
82
|
-
return {from, to};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function countSeries(labels: readonly string[], keys: readonly string[]): Series {
|
|
86
|
-
const counts = new Map(labels.map((label) => [label, 0]));
|
|
87
|
-
for (const key of keys) {
|
|
88
|
-
if (counts.has(key)) {
|
|
89
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return {labels: [...labels], values: labels.map((label) => counts.get(label) ?? 0)};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function countStackedSeries(
|
|
96
|
-
labels: readonly string[],
|
|
97
|
-
buckets: readonly string[],
|
|
98
|
-
entries: readonly {readonly key: string; readonly bucket: string}[],
|
|
99
|
-
): StackedSeries {
|
|
100
|
-
const bucketValues = new Map(buckets.map((bucket) => [bucket, Array.from({length: labels.length}, () => 0)]));
|
|
101
|
-
const indexByLabel = new Map(labels.map((label, index) => [label, index]));
|
|
102
|
-
for (const {key, bucket} of entries) {
|
|
103
|
-
const values = bucketValues.get(bucket);
|
|
104
|
-
const index = indexByLabel.get(key);
|
|
105
|
-
if (values === undefined || index === undefined) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
values[index] = (values[index] ?? 0) + 1;
|
|
109
|
-
}
|
|
110
|
-
return {labels: [...labels], datasets: buckets.map((bucket) => ({label: bucket, values: [...(bucketValues.get(bucket) ?? [])]}))};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function monthLabels(end: string): string[] {
|
|
114
|
-
const endDate = parseDateText(end);
|
|
115
|
-
const result: string[] = [];
|
|
116
|
-
for (let offset = 11; offset >= 0; offset--) {
|
|
117
|
-
result.push(dateText(new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - offset, 1))).slice(0, 7));
|
|
118
|
-
}
|
|
119
|
-
return result;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function aggregate(commits: readonly Commit[], range: DateRange): ReportData {
|
|
123
|
-
parseDateText(range.from);
|
|
124
|
-
parseDateText(range.to);
|
|
125
|
-
if (range.from > range.to) {
|
|
126
|
-
throw new Error('Report date range is reversed');
|
|
127
|
-
}
|
|
128
|
-
const selected = commits.filter((commit) => {
|
|
129
|
-
const date = commit.date.slice(0, 10);
|
|
130
|
-
return date >= range.from && date <= range.to;
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const userCounts = new Map<string, number>();
|
|
134
|
-
for (const commit of selected) {
|
|
135
|
-
const author = commit.author === null || commit.author === '' ? '(no author)' : commit.author;
|
|
136
|
-
userCounts.set(author, (userCounts.get(author) ?? 0) + 1);
|
|
137
|
-
}
|
|
138
|
-
const ranked = [...userCounts.entries()].sort(
|
|
139
|
-
([leftName, leftCount], [rightName, rightCount]) => rightCount - leftCount || leftName.localeCompare(rightName),
|
|
140
|
-
);
|
|
141
|
-
let userLabels: string[];
|
|
142
|
-
let userValues: number[];
|
|
143
|
-
const bucketOf = new Map<string, string>();
|
|
144
|
-
if (ranked.length > DEFAULT_CONTRIBUTORS) {
|
|
145
|
-
const top = ranked.slice(0, DEFAULT_CONTRIBUTORS);
|
|
146
|
-
const others = ranked.slice(DEFAULT_CONTRIBUTORS).reduce((sum, [, count]) => sum + count, 0);
|
|
147
|
-
userLabels = [...top.map(([name]) => name), OTHER_CONTRIBUTORS_LABEL];
|
|
148
|
-
userValues = [...top.map(([, count]) => count), others];
|
|
149
|
-
for (const [name] of top) {
|
|
150
|
-
bucketOf.set(name, name);
|
|
151
|
-
}
|
|
152
|
-
for (const [name] of ranked.slice(DEFAULT_CONTRIBUTORS)) {
|
|
153
|
-
bucketOf.set(name, OTHER_CONTRIBUTORS_LABEL);
|
|
154
|
-
}
|
|
155
|
-
} else {
|
|
156
|
-
userLabels = ranked.map(([name]) => name);
|
|
157
|
-
userValues = ranked.map(([, count]) => count);
|
|
158
|
-
for (const [name] of ranked) {
|
|
159
|
-
bucketOf.set(name, name);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const weekdayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
163
|
-
const hourLabels = Array.from({length: 24}, (_, hour) => hour.toString().padStart(2, '0'));
|
|
164
|
-
const dayLabels = Array.from({length: 30}, (_, index) => addDays(range.to, index - 29));
|
|
165
|
-
const months = monthLabels(range.to);
|
|
166
|
-
const recent = [...selected].sort((left, right) => right.revision - left.revision).slice(0, DEFAULT_RECENT);
|
|
167
|
-
const daysByUser = countStackedSeries(
|
|
168
|
-
dayLabels,
|
|
169
|
-
userLabels,
|
|
170
|
-
selected.map((commit) => {
|
|
171
|
-
const author = commit.author === null || commit.author === '' ? '(no author)' : commit.author;
|
|
172
|
-
return {key: commit.date.slice(0, 10), bucket: bucketOf.get(author) ?? OTHER_CONTRIBUTORS_LABEL};
|
|
173
|
-
}),
|
|
174
|
-
);
|
|
175
|
-
return {
|
|
176
|
-
range,
|
|
177
|
-
total: selected.length,
|
|
178
|
-
users: {labels: userLabels, values: userValues},
|
|
179
|
-
weekdays: countSeries(
|
|
180
|
-
weekdayLabels,
|
|
181
|
-
selected.map((commit) => weekdayLabels[(new Date(commit.date).getUTCDay() + 6) % 7] ?? 'Monday'),
|
|
182
|
-
),
|
|
183
|
-
hours: countSeries(
|
|
184
|
-
hourLabels,
|
|
185
|
-
selected.map((commit) => new Date(commit.date).getUTCHours().toString().padStart(2, '0')),
|
|
186
|
-
),
|
|
187
|
-
days: countSeries(
|
|
188
|
-
dayLabels,
|
|
189
|
-
selected.map((commit) => commit.date.slice(0, 10)),
|
|
190
|
-
),
|
|
191
|
-
daysByUser,
|
|
192
|
-
months: countSeries(
|
|
193
|
-
months,
|
|
194
|
-
selected.map((commit) => commit.date.slice(0, 7)),
|
|
195
|
-
),
|
|
196
|
-
recent,
|
|
197
|
-
};
|
|
198
|
-
}
|
package/src/cli.test.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import {readFileSync} from 'node:fs';
|
|
2
|
-
import {describe, expect, it} from 'vitest';
|
|
3
|
-
import {createProgram} from './cli.js';
|
|
4
|
-
|
|
5
|
-
describe('CLI', () => {
|
|
6
|
-
it('reports the package version', () => {
|
|
7
|
-
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {version: string};
|
|
8
|
-
expect(createProgram().version()).toBe(packageJson.version);
|
|
9
|
-
});
|
|
10
|
-
});
|
package/src/cli.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import {Command, Option} from 'commander';
|
|
2
|
-
import {gather, type GatherOptions} from './gather.js';
|
|
3
|
-
import {generate, type GenerateOptions} from './report.js';
|
|
4
|
-
|
|
5
|
-
declare const APP_VERSION: string;
|
|
6
|
-
|
|
7
|
-
type CommonGatherOptions = Omit<GatherOptions, 'url'> & {readonly url: string};
|
|
8
|
-
|
|
9
|
-
function positiveInteger(value: string): number {
|
|
10
|
-
const parsed = Number(value);
|
|
11
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
12
|
-
throw new Error('Expected a positive integer');
|
|
13
|
-
}
|
|
14
|
-
return parsed;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function gatherOptions(command: Command): Command {
|
|
18
|
-
return command
|
|
19
|
-
.requiredOption('--url <url>', 'SVN repository URL')
|
|
20
|
-
.option('--username <username>', 'SVN username')
|
|
21
|
-
.option('--password-env <name>', 'environment variable containing the SVN password', 'SVN_PASSWORD')
|
|
22
|
-
.option('--data-file <path>', 'JSON state file', 'svn-data.json')
|
|
23
|
-
.option('--svn-binary <path>', 'SVN executable', 'svn');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function generateOptions(command: Command, includeDataFile = true): Command {
|
|
27
|
-
if (includeDataFile) {
|
|
28
|
-
command.option('--data-file <path>', 'JSON state file', 'svn-data.json');
|
|
29
|
-
}
|
|
30
|
-
return command
|
|
31
|
-
.option('--output-dir <path>', 'report output directory', 'output')
|
|
32
|
-
.option('--from <date>', 'first UTC date (YYYY-MM-DD)')
|
|
33
|
-
.option('--to <date>', 'last UTC date (YYYY-MM-DD)')
|
|
34
|
-
.addOption(new Option('--relative-days <days>', 'rolling number of UTC days').argParser(positiveInteger))
|
|
35
|
-
.option('--title <title>', 'report title', 'Subversion activity');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function createProgram(): Command {
|
|
39
|
-
const program = new Command().name('svn-visualizer').description('Generate standalone HTML activity reports from Subversion history').version(APP_VERSION);
|
|
40
|
-
|
|
41
|
-
gatherOptions(program.command('gather').description('Incrementally gather SVN history')).action(async (options: CommonGatherOptions) => {
|
|
42
|
-
const result = await gather(options);
|
|
43
|
-
console.log(result.added === 0 ? 'No new commits.' : `Gathered ${String(result.added)} new commit(s).`);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
generateOptions(program.command('generate').description('Generate a standalone HTML report')).action(async (options: GenerateOptions) => {
|
|
47
|
-
const output = await generate(options);
|
|
48
|
-
console.log(`Generated ${output}`);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
generateOptions(gatherOptions(program.command('report').description('Gather history and generate a report')), false).action(
|
|
52
|
-
async (options: CommonGatherOptions & GenerateOptions) => {
|
|
53
|
-
const result = await gather(options);
|
|
54
|
-
console.log(result.added === 0 ? 'No new commits.' : `Gathered ${String(result.added)} new commit(s).`);
|
|
55
|
-
const output = await generate(options);
|
|
56
|
-
console.log(`Generated ${output}`);
|
|
57
|
-
},
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
return program;
|
|
61
|
-
}
|