@aaronshaf/ger 1.2.11 → 2.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.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -196
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,153 @@
1
+ import type { CommentInfo } from '@/schemas/gerrit'
2
+ import { colors, formatDate } from './formatters'
3
+
4
+ export interface CommentWithContext {
5
+ comment: CommentInfo
6
+ context?: {
7
+ before: string[]
8
+ line?: string
9
+ after: string[]
10
+ }
11
+ }
12
+
13
+ export const formatCommentsPretty = (comments: CommentWithContext[]): void => {
14
+ if (comments.length === 0) {
15
+ console.log('No comments found on this change')
16
+ return
17
+ }
18
+
19
+ console.log(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:\n`)
20
+
21
+ let currentPath: string | undefined
22
+
23
+ for (const { comment, context } of comments) {
24
+ // Group by file
25
+ if (comment.path !== currentPath) {
26
+ currentPath = comment.path
27
+ console.log(`${colors.blue}═══ ${currentPath} ═══${colors.reset}`)
28
+ }
29
+
30
+ // Comment metadata
31
+ const author = comment.author?.name || 'Unknown'
32
+ const date = comment.updated ? formatDate(comment.updated) : ''
33
+ const status = comment.unresolved ? `${colors.yellow}[UNRESOLVED]${colors.reset} ` : ''
34
+
35
+ console.log(`\n${status}${colors.dim}${author} • ${date}${colors.reset}`)
36
+
37
+ if (comment.line) {
38
+ console.log(`${colors.dim}Line ${comment.line}:${colors.reset}`)
39
+
40
+ // Show context if available
41
+ if (context && (context.before.length > 0 || context.line || context.after.length > 0)) {
42
+ console.log(`${colors.dim}───────────────────${colors.reset}`)
43
+ for (const line of context.before) {
44
+ console.log(`${colors.dim} ${line}${colors.reset}`)
45
+ }
46
+ if (context.line) {
47
+ console.log(`${colors.green}> ${context.line}${colors.reset}`)
48
+ }
49
+ for (const line of context.after) {
50
+ console.log(`${colors.dim} ${line}${colors.reset}`)
51
+ }
52
+ console.log(`${colors.dim}───────────────────${colors.reset}`)
53
+ }
54
+ }
55
+
56
+ // Comment message (indent each line)
57
+ const messageLines = comment.message.split('\n')
58
+ for (const line of messageLines) {
59
+ console.log(` ${line}`)
60
+ }
61
+ }
62
+ }
63
+
64
+ // Escape special XML characters to prevent XSS
65
+ const escapeXml = (str: string): string => {
66
+ return str
67
+ .replace(/&/g, '&')
68
+ .replace(/</g, '&lt;')
69
+ .replace(/>/g, '&gt;')
70
+ .replace(/"/g, '&quot;')
71
+ .replace(/'/g, '&apos;')
72
+ }
73
+
74
+ export const formatCommentsXml = (changeId: string, comments: CommentWithContext[]): void => {
75
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
76
+ console.log(`<comments_result>`)
77
+ console.log(` <change_id>${escapeXml(changeId)}</change_id>`)
78
+ console.log(` <comment_count>${comments.length}</comment_count>`)
79
+ console.log(` <comments>`)
80
+
81
+ for (const { comment, context } of comments) {
82
+ console.log(` <comment>`)
83
+ console.log(` <id>${escapeXml(comment.id)}</id>`)
84
+ if (comment.path) {
85
+ console.log(` <path><![CDATA[${comment.path}]]></path>`)
86
+ }
87
+ if (comment.line) {
88
+ console.log(` <line>${comment.line}</line>`)
89
+ }
90
+ if (comment.range) {
91
+ console.log(` <range>`)
92
+ console.log(` <start_line>${comment.range.start_line}</start_line>`)
93
+ console.log(` <end_line>${comment.range.end_line}</end_line>`)
94
+ if (comment.range.start_character !== undefined) {
95
+ console.log(` <start_character>${comment.range.start_character}</start_character>`)
96
+ }
97
+ if (comment.range.end_character !== undefined) {
98
+ console.log(` <end_character>${comment.range.end_character}</end_character>`)
99
+ }
100
+ console.log(` </range>`)
101
+ }
102
+ if (comment.author) {
103
+ console.log(` <author>`)
104
+ if (comment.author.name) {
105
+ console.log(` <name><![CDATA[${comment.author.name}]]></name>`)
106
+ }
107
+ if (comment.author.email) {
108
+ console.log(` <email>${escapeXml(comment.author.email)}</email>`)
109
+ }
110
+ if (comment.author._account_id !== undefined) {
111
+ console.log(` <account_id>${comment.author._account_id}</account_id>`)
112
+ }
113
+ console.log(` </author>`)
114
+ }
115
+ if (comment.updated) {
116
+ console.log(` <updated>${escapeXml(comment.updated)}</updated>`)
117
+ }
118
+ if (comment.unresolved !== undefined) {
119
+ console.log(` <unresolved>${comment.unresolved}</unresolved>`)
120
+ }
121
+ if (comment.in_reply_to) {
122
+ console.log(` <in_reply_to>${escapeXml(comment.in_reply_to)}</in_reply_to>`)
123
+ }
124
+ console.log(` <message><![CDATA[${comment.message}]]></message>`)
125
+
126
+ if (context && (context.before.length > 0 || context.line || context.after.length > 0)) {
127
+ console.log(` <diff_context>`)
128
+ if (context.before.length > 0) {
129
+ console.log(` <before>`)
130
+ for (const line of context.before) {
131
+ console.log(` <line><![CDATA[${line}]]></line>`)
132
+ }
133
+ console.log(` </before>`)
134
+ }
135
+ if (context.line) {
136
+ console.log(` <target_line><![CDATA[${context.line}]]></target_line>`)
137
+ }
138
+ if (context.after.length > 0) {
139
+ console.log(` <after>`)
140
+ for (const line of context.after) {
141
+ console.log(` <line><![CDATA[${line}]]></line>`)
142
+ }
143
+ console.log(` </after>`)
144
+ }
145
+ console.log(` </diff_context>`)
146
+ }
147
+
148
+ console.log(` </comment>`)
149
+ }
150
+
151
+ console.log(` </comments>`)
152
+ console.log(`</comments_result>`)
153
+ }
@@ -0,0 +1,103 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import type { FileDiffContent } from '@/schemas/gerrit'
4
+
5
+ export interface DiffContext {
6
+ before: string[]
7
+ line?: string
8
+ after: string[]
9
+ }
10
+
11
+ /**
12
+ * Extracts context around a specific line number from a diff.
13
+ * This is a more accurate implementation that properly tracks line numbers
14
+ * across different diff sections.
15
+ */
16
+ export const extractDiffContext = (
17
+ diff: FileDiffContent,
18
+ targetLine: number,
19
+ contextLines: number = 2,
20
+ ): DiffContext => {
21
+ const context: DiffContext = {
22
+ before: [],
23
+ after: [],
24
+ }
25
+
26
+ let currentNewLine = 1
27
+ const _foundTarget = false
28
+ const collectedLines: Array<{ line: string; lineNum: number; type: 'context' | 'added' }> = []
29
+
30
+ for (const section of diff.content) {
31
+ // Context lines (present in both old and new)
32
+ if (section.ab) {
33
+ for (const line of section.ab) {
34
+ collectedLines.push({ line, lineNum: currentNewLine, type: 'context' })
35
+ currentNewLine++
36
+ }
37
+ }
38
+
39
+ // Added lines (only in new file)
40
+ if (section.b) {
41
+ for (const line of section.b) {
42
+ collectedLines.push({ line, lineNum: currentNewLine, type: 'added' })
43
+ currentNewLine++
44
+ }
45
+ }
46
+
47
+ // Skip lines (large unchanged sections)
48
+ if (section.skip) {
49
+ // If target is in skipped section, we can't show context
50
+ if (currentNewLine <= targetLine && targetLine < currentNewLine + section.skip) {
51
+ return context // Return empty context
52
+ }
53
+ currentNewLine += section.skip
54
+ }
55
+
56
+ // Removed lines don't affect new file line numbers
57
+ // section.a is ignored for line counting
58
+ }
59
+
60
+ // Find the target line and extract context
61
+ const targetIndex = collectedLines.findIndex((item) => item.lineNum === targetLine)
62
+ if (targetIndex !== -1) {
63
+ // Get before context
64
+ for (let i = Math.max(0, targetIndex - contextLines); i < targetIndex; i++) {
65
+ context.before.push(collectedLines[i].line)
66
+ }
67
+
68
+ // Get target line
69
+ context.line = collectedLines[targetIndex].line
70
+
71
+ // Get after context
72
+ for (
73
+ let i = targetIndex + 1;
74
+ i < Math.min(collectedLines.length, targetIndex + contextLines + 1);
75
+ i++
76
+ ) {
77
+ context.after.push(collectedLines[i].line)
78
+ }
79
+ }
80
+
81
+ return context
82
+ }
83
+
84
+ export const getDiffContext = (
85
+ changeId: string,
86
+ path: string,
87
+ line?: number,
88
+ ): Effect.Effect<DiffContext, ApiError, GerritApiService> =>
89
+ Effect.gen(function* () {
90
+ if (!line || path === 'Commit Message' || path === '/COMMIT_MSG') {
91
+ return { before: [], after: [] }
92
+ }
93
+
94
+ const gerritApi = yield* GerritApiService
95
+
96
+ try {
97
+ const diff = yield* gerritApi.getFileDiff(changeId, path)
98
+ return extractDiffContext(diff, line)
99
+ } catch {
100
+ // Return empty context on error
101
+ return { before: [], after: [] }
102
+ }
103
+ })
@@ -0,0 +1,141 @@
1
+ import { colors } from './formatters'
2
+
3
+ interface DiffStats {
4
+ additions: number
5
+ deletions: number
6
+ files: number
7
+ }
8
+
9
+ /**
10
+ * Format a unified diff for pretty human-readable output
11
+ */
12
+ export const formatDiffPretty = (diffContent: string): string => {
13
+ if (!diffContent || typeof diffContent !== 'string') {
14
+ const emptyStats = { additions: 0, deletions: 0, files: 0 }
15
+ return formatDiffSummary(emptyStats) + '\n\n' + 'No diff content available'
16
+ }
17
+
18
+ const lines = diffContent.split('\n')
19
+ const formattedLines: string[] = []
20
+ let stats: DiffStats = { additions: 0, deletions: 0, files: 0 }
21
+
22
+ for (const line of lines) {
23
+ if (line.startsWith('diff --git')) {
24
+ stats.files++
25
+ // File header with colors
26
+ formattedLines.push(`${colors.bold}${colors.blue}${line}${colors.reset}`)
27
+ } else if (line.startsWith('index ')) {
28
+ // Index line
29
+ formattedLines.push(`${colors.dim}${line}${colors.reset}`)
30
+ } else if (line.startsWith('---')) {
31
+ // Old file marker
32
+ formattedLines.push(`${colors.red}${line}${colors.reset}`)
33
+ } else if (line.startsWith('+++')) {
34
+ // New file marker
35
+ formattedLines.push(`${colors.green}${line}${colors.reset}`)
36
+ } else if (line.startsWith('@@')) {
37
+ // Hunk header
38
+ formattedLines.push(`${colors.cyan}${line}${colors.reset}`)
39
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
40
+ // Added lines
41
+ stats.additions++
42
+ formattedLines.push(`${colors.green}${line}${colors.reset}`)
43
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
44
+ // Removed lines
45
+ stats.deletions++
46
+ formattedLines.push(`${colors.red}${line}${colors.reset}`)
47
+ } else if (line.startsWith(' ')) {
48
+ // Context lines
49
+ formattedLines.push(`${colors.dim}${line}${colors.reset}`)
50
+ } else {
51
+ // Other lines (usually empty or metadata)
52
+ formattedLines.push(line)
53
+ }
54
+ }
55
+
56
+ // Add summary at the top
57
+ const summary = formatDiffSummary(stats)
58
+
59
+ return summary + '\n\n' + formattedLines.join('\n')
60
+ }
61
+
62
+ /**
63
+ * Format diff summary statistics
64
+ */
65
+ export const formatDiffSummary = (stats: DiffStats): string => {
66
+ const { additions, deletions, files } = stats
67
+ const total = additions + deletions
68
+
69
+ let summary = `${colors.bold}Changes summary:${colors.reset} `
70
+
71
+ if (files > 0) {
72
+ summary += `${files} file${files !== 1 ? 's' : ''} changed`
73
+ }
74
+
75
+ if (additions > 0 || deletions > 0) {
76
+ if (files > 0) summary += ', '
77
+
78
+ if (additions > 0) {
79
+ summary += `${colors.green}+${additions} addition${additions !== 1 ? 's' : ''}${colors.reset}`
80
+ }
81
+
82
+ if (additions > 0 && deletions > 0) {
83
+ summary += ', '
84
+ }
85
+
86
+ if (deletions > 0) {
87
+ summary += `${colors.red}-${deletions} deletion${deletions !== 1 ? 's' : ''}${colors.reset}`
88
+ }
89
+ }
90
+
91
+ if (total === 0 && files === 0) {
92
+ summary += 'No changes detected'
93
+ }
94
+
95
+ return summary
96
+ }
97
+
98
+ /**
99
+ * Format a list of changed files for pretty output
100
+ */
101
+ export const formatFilesList = (files: string[]): string => {
102
+ if (!files || files.length === 0) {
103
+ return 'No files changed'
104
+ }
105
+
106
+ const header = `${colors.bold}Changed files (${files.length}):${colors.reset}\n`
107
+ const fileList = files
108
+ .map((file) => {
109
+ // Simple file status indicators - we could enhance this if we had status info
110
+ return ` ${colors.blue}•${colors.reset} ${file}`
111
+ })
112
+ .join('\n')
113
+
114
+ return header + fileList
115
+ }
116
+
117
+ /**
118
+ * Extract diff statistics from unified diff content
119
+ */
120
+ export const extractDiffStats = (diffContent: string): DiffStats => {
121
+ if (!diffContent || typeof diffContent !== 'string') {
122
+ return { additions: 0, deletions: 0, files: 0 }
123
+ }
124
+
125
+ const lines = diffContent.split('\n')
126
+ let additions = 0
127
+ let deletions = 0
128
+ let files = 0
129
+
130
+ for (const line of lines) {
131
+ if (line.startsWith('diff --git')) {
132
+ files++
133
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
134
+ additions++
135
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
136
+ deletions++
137
+ }
138
+ }
139
+
140
+ return { additions, deletions, files }
141
+ }
@@ -0,0 +1,85 @@
1
+ import type { ChangeInfo } from '@/schemas/gerrit'
2
+
3
+ export const formatDate = (dateStr: string): string => {
4
+ const date = new Date(dateStr)
5
+ const now = new Date()
6
+
7
+ // Check if today
8
+ if (date.toDateString() === now.toDateString()) {
9
+ return date.toLocaleTimeString('en-US', {
10
+ hour: 'numeric',
11
+ minute: '2-digit',
12
+ hour12: true,
13
+ })
14
+ }
15
+
16
+ // Check if this year
17
+ if (date.getFullYear() === now.getFullYear()) {
18
+ return date.toLocaleDateString('en-US', {
19
+ month: 'short',
20
+ day: '2-digit',
21
+ })
22
+ }
23
+
24
+ // Otherwise show full date
25
+ return date.toLocaleDateString('en-US', {
26
+ month: 'short',
27
+ day: '2-digit',
28
+ year: 'numeric',
29
+ })
30
+ }
31
+
32
+ export const getStatusIndicator = (change: ChangeInfo): string => {
33
+ const indicators: string[] = []
34
+
35
+ // Check for labels only if they exist
36
+ if (change.labels) {
37
+ // Check for Code-Review
38
+ if (change.labels['Code-Review']) {
39
+ const cr = change.labels['Code-Review']
40
+ if (cr.approved || cr.value === 2) {
41
+ indicators.push(`${colors.green}✓${colors.reset}`)
42
+ } else if (cr.rejected || cr.value === -2) {
43
+ indicators.push(`${colors.red}✗${colors.reset}`)
44
+ } else if (cr.recommended || cr.value === 1) {
45
+ indicators.push(`${colors.cyan}↑${colors.reset}`)
46
+ } else if (cr.disliked || cr.value === -1) {
47
+ indicators.push(`${colors.yellow}↓${colors.reset}`)
48
+ }
49
+ }
50
+
51
+ // Check for Verified
52
+ if (change.labels.Verified) {
53
+ const v = change.labels.Verified
54
+ if (v.approved || v.value === 1) {
55
+ indicators.push(`${colors.green}✓${colors.reset}`)
56
+ } else if (v.rejected || v.value === -1) {
57
+ indicators.push(`${colors.red}✗${colors.reset}`)
58
+ }
59
+ }
60
+ }
61
+
62
+ // Check if submittable (regardless of labels)
63
+ if (change.submittable) {
64
+ indicators.push('🚀')
65
+ }
66
+
67
+ // Check if WIP (regardless of labels)
68
+ if (change.work_in_progress) {
69
+ indicators.push('🚧')
70
+ }
71
+
72
+ return indicators.length > 0 ? indicators.join(' ') : '' // Double space for proper alignment
73
+ }
74
+
75
+ export const colors = {
76
+ green: '\x1b[32m',
77
+ yellow: '\x1b[33m',
78
+ red: '\x1b[31m',
79
+ blue: '\x1b[34m',
80
+ cyan: '\x1b[36m',
81
+ gray: '\x1b[90m',
82
+ reset: '\x1b[0m',
83
+ bold: '\x1b[1m',
84
+ dim: '\x1b[2m',
85
+ }