@aaronshaf/ger 1.2.10 → 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 -180
  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,117 @@
1
+ import { Effect } from 'effect'
2
+
3
+ /**
4
+ * Safely sanitizes URLs to prevent shell injection attacks
5
+ * Only allows HTTPS URLs with valid characters
6
+ */
7
+ export const sanitizeUrl = (url: string): Effect.Effect<string, Error> =>
8
+ Effect.try({
9
+ try: () => {
10
+ const parsed = new URL(url)
11
+
12
+ // Only allow https protocol
13
+ if (parsed.protocol !== 'https:') {
14
+ throw new Error(`Invalid protocol: ${parsed.protocol}. Only HTTPS is allowed.`)
15
+ }
16
+
17
+ // Check for suspicious characters that could be shell injection
18
+ const dangerousChars = /[;&|`$(){}[\]\\'"<>]/
19
+ if (dangerousChars.test(url)) {
20
+ throw new Error('URL contains potentially dangerous characters')
21
+ }
22
+
23
+ // Validate hostname format
24
+ if (!parsed.hostname || parsed.hostname.length === 0) {
25
+ throw new Error('Invalid hostname')
26
+ }
27
+
28
+ // Return the sanitized URL
29
+ return parsed.toString()
30
+ },
31
+ catch: (error) =>
32
+ new Error(`Invalid URL format: ${error instanceof Error ? error.message : 'Unknown error'}`),
33
+ })
34
+
35
+ /**
36
+ * Synchronous URL sanitization for non-Effect contexts
37
+ * Throws error if URL is invalid or unsafe
38
+ */
39
+ export const sanitizeUrlSync = (url: string): string => {
40
+ try {
41
+ const parsed = new URL(url)
42
+
43
+ // Only allow https protocol
44
+ if (parsed.protocol !== 'https:') {
45
+ throw new Error(`Invalid protocol: ${parsed.protocol}. Only HTTPS is allowed.`)
46
+ }
47
+
48
+ // Check for suspicious characters that could be shell injection
49
+ const dangerousChars = /[;&|`$(){}[\]\\'"<>]/
50
+ if (dangerousChars.test(url)) {
51
+ throw new Error('URL contains potentially dangerous characters')
52
+ }
53
+
54
+ // Validate hostname format
55
+ if (!parsed.hostname || parsed.hostname.length === 0) {
56
+ throw new Error('Invalid hostname')
57
+ }
58
+
59
+ return parsed.toString()
60
+ } catch (error) {
61
+ throw new Error(
62
+ `Invalid URL format: ${error instanceof Error ? error.message : 'Unknown error'}`,
63
+ )
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Gets the appropriate command to open URLs based on platform
69
+ */
70
+ export const getOpenCommand = (): string => {
71
+ switch (process.platform) {
72
+ case 'darwin':
73
+ return 'open'
74
+ case 'win32':
75
+ return 'start'
76
+ default:
77
+ return 'xdg-open'
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Safely sanitizes content for inclusion in XML CDATA sections
83
+ * Prevents XXE attacks and CDATA injection
84
+ */
85
+ export const sanitizeCDATA = (content: string): string => {
86
+ if (typeof content !== 'string') {
87
+ throw new Error('Content must be a string')
88
+ }
89
+
90
+ // Replace CDATA end sequences to prevent CDATA injection
91
+ let sanitized = content.replace(/]]>/g, ']]&gt;')
92
+
93
+ // Replace null bytes which can cause issues in XML processing
94
+ sanitized = sanitized.replace(/\0/g, '')
95
+
96
+ // Replace control characters except for allowed ones (tab \x09, newline \x0A, carriage return \x0D)
97
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
98
+
99
+ return sanitized
100
+ }
101
+
102
+ /**
103
+ * Safely escapes content for XML element values
104
+ * Escapes XML special characters
105
+ */
106
+ export const escapeXML = (content: string): string => {
107
+ if (typeof content !== 'string') {
108
+ throw new Error('Content must be a string')
109
+ }
110
+
111
+ return content
112
+ .replace(/&/g, '&amp;')
113
+ .replace(/</g, '&lt;')
114
+ .replace(/>/g, '&gt;')
115
+ .replace(/"/g, '&quot;')
116
+ .replace(/'/g, '&apos;')
117
+ }
@@ -0,0 +1,100 @@
1
+ import type { ChangeInfo } from '@/schemas/gerrit'
2
+
3
+ /**
4
+ * Status indicator configuration
5
+ */
6
+ export interface StatusIndicatorConfig {
7
+ approved: string
8
+ rejected: string
9
+ recommended: string
10
+ disliked: string
11
+ verified: string
12
+ failed: string
13
+ empty: string
14
+ }
15
+
16
+ /**
17
+ * Default status indicators using emoji
18
+ */
19
+ export const DEFAULT_STATUS_INDICATORS: StatusIndicatorConfig = {
20
+ approved: '✓',
21
+ rejected: '✗',
22
+ recommended: '↑',
23
+ disliked: '↓',
24
+ verified: '✓',
25
+ failed: '✗',
26
+ empty: ' ',
27
+ }
28
+
29
+ /**
30
+ * Gets status indicators for a change based on its labels
31
+ */
32
+ export const getStatusIndicators = (
33
+ change: ChangeInfo,
34
+ config: StatusIndicatorConfig = DEFAULT_STATUS_INDICATORS,
35
+ ): string[] => {
36
+ const indicators: string[] = []
37
+
38
+ // Check Code-Review label
39
+ if (change.labels?.['Code-Review']) {
40
+ const cr = change.labels['Code-Review']
41
+ if (cr.approved || cr.value === 2) {
42
+ indicators.push(config.approved)
43
+ } else if (cr.rejected || cr.value === -2) {
44
+ indicators.push(config.rejected)
45
+ } else if (cr.recommended || cr.value === 1) {
46
+ indicators.push(config.recommended)
47
+ } else if (cr.disliked || cr.value === -1) {
48
+ indicators.push(config.disliked)
49
+ }
50
+ }
51
+
52
+ // Check Verified label
53
+ if (change.labels?.['Verified']) {
54
+ const v = change.labels.Verified
55
+ if (v.approved || v.value === 1) {
56
+ // Only add verified indicator if not already approved
57
+ if (!indicators.includes(config.approved)) {
58
+ indicators.push(config.verified)
59
+ }
60
+ } else if (v.rejected || v.value === -1) {
61
+ indicators.push(config.failed)
62
+ }
63
+ }
64
+
65
+ return indicators
66
+ }
67
+
68
+ /**
69
+ * Gets a formatted status string with consistent padding
70
+ */
71
+ export const getStatusString = (
72
+ change: ChangeInfo,
73
+ config: StatusIndicatorConfig = DEFAULT_STATUS_INDICATORS,
74
+ padding = 8,
75
+ ): string => {
76
+ const indicators = getStatusIndicators(change, config)
77
+ const statusStr = indicators.length > 0 ? indicators.join(' ') : config.empty
78
+ return statusStr.padEnd(padding, ' ')
79
+ }
80
+
81
+ /**
82
+ * Gets label value with proper type safety
83
+ */
84
+ export const getLabelValue = (labelInfo: unknown): number => {
85
+ return typeof labelInfo === 'object' &&
86
+ labelInfo !== null &&
87
+ 'value' in labelInfo &&
88
+ typeof labelInfo.value === 'number'
89
+ ? labelInfo.value
90
+ : 0
91
+ }
92
+
93
+ /**
94
+ * Gets label color based on value
95
+ */
96
+ export const getLabelColor = (value: number): 'green' | 'red' | 'yellow' => {
97
+ if (value > 0) return 'green'
98
+ if (value < 0) return 'red'
99
+ return 'yellow'
100
+ }
@@ -0,0 +1,271 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { extractChangeNumber, isValidChangeId, normalizeGerritHost } from './url-parser'
3
+
4
+ describe('extractChangeNumber', () => {
5
+ test('extracts change number from standard Gerrit URL', () => {
6
+ const url = 'https://gerrit.example.com/c/my-project/+/384571'
7
+ expect(extractChangeNumber(url)).toBe('384571')
8
+ })
9
+
10
+ test('extracts change number from URL with trailing slash', () => {
11
+ const url = 'https://gerrit.example.com/c/my-project/+/384571/'
12
+ expect(extractChangeNumber(url)).toBe('384571')
13
+ })
14
+
15
+ test('extracts change number from URL with patchset', () => {
16
+ const url = 'https://gerrit.example.com/c/my-project/+/384571/2'
17
+ expect(extractChangeNumber(url)).toBe('384571')
18
+ })
19
+
20
+ test('extracts change number from hash-based URL', () => {
21
+ const url = 'https://gerrit.example.com/#/c/my-project/+/384571/'
22
+ expect(extractChangeNumber(url)).toBe('384571')
23
+ })
24
+
25
+ test('extracts change number from simplified URL format', () => {
26
+ const url = 'https://gerrit.example.com/c/+/384571'
27
+ expect(extractChangeNumber(url)).toBe('384571')
28
+ })
29
+
30
+ test('extracts change number from hash-based simplified URL', () => {
31
+ const url = 'https://gerrit.example.com/#/c/+/384571'
32
+ expect(extractChangeNumber(url)).toBe('384571')
33
+ })
34
+
35
+ test('returns plain change number as-is', () => {
36
+ expect(extractChangeNumber('384571')).toBe('384571')
37
+ })
38
+
39
+ test('returns Change-Id format as-is', () => {
40
+ const changeId = 'Iabcdef1234567890abcdef1234567890abcdef12'
41
+ expect(extractChangeNumber(changeId)).toBe(changeId)
42
+ })
43
+
44
+ test('returns original input for invalid URLs', () => {
45
+ const invalidUrl = 'https://gerrit.example.com/invalid/path'
46
+ expect(extractChangeNumber(invalidUrl)).toBe(invalidUrl)
47
+ })
48
+
49
+ test('handles malformed URLs gracefully', () => {
50
+ const malformed = 'not-a-url-at-all'
51
+ expect(extractChangeNumber(malformed)).toBe(malformed)
52
+ })
53
+
54
+ test('handles http:// URLs', () => {
55
+ const httpUrl = 'http://gerrit.example.com/c/project/+/123456'
56
+ expect(extractChangeNumber(httpUrl)).toBe('123456')
57
+ })
58
+
59
+ test('handles malformed https URLs that throw in URL constructor', () => {
60
+ const malformed = 'https://[invalid-url'
61
+ expect(extractChangeNumber(malformed)).toBe(malformed)
62
+ })
63
+
64
+ test('handles empty string', () => {
65
+ expect(extractChangeNumber('')).toBe('')
66
+ })
67
+
68
+ test('handles whitespace', () => {
69
+ expect(extractChangeNumber(' 384571 ')).toBe('384571')
70
+ })
71
+ })
72
+
73
+ describe('isValidChangeId', () => {
74
+ test('validates numeric change IDs', () => {
75
+ expect(isValidChangeId('384571')).toBe(true)
76
+ expect(isValidChangeId('1')).toBe(true)
77
+ expect(isValidChangeId('999999')).toBe(true)
78
+ })
79
+
80
+ test('rejects zero and negative numbers', () => {
81
+ expect(isValidChangeId('0')).toBe(false)
82
+ expect(isValidChangeId('-1')).toBe(false)
83
+ })
84
+
85
+ test('validates Change-Id format', () => {
86
+ const validChangeId = 'Iabcdef1234567890abcdef1234567890abcdef12'
87
+ expect(isValidChangeId(validChangeId)).toBe(true)
88
+ })
89
+
90
+ test('rejects invalid Change-Id format', () => {
91
+ // Only reject if it doesn't follow the strict Change-Id format when it starts with 'I' and is long
92
+ expect(isValidChangeId('abcdef1234567890abcdef1234567890abcdef12')).toBe(true) // valid topic name
93
+ expect(isValidChangeId('Iabc')).toBe(true) // could be a valid topic or branch name
94
+ })
95
+
96
+ test('validates other identifier formats', () => {
97
+ expect(isValidChangeId('topic-branch')).toBe(true)
98
+ expect(isValidChangeId('feature/new-thing')).toBe(true)
99
+ })
100
+
101
+ test('rejects empty and whitespace-only strings', () => {
102
+ expect(isValidChangeId('')).toBe(false)
103
+ expect(isValidChangeId(' ')).toBe(false)
104
+ expect(isValidChangeId('has spaces')).toBe(false)
105
+ })
106
+
107
+ test('handles exact Change-Id format validation', () => {
108
+ // Valid Change-Id: starts with 'I' and exactly 40 hex chars
109
+ expect(isValidChangeId('I1234567890abcdef1234567890abcdef12345678')).toBe(true)
110
+
111
+ // Invalid: wrong length
112
+ expect(isValidChangeId('I123')).toBe(true) // this is treated as a valid topic name
113
+ expect(isValidChangeId('I1234567890abcdef1234567890abcdef123456789')).toBe(true) // too long, treated as topic
114
+
115
+ // Invalid: non-hex characters
116
+ expect(isValidChangeId('I1234567890abcdef1234567890abcdef1234567g')).toBe(true) // treated as topic name
117
+ })
118
+
119
+ test('rejects strings starting with dash', () => {
120
+ expect(isValidChangeId('-123')).toBe(false)
121
+ expect(isValidChangeId('-abc')).toBe(false)
122
+ })
123
+ })
124
+
125
+ describe('normalizeGerritHost', () => {
126
+ describe('adding protocol', () => {
127
+ test('adds https:// when no protocol is provided', () => {
128
+ expect(normalizeGerritHost('gerrit.example.com')).toBe('https://gerrit.example.com')
129
+ })
130
+
131
+ test('adds https:// to hostname with port', () => {
132
+ expect(normalizeGerritHost('gerrit.example.com:8080')).toBe('https://gerrit.example.com:8080')
133
+ })
134
+
135
+ test('adds https:// to localhost', () => {
136
+ expect(normalizeGerritHost('localhost:8080')).toBe('https://localhost:8080')
137
+ })
138
+
139
+ test('adds https:// to IP address', () => {
140
+ expect(normalizeGerritHost('192.168.1.100')).toBe('https://192.168.1.100')
141
+ })
142
+
143
+ test('adds https:// to IP address with port', () => {
144
+ expect(normalizeGerritHost('192.168.1.100:8080')).toBe('https://192.168.1.100:8080')
145
+ })
146
+ })
147
+
148
+ describe('preserving existing protocol', () => {
149
+ test('preserves https:// when already present', () => {
150
+ expect(normalizeGerritHost('https://gerrit.example.com')).toBe('https://gerrit.example.com')
151
+ })
152
+
153
+ test('preserves http:// when explicitly provided', () => {
154
+ expect(normalizeGerritHost('http://gerrit.example.com')).toBe('http://gerrit.example.com')
155
+ })
156
+
157
+ test('preserves https:// with port', () => {
158
+ expect(normalizeGerritHost('https://gerrit.example.com:8080')).toBe(
159
+ 'https://gerrit.example.com:8080',
160
+ )
161
+ })
162
+
163
+ test('preserves http:// with port', () => {
164
+ expect(normalizeGerritHost('http://gerrit.example.com:8080')).toBe(
165
+ 'http://gerrit.example.com:8080',
166
+ )
167
+ })
168
+ })
169
+
170
+ describe('removing trailing slashes', () => {
171
+ test('removes single trailing slash', () => {
172
+ expect(normalizeGerritHost('https://gerrit.example.com/')).toBe('https://gerrit.example.com')
173
+ })
174
+
175
+ test('removes trailing slash from URL without protocol', () => {
176
+ expect(normalizeGerritHost('gerrit.example.com/')).toBe('https://gerrit.example.com')
177
+ })
178
+
179
+ test('removes trailing slash from URL with port', () => {
180
+ expect(normalizeGerritHost('https://gerrit.example.com:8080/')).toBe(
181
+ 'https://gerrit.example.com:8080',
182
+ )
183
+ })
184
+
185
+ test('handles URL without trailing slash', () => {
186
+ expect(normalizeGerritHost('https://gerrit.example.com')).toBe('https://gerrit.example.com')
187
+ })
188
+
189
+ test('does not remove slash from path', () => {
190
+ expect(normalizeGerritHost('https://gerrit.example.com/gerrit')).toBe(
191
+ 'https://gerrit.example.com/gerrit',
192
+ )
193
+ })
194
+
195
+ test('removes trailing slash from path', () => {
196
+ expect(normalizeGerritHost('https://gerrit.example.com/gerrit/')).toBe(
197
+ 'https://gerrit.example.com/gerrit',
198
+ )
199
+ })
200
+ })
201
+
202
+ describe('whitespace handling', () => {
203
+ test('trims leading whitespace', () => {
204
+ expect(normalizeGerritHost(' gerrit.example.com')).toBe('https://gerrit.example.com')
205
+ })
206
+
207
+ test('trims trailing whitespace', () => {
208
+ expect(normalizeGerritHost('gerrit.example.com ')).toBe('https://gerrit.example.com')
209
+ })
210
+
211
+ test('trims whitespace from URL with protocol', () => {
212
+ expect(normalizeGerritHost(' https://gerrit.example.com ')).toBe(
213
+ 'https://gerrit.example.com',
214
+ )
215
+ })
216
+
217
+ test('trims whitespace and removes trailing slash', () => {
218
+ expect(normalizeGerritHost(' gerrit.example.com/ ')).toBe('https://gerrit.example.com')
219
+ })
220
+ })
221
+
222
+ describe('combined scenarios', () => {
223
+ test('adds protocol and removes trailing slash', () => {
224
+ expect(normalizeGerritHost('gerrit.example.com/')).toBe('https://gerrit.example.com')
225
+ })
226
+
227
+ test('trims, adds protocol, and removes trailing slash', () => {
228
+ expect(normalizeGerritHost(' gerrit.example.com/ ')).toBe('https://gerrit.example.com')
229
+ })
230
+
231
+ test('handles subdomain with port', () => {
232
+ expect(normalizeGerritHost('review.git.example.com:8443')).toBe(
233
+ 'https://review.git.example.com:8443',
234
+ )
235
+ })
236
+
237
+ test('handles complex URL with path', () => {
238
+ expect(normalizeGerritHost('gerrit.example.com/gerrit')).toBe(
239
+ 'https://gerrit.example.com/gerrit',
240
+ )
241
+ })
242
+
243
+ test('normalizes complete real-world example', () => {
244
+ expect(normalizeGerritHost('gerrit-review.example.org')).toBe(
245
+ 'https://gerrit-review.example.org',
246
+ )
247
+ })
248
+ })
249
+
250
+ describe('edge cases', () => {
251
+ test('handles empty string', () => {
252
+ // Empty string becomes 'https:/' after normalization (protocol added, then trailing slash removed)
253
+ expect(normalizeGerritHost('')).toBe('https:/')
254
+ })
255
+
256
+ test('handles whitespace-only string', () => {
257
+ // Whitespace-only string becomes 'https:/' after normalization
258
+ expect(normalizeGerritHost(' ')).toBe('https:/')
259
+ })
260
+
261
+ test('handles just a slash', () => {
262
+ // Just a slash becomes 'https://' (protocol added to '/', then trailing slash removed leaving '//')
263
+ expect(normalizeGerritHost('/')).toBe('https://')
264
+ })
265
+
266
+ test('handles protocol only', () => {
267
+ // Protocol only becomes 'https:/' (trailing slash removed)
268
+ expect(normalizeGerritHost('https://')).toBe('https:/')
269
+ })
270
+ })
271
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Utility functions for parsing Gerrit URLs and extracting change numbers
3
+ */
4
+
5
+ /**
6
+ * Extracts a change number from various Gerrit URL formats
7
+ *
8
+ * Supported formats:
9
+ * - https://gerrit.example.com/c/project-name/+/123456
10
+ * - https://gerrit.example.com/c/project-name/+/123456/
11
+ * - https://gerrit.example.com/c/project-name/+/123456/1
12
+ * - https://gerrit.example.com/#/c/project-name/+/123456/
13
+ * - 123456 (plain change number - returned as-is)
14
+ *
15
+ * @param input - The input string (URL or change number)
16
+ * @returns The extracted change number as a string, or the original input if not a URL
17
+ */
18
+ export const extractChangeNumber = (input: string): string => {
19
+ const trimmed = input.trim()
20
+
21
+ // If it's already just a number or change ID (like "123456" or "Iabcd1234..."), return as-is
22
+ if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
23
+ return trimmed
24
+ }
25
+
26
+ // Parse URL and extract change number
27
+ try {
28
+ const url = new URL(trimmed)
29
+
30
+ // Match different Gerrit URL patterns
31
+ // Pattern: /c/project-name/+/123456 or /#/c/project-name/+/123456
32
+ const patterns = [
33
+ /\/c\/[^/]+\/\+\/(\d+)/, // /c/project/+/123456
34
+ /#\/c\/[^/]+\/\+\/(\d+)/, // /#/c/project/+/123456
35
+ /\/c\/\+\/(\d+)/, // /c/+/123456 (simplified format)
36
+ /#\/c\/\+\/(\d+)/, // /#/c/+/123456 (simplified format)
37
+ ]
38
+
39
+ const fullPath = url.pathname + url.hash
40
+
41
+ for (const pattern of patterns) {
42
+ const match = fullPath.match(pattern)
43
+ if (match?.[1]) {
44
+ return match[1]
45
+ }
46
+ }
47
+
48
+ // If no pattern matches, return the original input
49
+ return trimmed
50
+ } catch {
51
+ // If URL parsing fails, return the original input
52
+ return trimmed
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Normalizes a Gerrit host URL by adding https:// if no protocol is provided
58
+ * and removing trailing slashes
59
+ *
60
+ * @param host - The host URL to normalize (e.g., "gerrit.example.com" or "https://gerrit.example.com")
61
+ * @returns The normalized URL with protocol and without trailing slash
62
+ *
63
+ * @example
64
+ * normalizeGerritHost("gerrit.example.com") // returns "https://gerrit.example.com"
65
+ * normalizeGerritHost("gerrit.example.com:8080") // returns "https://gerrit.example.com:8080"
66
+ * normalizeGerritHost("http://gerrit.example.com") // returns "http://gerrit.example.com"
67
+ * normalizeGerritHost("https://gerrit.example.com/") // returns "https://gerrit.example.com"
68
+ */
69
+ export const normalizeGerritHost = (host: string): string => {
70
+ let normalized = host.trim()
71
+
72
+ // Add https:// if no protocol provided
73
+ if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
74
+ normalized = `https://${normalized}`
75
+ }
76
+
77
+ // Remove trailing slash
78
+ normalized = normalized.replace(/\/$/, '')
79
+
80
+ return normalized
81
+ }
82
+
83
+ /**
84
+ * Validates if a string is a valid Gerrit change identifier
85
+ *
86
+ * @param changeId - The change ID to validate
87
+ * @returns true if it looks like a valid change ID
88
+ */
89
+ export const isValidChangeId = (changeId: string): boolean => {
90
+ const trimmed = changeId.trim()
91
+
92
+ if (trimmed.length === 0) {
93
+ return false
94
+ }
95
+
96
+ // Numeric change IDs (most common)
97
+ if (/^\d+$/.test(trimmed)) {
98
+ return parseInt(trimmed, 10) > 0
99
+ }
100
+
101
+ // Change-Id format (starts with 'I' followed by exactly 40 hex characters)
102
+ if (/^I[a-f0-9]{40}$/.test(trimmed)) {
103
+ return true
104
+ }
105
+
106
+ // Reject strings with whitespace
107
+ if (/\s/.test(trimmed)) {
108
+ return false
109
+ }
110
+
111
+ // Reject negative numbers or other invalid formats
112
+ if (trimmed.startsWith('-')) {
113
+ return false
114
+ }
115
+
116
+ // Topic branches or other identifiers (at least 1 character, no whitespace)
117
+ return trimmed.length > 0
118
+ }