@aaronshaf/ger 3.0.2 → 4.0.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/package.json +1 -1
- package/skills/gerrit-workflow/SKILL.md +228 -141
- package/skills/gerrit-workflow/examples.md +133 -426
- package/skills/gerrit-workflow/reference.md +470 -408
- package/src/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +55 -96
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +56 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/tree.test.ts +517 -0
- package/tests/update.test.ts +86 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Schema } from '@effect/schema'
|
|
2
|
+
import type { Effect } from 'effect'
|
|
3
|
+
import type {
|
|
4
|
+
ChangeInfo,
|
|
5
|
+
CommentInfo,
|
|
6
|
+
MessageInfo,
|
|
7
|
+
DiffOptions,
|
|
8
|
+
FileDiffContent,
|
|
9
|
+
FileInfo,
|
|
10
|
+
ProjectInfo,
|
|
11
|
+
ReviewInput,
|
|
12
|
+
ReviewerInput,
|
|
13
|
+
ReviewerResult,
|
|
14
|
+
RevisionInfo,
|
|
15
|
+
SubmitInfo,
|
|
16
|
+
GroupInfo,
|
|
17
|
+
GroupDetailInfo,
|
|
18
|
+
AccountInfo,
|
|
19
|
+
} from '@/schemas/gerrit'
|
|
20
|
+
import type { ReviewerListItem } from '@/schemas/reviewer'
|
|
21
|
+
|
|
22
|
+
export interface ApiErrorFields {
|
|
23
|
+
readonly message: string
|
|
24
|
+
readonly status?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ApiErrorSchema = Schema.TaggedError<ApiErrorFields>()('ApiError', {
|
|
28
|
+
message: Schema.String,
|
|
29
|
+
status: Schema.optional(Schema.Number),
|
|
30
|
+
} as const) as unknown
|
|
31
|
+
|
|
32
|
+
export class ApiError
|
|
33
|
+
extends (ApiErrorSchema as new (
|
|
34
|
+
args: ApiErrorFields,
|
|
35
|
+
) => ApiErrorFields & Error & { readonly _tag: 'ApiError' })
|
|
36
|
+
implements Error
|
|
37
|
+
{
|
|
38
|
+
readonly name = 'ApiError'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GerritApiServiceImpl {
|
|
42
|
+
readonly getChange: (changeId: string) => Effect.Effect<ChangeInfo, ApiError>
|
|
43
|
+
readonly listChanges: (query?: string) => Effect.Effect<readonly ChangeInfo[], ApiError>
|
|
44
|
+
readonly listProjects: (options?: {
|
|
45
|
+
pattern?: string
|
|
46
|
+
}) => Effect.Effect<readonly ProjectInfo[], ApiError>
|
|
47
|
+
readonly postReview: (changeId: string, review: ReviewInput) => Effect.Effect<void, ApiError>
|
|
48
|
+
readonly abandonChange: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
49
|
+
readonly restoreChange: (
|
|
50
|
+
changeId: string,
|
|
51
|
+
message?: string,
|
|
52
|
+
) => Effect.Effect<ChangeInfo, ApiError>
|
|
53
|
+
readonly rebaseChange: (
|
|
54
|
+
changeId: string,
|
|
55
|
+
options?: { base?: string; allowConflicts?: boolean },
|
|
56
|
+
) => Effect.Effect<ChangeInfo, ApiError>
|
|
57
|
+
readonly submitChange: (changeId: string) => Effect.Effect<SubmitInfo, ApiError>
|
|
58
|
+
readonly testConnection: Effect.Effect<boolean, ApiError>
|
|
59
|
+
readonly getRevision: (
|
|
60
|
+
changeId: string,
|
|
61
|
+
revisionId?: string,
|
|
62
|
+
) => Effect.Effect<RevisionInfo, ApiError>
|
|
63
|
+
readonly getFiles: (
|
|
64
|
+
changeId: string,
|
|
65
|
+
revisionId?: string,
|
|
66
|
+
) => Effect.Effect<Record<string, FileInfo>, ApiError>
|
|
67
|
+
readonly getFileDiff: (
|
|
68
|
+
changeId: string,
|
|
69
|
+
filePath: string,
|
|
70
|
+
revisionId?: string,
|
|
71
|
+
base?: string,
|
|
72
|
+
) => Effect.Effect<FileDiffContent, ApiError>
|
|
73
|
+
readonly getFileContent: (
|
|
74
|
+
changeId: string,
|
|
75
|
+
filePath: string,
|
|
76
|
+
revisionId?: string,
|
|
77
|
+
) => Effect.Effect<string, ApiError>
|
|
78
|
+
readonly getPatch: (changeId: string, revisionId?: string) => Effect.Effect<string, ApiError>
|
|
79
|
+
readonly getDiff: (
|
|
80
|
+
changeId: string,
|
|
81
|
+
options?: DiffOptions,
|
|
82
|
+
) => Effect.Effect<string | string[] | Record<string, unknown> | FileDiffContent, ApiError>
|
|
83
|
+
readonly getComments: (
|
|
84
|
+
changeId: string,
|
|
85
|
+
revisionId?: string,
|
|
86
|
+
) => Effect.Effect<Record<string, readonly CommentInfo[]>, ApiError>
|
|
87
|
+
readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
|
|
88
|
+
readonly addReviewer: (
|
|
89
|
+
changeId: string,
|
|
90
|
+
reviewer: string,
|
|
91
|
+
options?: { state?: 'REVIEWER' | 'CC'; notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
|
|
92
|
+
) => Effect.Effect<ReviewerResult, ApiError>
|
|
93
|
+
readonly listGroups: (options?: {
|
|
94
|
+
owned?: boolean
|
|
95
|
+
project?: string
|
|
96
|
+
user?: string
|
|
97
|
+
pattern?: string
|
|
98
|
+
limit?: number
|
|
99
|
+
skip?: number
|
|
100
|
+
}) => Effect.Effect<readonly GroupInfo[], ApiError>
|
|
101
|
+
readonly getGroup: (groupId: string) => Effect.Effect<GroupInfo, ApiError>
|
|
102
|
+
readonly getGroupDetail: (groupId: string) => Effect.Effect<GroupDetailInfo, ApiError>
|
|
103
|
+
readonly getGroupMembers: (groupId: string) => Effect.Effect<readonly AccountInfo[], ApiError>
|
|
104
|
+
readonly getReviewers: (changeId: string) => Effect.Effect<readonly ReviewerListItem[], ApiError>
|
|
105
|
+
readonly removeReviewer: (
|
|
106
|
+
changeId: string,
|
|
107
|
+
accountId: string,
|
|
108
|
+
options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
|
|
109
|
+
) => Effect.Effect<void, ApiError>
|
|
110
|
+
readonly getTopic: (changeId: string) => Effect.Effect<string | null, ApiError>
|
|
111
|
+
readonly setTopic: (changeId: string, topic: string) => Effect.Effect<string, ApiError>
|
|
112
|
+
readonly deleteTopic: (changeId: string) => Effect.Effect<void, ApiError>
|
|
113
|
+
readonly setReady: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
114
|
+
readonly setWip: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
115
|
+
readonly fetchMergedChanges: (options: {
|
|
116
|
+
after: string
|
|
117
|
+
before?: string
|
|
118
|
+
repo?: string
|
|
119
|
+
maxResults?: number
|
|
120
|
+
}) => Effect.Effect<readonly ChangeInfo[], ApiError>
|
|
121
|
+
}
|
package/src/api/gerrit.ts
CHANGED
|
@@ -24,105 +24,14 @@ import { convertToUnifiedDiff } from '@/utils/diff-formatters'
|
|
|
24
24
|
import { ConfigService } from '@/services/config'
|
|
25
25
|
import { normalizeChangeIdentifier } from '@/utils/change-id'
|
|
26
26
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
readonly listProjects: (options?: {
|
|
31
|
-
pattern?: string
|
|
32
|
-
}) => Effect.Effect<readonly ProjectInfo[], ApiError>
|
|
33
|
-
readonly postReview: (changeId: string, review: ReviewInput) => Effect.Effect<void, ApiError>
|
|
34
|
-
readonly abandonChange: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
35
|
-
readonly restoreChange: (
|
|
36
|
-
changeId: string,
|
|
37
|
-
message?: string,
|
|
38
|
-
) => Effect.Effect<ChangeInfo, ApiError>
|
|
39
|
-
readonly rebaseChange: (
|
|
40
|
-
changeId: string,
|
|
41
|
-
options?: { base?: string },
|
|
42
|
-
) => Effect.Effect<ChangeInfo, ApiError>
|
|
43
|
-
readonly submitChange: (changeId: string) => Effect.Effect<SubmitInfo, ApiError>
|
|
44
|
-
readonly testConnection: Effect.Effect<boolean, ApiError>
|
|
45
|
-
readonly getRevision: (
|
|
46
|
-
changeId: string,
|
|
47
|
-
revisionId?: string,
|
|
48
|
-
) => Effect.Effect<RevisionInfo, ApiError>
|
|
49
|
-
readonly getFiles: (
|
|
50
|
-
changeId: string,
|
|
51
|
-
revisionId?: string,
|
|
52
|
-
) => Effect.Effect<Record<string, FileInfo>, ApiError>
|
|
53
|
-
readonly getFileDiff: (
|
|
54
|
-
changeId: string,
|
|
55
|
-
filePath: string,
|
|
56
|
-
revisionId?: string,
|
|
57
|
-
base?: string,
|
|
58
|
-
) => Effect.Effect<FileDiffContent, ApiError>
|
|
59
|
-
readonly getFileContent: (
|
|
60
|
-
changeId: string,
|
|
61
|
-
filePath: string,
|
|
62
|
-
revisionId?: string,
|
|
63
|
-
) => Effect.Effect<string, ApiError>
|
|
64
|
-
readonly getPatch: (changeId: string, revisionId?: string) => Effect.Effect<string, ApiError>
|
|
65
|
-
readonly getDiff: (
|
|
66
|
-
changeId: string,
|
|
67
|
-
options?: DiffOptions,
|
|
68
|
-
) => Effect.Effect<string | string[] | Record<string, unknown> | FileDiffContent, ApiError>
|
|
69
|
-
readonly getComments: (
|
|
70
|
-
changeId: string,
|
|
71
|
-
revisionId?: string,
|
|
72
|
-
) => Effect.Effect<Record<string, readonly CommentInfo[]>, ApiError>
|
|
73
|
-
readonly getMessages: (changeId: string) => Effect.Effect<readonly MessageInfo[], ApiError>
|
|
74
|
-
readonly addReviewer: (
|
|
75
|
-
changeId: string,
|
|
76
|
-
reviewer: string,
|
|
77
|
-
options?: { state?: 'REVIEWER' | 'CC'; notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
|
|
78
|
-
) => Effect.Effect<ReviewerResult, ApiError>
|
|
79
|
-
readonly listGroups: (options?: {
|
|
80
|
-
owned?: boolean
|
|
81
|
-
project?: string
|
|
82
|
-
user?: string
|
|
83
|
-
pattern?: string
|
|
84
|
-
limit?: number
|
|
85
|
-
skip?: number
|
|
86
|
-
}) => Effect.Effect<readonly GroupInfo[], ApiError>
|
|
87
|
-
readonly getGroup: (groupId: string) => Effect.Effect<GroupInfo, ApiError>
|
|
88
|
-
readonly getGroupDetail: (groupId: string) => Effect.Effect<GroupDetailInfo, ApiError>
|
|
89
|
-
readonly getGroupMembers: (groupId: string) => Effect.Effect<readonly AccountInfo[], ApiError>
|
|
90
|
-
readonly getReviewers: (changeId: string) => Effect.Effect<readonly ReviewerListItem[], ApiError>
|
|
91
|
-
readonly removeReviewer: (
|
|
92
|
-
changeId: string,
|
|
93
|
-
accountId: string,
|
|
94
|
-
options?: { notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL' },
|
|
95
|
-
) => Effect.Effect<void, ApiError>
|
|
96
|
-
readonly getTopic: (changeId: string) => Effect.Effect<string | null, ApiError>
|
|
97
|
-
readonly setTopic: (changeId: string, topic: string) => Effect.Effect<string, ApiError>
|
|
98
|
-
readonly deleteTopic: (changeId: string) => Effect.Effect<void, ApiError>
|
|
99
|
-
readonly setReady: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
100
|
-
readonly setWip: (changeId: string, message?: string) => Effect.Effect<void, ApiError>
|
|
101
|
-
}
|
|
27
|
+
export type { GerritApiServiceImpl, ApiErrorFields } from './gerrit-types'
|
|
28
|
+
export { ApiError } from './gerrit-types'
|
|
29
|
+
import { ApiError, type GerritApiServiceImpl } from './gerrit-types'
|
|
102
30
|
|
|
103
31
|
export const GerritApiService: Context.Tag<GerritApiServiceImpl, GerritApiServiceImpl> =
|
|
104
32
|
Context.GenericTag<GerritApiServiceImpl>('GerritApiService')
|
|
105
33
|
export type GerritApiService = Context.Tag.Identifier<typeof GerritApiService>
|
|
106
34
|
|
|
107
|
-
export interface ApiErrorFields {
|
|
108
|
-
readonly message: string
|
|
109
|
-
readonly status?: number
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const ApiErrorSchema = Schema.TaggedError<ApiErrorFields>()('ApiError', {
|
|
113
|
-
message: Schema.String,
|
|
114
|
-
status: Schema.optional(Schema.Number),
|
|
115
|
-
} as const) as unknown
|
|
116
|
-
|
|
117
|
-
export class ApiError
|
|
118
|
-
extends (ApiErrorSchema as new (
|
|
119
|
-
args: ApiErrorFields,
|
|
120
|
-
) => ApiErrorFields & Error & { readonly _tag: 'ApiError' })
|
|
121
|
-
implements Error
|
|
122
|
-
{
|
|
123
|
-
readonly name = 'ApiError'
|
|
124
|
-
}
|
|
125
|
-
|
|
126
35
|
const createAuthHeader = (credentials: GerritCredentials): string => {
|
|
127
36
|
const auth = btoa(`${credentials.username}:${credentials.password}`)
|
|
128
37
|
return `Basic ${auth}`
|
|
@@ -260,12 +169,17 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
260
169
|
return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo)
|
|
261
170
|
})
|
|
262
171
|
|
|
263
|
-
const rebaseChange = (
|
|
172
|
+
const rebaseChange = (
|
|
173
|
+
changeId: string,
|
|
174
|
+
options?: { base?: string; allowConflicts?: boolean },
|
|
175
|
+
) =>
|
|
264
176
|
Effect.gen(function* () {
|
|
265
177
|
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
266
178
|
const normalized = yield* normalizeAndValidate(changeId)
|
|
267
179
|
const url = `${credentials.host}/a/changes/${encodeURIComponent(normalized)}/revisions/current/rebase`
|
|
268
|
-
const body
|
|
180
|
+
const body: Record<string, string | boolean> = {}
|
|
181
|
+
if (options?.base) body['base'] = options.base
|
|
182
|
+
if (options?.allowConflicts) body['allow_conflicts'] = true
|
|
269
183
|
return yield* makeRequest(url, authHeader, 'POST', body, ChangeInfo)
|
|
270
184
|
})
|
|
271
185
|
|
|
@@ -661,6 +575,50 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
661
575
|
yield* makeRequest(url, authHeader, 'POST', body)
|
|
662
576
|
})
|
|
663
577
|
|
|
578
|
+
const fetchMergedChanges = (options: {
|
|
579
|
+
after: string
|
|
580
|
+
before?: string
|
|
581
|
+
repo?: string
|
|
582
|
+
maxResults?: number
|
|
583
|
+
}) =>
|
|
584
|
+
Effect.gen(function* () {
|
|
585
|
+
const { credentials, authHeader } = yield* getCredentialsAndAuth
|
|
586
|
+
const limit = options.maxResults ?? 500
|
|
587
|
+
const pageSize = Math.min(limit, 500)
|
|
588
|
+
const allChanges: ChangeInfo[] = []
|
|
589
|
+
let start = 0
|
|
590
|
+
let hasMore = true
|
|
591
|
+
|
|
592
|
+
while (hasMore) {
|
|
593
|
+
let q = `status:merged after:${options.after}`
|
|
594
|
+
if (options.before) q += ` before:${options.before}`
|
|
595
|
+
if (options.repo) q += ` project:${options.repo}`
|
|
596
|
+
const url = `${credentials.host}/a/changes/?q=${encodeURIComponent(q)}&o=DETAILED_ACCOUNTS&n=${pageSize}&S=${start}`
|
|
597
|
+
const page = yield* makeRequest(
|
|
598
|
+
url,
|
|
599
|
+
authHeader,
|
|
600
|
+
'GET',
|
|
601
|
+
undefined,
|
|
602
|
+
Schema.Array(ChangeInfo),
|
|
603
|
+
)
|
|
604
|
+
allChanges.push(...page)
|
|
605
|
+
const remaining = limit - allChanges.length
|
|
606
|
+
if (page.length < pageSize || remaining <= 0) {
|
|
607
|
+
hasMore = false
|
|
608
|
+
} else {
|
|
609
|
+
start += pageSize
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (allChanges.length >= limit) {
|
|
614
|
+
console.warn(
|
|
615
|
+
`Warning: results capped at ${limit}. Use --start-date to narrow the date range.`,
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return allChanges as readonly ChangeInfo[]
|
|
620
|
+
})
|
|
621
|
+
|
|
664
622
|
return {
|
|
665
623
|
getChange,
|
|
666
624
|
listChanges,
|
|
@@ -691,6 +649,7 @@ export const GerritApiServiceLive: Layer.Layer<GerritApiService, never, ConfigSe
|
|
|
691
649
|
deleteTopic,
|
|
692
650
|
setReady,
|
|
693
651
|
setWip,
|
|
652
|
+
fetchMergedChanges,
|
|
694
653
|
}
|
|
695
654
|
}),
|
|
696
655
|
)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { Console, Effect } from 'effect'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import type { ChangeInfo } from '@/schemas/gerrit'
|
|
4
|
+
import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
|
|
5
|
+
import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
|
|
6
|
+
import { writeFileSync } from 'node:fs'
|
|
7
|
+
import { escapeXML } from '@/utils/shell-safety'
|
|
8
|
+
|
|
9
|
+
export interface AnalyzeOptions {
|
|
10
|
+
startDate?: string
|
|
11
|
+
endDate?: string
|
|
12
|
+
repo?: string
|
|
13
|
+
json?: boolean
|
|
14
|
+
xml?: boolean
|
|
15
|
+
markdown?: boolean
|
|
16
|
+
csv?: boolean
|
|
17
|
+
output?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type AnalyzeErrors = ConfigError | ApiError | Error
|
|
21
|
+
|
|
22
|
+
interface RepoStats {
|
|
23
|
+
name: string
|
|
24
|
+
count: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AuthorStats {
|
|
28
|
+
name: string
|
|
29
|
+
email: string
|
|
30
|
+
count: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface MonthStats {
|
|
34
|
+
month: string
|
|
35
|
+
count: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AnalyticsResult {
|
|
39
|
+
totalMerged: number
|
|
40
|
+
dateRange: { start: string; end: string }
|
|
41
|
+
byRepo: readonly RepoStats[]
|
|
42
|
+
byAuthor: readonly AuthorStats[]
|
|
43
|
+
timeline: readonly MonthStats[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const getDefaultStartDate = (): string => {
|
|
47
|
+
const d = new Date()
|
|
48
|
+
return `${d.getFullYear()}-01-01`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const getDefaultEndDate = (): string => new Date().toISOString().slice(0, 10)
|
|
52
|
+
|
|
53
|
+
const getChangeMonth = (change: ChangeInfo): string => {
|
|
54
|
+
const dateStr = change.submitted ?? change.updated ?? change.created ?? ''
|
|
55
|
+
return dateStr.slice(0, 7) // YYYY-MM
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const aggregateByRepo = (changes: readonly ChangeInfo[]): readonly RepoStats[] => {
|
|
59
|
+
const counts = new Map<string, number>()
|
|
60
|
+
for (const c of changes) {
|
|
61
|
+
counts.set(c.project, (counts.get(c.project) ?? 0) + 1)
|
|
62
|
+
}
|
|
63
|
+
return [...counts.entries()]
|
|
64
|
+
.map(([name, count]) => ({ name, count }))
|
|
65
|
+
.sort((a, b) => b.count - a.count)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const aggregateByAuthor = (changes: readonly ChangeInfo[]): readonly AuthorStats[] => {
|
|
69
|
+
const counts = new Map<string, { name: string; email: string; count: number }>()
|
|
70
|
+
for (const c of changes) {
|
|
71
|
+
const owner = c.owner
|
|
72
|
+
if (!owner) continue
|
|
73
|
+
const key = owner.email ?? owner.name ?? String(owner._account_id)
|
|
74
|
+
const existing = counts.get(key)
|
|
75
|
+
if (existing) {
|
|
76
|
+
existing.count++
|
|
77
|
+
} else {
|
|
78
|
+
counts.set(key, {
|
|
79
|
+
name: owner.name ?? 'Unknown',
|
|
80
|
+
email: owner.email ?? '',
|
|
81
|
+
count: 1,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...counts.values()].sort((a, b) => b.count - a.count)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const aggregateByMonth = (changes: readonly ChangeInfo[]): readonly MonthStats[] => {
|
|
89
|
+
const counts = new Map<string, number>()
|
|
90
|
+
for (const c of changes) {
|
|
91
|
+
const month = getChangeMonth(c)
|
|
92
|
+
counts.set(month, (counts.get(month) ?? 0) + 1)
|
|
93
|
+
}
|
|
94
|
+
return [...counts.entries()]
|
|
95
|
+
.map(([month, count]) => ({ month, count }))
|
|
96
|
+
.sort((a, b) => a.month.localeCompare(b.month))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const BAR_WIDTH = 30
|
|
100
|
+
|
|
101
|
+
const renderBar = (count: number, max: number): string => {
|
|
102
|
+
const filled = max > 0 ? Math.round((count / max) * BAR_WIDTH) : 0
|
|
103
|
+
return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const renderTerminal = (result: AnalyticsResult): string => {
|
|
107
|
+
const lines: string[] = []
|
|
108
|
+
|
|
109
|
+
lines.push('')
|
|
110
|
+
lines.push(chalk.bold.cyan(' Contribution Analytics'))
|
|
111
|
+
lines.push(chalk.dim(` ${result.dateRange.start} → ${result.dateRange.end}`))
|
|
112
|
+
lines.push('')
|
|
113
|
+
lines.push(chalk.bold(` Total merged: ${chalk.green(String(result.totalMerged))}`))
|
|
114
|
+
lines.push('')
|
|
115
|
+
|
|
116
|
+
// By Repo
|
|
117
|
+
lines.push(chalk.bold.yellow(' ── Changes by Repository ──'))
|
|
118
|
+
lines.push('')
|
|
119
|
+
const maxRepo = result.byRepo[0]?.count ?? 1
|
|
120
|
+
for (const r of result.byRepo.slice(0, 15)) {
|
|
121
|
+
const bar = renderBar(r.count, maxRepo)
|
|
122
|
+
const name = r.name.length > 35 ? `...${r.name.slice(-32)}` : r.name
|
|
123
|
+
lines.push(
|
|
124
|
+
` ${chalk.cyan(name.padEnd(35))} ${chalk.green(bar)} ${chalk.bold(String(r.count))}`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
lines.push('')
|
|
128
|
+
|
|
129
|
+
// By Author
|
|
130
|
+
lines.push(chalk.bold.yellow(' ── Changes by Author ──'))
|
|
131
|
+
lines.push('')
|
|
132
|
+
const maxAuthor = result.byAuthor[0]?.count ?? 1
|
|
133
|
+
for (const a of result.byAuthor.slice(0, 10)) {
|
|
134
|
+
const bar = renderBar(a.count, maxAuthor)
|
|
135
|
+
const label = a.name.length > 25 ? `${a.name.slice(0, 22)}...` : a.name
|
|
136
|
+
lines.push(
|
|
137
|
+
` ${chalk.magenta(label.padEnd(25))} ${chalk.green(bar)} ${chalk.bold(String(a.count))}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
lines.push('')
|
|
141
|
+
|
|
142
|
+
// Timeline
|
|
143
|
+
lines.push(chalk.bold.yellow(' ── Timeline ──'))
|
|
144
|
+
lines.push('')
|
|
145
|
+
const maxMonth = Math.max(...result.timeline.map((m) => m.count), 1)
|
|
146
|
+
for (const m of result.timeline) {
|
|
147
|
+
const bar = renderBar(m.count, maxMonth)
|
|
148
|
+
lines.push(` ${chalk.blue(m.month)} ${chalk.green(bar)} ${chalk.bold(String(m.count))}`)
|
|
149
|
+
}
|
|
150
|
+
lines.push('')
|
|
151
|
+
|
|
152
|
+
return lines.join('\n')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const renderMarkdown = (result: AnalyticsResult): string => {
|
|
156
|
+
const lines: string[] = []
|
|
157
|
+
lines.push(`# Contribution Analytics`)
|
|
158
|
+
lines.push(``)
|
|
159
|
+
lines.push(`**Date range:** ${result.dateRange.start} → ${result.dateRange.end}`)
|
|
160
|
+
lines.push(`**Total merged:** ${result.totalMerged}`)
|
|
161
|
+
lines.push(``)
|
|
162
|
+
|
|
163
|
+
lines.push(`## Changes by Repository`)
|
|
164
|
+
lines.push(``)
|
|
165
|
+
lines.push(`| Repository | Count |`)
|
|
166
|
+
lines.push(`|---|---|`)
|
|
167
|
+
for (const r of result.byRepo) lines.push(`| ${r.name} | ${r.count} |`)
|
|
168
|
+
lines.push(``)
|
|
169
|
+
|
|
170
|
+
lines.push(`## Changes by Author`)
|
|
171
|
+
lines.push(``)
|
|
172
|
+
lines.push(`| Author | Email | Count |`)
|
|
173
|
+
lines.push(`|---|---|---|`)
|
|
174
|
+
for (const a of result.byAuthor) lines.push(`| ${a.name} | ${a.email} | ${a.count} |`)
|
|
175
|
+
lines.push(``)
|
|
176
|
+
|
|
177
|
+
lines.push(`## Timeline`)
|
|
178
|
+
lines.push(``)
|
|
179
|
+
lines.push(`| Month | Count |`)
|
|
180
|
+
lines.push(`|---|---|`)
|
|
181
|
+
for (const m of result.timeline) lines.push(`| ${m.month} | ${m.count} |`)
|
|
182
|
+
lines.push(``)
|
|
183
|
+
|
|
184
|
+
return lines.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const csvField = (v: string): string => `"${v.replace(/"/g, '""')}"`
|
|
188
|
+
|
|
189
|
+
const renderCsv = (result: AnalyticsResult): string => {
|
|
190
|
+
const lines: string[] = []
|
|
191
|
+
lines.push('section,key,count')
|
|
192
|
+
for (const r of result.byRepo) lines.push(`repo,${csvField(r.name)},${r.count}`)
|
|
193
|
+
for (const a of result.byAuthor)
|
|
194
|
+
lines.push(`author,${csvField(`${a.name} <${a.email}>`)},${a.count}`)
|
|
195
|
+
for (const m of result.timeline) lines.push(`timeline,${csvField(m.month)},${m.count}`)
|
|
196
|
+
return lines.join('\n')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const renderXml = (result: AnalyticsResult): string => {
|
|
200
|
+
const lines: string[] = []
|
|
201
|
+
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
202
|
+
lines.push(`<analytics>`)
|
|
203
|
+
lines.push(` <total_merged>${result.totalMerged}</total_merged>`)
|
|
204
|
+
lines.push(` <date_range start="${result.dateRange.start}" end="${result.dateRange.end}"/>`)
|
|
205
|
+
|
|
206
|
+
lines.push(` <by_repo>`)
|
|
207
|
+
for (const r of result.byRepo) {
|
|
208
|
+
lines.push(` <repo name="${escapeXML(r.name)}" count="${r.count}"/>`)
|
|
209
|
+
}
|
|
210
|
+
lines.push(` </by_repo>`)
|
|
211
|
+
|
|
212
|
+
lines.push(` <by_author>`)
|
|
213
|
+
for (const a of result.byAuthor) {
|
|
214
|
+
const escaped = a.name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
215
|
+
lines.push(` <author name="${escaped}" email="${a.email}" count="${a.count}"/>`)
|
|
216
|
+
}
|
|
217
|
+
lines.push(` </by_author>`)
|
|
218
|
+
|
|
219
|
+
lines.push(` <timeline>`)
|
|
220
|
+
for (const m of result.timeline) {
|
|
221
|
+
lines.push(` <month value="${m.month}" count="${m.count}"/>`)
|
|
222
|
+
}
|
|
223
|
+
lines.push(` </timeline>`)
|
|
224
|
+
|
|
225
|
+
lines.push(`</analytics>`)
|
|
226
|
+
return lines.join('\n')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const analyzeCommand = (
|
|
230
|
+
options: AnalyzeOptions,
|
|
231
|
+
): Effect.Effect<void, AnalyzeErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const startDate = options.startDate ?? getDefaultStartDate()
|
|
234
|
+
const endDate = options.endDate ?? getDefaultEndDate()
|
|
235
|
+
|
|
236
|
+
const _configService = yield* ConfigService
|
|
237
|
+
const apiService = yield* GerritApiService
|
|
238
|
+
|
|
239
|
+
const isMachineReadable =
|
|
240
|
+
options.json || options.xml || options.markdown || options.csv || options.output
|
|
241
|
+
if (!isMachineReadable) {
|
|
242
|
+
yield* Console.log(chalk.dim(`Fetching merged changes from ${startDate} to ${endDate}...`))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const changes = yield* apiService.fetchMergedChanges({
|
|
246
|
+
after: startDate,
|
|
247
|
+
before: endDate,
|
|
248
|
+
repo: options.repo,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const result: AnalyticsResult = {
|
|
252
|
+
totalMerged: changes.length,
|
|
253
|
+
dateRange: { start: startDate, end: endDate },
|
|
254
|
+
byRepo: aggregateByRepo(changes),
|
|
255
|
+
byAuthor: aggregateByAuthor(changes),
|
|
256
|
+
timeline: aggregateByMonth(changes),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let output: string
|
|
260
|
+
|
|
261
|
+
if (options.json) {
|
|
262
|
+
output = JSON.stringify(result, null, 2)
|
|
263
|
+
} else if (options.xml) {
|
|
264
|
+
output = renderXml(result)
|
|
265
|
+
} else if (options.markdown) {
|
|
266
|
+
output = renderMarkdown(result)
|
|
267
|
+
} else if (options.csv) {
|
|
268
|
+
output = renderCsv(result)
|
|
269
|
+
} else {
|
|
270
|
+
output = renderTerminal(result)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (options.output) {
|
|
274
|
+
const outputPath = options.output
|
|
275
|
+
yield* Effect.try({
|
|
276
|
+
try: () => writeFileSync(outputPath, output, 'utf8'),
|
|
277
|
+
catch: (e) =>
|
|
278
|
+
new Error(`Failed to write output file: ${e instanceof Error ? e.message : String(e)}`),
|
|
279
|
+
})
|
|
280
|
+
yield* Console.log(chalk.green(`✓ Output written to ${options.output}`))
|
|
281
|
+
} else {
|
|
282
|
+
yield* Console.log(output)
|
|
283
|
+
}
|
|
284
|
+
})
|