@aaronshaf/ger 3.0.2 → 4.0.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "3.0.2",
3
+ "version": "4.0.0",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
@@ -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 interface GerritApiServiceImpl {
28
- readonly getChange: (changeId: string) => Effect.Effect<ChangeInfo, ApiError>
29
- readonly listChanges: (query?: string) => Effect.Effect<readonly ChangeInfo[], ApiError>
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 = (changeId: string, options?: { base?: string }) =>
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 = options?.base ? { base: options.base } : {}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
+ })