@aaronshaf/ger 0.1.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 (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -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 +78 -0
  6. package/.github/workflows/claude.yml +64 -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 +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ import type { MessageInfo } from '@/schemas/gerrit'
2
+
3
+ /**
4
+ * Filters out automated messages and empty messages, keeping meaningful review activity
5
+ */
6
+ export const filterMeaningfulMessages = (messages: readonly MessageInfo[]): MessageInfo[] => {
7
+ return messages.filter((msg) => {
8
+ // Keep messages that have content beyond automated tags
9
+ if (!msg.message || msg.message.trim().length === 0) return false
10
+
11
+ // Skip some automated messages but keep build/review status messages
12
+ if (msg.tag === 'autogenerated:gerrit:newPatchSet') return false
13
+ if (msg.tag === 'autogenerated:gerrit:merged') return false
14
+
15
+ return true
16
+ })
17
+ }
18
+
19
+ /**
20
+ * Sorts messages by date with newest first
21
+ */
22
+ export const sortMessagesByDate = (messages: readonly MessageInfo[]): MessageInfo[] => {
23
+ return [...messages].sort((a, b) => {
24
+ return new Date(b.date).getTime() - new Date(a.date).getTime()
25
+ })
26
+ }
@@ -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,123 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { extractChangeNumber, isValidChangeId } 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
+ })
@@ -0,0 +1,91 @@
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
+ * Validates if a string is a valid Gerrit change identifier
58
+ *
59
+ * @param changeId - The change ID to validate
60
+ * @returns true if it looks like a valid change ID
61
+ */
62
+ export const isValidChangeId = (changeId: string): boolean => {
63
+ const trimmed = changeId.trim()
64
+
65
+ if (trimmed.length === 0) {
66
+ return false
67
+ }
68
+
69
+ // Numeric change IDs (most common)
70
+ if (/^\d+$/.test(trimmed)) {
71
+ return parseInt(trimmed, 10) > 0
72
+ }
73
+
74
+ // Change-Id format (starts with 'I' followed by exactly 40 hex characters)
75
+ if (/^I[a-f0-9]{40}$/.test(trimmed)) {
76
+ return true
77
+ }
78
+
79
+ // Reject strings with whitespace
80
+ if (/\s/.test(trimmed)) {
81
+ return false
82
+ }
83
+
84
+ // Reject negative numbers or other invalid formats
85
+ if (trimmed.startsWith('-')) {
86
+ return false
87
+ }
88
+
89
+ // Topic branches or other identifiers (at least 1 character, no whitespace)
90
+ return trimmed.length > 0
91
+ }