@aaronshaf/ger 0.1.0 → 0.1.1

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.
@@ -3,7 +3,7 @@
3
3
  **YOUR ENTIRE OUTPUT MUST BE WRAPPED IN <response></response> TAGS.**
4
4
  **NEVER USE BACKTICKS ANYWHERE IN YOUR RESPONSE - they cause shell execution errors.**
5
5
 
6
- Output ONLY a JSON array wrapped in response tags. No other text before or after the tags.
6
+ Output ONLY a JSON array wrapped in response tags. **EMPTY ARRAY IS PERFECTLY VALID** for clean code without issues. No other text before or after the tags.
7
7
 
8
8
  ## JSON Structure for Inline Comments
9
9
 
@@ -59,30 +59,55 @@ Line numbers refer to the final file (REVISION), not the diff.
59
59
 
60
60
  ## Priority Guidelines for Inline Comments
61
61
 
62
- ### ALWAYS Comment On
63
- - Security vulnerabilities (injection, auth bypass, data exposure)
64
- - Data corruption or loss risks
65
- - Logic errors that produce wrong results
66
- - Resource leaks (memory, connections, handles)
67
- - Race conditions and concurrency bugs
68
-
69
- ### USUALLY Comment On
70
- - Missing error handling for likely failure cases
71
- - Performance problems (N+1 queries, unbounded loops)
72
- - Type safety issues and invalid casts
73
- - Missing input validation
74
- - Incorrect API usage
75
-
76
- ### RARELY Comment On
77
- - Style preferences (unless egregious)
78
- - Minor optimizations without measurement
79
- - Alternative approaches that are equivalent
80
- - Issues in unchanged code
81
- - Formatting (unless it obscures logic)
62
+ ### ALWAYS Comment On (Real Problems Only)
63
+ - **Bugs**: Logic errors, null pointer risks, incorrect algorithms
64
+ - **Security**: Injection vulnerabilities, auth bypasses, data leaks
65
+ - **Crashes**: Unhandled exceptions, resource exhaustion, infinite loops
66
+ - **Data loss**: Operations that corrupt or lose user data
67
+
68
+ ### SOMETIMES Comment On (If Significant)
69
+ - **Performance**: N+1 queries, memory leaks, algorithmic complexity issues
70
+ - **Error handling**: Missing try/catch for operations that commonly fail
71
+ - **Type safety**: Dangerous casts, missing validation for external input
72
+
73
+ ### NEVER Comment On (Time Wasters)
74
+ - **Style/formatting**: Let automated tools handle this
75
+ - **Working code**: If it functions correctly, leave it alone
76
+ - **Personal preferences**: "I would have done this differently"
77
+ - **Nitpicks**: Variable names, spacing, minor wording
78
+ - **Compliments**: Don't waste time praising obvious competence
79
+
80
+ ## GIT REPOSITORY ACCESS
81
+
82
+ You are running in a git repository with full access to:
83
+ - git diff, git show, git log for understanding changes and context
84
+ - git blame for code ownership and history
85
+ - All project files for architectural understanding
86
+ - Use these commands to provide comprehensive, accurate reviews
82
87
 
83
88
  ## FINAL REMINDER
84
89
 
85
- Your ENTIRE output must be a JSON array wrapped in <response></response> tags.
86
- Every message must start with "🤖 ".
87
- Never use backticks in your response.
88
- Focus on substantial technical issues, not preferences.
90
+ **CRITICAL: Your ENTIRE output must be a JSON array wrapped in <response></response> tags.**
91
+
92
+ Example formats:
93
+ ```
94
+ <response>
95
+ []
96
+ </response>
97
+ ```
98
+ (Empty array for clean code - this is GOOD!)
99
+
100
+ ```
101
+ <response>
102
+ [{"file": "auth.js", "line": 42, "message": "🤖 SQL injection vulnerability: query uses string concatenation"}]
103
+ </response>
104
+ ```
105
+ (Only comment on real problems)
106
+
107
+ **REQUIREMENTS**:
108
+ - Every message must start with "🤖 "
109
+ - Never use backticks in your response
110
+ - Empty arrays are encouraged for clean code
111
+ - Focus on bugs, security, crashes - ignore style preferences
112
+ - Use git commands to understand context before commenting
113
+ - NO TEXT OUTSIDE THE <response></response> TAGS
@@ -41,11 +41,10 @@ Gerrit uses a LIMITED markdown subset. Follow these rules EXACTLY:
41
41
 
42
42
  **YOUR ENTIRE OUTPUT MUST BE WRAPPED IN <response></response> TAGS.**
43
43
 
44
- The review content inside the response tags should start with "🤖 [Your Tool Name] ([Your Model])" followed by your analysis. For example:
44
+ Start with "🤖 [Your Tool Name] ([Your Model])" then provide a **CONCISE** engineering assessment. Examples:
45
45
  - If you are Claude Sonnet 4: "🤖 Claude (Sonnet 4)"
46
- - If you are GPT-4: "🤖 OpenAI (GPT-4)"
47
- - If you are Llama: "🤖 Llama (70B)"
48
- - etc.
46
+ - For clean code: "No significant issues found. Change is ready for merge."
47
+ - For problematic code: Focus only on critical/important issues, skip minor concerns
49
48
 
50
49
  ## Example Output Format
51
50
 
@@ -139,14 +138,40 @@ The security issues are blocking and must be fixed. The performance concerns sho
139
138
  - Skip trivial issues unless they indicate patterns
140
139
  - Include concrete fix suggestions
141
140
 
141
+ ## GIT REPOSITORY ACCESS
142
+
143
+ You are running in a git repository with full access to:
144
+ - git diff, git show, git log for understanding changes and context
145
+ - git blame for code ownership and history
146
+ - All project files for architectural understanding
147
+ - Use these commands to explore the codebase and provide comprehensive reviews
148
+
142
149
  ## FINAL REMINDER
143
150
 
144
- Your ENTIRE output must be wrapped in <response></response> tags.
145
- Start with "🤖 [Your Tool Name] ([Your Model])" then proceed with your analysis.
146
- Use Gerrit's limited markdown format - NO backticks, NO markdown bold/italic.
151
+ **CRITICAL: Your ENTIRE output must be wrapped in <response></response> tags.**
152
+
153
+ Example format:
154
+ ```
155
+ <response>
156
+ 🤖 Claude (Sonnet 4)
157
+
158
+ OVERALL ASSESSMENT
159
+
160
+ Your review content here...
161
+ </response>
162
+ ```
163
+
164
+ MANDATORY REQUIREMENTS:
165
+ - Start with "🤖 [Your Tool Name] ([Your Model])"
166
+ - Be CONCISE - engineers value brevity over verbosity
167
+ - For clean code, simply state "No significant issues found"
168
+ - Focus on material problems, skip style preferences and compliments
169
+ - Use Gerrit's limited markdown format - NO backticks, NO markdown bold/italic
170
+ - Use git commands to understand context before writing review
171
+ - NO TEXT OUTSIDE THE <response></response> TAGS
147
172
 
148
173
  CRITICAL FORMATTING RULES:
149
174
  - Add blank lines between sections and before/after code blocks
150
- - Use exactly 4 spaces to start each line of code blocks
175
+ - Use exactly 4 spaces to start each line of code blocks
151
176
  - Keep code blocks simple and readable
152
177
  - Add proper spacing for readability
@@ -79,12 +79,12 @@ export const AiServiceEnhanced = Layer.effect(
79
79
  return responseMatch[1].trim()
80
80
  })
81
81
 
82
- const runPrompt = (prompt: string, input: string) =>
82
+ const runPrompt = (prompt: string, input: string = '', options: { cwd?: string } = {}) =>
83
83
  Effect.gen(function* () {
84
84
  const tool = yield* detectAiTool()
85
85
 
86
86
  // Prepare the command based on the tool
87
- const fullInput = `${prompt}\n\n${input}`
87
+ const fullInput = input ? `${prompt}\n\n${input}` : prompt
88
88
  let command: string
89
89
 
90
90
  switch (tool) {
@@ -114,6 +114,7 @@ export const AiServiceEnhanced = Layer.effect(
114
114
  const child = require('node:child_process').spawn(command, {
115
115
  shell: true,
116
116
  stdio: ['pipe', 'pipe', 'pipe'],
117
+ cwd: options.cwd || process.cwd(),
117
118
  })
118
119
 
119
120
  // Write input to stdin
@@ -25,7 +25,8 @@ export class AiService extends Context.Tag('AiService')<
25
25
  {
26
26
  readonly runPrompt: (
27
27
  prompt: string,
28
- input: string,
28
+ input?: string,
29
+ options?: { cwd?: string },
29
30
  ) => Effect.Effect<string, AiServiceError | NoAiToolFoundError | AiResponseParseError>
30
31
  readonly detectAiTool: () => Effect.Effect<string, NoAiToolFoundError>
31
32
  readonly extractResponseTag: (output: string) => Effect.Effect<string, AiResponseParseError>
@@ -76,7 +77,7 @@ export const AiServiceLive = Layer.succeed(
76
77
  return responseMatch[1].trim()
77
78
  }),
78
79
 
79
- runPrompt: (prompt: string, input: string) =>
80
+ runPrompt: (prompt: string, input: string = '', options: { cwd?: string } = {}) =>
80
81
  Effect.gen(function* () {
81
82
  const tool = yield* Effect.gen(function* () {
82
83
  // Try to detect available AI tools in order of preference
@@ -101,7 +102,7 @@ export const AiServiceLive = Layer.succeed(
101
102
  })
102
103
 
103
104
  // Prepare the command based on the tool
104
- const fullInput = `${prompt}\n\n${input}`
105
+ const fullInput = input ? `${prompt}\n\n${input}` : prompt
105
106
  let command: string
106
107
 
107
108
  switch (tool) {
@@ -127,6 +128,7 @@ export const AiServiceLive = Layer.succeed(
127
128
  const child = require('node:child_process').spawn(command, {
128
129
  shell: true,
129
130
  stdio: ['pipe', 'pipe', 'pipe'],
131
+ cwd: options.cwd || process.cwd(),
130
132
  })
131
133
 
132
134
  // Write input to stdin
@@ -168,9 +170,15 @@ export const AiServiceLive = Layer.succeed(
168
170
  const responseMatch = result.stdout.match(/<response>([\s\S]*?)<\/response>/i)
169
171
 
170
172
  if (!responseMatch || !responseMatch[1]) {
173
+ // Enhanced error message with truncated output for debugging
174
+ const truncatedOutput =
175
+ result.stdout.length > 500
176
+ ? result.stdout.substring(0, 500) + '...[truncated]'
177
+ : result.stdout
178
+
171
179
  return yield* Effect.fail(
172
180
  new AiResponseParseError({
173
- message: 'No <response> tag found in AI output',
181
+ message: `No <response> tag found in AI output. Raw output: ${truncatedOutput}`,
174
182
  rawOutput: result.stdout,
175
183
  }),
176
184
  )
@@ -0,0 +1,297 @@
1
+ import { Effect, Console, pipe, Layer, Context } from 'effect'
2
+ import { Schema } from 'effect'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import * as fs from 'node:fs/promises'
6
+ import { spawn } from 'node:child_process'
7
+
8
+ // Error types
9
+ export class WorktreeCreationError extends Schema.TaggedError<WorktreeCreationError>()(
10
+ 'WorktreeCreationError',
11
+ {
12
+ message: Schema.String,
13
+ cause: Schema.optional(Schema.Unknown),
14
+ },
15
+ ) {}
16
+
17
+ export class PatchsetFetchError extends Schema.TaggedError<PatchsetFetchError>()(
18
+ 'PatchsetFetchError',
19
+ {
20
+ message: Schema.String,
21
+ cause: Schema.optional(Schema.Unknown),
22
+ },
23
+ ) {}
24
+
25
+ export class DirtyRepoError extends Schema.TaggedError<DirtyRepoError>()('DirtyRepoError', {
26
+ message: Schema.String,
27
+ }) {}
28
+
29
+ export class NotGitRepoError extends Schema.TaggedError<NotGitRepoError>()('NotGitRepoError', {
30
+ message: Schema.String,
31
+ }) {}
32
+
33
+ export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
34
+
35
+ // Worktree info
36
+ export interface WorktreeInfo {
37
+ path: string
38
+ changeId: string
39
+ originalCwd: string
40
+ timestamp: number
41
+ pid: number
42
+ }
43
+
44
+ // Git command runner with Effect
45
+ const runGitCommand = (
46
+ args: string[],
47
+ options: { cwd?: string } = {},
48
+ ): Effect.Effect<string, GitError, never> =>
49
+ Effect.async<string, GitError, never>((resume) => {
50
+ const child = spawn('git', args, {
51
+ cwd: options.cwd || process.cwd(),
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ })
54
+
55
+ let stdout = ''
56
+ let stderr = ''
57
+
58
+ child.stdout?.on('data', (data) => {
59
+ stdout += data.toString()
60
+ })
61
+
62
+ child.stderr?.on('data', (data) => {
63
+ stderr += data.toString()
64
+ })
65
+
66
+ child.on('close', (code) => {
67
+ if (code === 0) {
68
+ resume(Effect.succeed(stdout.trim()))
69
+ } else {
70
+ const errorMessage = `Git command failed: git ${args.join(' ')}\nStderr: ${stderr}`
71
+
72
+ // Classify error based on command and output
73
+ if (args[0] === 'worktree' && args[1] === 'add') {
74
+ resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
75
+ } else if (args[0] === 'fetch' || args[0] === 'checkout') {
76
+ resume(Effect.fail(new PatchsetFetchError({ message: errorMessage })))
77
+ } else {
78
+ resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
79
+ }
80
+ }
81
+ })
82
+
83
+ child.on('error', (error) => {
84
+ resume(
85
+ Effect.fail(
86
+ new WorktreeCreationError({
87
+ message: `Failed to spawn git: ${error.message}`,
88
+ cause: error,
89
+ }),
90
+ ),
91
+ )
92
+ })
93
+ })
94
+
95
+ // Check if current directory is a git repository
96
+ const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
97
+ pipe(
98
+ runGitCommand(['rev-parse', '--git-dir']),
99
+ Effect.mapError(
100
+ () => new NotGitRepoError({ message: 'Current directory is not a git repository' }),
101
+ ),
102
+ Effect.map(() => undefined),
103
+ )
104
+
105
+ // Check if working directory is clean
106
+ const validateCleanRepo = (): Effect.Effect<void, DirtyRepoError, never> =>
107
+ pipe(
108
+ runGitCommand(['status', '--porcelain']),
109
+ Effect.mapError(() => new DirtyRepoError({ message: 'Failed to check repository status' })),
110
+ Effect.flatMap((output) =>
111
+ output.trim() === ''
112
+ ? Effect.succeed(undefined)
113
+ : Effect.fail(
114
+ new DirtyRepoError({
115
+ message:
116
+ 'Working directory has uncommitted changes. Please commit or stash changes before review.',
117
+ }),
118
+ ),
119
+ ),
120
+ )
121
+
122
+ // Generate unique worktree path
123
+ const generateWorktreePath = (changeId: string): string => {
124
+ const timestamp = Date.now()
125
+ const pid = process.pid
126
+ const uniqueId = `${changeId}-${timestamp}-${pid}`
127
+ return path.join(os.homedir(), '.ger', 'worktrees', uniqueId)
128
+ }
129
+
130
+ // Ensure .ger directory exists
131
+ const ensureGerDirectory = (): Effect.Effect<void, never, never> =>
132
+ Effect.tryPromise({
133
+ try: async () => {
134
+ const gerDir = path.join(os.homedir(), '.ger', 'worktrees')
135
+ await fs.mkdir(gerDir, { recursive: true })
136
+ },
137
+ catch: () => undefined, // Ignore errors, will fail later if directory can't be created
138
+ }).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
139
+
140
+ // Build Gerrit refspec for change
141
+ const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string => {
142
+ // Extract change number from changeId if it contains non-numeric characters
143
+ const numericChangeNumber = changeNumber.replace(/\D/g, '')
144
+ return `refs/changes/${numericChangeNumber.slice(-2)}/${numericChangeNumber}/${patchsetNumber}`
145
+ }
146
+
147
+ // Get the current HEAD commit hash to avoid branch conflicts
148
+ const getCurrentCommit = (): Effect.Effect<string, GitError, never> =>
149
+ pipe(
150
+ runGitCommand(['rev-parse', 'HEAD']),
151
+ Effect.map((output) => output.trim()),
152
+ Effect.catchAll(() =>
153
+ // Fallback: try to get commit from default branch
154
+ pipe(
155
+ runGitCommand(['rev-parse', 'origin/main']),
156
+ Effect.catchAll(() => runGitCommand(['rev-parse', 'origin/master'])),
157
+ Effect.catchAll(() => Effect.succeed('HEAD')),
158
+ ),
159
+ ),
160
+ )
161
+
162
+ // Get latest patchset number for a change
163
+ const getLatestPatchsetNumber = (
164
+ changeId: string,
165
+ ): Effect.Effect<number, PatchsetFetchError, never> =>
166
+ pipe(
167
+ runGitCommand(['ls-remote', 'origin', `refs/changes/*/${changeId.replace(/\D/g, '')}/*`]),
168
+ Effect.mapError(
169
+ (error) =>
170
+ new PatchsetFetchError({ message: `Failed to get patchset info: ${error.message}` }),
171
+ ),
172
+ Effect.map((output) => {
173
+ const lines = output.split('\n').filter((line) => line.trim())
174
+ if (lines.length === 0) {
175
+ return 1 // Default to patchset 1 if no refs found
176
+ }
177
+
178
+ // Extract patchset numbers and return the highest
179
+ const patchsetNumbers = lines
180
+ .map((line) => {
181
+ const match = line.match(/refs\/changes\/\d+\/\d+\/(\d+)$/)
182
+ return match ? parseInt(match[1], 10) : 0
183
+ })
184
+ .filter((num) => num > 0)
185
+
186
+ return patchsetNumbers.length > 0 ? Math.max(...patchsetNumbers) : 1
187
+ }),
188
+ )
189
+
190
+ // GitWorktreeService implementation
191
+ export interface GitWorktreeServiceImpl {
192
+ validatePreconditions: () => Effect.Effect<void, GitError, never>
193
+ createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitError, never>
194
+ fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, GitError, never>
195
+ cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never>
196
+ getChangedFiles: () => Effect.Effect<string[], GitError, never>
197
+ }
198
+
199
+ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
200
+ validatePreconditions: () =>
201
+ Effect.gen(function* () {
202
+ yield* validateGitRepo()
203
+ yield* validateCleanRepo()
204
+ yield* Console.log('✓ Git repository validation passed')
205
+ }),
206
+
207
+ createWorktree: (changeId: string) =>
208
+ Effect.gen(function* () {
209
+ yield* Console.log(`→ Creating worktree for change ${changeId}...`)
210
+
211
+ // Get current commit hash to avoid branch conflicts
212
+ const currentCommit = yield* getCurrentCommit()
213
+ yield* Console.log(`→ Using base commit: ${currentCommit.substring(0, 7)}`)
214
+
215
+ // Ensure .ger directory exists
216
+ yield* ensureGerDirectory()
217
+
218
+ // Generate unique path
219
+ const worktreePath = generateWorktreePath(changeId)
220
+ const originalCwd = process.cwd()
221
+
222
+ // Create worktree using commit hash (no branch conflicts)
223
+ yield* runGitCommand(['worktree', 'add', '--detach', worktreePath, currentCommit])
224
+
225
+ const worktreeInfo: WorktreeInfo = {
226
+ path: worktreePath,
227
+ changeId,
228
+ originalCwd,
229
+ timestamp: Date.now(),
230
+ pid: process.pid,
231
+ }
232
+
233
+ yield* Console.log(`✓ Worktree created at ${worktreePath}`)
234
+ return worktreeInfo
235
+ }),
236
+
237
+ fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) =>
238
+ Effect.gen(function* () {
239
+ yield* Console.log(`→ Fetching and checking out patchset for ${worktreeInfo.changeId}...`)
240
+
241
+ // Get latest patchset number
242
+ const patchsetNumber = yield* getLatestPatchsetNumber(worktreeInfo.changeId)
243
+ const refspec = buildRefspec(worktreeInfo.changeId, patchsetNumber)
244
+
245
+ yield* Console.log(`→ Using refspec: ${refspec}`)
246
+
247
+ // Fetch the change
248
+ yield* runGitCommand(['fetch', 'origin', refspec], { cwd: worktreeInfo.path })
249
+
250
+ // Checkout FETCH_HEAD
251
+ yield* runGitCommand(['checkout', 'FETCH_HEAD'], { cwd: worktreeInfo.path })
252
+
253
+ yield* Console.log(`✓ Checked out patchset ${patchsetNumber} for ${worktreeInfo.changeId}`)
254
+ }),
255
+
256
+ cleanup: (worktreeInfo: WorktreeInfo) =>
257
+ Effect.gen(function* () {
258
+ yield* Console.log(`→ Cleaning up worktree for ${worktreeInfo.changeId}...`)
259
+
260
+ // Always restore original working directory first
261
+ try {
262
+ process.chdir(worktreeInfo.originalCwd)
263
+ } catch (error) {
264
+ yield* Console.warn(`Warning: Could not restore original directory: ${error}`)
265
+ }
266
+
267
+ // Attempt to remove worktree (don't fail if this doesn't work)
268
+ yield* pipe(
269
+ runGitCommand(['worktree', 'remove', '--force', worktreeInfo.path]),
270
+ Effect.catchAll((error) =>
271
+ Effect.gen(function* () {
272
+ yield* Console.warn(`Warning: Could not remove worktree: ${error.message}`)
273
+ yield* Console.warn(`Manual cleanup may be required: ${worktreeInfo.path}`)
274
+ }),
275
+ ),
276
+ )
277
+
278
+ yield* Console.log(`✓ Cleanup completed for ${worktreeInfo.changeId}`)
279
+ }),
280
+
281
+ getChangedFiles: () =>
282
+ Effect.gen(function* () {
283
+ // Get list of changed files in current worktree
284
+ const output = yield* runGitCommand(['diff', '--name-only', 'HEAD~1'])
285
+ const files = output.split('\n').filter((file) => file.trim())
286
+ return files
287
+ }),
288
+ }
289
+
290
+ // Export service tag for dependency injection
291
+ export class GitWorktreeService extends Context.Tag('GitWorktreeService')<
292
+ GitWorktreeService,
293
+ GitWorktreeServiceImpl
294
+ >() {}
295
+
296
+ // Export service layer
297
+ export const GitWorktreeServiceLive = Layer.succeed(GitWorktreeService, GitWorktreeServiceImplLive)
@@ -0,0 +1,89 @@
1
+ import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
2
+ import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
3
+
4
+ export const formatChangeAsXML = (change: ChangeInfo): string[] => {
5
+ const lines: string[] = []
6
+ lines.push(` <change>`)
7
+ lines.push(` <id>${escapeXML(change.change_id)}</id>`)
8
+ lines.push(` <number>${change._number}</number>`)
9
+ lines.push(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
10
+ lines.push(` <status>${escapeXML(change.status)}</status>`)
11
+ lines.push(` <project>${escapeXML(change.project)}</project>`)
12
+ lines.push(` <branch>${escapeXML(change.branch)}</branch>`)
13
+ lines.push(` <owner>`)
14
+ if (change.owner?.name) {
15
+ lines.push(` <name><![CDATA[${sanitizeCDATA(change.owner.name)}]]></name>`)
16
+ }
17
+ if (change.owner?.email) {
18
+ lines.push(` <email>${escapeXML(change.owner.email)}</email>`)
19
+ }
20
+ lines.push(` </owner>`)
21
+ lines.push(` <created>${escapeXML(change.created || '')}</created>`)
22
+ lines.push(` <updated>${escapeXML(change.updated || '')}</updated>`)
23
+ lines.push(` </change>`)
24
+ return lines
25
+ }
26
+
27
+ export const formatCommentsAsXML = (comments: readonly CommentInfo[]): string[] => {
28
+ const lines: string[] = []
29
+ lines.push(` <comments>`)
30
+ lines.push(` <count>${comments.length}</count>`)
31
+ for (const comment of comments) {
32
+ lines.push(` <comment>`)
33
+ if (comment.id) lines.push(` <id>${escapeXML(comment.id)}</id>`)
34
+ if (comment.path) {
35
+ lines.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
36
+ }
37
+ if (comment.line) lines.push(` <line>${comment.line}</line>`)
38
+ if (comment.author?.name) {
39
+ lines.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
40
+ }
41
+ if (comment.updated) lines.push(` <updated>${escapeXML(comment.updated)}</updated>`)
42
+ if (comment.message) {
43
+ lines.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
44
+ }
45
+ if (comment.unresolved) lines.push(` <unresolved>true</unresolved>`)
46
+ lines.push(` </comment>`)
47
+ }
48
+ lines.push(` </comments>`)
49
+ return lines
50
+ }
51
+
52
+ export const formatMessagesAsXML = (messages: readonly MessageInfo[]): string[] => {
53
+ const lines: string[] = []
54
+ lines.push(` <messages>`)
55
+ lines.push(` <count>${messages.length}</count>`)
56
+ for (const message of messages) {
57
+ lines.push(` <message>`)
58
+ lines.push(` <id>${escapeXML(message.id)}</id>`)
59
+ if (message.author?.name) {
60
+ lines.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
61
+ }
62
+ if (message.author?._account_id) {
63
+ lines.push(` <author_id>${message.author._account_id}</author_id>`)
64
+ }
65
+ lines.push(` <date>${escapeXML(message.date)}</date>`)
66
+ if (message._revision_number) {
67
+ lines.push(` <revision>${message._revision_number}</revision>`)
68
+ }
69
+ if (message.tag) {
70
+ lines.push(` <tag>${escapeXML(message.tag)}</tag>`)
71
+ }
72
+ lines.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
73
+ lines.push(` </message>`)
74
+ }
75
+ lines.push(` </messages>`)
76
+ return lines
77
+ }
78
+
79
+ export const flattenComments = (
80
+ commentsMap: Record<string, readonly CommentInfo[]>,
81
+ ): CommentInfo[] => {
82
+ const comments: CommentInfo[] = []
83
+ for (const [path, fileComments] of Object.entries(commentsMap)) {
84
+ for (const comment of fileComments) {
85
+ comments.push({ ...comment, path })
86
+ }
87
+ }
88
+ return comments
89
+ }