@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.eslintrc.js +12 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +78 -0
- package/.github/workflows/claude.yml +64 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +103 -0
- package/DEVELOPMENT.md +361 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/ger +3 -0
- package/biome.json +36 -0
- package/bun.lock +688 -0
- package/bunfig.toml +8 -0
- package/oxlint.json +24 -0
- package/package.json +55 -0
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/src/api/gerrit.ts +466 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/review.ts +593 -0
- package/src/cli/commands/setup.ts +230 -0
- package/src/cli/commands/show.ts +303 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +420 -0
- package/src/prompts/default-review.md +80 -0
- package/src/prompts/system-inline-review.md +88 -0
- package/src/prompts/system-overall-review.md +152 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +75 -0
- package/src/schemas/gerrit.ts +455 -0
- package/src/services/ai-enhanced.ts +167 -0
- package/src/services/ai.ts +182 -0
- package/src/services/config.test.ts +414 -0
- package/src/services/config.ts +206 -0
- package/src/test-utils/mock-generator.ts +73 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +123 -0
- package/src/utils/url-parser.ts +91 -0
- package/tests/abandon.test.ts +163 -0
- package/tests/ai-service.test.ts +489 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +707 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +318 -0
- package/tests/mocks/fetch-mock.ts +139 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/review.test.ts +669 -0
- package/tests/setup.ts +13 -0
- package/tests/show.test.ts +439 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/prompt-helpers.test.ts +175 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- 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, ']]>')
|
|
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, '&')
|
|
113
|
+
.replace(/</g, '<')
|
|
114
|
+
.replace(/>/g, '>')
|
|
115
|
+
.replace(/"/g, '"')
|
|
116
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|