@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,206 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
import { Schema } from '@effect/schema'
|
|
5
|
+
import { Context, Effect, Layer } from 'effect'
|
|
6
|
+
import { GerritCredentials } from '@/schemas/gerrit'
|
|
7
|
+
import { AiConfig, AppConfig, aiConfigFromFlat, migrateFromNestedConfig } from '@/schemas/config'
|
|
8
|
+
|
|
9
|
+
export interface ConfigServiceImpl {
|
|
10
|
+
readonly getCredentials: Effect.Effect<GerritCredentials, ConfigError>
|
|
11
|
+
readonly saveCredentials: (credentials: GerritCredentials) => Effect.Effect<void, ConfigError>
|
|
12
|
+
readonly deleteCredentials: Effect.Effect<void, ConfigError>
|
|
13
|
+
readonly getAiConfig: Effect.Effect<AiConfig, ConfigError>
|
|
14
|
+
readonly saveAiConfig: (config: AiConfig) => Effect.Effect<void, ConfigError>
|
|
15
|
+
readonly getFullConfig: Effect.Effect<AppConfig, ConfigError>
|
|
16
|
+
readonly saveFullConfig: (config: AppConfig) => Effect.Effect<void, ConfigError>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConfigService extends Context.Tag('ConfigService')<
|
|
20
|
+
ConfigService,
|
|
21
|
+
ConfigServiceImpl
|
|
22
|
+
>() {}
|
|
23
|
+
|
|
24
|
+
export class ConfigError extends Schema.TaggedError<ConfigError>()('ConfigError', {
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
} as const) {}
|
|
27
|
+
|
|
28
|
+
// File-based storage
|
|
29
|
+
const CONFIG_DIR = path.join(os.homedir(), '.ger')
|
|
30
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
31
|
+
|
|
32
|
+
const readFileConfig = (): unknown | null => {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
35
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf8')
|
|
36
|
+
const parsed = JSON.parse(content)
|
|
37
|
+
|
|
38
|
+
// Check if this is the old nested format and migrate if needed
|
|
39
|
+
if (parsed && typeof parsed === 'object' && 'credentials' in parsed) {
|
|
40
|
+
// Migrate from nested format to flat format with validation
|
|
41
|
+
const migrated = migrateFromNestedConfig(parsed)
|
|
42
|
+
|
|
43
|
+
// Save the migrated config immediately
|
|
44
|
+
try {
|
|
45
|
+
writeFileConfig(migrated)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Log migration write failure but continue to return migrated config
|
|
48
|
+
console.warn('Warning: Failed to save migrated config to disk:', error)
|
|
49
|
+
// Config migration succeeded in memory, user can still proceed
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return migrated
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsed
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Ignore errors
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const writeFileConfig = (config: AppConfig): void => {
|
|
64
|
+
// Ensure config directory exists
|
|
65
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
66
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8')
|
|
70
|
+
// Set restrictive permissions
|
|
71
|
+
fs.chmodSync(CONFIG_FILE, 0o600)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const deleteFileConfig = (): void => {
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
77
|
+
fs.unlinkSync(CONFIG_FILE)
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Ignore errors
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const ConfigServiceLive: Layer.Layer<ConfigService, never, never> = Layer.effect(
|
|
85
|
+
ConfigService,
|
|
86
|
+
Effect.sync(() => {
|
|
87
|
+
const getFullConfig = Effect.gen(function* () {
|
|
88
|
+
const fileContent = readFileConfig()
|
|
89
|
+
if (!fileContent) {
|
|
90
|
+
return yield* Effect.fail(
|
|
91
|
+
new ConfigError({
|
|
92
|
+
message: 'Configuration not found. Run "ger setup" to set up your credentials.',
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse as flat config
|
|
98
|
+
const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(fileContent).pipe(
|
|
99
|
+
Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return fullConfigResult
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const saveFullConfig = (config: AppConfig) =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
// Validate config using schema
|
|
108
|
+
const validatedConfig = yield* Schema.decodeUnknown(AppConfig)(config).pipe(
|
|
109
|
+
Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
writeFileConfig(validatedConfig)
|
|
114
|
+
} catch {
|
|
115
|
+
yield* Effect.fail(new ConfigError({ message: 'Failed to save configuration to file' }))
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const getCredentials = Effect.gen(function* () {
|
|
120
|
+
const config = yield* getFullConfig
|
|
121
|
+
return {
|
|
122
|
+
host: config.host,
|
|
123
|
+
username: config.username,
|
|
124
|
+
password: config.password,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const saveCredentials = (credentials: GerritCredentials) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
// Validate credentials using schema
|
|
131
|
+
const validatedCredentials = yield* Schema.decodeUnknown(GerritCredentials)(
|
|
132
|
+
credentials,
|
|
133
|
+
).pipe(Effect.mapError(() => new ConfigError({ message: 'Invalid credentials format' })))
|
|
134
|
+
|
|
135
|
+
// Get existing config or create new one
|
|
136
|
+
const existingConfig = yield* getFullConfig.pipe(
|
|
137
|
+
Effect.orElseSucceed(() => {
|
|
138
|
+
// Create default config using Schema validation instead of type assertion
|
|
139
|
+
const defaultConfig = {
|
|
140
|
+
host: validatedCredentials.host,
|
|
141
|
+
username: validatedCredentials.username,
|
|
142
|
+
password: validatedCredentials.password,
|
|
143
|
+
aiAutoDetect: true,
|
|
144
|
+
}
|
|
145
|
+
// Validate the default config structure
|
|
146
|
+
return Schema.decodeUnknownSync(AppConfig)(defaultConfig)
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Update credentials in flat config
|
|
151
|
+
const updatedConfig: AppConfig = {
|
|
152
|
+
...existingConfig,
|
|
153
|
+
host: validatedCredentials.host,
|
|
154
|
+
username: validatedCredentials.username,
|
|
155
|
+
password: validatedCredentials.password,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
yield* saveFullConfig(updatedConfig)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const deleteCredentials = Effect.gen(function* () {
|
|
162
|
+
try {
|
|
163
|
+
deleteFileConfig()
|
|
164
|
+
yield* Effect.void
|
|
165
|
+
} catch {
|
|
166
|
+
// Ignore errors
|
|
167
|
+
yield* Effect.void
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const getAiConfig = Effect.gen(function* () {
|
|
172
|
+
const config = yield* getFullConfig
|
|
173
|
+
return aiConfigFromFlat(config)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const saveAiConfig = (aiConfig: AiConfig) =>
|
|
177
|
+
Effect.gen(function* () {
|
|
178
|
+
// Validate AI config using schema
|
|
179
|
+
const validatedAiConfig = yield* Schema.decodeUnknown(AiConfig)(aiConfig).pipe(
|
|
180
|
+
Effect.mapError(() => new ConfigError({ message: 'Invalid AI configuration format' })),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// Get existing config
|
|
184
|
+
const existingConfig = yield* getFullConfig
|
|
185
|
+
|
|
186
|
+
// Update AI config in flat structure
|
|
187
|
+
const updatedConfig: AppConfig = {
|
|
188
|
+
...existingConfig,
|
|
189
|
+
aiTool: validatedAiConfig.tool,
|
|
190
|
+
aiAutoDetect: validatedAiConfig.autoDetect,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
yield* saveFullConfig(updatedConfig)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
getCredentials,
|
|
198
|
+
saveCredentials,
|
|
199
|
+
deleteCredentials,
|
|
200
|
+
getAiConfig,
|
|
201
|
+
saveAiConfig,
|
|
202
|
+
getFullConfig,
|
|
203
|
+
saveFullConfig,
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Schema } from '@effect/schema'
|
|
2
|
+
import type { ChangeInfo, FileDiffContent, FileInfo } from '@/schemas/gerrit'
|
|
3
|
+
|
|
4
|
+
export const generateMockChange = (
|
|
5
|
+
overrides?: Partial<Schema.Schema.Type<typeof ChangeInfo>>,
|
|
6
|
+
): Schema.Schema.Type<typeof ChangeInfo> => {
|
|
7
|
+
const base: Schema.Schema.Type<typeof ChangeInfo> = {
|
|
8
|
+
id: 'myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940',
|
|
9
|
+
project: 'myProject',
|
|
10
|
+
branch: 'master',
|
|
11
|
+
change_id: 'I8473b95934b5732ac55d26311a706c9c2bde9940',
|
|
12
|
+
subject: 'Implementing new feature',
|
|
13
|
+
status: 'NEW' as const,
|
|
14
|
+
created: '2023-12-01 10:00:00.000000000',
|
|
15
|
+
updated: '2023-12-01 15:30:00.000000000',
|
|
16
|
+
insertions: 25,
|
|
17
|
+
deletions: 3,
|
|
18
|
+
_number: 12345,
|
|
19
|
+
owner: {
|
|
20
|
+
_account_id: 1000096,
|
|
21
|
+
name: 'John Developer',
|
|
22
|
+
email: 'john@example.com',
|
|
23
|
+
username: 'jdeveloper',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { ...base, ...overrides }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const generateMockFiles = (): Record<string, Schema.Schema.Type<typeof FileInfo>> => {
|
|
31
|
+
return {
|
|
32
|
+
'src/main.ts': {
|
|
33
|
+
status: 'M' as const,
|
|
34
|
+
lines_inserted: 15,
|
|
35
|
+
lines_deleted: 3,
|
|
36
|
+
size_delta: 120,
|
|
37
|
+
size: 1200,
|
|
38
|
+
},
|
|
39
|
+
'tests/main.test.ts': {
|
|
40
|
+
status: 'A' as const,
|
|
41
|
+
lines_inserted: 45,
|
|
42
|
+
lines_deleted: 0,
|
|
43
|
+
size_delta: 450,
|
|
44
|
+
size: 450,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const generateMockFileDiff = (): Schema.Schema.Type<typeof FileDiffContent> => {
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
ab: ['function main() {', ' console.log("Hello, world!")'],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
a: [' return 0'],
|
|
57
|
+
b: [' return process.exit(0)'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
ab: ['}'],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
change_type: 'MODIFIED' as const,
|
|
64
|
+
diff_header: ['--- a/src/main.ts', '+++ b/src/main.ts'],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const generateMockAccount = () => ({
|
|
69
|
+
_account_id: 1000096,
|
|
70
|
+
name: 'Test User',
|
|
71
|
+
email: 'test@example.com',
|
|
72
|
+
username: 'testuser',
|
|
73
|
+
})
|
|
@@ -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, '<')
|
|
69
|
+
.replace(/>/g, '>')
|
|
70
|
+
.replace(/"/g, '"')
|
|
71
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|