@renfeng/kiro-code-review 1.9.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/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/api/gitlab-api.d.ts +122 -0
  4. package/dist/api/gitlab-api.d.ts.map +1 -0
  5. package/dist/api/gitlab-api.js +334 -0
  6. package/dist/api/gitlab-api.js.map +1 -0
  7. package/dist/bin/index.d.ts +11 -0
  8. package/dist/bin/index.d.ts.map +1 -0
  9. package/dist/bin/index.js +67 -0
  10. package/dist/bin/index.js.map +1 -0
  11. package/dist/config/glab-config.loader.d.ts +14 -0
  12. package/dist/config/glab-config.loader.d.ts.map +1 -0
  13. package/dist/config/glab-config.loader.js +87 -0
  14. package/dist/config/glab-config.loader.js.map +1 -0
  15. package/dist/errors/error-handler.d.ts +45 -0
  16. package/dist/errors/error-handler.d.ts.map +1 -0
  17. package/dist/errors/error-handler.js +134 -0
  18. package/dist/errors/error-handler.js.map +1 -0
  19. package/dist/logging/index.d.ts +2 -0
  20. package/dist/logging/index.d.ts.map +1 -0
  21. package/dist/logging/index.js +2 -0
  22. package/dist/logging/index.js.map +1 -0
  23. package/dist/logging/logger.service.d.ts +34 -0
  24. package/dist/logging/logger.service.d.ts.map +1 -0
  25. package/dist/logging/logger.service.js +91 -0
  26. package/dist/logging/logger.service.js.map +1 -0
  27. package/dist/servers/general-server.d.ts +18 -0
  28. package/dist/servers/general-server.d.ts.map +1 -0
  29. package/dist/servers/general-server.js +73 -0
  30. package/dist/servers/general-server.js.map +1 -0
  31. package/dist/servers/git-server.d.ts +17 -0
  32. package/dist/servers/git-server.d.ts.map +1 -0
  33. package/dist/servers/git-server.js +74 -0
  34. package/dist/servers/git-server.js.map +1 -0
  35. package/dist/servers/gitlab-server.d.ts +18 -0
  36. package/dist/servers/gitlab-server.d.ts.map +1 -0
  37. package/dist/servers/gitlab-server.js +139 -0
  38. package/dist/servers/gitlab-server.js.map +1 -0
  39. package/dist/services/__tests__/batch-retro.service.test.d.ts +9 -0
  40. package/dist/services/__tests__/batch-retro.service.test.d.ts.map +1 -0
  41. package/dist/services/__tests__/batch-retro.service.test.js +235 -0
  42. package/dist/services/__tests__/batch-retro.service.test.js.map +1 -0
  43. package/dist/services/__tests__/general-save-diffs.test.d.ts +5 -0
  44. package/dist/services/__tests__/general-save-diffs.test.d.ts.map +1 -0
  45. package/dist/services/__tests__/general-save-diffs.test.js +99 -0
  46. package/dist/services/__tests__/general-save-diffs.test.js.map +1 -0
  47. package/dist/services/__tests__/git-diff-files.test.d.ts +8 -0
  48. package/dist/services/__tests__/git-diff-files.test.d.ts.map +1 -0
  49. package/dist/services/__tests__/git-diff-files.test.js +64 -0
  50. package/dist/services/__tests__/git-diff-files.test.js.map +1 -0
  51. package/dist/services/__tests__/review-tools.service.test.d.ts +8 -0
  52. package/dist/services/__tests__/review-tools.service.test.d.ts.map +1 -0
  53. package/dist/services/__tests__/review-tools.service.test.js +169 -0
  54. package/dist/services/__tests__/review-tools.service.test.js.map +1 -0
  55. package/dist/services/__tests__/review-utils.service.test.d.ts +8 -0
  56. package/dist/services/__tests__/review-utils.service.test.d.ts.map +1 -0
  57. package/dist/services/__tests__/review-utils.service.test.js +306 -0
  58. package/dist/services/__tests__/review-utils.service.test.js.map +1 -0
  59. package/dist/services/anchor-resolver.d.ts +29 -0
  60. package/dist/services/anchor-resolver.d.ts.map +1 -0
  61. package/dist/services/anchor-resolver.js +97 -0
  62. package/dist/services/anchor-resolver.js.map +1 -0
  63. package/dist/services/general.service.d.ts +42 -0
  64. package/dist/services/general.service.d.ts.map +1 -0
  65. package/dist/services/general.service.js +157 -0
  66. package/dist/services/general.service.js.map +1 -0
  67. package/dist/services/git-tools.service.d.ts +123 -0
  68. package/dist/services/git-tools.service.d.ts.map +1 -0
  69. package/dist/services/git-tools.service.js +231 -0
  70. package/dist/services/git-tools.service.js.map +1 -0
  71. package/dist/services/review-tools.service.d.ts +130 -0
  72. package/dist/services/review-tools.service.d.ts.map +1 -0
  73. package/dist/services/review-tools.service.js +902 -0
  74. package/dist/services/review-tools.service.js.map +1 -0
  75. package/dist/tools/general-tools.d.ts +9 -0
  76. package/dist/tools/general-tools.d.ts.map +1 -0
  77. package/dist/tools/general-tools.js +99 -0
  78. package/dist/tools/general-tools.js.map +1 -0
  79. package/dist/tools/git-tools.d.ts +8 -0
  80. package/dist/tools/git-tools.d.ts.map +1 -0
  81. package/dist/tools/git-tools.js +175 -0
  82. package/dist/tools/git-tools.js.map +1 -0
  83. package/dist/tools/gitlab-tools.d.ts +8 -0
  84. package/dist/tools/gitlab-tools.d.ts.map +1 -0
  85. package/dist/tools/gitlab-tools.js +348 -0
  86. package/dist/tools/gitlab-tools.js.map +1 -0
  87. package/dist/types.d.ts +294 -0
  88. package/dist/types.d.ts.map +1 -0
  89. package/dist/types.js +7 -0
  90. package/dist/types.js.map +1 -0
  91. package/package.json +51 -0
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Review Tools Service
3
+ *
4
+ * Implements business logic for the code review and todo triage MCP tools.
5
+ */
6
+ import { GitLabAPI } from '../api/gitlab-api.js';
7
+ import { Logger } from '../logging/logger.service.js';
8
+ import { readFileSync } from 'fs';
9
+ import { resolveAnchorInDiff } from './anchor-resolver.js';
10
+ import { GitToolsService } from './git-tools.service.js';
11
+ /** HTML comment marker to identify the non-blocking checklist draft */
12
+ const CHECKLIST_MARKER = '<!-- non-blocking-checklist -->';
13
+ export class ReviewToolsService {
14
+ logger;
15
+ gitTools;
16
+ constructor(logger) {
17
+ this.logger = logger || new Logger('ReviewToolsService');
18
+ }
19
+ createApi(gitlabUrl, token) {
20
+ return new GitLabAPI({ baseUrl: gitlabUrl, token }, this.logger.child('GitLabAPI'));
21
+ }
22
+ async getProjectSettings(params, gitlabUrl, token) {
23
+ const api = this.createApi(gitlabUrl, token);
24
+ return api.getProjectSettings(String(params.project));
25
+ }
26
+ async extractMrMetadata(params, gitlabUrl, token) {
27
+ const api = this.createApi(gitlabUrl, token);
28
+ return api.getMergeRequestMetadata(String(params.project), params.mergeRequestIid);
29
+ }
30
+ async extractVersionRefs(params, gitlabUrl, token) {
31
+ const api = this.createApi(gitlabUrl, token);
32
+ const versions = await api.getMergeRequestVersions(String(params.project), params.mergeRequestIid);
33
+ if (!versions.length)
34
+ throw new Error(`No versions found for MR !${params.mergeRequestIid}`);
35
+ const latest = versions[0];
36
+ return { head_sha: latest.head_commit_sha, base_sha: latest.base_commit_sha, start_sha: latest.start_commit_sha };
37
+ }
38
+ async extractPriorDiscussions(params, gitlabUrl, token) {
39
+ const api = this.createApi(gitlabUrl, token);
40
+ const discussions = await api.getDiscussions(String(params.project), params.mergeRequestIid);
41
+ const result = [];
42
+ for (const d of discussions) {
43
+ const notes = d.notes || [];
44
+ if (!notes.length)
45
+ continue;
46
+ const first = notes[0];
47
+ if (!first.resolvable || first.system)
48
+ continue;
49
+ result.push({
50
+ discussionId: d.id,
51
+ file: first.position?.new_path ?? null,
52
+ line: first.position?.new_line ?? null,
53
+ resolved: !!first.resolved,
54
+ topic: (first.body || '').slice(0, 300),
55
+ replies: notes.slice(1).filter(n => !n.system).map(n => ({
56
+ author: n.author?.username || 'unknown',
57
+ body: (n.body || '').slice(0, 300),
58
+ })),
59
+ });
60
+ }
61
+ return result;
62
+ }
63
+ async extractDraftNotes(params, gitlabUrl, token) {
64
+ const api = this.createApi(gitlabUrl, token);
65
+ const drafts = await api.getDraftNotes(String(params.project), params.mergeRequestIid);
66
+ return drafts.map(d => ({
67
+ id: d.id,
68
+ note: (d.note || '').slice(0, 500),
69
+ file: d.position?.new_path ?? d.position?.old_path ?? null,
70
+ line: d.position?.new_line ?? null,
71
+ old_line: d.position?.old_line ?? null,
72
+ }));
73
+ }
74
+ async postDraftNotes(params, gitlabUrl, token) {
75
+ const api = this.createApi(gitlabUrl, token);
76
+ const project = String(params.project);
77
+ const mr = params.mergeRequestIid;
78
+ const { headSha, baseSha, startSha, findings } = params;
79
+ const comments = findings.comments || [];
80
+ // Resolve anchor-based findings to line numbers when repo context is available
81
+ if (params.repoDir && params.mergeBase) {
82
+ if (!this.gitTools)
83
+ this.gitTools = new GitToolsService(this.logger.child('GitTools'));
84
+ const diffCache = new Map();
85
+ for (const c of comments) {
86
+ if (!c.file || !c.anchor || c.changed === 'general')
87
+ continue;
88
+ if (c.line != null || c.old_line != null)
89
+ continue; // already resolved
90
+ let fileDiff = diffCache.get(c.file);
91
+ if (fileDiff === undefined) {
92
+ try {
93
+ fileDiff = await this.gitTools.runGit(params.repoDir, ['diff', params.mergeBase, 'HEAD', '--', c.file]);
94
+ diffCache.set(c.file, fileDiff);
95
+ }
96
+ catch {
97
+ diffCache.set(c.file, null);
98
+ c.changed = 'general';
99
+ continue;
100
+ }
101
+ }
102
+ if (!fileDiff) {
103
+ c.changed = 'general';
104
+ continue;
105
+ }
106
+ const result = resolveAnchorInDiff(fileDiff, c);
107
+ if (result) {
108
+ c.changed = result.changed;
109
+ c.line = result.line;
110
+ c.old_line = result.old_line;
111
+ }
112
+ else {
113
+ c.changed = 'general';
114
+ }
115
+ }
116
+ this.logger.info('Anchor resolution complete (inline)', {
117
+ total: comments.length,
118
+ resolved: comments.filter((c) => c.line != null || c.old_line != null).length,
119
+ });
120
+ }
121
+ let posted = 0, blockingCount = 0, nonBlockingCount = 0;
122
+ const errors = [];
123
+ for (let i = 0; i < comments.length; i++) {
124
+ const c = comments[i];
125
+ const note = c.note || '';
126
+ const filePath = c.file;
127
+ const line = c.line;
128
+ const oldLine = c.old_line;
129
+ const changed = c.changed || 'new';
130
+ if (c.blocking !== false)
131
+ blockingCount++;
132
+ else {
133
+ nonBlockingCount++;
134
+ continue; // Non-blocking findings are compiled into a checklist draft at the end
135
+ }
136
+ let bodyDict;
137
+ if (!filePath || (line == null && oldLine == null) || changed === 'general') {
138
+ // Prepend a file link when file context is available (e.g. anchor on unchanged line)
139
+ if (filePath) {
140
+ const fileUrl = `${gitlabUrl}/${project}/-/blob/${headSha}/${filePath}`;
141
+ const lineNum = line ?? oldLine;
142
+ const linkText = lineNum ? `([L${lineNum}](${fileUrl}#L${lineNum}))` : `([link](${fileUrl}))`;
143
+ bodyDict = { note: `**\`${filePath}\`** ${linkText}\n\n${note}` };
144
+ }
145
+ else {
146
+ bodyDict = { note };
147
+ }
148
+ }
149
+ else {
150
+ const position = {
151
+ position_type: 'text', base_sha: baseSha, start_sha: startSha, head_sha: headSha,
152
+ old_path: filePath, new_path: filePath,
153
+ };
154
+ if (changed === 'new') {
155
+ position.old_line = null;
156
+ position.new_line = line;
157
+ }
158
+ else if (changed === 'old') {
159
+ position.old_line = oldLine;
160
+ position.new_line = null;
161
+ }
162
+ else if (changed === 'context') {
163
+ position.old_line = oldLine ?? line;
164
+ position.new_line = line;
165
+ }
166
+ else {
167
+ position.new_line = line;
168
+ }
169
+ bodyDict = { note, position };
170
+ }
171
+ try {
172
+ const result = await api.postDraftNote(project, mr, bodyDict);
173
+ this.logger.debug(`OK: comment ${i} draft=${result.id} line_code=${result.line_code}`);
174
+ // GitLab bug: deleted-line inline comments (old_line only) often get
175
+ // line_code=null, causing ghost duplication or invisible comments.
176
+ // Detect and repost as a general note with file context in the body.
177
+ if (changed === 'old' && !result.line_code) {
178
+ this.logger.warn(`comment ${i}: line_code=null on deleted line, reposting as general note`);
179
+ try {
180
+ await api.deleteDraftNote(project, mr, result.id);
181
+ }
182
+ catch { /* best effort */ }
183
+ const prefix = `**\`${filePath}\`** (removed line ~${oldLine})\n\n`;
184
+ const fallback = await api.postDraftNote(project, mr, { note: prefix + note });
185
+ this.logger.debug(`OK: comment ${i} reposted as general draft=${fallback.id}`);
186
+ }
187
+ posted++;
188
+ }
189
+ catch (err) {
190
+ const msg = err instanceof Error ? err.message : String(err);
191
+ errors.push({ index: i, error: msg });
192
+ this.logger.warn(`FAIL: comment ${i}: ${msg}`);
193
+ }
194
+ }
195
+ // Post non-blocking findings as a single general checklist draft
196
+ if (nonBlockingCount > 0) {
197
+ const nonBlocking = comments.filter(c => c.blocking === false && c.note);
198
+ const lines = ['### Suggestions', ''];
199
+ for (const nb of nonBlocking) {
200
+ const firstLine = nb.note.split('\n')[0];
201
+ const noteOneLine = nb.note.includes('\n') ? firstLine + ' …' : firstLine;
202
+ if (!nb.file) {
203
+ lines.push(`- [ ] **General**: ${noteOneLine}`);
204
+ }
205
+ else {
206
+ const fileUrl = `${gitlabUrl}/${project}/-/blob/${headSha}/${nb.file}`;
207
+ const lineNum = nb.line ?? nb.old_line;
208
+ if (lineNum) {
209
+ lines.push(`- [ ] **${nb.file}** ([L${lineNum}](${fileUrl}#L${lineNum})): ${noteOneLine}`);
210
+ }
211
+ else {
212
+ lines.push(`- [ ] **${nb.file}** ([link](${fileUrl})): ${noteOneLine}`);
213
+ }
214
+ }
215
+ }
216
+ try {
217
+ const checklistBody = lines.join('\n');
218
+ await api.postDraftNote(project, mr, { note: CHECKLIST_MARKER + '\n' + checklistBody });
219
+ posted++;
220
+ this.logger.info('Non-blocking checklist posted as general draft');
221
+ }
222
+ catch (err) {
223
+ const msg = err instanceof Error ? err.message : String(err);
224
+ errors.push({ index: -1, error: `checklist draft: ${msg}` });
225
+ this.logger.warn(`FAIL: checklist draft: ${msg}`);
226
+ }
227
+ }
228
+ this.logger.info('Draft notes posted', { posted, blocking: blockingCount, non_blocking: nonBlockingCount, errors: errors.length });
229
+ return { posted, blocking: blockingCount, non_blocking: nonBlockingCount, errors };
230
+ }
231
+ async deleteDraftNotes(params, gitlabUrl, token) {
232
+ const api = this.createApi(gitlabUrl, token);
233
+ const project = String(params.project);
234
+ const mr = params.mergeRequestIid;
235
+ let draftsToDelete;
236
+ if (params.draftNoteIds?.length) {
237
+ draftsToDelete = params.draftNoteIds.map(id => ({ id }));
238
+ }
239
+ else {
240
+ draftsToDelete = await api.getDraftNotes(project, mr);
241
+ }
242
+ if (draftsToDelete.length === 0)
243
+ return { deleted: 0, errors: [] };
244
+ let deleted = 0;
245
+ const errors = [];
246
+ for (const draft of draftsToDelete) {
247
+ try {
248
+ await api.deleteDraftNote(project, mr, draft.id);
249
+ deleted++;
250
+ }
251
+ catch (err) {
252
+ errors.push(`Failed to delete draft ${draft.id}: ${err instanceof Error ? err.message : String(err)}`);
253
+ }
254
+ }
255
+ this.logger.info('Draft notes deleted', { deleted, total: draftsToDelete.length, errors: errors.length });
256
+ return { deleted, errors };
257
+ }
258
+ async publishReview(params, gitlabUrl, token) {
259
+ const api = this.createApi(gitlabUrl, token);
260
+ const project = String(params.project);
261
+ const mr = params.mergeRequestIid;
262
+ // Step 1: Read actual drafts — the user may have edited or deleted some
263
+ const drafts = await api.getDraftNotes(project, mr);
264
+ // Extract checklist draft (non-blocking) before bulk publish
265
+ let checklistBody = null;
266
+ let blockingDraftCount = 0;
267
+ for (const draft of drafts) {
268
+ if (draft.note.includes(CHECKLIST_MARKER)) {
269
+ checklistBody = draft.note.slice(draft.note.indexOf(CHECKLIST_MARKER) + CHECKLIST_MARKER.length).trim();
270
+ try {
271
+ await api.deleteDraftNote(project, mr, draft.id);
272
+ this.logger.info(`Extracted checklist draft ${draft.id} for plain comment repost`);
273
+ }
274
+ catch (err) {
275
+ this.logger.warn(`Failed to delete checklist draft ${draft.id}, it will be bulk-published as resolvable: ${err instanceof Error ? err.message : String(err)}`);
276
+ }
277
+ }
278
+ else {
279
+ blockingDraftCount++;
280
+ }
281
+ }
282
+ // Step 2: Bulk publish remaining drafts (blocking only now)
283
+ this.logger.info('Publishing draft notes...');
284
+ const publishOk = await api.bulkPublishDraftNotes(project, mr);
285
+ // Step 3: Re-post checklist as a plain (non-resolvable) comment
286
+ if (checklistBody) {
287
+ try {
288
+ await api.postNote(project, mr, checklistBody);
289
+ this.logger.info('Non-blocking checklist posted as plain comment');
290
+ }
291
+ catch (err) {
292
+ this.logger.warn(`Failed to post non-blocking checklist as plain comment: ${err instanceof Error ? err.message : String(err)}`);
293
+ }
294
+ }
295
+ // Step 4: Award emoji on published notes (best-effort)
296
+ // Lazy-load username — only needed here and for reviewer assignment
297
+ let username;
298
+ const getUsername = async () => {
299
+ if (!username) {
300
+ const currentUser = await api.getCurrentUser();
301
+ username = currentUser.username || 'me';
302
+ }
303
+ return username;
304
+ };
305
+ const emojiResult = await this.awardEmojiOnPublishedNotes(api, project, mr, await getUsername(), params.emojiName);
306
+ // Step 5: Determine and apply verdict from actual drafts (unless opted out)
307
+ const applyVerdict = params.applyVerdict !== false;
308
+ const threadsBlockMerge = params.threadsBlockMerge === true;
309
+ let action = blockingDraftCount > 0 ? 'request_changes' : 'approve';
310
+ let actionSuccess = true;
311
+ if (!applyVerdict) {
312
+ this.logger.info(`Verdict skipped (applyVerdict=false). Determined verdict: ${action}`);
313
+ action = 'comment_only';
314
+ }
315
+ else if (action === 'request_changes') {
316
+ if (threadsBlockMerge) {
317
+ this.logger.info('Skipping /request_changes — project requires all threads resolved before merge (unresolved blocking threads already prevent merging)');
318
+ action = 'comment_only';
319
+ }
320
+ else {
321
+ this.logger.info('Requesting changes...');
322
+ try {
323
+ const resp = await api.postNote(project, mr, '/request_changes');
324
+ const respText = typeof resp === 'string' ? resp : JSON.stringify(resp || '');
325
+ if (respText.includes('Could not apply') || respText.toLowerCase().includes('error')) {
326
+ this.logger.warn('/request_changes failed — falling back to comment-only');
327
+ action = 'comment_only';
328
+ actionSuccess = false;
329
+ }
330
+ }
331
+ catch {
332
+ action = 'comment_only';
333
+ actionSuccess = false;
334
+ }
335
+ }
336
+ }
337
+ else if (action === 'approve') {
338
+ this.logger.info('Approving MR...');
339
+ await api.postNote(project, mr, '/approve');
340
+ }
341
+ else {
342
+ this.logger.info('Comment-only review — no action applied');
343
+ }
344
+ // Step 6: Self-assign as reviewer only when /request_changes was actually applied
345
+ let reviewerUsername = '';
346
+ if (action === 'request_changes') {
347
+ const uname = await getUsername();
348
+ this.logger.info(`Assigning ${uname} as reviewer (request_changes)...`);
349
+ await api.postNote(project, mr, `/assign_reviewer @${uname}`);
350
+ reviewerUsername = uname;
351
+ }
352
+ else {
353
+ this.logger.info('Skipping reviewer assignment — not requesting changes');
354
+ }
355
+ return { publish_ok: publishOk, resolved_threads: 0, reviewer_assigned: reviewerUsername, action, action_success: actionSuccess, emojis: emojiResult };
356
+ }
357
+ async postComment(params, gitlabUrl, token) {
358
+ const api = this.createApi(gitlabUrl, token);
359
+ const project = String(params.project);
360
+ const mr = params.mergeRequestIid;
361
+ // Unescape double-escaped newlines from MCP JSON serialization round-trip
362
+ const body = params.body.replace(/\\n/g, '\n');
363
+ const result = await api.postNote(project, mr, body);
364
+ const noteUrl = `${gitlabUrl}/${project}/-/merge_requests/${mr}#note_${result.id}`;
365
+ this.logger.info('Comment posted', { noteId: result.id, mr });
366
+ return { id: result.id, url: noteUrl };
367
+ }
368
+ async editNote(params, gitlabUrl, token) {
369
+ const api = this.createApi(gitlabUrl, token);
370
+ const project = String(params.project);
371
+ const mr = params.mergeRequestIid;
372
+ // Unescape double-escaped newlines from MCP JSON serialization round-trip
373
+ const body = params.body.replace(/\\n/g, '\n');
374
+ const result = await api.editNote(project, mr, params.noteId, body);
375
+ const noteUrl = `${gitlabUrl}/${project}/-/merge_requests/${mr}#note_${result.id}`;
376
+ this.logger.info('Note edited', { noteId: result.id, mr });
377
+ return { id: result.id, body: result.body, url: noteUrl };
378
+ }
379
+ isRenovate(title, sourceBranch = '') {
380
+ const t = title.toLowerCase();
381
+ return t.startsWith('[renovate') || title.includes('chore(deps):') || title.includes('fix(deps):') || sourceBranch.startsWith('renovate/');
382
+ }
383
+ async fetchAndCategorizeTodos(_params, gitlabUrl, token) {
384
+ const api = this.createApi(gitlabUrl, token);
385
+ const todos = await api.getTodos('pending');
386
+ const done = [];
387
+ const review = [];
388
+ const skip = [];
389
+ for (const t of todos) {
390
+ const target = (t.target || {});
391
+ const targetType = String(t.target_type || '');
392
+ const action = String(t.action_name || '');
393
+ const state = String(target.state || '');
394
+ const title = String(target.title || '');
395
+ const mrIid = target.iid || '';
396
+ const projectObj = (t.project || {});
397
+ const projectPath = String(projectObj.path_with_namespace || '');
398
+ const todoId = t.id;
399
+ const webUrl = String(target.web_url || '');
400
+ const sourceBranch = String(target.source_branch || '');
401
+ const item = { todo_id: todoId, target_type: targetType, action, state, title, mr_iid: mrIid, project: projectPath, web_url: webUrl };
402
+ if (targetType !== 'MergeRequest') {
403
+ item.reason = 'not a merge request';
404
+ skip.push(item);
405
+ continue;
406
+ }
407
+ const isDraft = title.startsWith('Draft:');
408
+ const isRenovate = this.isRenovate(title, sourceBranch);
409
+ if (state === 'merged' || state === 'closed' || isDraft || isRenovate) {
410
+ if (state === 'merged' || state === 'closed')
411
+ item.reason = state;
412
+ else if (isDraft)
413
+ item.reason = 'draft';
414
+ else
415
+ item.reason = 'renovate';
416
+ done.push(item);
417
+ continue;
418
+ }
419
+ if (state === 'opened' && ['review_requested', 'marked', 'mentioned'].includes(action)) {
420
+ review.push(item);
421
+ continue;
422
+ }
423
+ item.reason = `action=${action}, not a review request or mention`;
424
+ skip.push(item);
425
+ }
426
+ // Deduplicate review list by MR — keep the highest-priority action per unique MR
427
+ const actionPriority = { marked: 0, review_requested: 1, mentioned: 2 };
428
+ const reviewByMr = new Map();
429
+ for (const item of review) {
430
+ const key = `${item.project}:${item.mr_iid}`;
431
+ const existing = reviewByMr.get(key);
432
+ if (!existing || (actionPriority[item.action] ?? 99) < (actionPriority[existing.action] ?? 99)) {
433
+ if (existing) {
434
+ existing.reason = 'duplicate';
435
+ done.push(existing);
436
+ }
437
+ reviewByMr.set(key, item);
438
+ }
439
+ else {
440
+ item.reason = 'duplicate';
441
+ done.push(item);
442
+ }
443
+ }
444
+ const deduplicatedReview = [...reviewByMr.values()];
445
+ const dupeCount = review.length - deduplicatedReview.length;
446
+ if (dupeCount > 0)
447
+ this.logger.info(`Deduplicated ${dupeCount} duplicate MR todos`);
448
+ this.logger.info(`Categorized ${todos.length} todos`, { done: done.length, review: deduplicatedReview.length, skip: skip.length });
449
+ return { done, review: deduplicatedReview, skip };
450
+ }
451
+ async markTodosDone(params, gitlabUrl, token) {
452
+ const api = this.createApi(gitlabUrl, token);
453
+ const results = [];
454
+ for (const todoId of params.todoIds) {
455
+ try {
456
+ const resp = await api.markTodoDone(todoId);
457
+ results.push({ todo_id: todoId, state: resp.state || 'done' });
458
+ }
459
+ catch (err) {
460
+ results.push({ todo_id: todoId, error: err instanceof Error ? err.message : String(err) });
461
+ }
462
+ }
463
+ const succeeded = results.filter(r => r.state).length;
464
+ this.logger.info(`Marked ${succeeded}/${params.todoIds.length} todos as done`);
465
+ return results;
466
+ }
467
+ // --- Author workflow tools ---
468
+ async listNotes(params, gitlabUrl, token) {
469
+ const api = this.createApi(gitlabUrl, token);
470
+ const project = String(params.project);
471
+ const sort = params.sort ?? 'desc';
472
+ const perPage = params.perPage ?? 20;
473
+ this.logger.info('Listing notes', { project, mr: params.mergeRequestIid, sort, perPage });
474
+ const notes = await api.getNotes(project, params.mergeRequestIid, sort, perPage);
475
+ return notes.map(n => ({
476
+ id: n.id,
477
+ author: n.author.username,
478
+ body: n.body,
479
+ system: n.system,
480
+ created_at: n.created_at,
481
+ }));
482
+ }
483
+ async replyToDiscussion(params, gitlabUrl, token) {
484
+ const api = this.createApi(gitlabUrl, token);
485
+ const project = String(params.project);
486
+ this.logger.info('Replying to discussion', { project, mr: params.mergeRequestIid, discussionId: params.discussionId });
487
+ const result = await api.replyToDiscussion(project, params.mergeRequestIid, params.discussionId, params.body);
488
+ return { id: result.id, body: result.body };
489
+ }
490
+ async resolveDiscussion(params, gitlabUrl, token) {
491
+ const api = this.createApi(gitlabUrl, token);
492
+ const project = String(params.project);
493
+ const resolved = params.resolved ?? true;
494
+ this.logger.info('Setting discussion resolved', { project, mr: params.mergeRequestIid, discussionId: params.discussionId, resolved });
495
+ await api.setDiscussionResolved(project, params.mergeRequestIid, params.discussionId, resolved);
496
+ return { discussionId: params.discussionId, resolved };
497
+ }
498
+ // --- Checklist compilation (moved from review-utils — GitLab-specific) ---
499
+ async compileChecklist(params, gitlabUrl, token) {
500
+ // token unused — checklist reads from local findings file and constructs URLs
501
+ void token;
502
+ this.logger.info('Compiling checklist', { findingsPath: params.findingsPath, project: params.project });
503
+ const raw = readFileSync(params.findingsPath, 'utf-8');
504
+ const findings = JSON.parse(raw);
505
+ // Normalize 'comment' → 'note' alias
506
+ for (const c of findings.comments) {
507
+ const r = c;
508
+ if (!c.note && r['comment']) {
509
+ c.note = r['comment'];
510
+ delete r['comment'];
511
+ }
512
+ }
513
+ const baseUrl = gitlabUrl.replace(/\/+$/, '');
514
+ const nonBlocking = findings.comments.filter(c => !c.blocking);
515
+ if (nonBlocking.length === 0) {
516
+ return { markdown: '', itemCount: 0 };
517
+ }
518
+ const lines = ['### Suggestions', ''];
519
+ for (const comment of nonBlocking) {
520
+ if (!comment.note)
521
+ continue;
522
+ const firstLine = comment.note.split('\n')[0];
523
+ const noteOneLine = comment.note.includes('\n') ? firstLine + ' …' : firstLine;
524
+ if (!comment.file) {
525
+ lines.push(`- [ ] **General**: ${noteOneLine}`);
526
+ continue;
527
+ }
528
+ const fileUrl = `${baseUrl}/${params.project}/-/blob/${params.headSha}/${comment.file}`;
529
+ const lineNum = comment.line ?? comment.old_line;
530
+ if (lineNum) {
531
+ lines.push(`- [ ] **${comment.file}** ([L${lineNum}](${fileUrl}#L${lineNum})): ${noteOneLine}`);
532
+ }
533
+ else {
534
+ lines.push(`- [ ] **${comment.file}** ([link](${fileUrl})): ${noteOneLine}`);
535
+ }
536
+ }
537
+ const markdown = lines.join('\n');
538
+ const itemCount = lines.length - 2;
539
+ this.logger.info('Checklist compiled', { itemCount });
540
+ return { markdown, itemCount };
541
+ }
542
+ // --- Emoji Attribution ---
543
+ /**
544
+ * Award emoji on all non-system notes authored by the current user.
545
+ * Best-effort: failures are logged but don't break the publish flow.
546
+ */
547
+ async awardEmojiOnPublishedNotes(api, project, mr, username, emojiName) {
548
+ const emoji = emojiName || 'robot';
549
+ let awarded = 0;
550
+ let failed = 0;
551
+ try {
552
+ // getNotes fetches at most 100 notes (GitLab's per-page cap). On MRs with >100
553
+ // notes, older bot notes beyond the first page won't receive emoji. Acceptable
554
+ // for a best-effort feature since newest notes (desc order) are fetched first.
555
+ const notes = await api.getNotes(project, mr, 'desc', 100);
556
+ const myNotes = notes.filter(n => n.author.username === username && !n.system);
557
+ // Only award emoji on notes that don't already have it (avoids 409 on re-runs)
558
+ this.logger.info(`Awarding :${emoji}: on ${myNotes.length} notes`);
559
+ for (const note of myNotes) {
560
+ try {
561
+ await api.awardEmoji(project, mr, emoji, note.id);
562
+ awarded++;
563
+ }
564
+ catch (err) {
565
+ const msg = err instanceof Error ? err.message : String(err);
566
+ // 409 Conflict = emoji already awarded — skip silently
567
+ if (msg.includes('409 Conflict')) {
568
+ this.logger.debug(`Emoji already awarded on note ${note.id}`);
569
+ }
570
+ else {
571
+ this.logger.debug(`Failed to award emoji on note ${note.id}: ${msg}`);
572
+ failed++;
573
+ }
574
+ }
575
+ }
576
+ }
577
+ catch (err) {
578
+ this.logger.warn(`Failed to award emoji (best-effort): ${err instanceof Error ? err.message : String(err)}`);
579
+ }
580
+ return { awarded, failed, emoji_used: emoji };
581
+ }
582
+ // --- Custom Emoji ---
583
+ async checkCustomEmoji(params, gitlabUrl, token) {
584
+ const api = this.createApi(gitlabUrl, token);
585
+ const name = params.emojiName || 'kiro';
586
+ if (!params.groupPath)
587
+ return { existed: false, name };
588
+ // Build group paths from closest to root: org/team/frontend → org/team → org
589
+ const segments = params.groupPath.split('/');
590
+ const groupPaths = [];
591
+ for (let i = segments.length; i >= 1; i--) {
592
+ groupPaths.push(segments.slice(0, i).join('/'));
593
+ }
594
+ this.logger.info('Checking if custom emoji exists', { name, groupPaths });
595
+ for (const groupPath of groupPaths) {
596
+ try {
597
+ const existing = await api.getCustomEmoji(groupPath, name);
598
+ if (existing) {
599
+ this.logger.info('Custom emoji found', { name, groupPath });
600
+ return { existed: true, name, groupPath };
601
+ }
602
+ this.logger.debug('Custom emoji not found at this level', { name, groupPath });
603
+ }
604
+ catch (err) {
605
+ const msg = err instanceof Error ? err.message : String(err);
606
+ this.logger.debug('Cannot query group (permission or API issue), trying parent', { name, groupPath, error: msg });
607
+ }
608
+ }
609
+ this.logger.info('Custom emoji not found at any group level', { name, groupPaths });
610
+ return { existed: false, name };
611
+ }
612
+ async awardEmoji(params, gitlabUrl, token) {
613
+ const api = this.createApi(gitlabUrl, token);
614
+ const project = String(params.project);
615
+ this.logger.info('Awarding emoji', {
616
+ project, mr: params.mergeRequestIid, noteId: params.noteId, emoji: params.emojiName,
617
+ });
618
+ try {
619
+ return await api.awardEmoji(project, params.mergeRequestIid, params.emojiName, params.noteId);
620
+ }
621
+ catch (err) {
622
+ const msg = err instanceof Error ? err.message : String(err);
623
+ // 409 Conflict = emoji already awarded — treat as success (best-effort)
624
+ if (msg.includes('409 Conflict')) {
625
+ this.logger.debug('Emoji already awarded (409 conflict)', { project, mr: params.mergeRequestIid, noteId: params.noteId });
626
+ return { id: 0, name: params.emojiName };
627
+ }
628
+ this.logger.warn('Failed to award emoji (best-effort)', { project, mr: params.mergeRequestIid, noteId: params.noteId, error: msg });
629
+ return { id: 0, name: params.emojiName, error: msg };
630
+ }
631
+ }
632
+ // --- Review Retro ---
633
+ async listMergeRequests(params, gitlabUrl, token) {
634
+ const api = this.createApi(gitlabUrl, token);
635
+ const queryParams = {
636
+ state: params.state || 'merged',
637
+ scope: params.scope || 'all',
638
+ order_by: params.orderBy || 'updated_at',
639
+ sort: params.sort || 'desc',
640
+ };
641
+ if (params.reviewerUsername)
642
+ queryParams.reviewer_username = params.reviewerUsername;
643
+ if (params.authorUsername)
644
+ queryParams.author_username = params.authorUsername;
645
+ if (params.updatedAfter)
646
+ queryParams.updated_after = params.updatedAfter;
647
+ if (params.updatedBefore)
648
+ queryParams.updated_before = params.updatedBefore;
649
+ if (params.perPage)
650
+ queryParams.per_page = String(Math.min(params.perPage, 100));
651
+ this.logger.info('Listing merge requests', queryParams);
652
+ const raw = await api.listMergeRequests(queryParams);
653
+ // Return a compact summary for each MR
654
+ const results = raw.map(mr => ({
655
+ project: mr.references?.full?.replace(/!.*$/, '').replace(/^!/, '')
656
+ || mr.web_url?.match(/^https?:\/\/[^/]+\/(.+)\/-\/merge_requests/)?.[1]
657
+ || null,
658
+ mrIid: mr.iid,
659
+ title: mr.title,
660
+ state: mr.state,
661
+ author: mr.author?.username || null,
662
+ mergedAt: mr.merged_at || null,
663
+ updatedAt: mr.updated_at || null,
664
+ webUrl: mr.web_url || null,
665
+ }));
666
+ this.logger.info(`Found ${results.length} merge requests`);
667
+ return { count: results.length, mergeRequests: results };
668
+ }
669
+ async batchReviewRetro(params, gitlabUrl, token) {
670
+ // Step 1: Resolve MR list — either explicit or discovered
671
+ let mrList;
672
+ if (params.mergeRequests?.length) {
673
+ mrList = params.mergeRequests.map(mr => ({
674
+ project: mr.project, mrIid: mr.mergeRequestIid, title: null, author: null,
675
+ }));
676
+ }
677
+ else if (params.reviewerUsername) {
678
+ const listResult = await this.listMergeRequests({
679
+ state: 'merged',
680
+ reviewerUsername: params.reviewerUsername,
681
+ updatedAfter: params.updatedAfter,
682
+ updatedBefore: params.updatedBefore,
683
+ perPage: 100,
684
+ }, gitlabUrl, token);
685
+ mrList = listResult.mergeRequests
686
+ .filter(mr => mr.project) // skip entries with null project
687
+ .map(mr => ({ project: mr.project, mrIid: mr.mrIid, title: mr.title, author: mr.author }));
688
+ }
689
+ else {
690
+ throw new Error('Either mergeRequests or reviewerUsername is required');
691
+ }
692
+ // Step 2: Filter out excluded authors
693
+ if (params.excludeAuthors?.length) {
694
+ const excluded = new Set(params.excludeAuthors.map(a => a.toLowerCase()));
695
+ mrList = mrList.filter(mr => !mr.author || !excluded.has(mr.author.toLowerCase()));
696
+ }
697
+ this.logger.info(`Batch retro: ${mrList.length} MRs to analyze`);
698
+ // Step 3: Run review_retro on each MR (sequentially to avoid rate limits)
699
+ const perMr = [];
700
+ const errors = [];
701
+ const allCategories = {
702
+ code_changed: 0, author_explained: 0, reviewer_self_resolved: 0,
703
+ wont_fix: 0, duplicate: 0, still_open: 0, unknown: 0,
704
+ };
705
+ let totalDiscussions = 0;
706
+ let totalCodeChanged = 0;
707
+ let totalWithReply = 0;
708
+ for (const mr of mrList) {
709
+ try {
710
+ const result = await this.reviewRetro({
711
+ project: mr.project,
712
+ mergeRequestIid: mr.mrIid,
713
+ reviewer: params.reviewer,
714
+ }, gitlabUrl, token);
715
+ perMr.push({
716
+ project: mr.project,
717
+ mrIid: mr.mrIid,
718
+ title: mr.title,
719
+ author: mr.author,
720
+ total: result.stats.total,
721
+ stats: result.stats,
722
+ });
723
+ totalDiscussions += result.stats.total;
724
+ for (const [cat, count] of Object.entries(result.stats.byCategory)) {
725
+ allCategories[cat] += count;
726
+ }
727
+ totalCodeChanged += result.discussions.filter(d => d.codeChanged).length;
728
+ totalWithReply += result.discussions.filter(d => d.authorReply !== null).length;
729
+ }
730
+ catch (err) {
731
+ const msg = err instanceof Error ? err.message : String(err);
732
+ errors.push({ project: mr.project, mrIid: mr.mrIid, error: msg });
733
+ this.logger.warn(`Retro failed for ${mr.project}!${mr.mrIid}: ${msg}`);
734
+ }
735
+ }
736
+ const aggregateStats = {
737
+ total: totalDiscussions,
738
+ byCategory: allCategories,
739
+ codeChangeRate: totalDiscussions > 0 ? totalCodeChanged / totalDiscussions : 0,
740
+ responseRate: totalDiscussions > 0 ? totalWithReply / totalDiscussions : 0,
741
+ };
742
+ const mrsWithDiscussions = perMr.filter(m => m.total > 0).length;
743
+ this.logger.info('Batch retro complete', {
744
+ mrCount: mrList.length,
745
+ mrsWithDiscussions,
746
+ totalDiscussions,
747
+ errors: errors.length,
748
+ });
749
+ return {
750
+ mrCount: mrList.length,
751
+ mrsWithDiscussions,
752
+ mrsWithZeroDiscussions: mrList.length - mrsWithDiscussions - errors.length,
753
+ totalDiscussions,
754
+ aggregateStats,
755
+ perMr: perMr.filter(m => m.total > 0), // Only include MRs with discussions
756
+ errors,
757
+ };
758
+ }
759
+ async reviewRetro(params, gitlabUrl, token) {
760
+ const api = this.createApi(gitlabUrl, token);
761
+ const project = String(params.project);
762
+ const mr = params.mergeRequestIid;
763
+ this.logger.info('Running review retro', { project, mr, reviewer: params.reviewer });
764
+ // Fetch MR metadata (for author), discussions, and versions in parallel
765
+ const [mrData, rawDiscussions, versions] = await Promise.all([
766
+ api.getMergeRequest(project, mr),
767
+ api.getDiscussions(project, mr),
768
+ api.getMergeRequestVersions(project, mr),
769
+ ]);
770
+ const mrAuthor = mrData?.author?.username ?? null;
771
+ // Build version timeline: map head_sha → version index (newest = 0)
772
+ // Versions are returned newest-first by the API
773
+ const versionBySha = new Map();
774
+ for (let i = 0; i < versions.length; i++) {
775
+ versionBySha.set(versions[i].head_commit_sha, i);
776
+ }
777
+ // For each version, fetch the changed files (to detect inter-round changes)
778
+ // Cache to avoid duplicate fetches
779
+ const versionDiffsCache = new Map();
780
+ const getVersionDiffs = async (versionId) => {
781
+ if (versionDiffsCache.has(versionId))
782
+ return versionDiffsCache.get(versionId);
783
+ const files = await api.getMergeRequestVersionDiffs(project, mr, versionId);
784
+ const fileSet = new Set(files);
785
+ versionDiffsCache.set(versionId, fileSet);
786
+ return fileSet;
787
+ };
788
+ // Collect files changed in versions AFTER a given version index
789
+ const getFilesChangedAfterVersion = async (versionIndex) => {
790
+ const changedFiles = new Set();
791
+ // Versions newer than versionIndex have lower indices (0 = newest)
792
+ for (let i = 0; i < versionIndex; i++) {
793
+ const diffs = await getVersionDiffs(versions[i].id);
794
+ for (const f of diffs)
795
+ changedFiles.add(f);
796
+ }
797
+ return changedFiles;
798
+ };
799
+ const discussions = [];
800
+ for (const d of rawDiscussions) {
801
+ const notes = d.notes || [];
802
+ if (!notes.length)
803
+ continue;
804
+ const first = notes[0];
805
+ if (!first.resolvable || first.system)
806
+ continue;
807
+ // Filter by reviewer if specified
808
+ const reviewerUsername = first.author?.username || 'unknown';
809
+ if (params.reviewer && reviewerUsername !== params.reviewer)
810
+ continue;
811
+ const file = first.position?.new_path ?? null;
812
+ const line = first.position?.new_line ?? null;
813
+ const resolved = !!first.resolved;
814
+ const topic = (first.body || '').slice(0, 300);
815
+ const commentHeadSha = first.position?.head_sha ?? null;
816
+ // Detect author replies: replies from the MR author (not the reviewer)
817
+ const nonSystemReplies = notes.slice(1).filter(n => !n.system);
818
+ let authorReply = null;
819
+ if (mrAuthor && mrAuthor !== reviewerUsername) {
820
+ // Normal case: reviewer != MR author
821
+ const mrAuthorReplies = nonSystemReplies.filter(n => n.author?.username === mrAuthor);
822
+ if (mrAuthorReplies.length > 0) {
823
+ authorReply = (mrAuthorReplies[0].body || '').slice(0, 300);
824
+ }
825
+ }
826
+ else {
827
+ // Self-review: reviewer == MR author, any reply counts
828
+ if (nonSystemReplies.length > 0) {
829
+ authorReply = (nonSystemReplies[0].body || '').slice(0, 300);
830
+ }
831
+ }
832
+ // Detect inter-round code changes for the commented file
833
+ let codeChanged = false;
834
+ if (file && commentHeadSha) {
835
+ const commentVersionIndex = versionBySha.get(commentHeadSha);
836
+ if (commentVersionIndex !== undefined && commentVersionIndex > 0) {
837
+ // There are newer versions after this comment was posted
838
+ const laterChangedFiles = await getFilesChangedAfterVersion(commentVersionIndex);
839
+ codeChanged = laterChangedFiles.has(file);
840
+ }
841
+ // If comment is on the latest version (index 0), no later changes exist
842
+ }
843
+ const category = this.classifyDiscussion(resolved, authorReply, codeChanged, nonSystemReplies, topic);
844
+ discussions.push({
845
+ file, line, topic, resolved, category,
846
+ authorReply, codeChanged, replyCount: nonSystemReplies.length,
847
+ });
848
+ }
849
+ const stats = this.computeRetroStats(discussions);
850
+ this.logger.info('Review retro complete', {
851
+ total: stats.total,
852
+ codeChangeRate: stats.codeChangeRate,
853
+ responseRate: stats.responseRate,
854
+ });
855
+ return {
856
+ project,
857
+ mergeRequestIid: mr,
858
+ reviewer: params.reviewer ?? null,
859
+ discussions,
860
+ stats,
861
+ };
862
+ }
863
+ classifyDiscussion(resolved, authorReply, codeChanged, replies, topic) {
864
+ if (!resolved)
865
+ return 'still_open';
866
+ // Check for duplicate markers in replies
867
+ const allText = replies.map(r => (r.body || '').toLowerCase()).join(' ');
868
+ if (allText.includes('duplicate') || topic.toLowerCase().includes('duplicate')) {
869
+ return 'duplicate';
870
+ }
871
+ // Check for won't fix signals
872
+ const wontFixSignals = ['won\'t fix', 'wontfix', 'not going to', 'out of scope', 'by design', 'intentional'];
873
+ const allTextWithTopic = allText + ' ' + topic.toLowerCase();
874
+ if (wontFixSignals.some(s => allTextWithTopic.includes(s))) {
875
+ return 'wont_fix';
876
+ }
877
+ // Code changed after comment was posted = addressed with code
878
+ if (codeChanged)
879
+ return 'code_changed';
880
+ // Author replied but code didn't change = explained
881
+ if (authorReply)
882
+ return 'author_explained';
883
+ // Resolved without reply and no code change = self-resolved
884
+ return 'reviewer_self_resolved';
885
+ }
886
+ computeRetroStats(discussions) {
887
+ const total = discussions.length;
888
+ const categories = [
889
+ 'code_changed', 'author_explained', 'reviewer_self_resolved',
890
+ 'wont_fix', 'duplicate', 'still_open', 'unknown',
891
+ ];
892
+ const byCategory = Object.fromEntries(categories.map(c => [c, discussions.filter(d => d.category === c).length]));
893
+ const codeChangeRate = total > 0
894
+ ? discussions.filter(d => d.codeChanged).length / total
895
+ : 0;
896
+ const responseRate = total > 0
897
+ ? discussions.filter(d => d.authorReply !== null).length / total
898
+ : 0;
899
+ return { total, byCategory, codeChangeRate, responseRate };
900
+ }
901
+ }
902
+ //# sourceMappingURL=review-tools.service.js.map